Compare commits

...

453 Commits

Author SHA1 Message Date
Priec
9ff3c59961 Remove canvas .toml files from git tracking and ensure they remain ignored 2025-07-31 11:37:56 +02:00
Priec
c5f22d7da1 canvas library config is now required 2025-07-31 11:16:21 +02:00
Priec
3c62877757 removing compatibility code fully, we are now fresh without compat layer. We compiled successfuly 2025-07-30 22:54:02 +02:00
Priec
cc19c61f37 new canvas library changed client for compatibility 2025-07-30 22:42:32 +02:00
Priec
ad82bd4302 canvas robust solution to movement 2025-07-30 22:02:52 +02:00
Priec
d584a25fdb removed hardcoded values from the canvas library 2025-07-30 21:16:16 +02:00
Priec
baa4295059 removed _e files completely 2025-07-30 20:25:58 +02:00
Priec
6cbfac9d6e read only deleted completely 2025-07-30 19:39:27 +02:00
Priec
13d28f19ea removing _ro files completely 2025-07-30 19:30:55 +02:00
Priec
8fa86965b8 removing canvasstate permanently 2025-07-30 19:20:23 +02:00
Priec
72c38f613f canvasstate is now officially nonexistent as dep 2025-07-30 19:14:35 +02:00
Priec
e4982f871f add_logic is now using canvas library 2025-07-30 18:02:59 +02:00
Priec
4e0338276f autotrigger vs manual trigger 2025-07-30 17:16:20 +02:00
Priec
fe193f4f91 unimportant 2025-07-30 16:34:21 +02:00
Priec
0011ba0c04 add_table now ported to the canvas library also 2025-07-30 14:06:05 +02:00
Priec
3c2eef9596 registering canvas functions now instead of internal state 2025-07-30 13:24:49 +02:00
Priec
dac788351f autocomplete gui as original, needs logic change in the future 2025-07-30 13:00:23 +02:00
Priec
8d5bc1296e usage of the canvas is fully implemented, time to fix bugs. Working now fully 2025-07-30 12:51:18 +02:00
Priec
969ad229e4 compiled 2025-07-30 12:45:13 +02:00
Priec
0d291fcf57 auth not working with canvas crate yet 2025-07-30 12:08:35 +02:00
Priec
d711f4c491 usage of canvas lib for auth BROKEN 2025-07-30 11:14:05 +02:00
Priec
9369626e21 migration md 2025-07-29 23:56:53 +02:00
Priec
f84bb0dc9e compiled successfulywith rich suggestions now 2025-07-29 23:49:58 +02:00
Priec
20b428264e library is now conflicting with client and its breaking it, but lets change the client 2025-07-29 23:25:10 +02:00
Priec
05bb84fc98 different structure of the library 2025-07-29 23:04:48 +02:00
Priec
46a85e4b4a autocomplete separate traits, one for autocomplete one for canvas purely 2025-07-29 22:31:35 +02:00
Priec
b4d1572c79 autocomplete is now robust, but unified, time to split it up 2025-07-29 22:18:44 +02:00
Priec
b8e1b77222 compiled autocomplete 2025-07-29 21:46:55 +02:00
Priec
1a451a576f working, cleaning trash via cargo fix 2025-07-29 20:14:24 +02:00
Priec
074b2914d8 gui canvas with rounded corners 2025-07-29 20:00:16 +02:00
Priec
aec5f80879 gui of canvas is from the canvas crate now 2025-07-29 19:54:29 +02:00
Priec
a1fa42e204 config now finally works 2025-07-29 18:56:56 +02:00
Priec
306cb956a0 canvas has now its own config for keybindings, lets use that from the client 2025-07-29 17:16:03 +02:00
Priec
d837acde63 bugs fixed, canvas library now replaced internal canvas, only form is using it now 2025-07-29 16:05:45 +02:00
Priec
db938a2c8d canvas library usage instead of internal canvas on the form, others are still using canvas from state. Needed debugging because its not working yet 2025-07-29 15:20:00 +02:00
Priec
f24156775a canvas ready, lets implement now client 2025-07-29 12:47:43 +02:00
Priec
2a7f94cf17 strings to enum, eased state.rs 2025-07-29 10:12:16 +02:00
Priec
15922ed953 canvas compiled for the first time 2025-07-29 09:35:17 +02:00
Priec
7129ec97fd canvas in a separate crate 2025-07-29 08:33:49 +02:00
Priec
a921806e62 version 0.4.1 2025-07-29 08:28:33 +02:00
Priec
d1b28b4fdd init startup fail fixed to detect working profile not just default profile 2025-07-28 23:15:26 +02:00
Priec
64fd7e4af2 Add Nix flake development environment with direnv 2025-07-28 11:41:50 +02:00
Priec
7b52a739c2 basic nix flake added 2025-07-28 10:59:25 +02:00
filipriec
8127c7bb1b renamed again and fixed some minor stuff 2025-07-25 18:18:00 +02:00
filipriec
7437908baf removal of @ syntax as reference from the backend 2025-07-25 12:13:51 +02:00
filipriec
9eb46cb5d3 kompAC 2025-07-25 11:59:18 +02:00
filipriec
38a70128b0 prod code 2, time for new functionality 2025-07-25 00:08:16 +02:00
filipriec
c58ce52b33 fixing warnings and making prod code 2025-07-24 23:57:21 +02:00
filipriec
c82813185f code cleanup in post endpoint 2025-07-24 22:18:36 +02:00
filipriec
a96681e9d6 cleaned up code 2025-07-24 22:04:55 +02:00
filipriec
4df6c40034 removal of hardcoded fix, now working in general 2025-07-24 21:56:07 +02:00
filipriec
089d728cc7 passers 2025-07-24 10:55:03 +02:00
filipriec
aca3d718b5 CHECK THIS COMMIT I HAVE NO CLUE IF ITS CORRECT 2025-07-23 23:30:56 +02:00
filipriec
8a6a584cf3 compiletime error in test fixed 2025-07-23 00:43:11 +02:00
filipriec
00ed0cf796 test is now passing 2025-07-22 18:31:26 +02:00
filipriec
7e54b2fe43 we got a full passer now 2025-07-20 14:58:39 +02:00
filipriec
84871faad4 another passer, 2 more to go 2025-07-20 11:57:46 +02:00
filipriec
bcb433d7b2 fixing post table script 2025-07-20 11:46:00 +02:00
filipriec
7d1b130b68 we have a passer 2025-07-17 22:55:54 +02:00
filipriec
24c2376ea1 crucial self reference allowed 2025-07-17 22:06:53 +02:00
filipriec
810ef5fc10 post table script is now aware of a type in the database 2025-07-17 21:16:49 +02:00
filipriec
fe246b1fe6 reject boolean and text in math functions at the post table script 2025-07-16 22:31:55 +02:00
filipriec
de42bb48aa deprecated function removed, no need for backup 2025-07-13 08:51:47 +02:00
filipriec
17495c49ac steel scripts now have far better logic than before 2025-07-12 23:06:21 +02:00
filipriec
0e3a7a06a3 put needs more adjustements 2025-07-12 11:06:11 +02:00
filipriec
e0ee48eb9c put endpoint is now stronq boiii 2025-07-11 23:24:28 +02:00
filipriec
d2053b1d5a SCRIPTS only scripts reference to a linked table from this commit 2025-07-11 08:55:32 +02:00
filipriec
fbe8e53858 circular dependency fix in post script logic 2025-07-11 08:50:10 +02:00
filipriec
8fe2581b3f put request at halt until script fixes 2025-07-10 21:11:42 +02:00
filipriec
60cc0e562e adjusted tests for the put request 2025-07-09 22:24:33 +02:00
filipriec
26898d474f tests for steel in post request is fixed with all the errors found in the codebase 2025-07-08 22:04:11 +02:00
filipriec
2311fbaa3b post steel tests 2025-07-08 20:26:48 +02:00
filipriec
be99cd9423 forgotten lock 2025-07-08 18:07:28 +02:00
filipriec
a3dd6fa95b now fully functional without a steel decimal crate, we are only importing it via cargo, because steel decimal is a separate published crate now 2025-07-08 18:02:32 +02:00
filipriec
433d87c96d stuff around publishing crate successfuly done 2025-07-07 22:08:38 +02:00
filipriec
aff4383671 tests are passing well now 2025-07-07 20:29:51 +02:00
filipriec
b7c8f6b1a2 tests in the steel decimal crate with serious issue fixed 2025-07-07 19:24:08 +02:00
filipriec
3443839ba4 placing them properly 2025-07-07 18:47:50 +02:00
filipriec
6c31d48f3b steel decimal needs readme before being published to the crates io 2025-07-07 18:08:45 +02:00
filipriec
1770292fd8 all the tests are now passing perfectly well 2025-07-07 15:35:33 +02:00
filipriec
afdd5c5740 parser finally fixed 2025-07-07 15:07:08 +02:00
filipriec
11487f0833 more fixes, still not done yet 2025-07-07 13:32:06 +02:00
filipriec
4d5d22d0c2 precision to steel decimal crate implemented 2025-07-07 00:31:13 +02:00
filipriec
314a957922 fixes, now .0 from rust decimal is being discussed 2025-07-06 20:53:40 +02:00
filipriec
4c57b562e6 more fixes, not there yet tho 2025-07-05 10:00:04 +02:00
filipriec
a757acf51c we are now fully using steel decimal crate inside of the server crate 2025-07-04 13:11:47 +02:00
filipriec
f4a23be1a2 steel decimal crate with a proper setup, needs so much work to be done to be finished 2025-07-04 11:56:21 +02:00
filipriec
93c67ffa14 steel decimal crate implemented 2025-07-02 16:31:15 +02:00
filipriec
d1ebe4732f steel with decimal math, saving before separating steel to a separate crate 2025-07-02 14:44:37 +02:00
filipriec
7b7f3ca05a more tests for the frontend 2025-06-26 20:25:59 +02:00
filipriec
234613f831 more frontend tests 2025-06-26 20:03:47 +02:00
filipriec
f6d84e70cc testing frontend to connect to the backend in the form page 2025-06-26 19:19:08 +02:00
filipriec
5cd324b6ae client tests now have a proper structure 2025-06-26 11:56:18 +02:00
filipriec
a7457f5749 frontend tui tests 2025-06-25 23:00:51 +02:00
filipriec
a5afc75099 crit bug fixed 2025-06-25 17:33:37 +02:00
filipriec
625c9b3e09 adresar and uctovnictvo are now wiped out of the existence 2025-06-25 16:14:43 +02:00
filipriec
e20623ed53 removing adresar and uctovnictvo hardcoded way of doing things from the project entirely 2025-06-25 13:52:00 +02:00
filipriec
aa9adf7348 removed unused tests 2025-06-25 13:50:08 +02:00
filipriec
2e82aba0d1 full passer on the tables data now 2025-06-25 13:46:35 +02:00
filipriec
b7a3f0f8d9 count is now fixed and working properly 2025-06-25 12:40:27 +02:00
filipriec
38c82389f7 count gets a full passer in tests 2025-06-25 12:37:37 +02:00
filipriec
cb0a2bee17 get by count well tested 2025-06-25 11:47:25 +02:00
filipriec
dc99131794 ordering of the tests for tables data 2025-06-25 10:34:58 +02:00
filipriec
5c23f61a10 get method passing without any problem 2025-06-25 09:44:38 +02:00
filipriec
f87e3c03cb get test updated, working now 2025-06-25 09:16:32 +02:00
filipriec
d346670839 tests for delete endpoint are passing all the tests 2025-06-25 09:04:58 +02:00
filipriec
560d8b7234 delete tests robustness not yet fully working 2025-06-25 08:44:36 +02:00
filipriec
b297c2b311 working full passer on put request 2025-06-24 20:06:39 +02:00
filipriec
d390c567d5 more tests 2025-06-24 00:46:51 +02:00
filipriec
029e614b9c more put tests 2025-06-24 00:45:37 +02:00
filipriec
f9a78e4eec the tests for the put endpoint is now being tested and passing but its not what i would love 2025-06-23 23:25:45 +02:00
filipriec
d8758f7531 we are passing all the tests now properly with the table definition and the post tables data now 2025-06-23 13:52:29 +02:00
filipriec
4e86ecff84 its now passing all the tests 2025-06-22 23:05:38 +02:00
filipriec
070d091e07 robustness, one test still failing, will fix it 2025-06-22 23:02:41 +02:00
filipriec
7403b3c3f8 4 tests are failing 2025-06-22 22:15:08 +02:00
filipriec
1b1e7b7205 robust decimal solution to push tables data to the backend 2025-06-22 22:08:22 +02:00
filipriec
1b8f19f1ce tables data tests are now generalized, needs a bit more fixes, 6/6 are passing 2025-06-22 16:10:24 +02:00
filipriec
2a14eadf34 fixed compatibility layer to old tests git status REMOVE IN THE FUTURE 2025-06-22 14:00:49 +02:00
filipriec
fd36cd5795 tests are now passing fully 2025-06-22 13:13:20 +02:00
filipriec
f4286ac3c9 more changes and more fixes, 3 more tests to go 2025-06-22 12:48:36 +02:00
filipriec
92d5eb4844 needs last one to be fixed, otherwise its getting perfect 2025-06-21 23:57:52 +02:00
filipriec
87b9f6ab87 more fixes 2025-06-21 21:43:39 +02:00
filipriec
06d98aab5c 5 more tests to go 2025-06-21 21:01:49 +02:00
filipriec
298f56a53c tests are passing better than ever before, its looking decent actually nowc 2025-06-21 16:18:32 +02:00
filipriec
714a5f2f1c tests compiled 2025-06-21 15:11:27 +02:00
filipriec
4e29d0084f compiled with the profile to be schemas 2025-06-21 10:37:37 +02:00
filipriec
63f1b4da2e changing profile id to schema in the whole project 2025-06-21 09:57:14 +02:00
filipriec
9477f53432 big change in the schema, its profile names now and not gen 2025-06-20 22:31:49 +02:00
filipriec
ed786f087c changing test for a huge change in a project 2025-06-20 20:07:07 +02:00
filipriec
8e22ea05ff improvements and fixing of the tests 2025-06-20 19:59:42 +02:00
filipriec
8414657224 gen isolated tables 2025-06-18 23:19:19 +02:00
filipriec
e25213ed1b tests are robusts running in parallel 2025-06-18 22:38:00 +02:00
filipriec
4843b0778c robust testing of the table definitions 2025-06-18 21:37:30 +02:00
filipriec
f5fae98c69 tests now working via make file 2025-06-18 14:44:38 +02:00
filipriec
6faf0a4a31 tests for table definitions 2025-06-17 22:46:04 +02:00
filipriec
011fafc0ff now working proper types 2025-06-17 17:31:11 +02:00
filipriec
8ebe74484c now not creating tables with the year_ prefix and living in the gen schema by default 2025-06-17 11:45:55 +02:00
filipriec
3eb9523103 you are going to kill me but please dont, i just cleaned up migration file and its 100% valid, do not use any version before this version and after this version so many things needs to be changed so haha... im ashamed but i love it at the same time 2025-06-17 11:21:33 +02:00
filipriec
3dfa922b9e unimportant change 2025-06-17 10:27:22 +02:00
filipriec
248d54a30f accpeting now null in the post table data as nothing 2025-06-16 22:51:05 +02:00
filipriec
b30fef4ccd post doesnt work, but refactored code displays the autocomplete at least, needs fix 2025-06-16 16:42:25 +02:00
filipriec
a9c4527318 complete redesign oh how client is displaying data 2025-06-16 16:10:24 +02:00
filipriec
c31f08d5b8 fixing post with links 2025-06-16 14:42:49 +02:00
filipriec
9e0fa9ddb1 autocomplete now autocompleting data not just id 2025-06-16 11:54:54 +02:00
filipriec
8fcd28832d better answer parsing 2025-06-16 11:14:04 +02:00
filipriec
cccf029464 autocomplete is now perfectc 2025-06-16 10:52:28 +02:00
filipriec
512e7fb9e7 suggestions in the dropdown menu now works amazingly well 2025-06-15 23:11:27 +02:00
filipriec
0e69df8282 empty search is now allowed 2025-06-15 18:36:01 +02:00
filipriec
eb5532c200 finally works as i wanted it to 2025-06-15 14:23:19 +02:00
filipriec
49ed1dfe33 trash 2025-06-15 13:52:43 +02:00
filipriec
62d1c3f7f5 suggestion works, but not exactly, needs more stuff 2025-06-15 13:35:45 +02:00
filipriec
b49dce3334 dropdown is being triggered 2025-06-15 12:15:25 +02:00
filipriec
8ace9bc4d1 links are now in the get method of the backend 2025-06-14 18:09:30 +02:00
filipriec
ce490007ed fixing server responses, now push data links fixed 2025-06-14 17:39:59 +02:00
filipriec
eb96c64e26 links to the other tables 2025-06-14 12:47:59 +02:00
filipriec
2ac96a8486 working perfectly well with the search and debug in the status line when enabled 2025-06-13 20:46:33 +02:00
filipriec
b8e6cc22af way better debugging in the status line now 2025-06-13 16:57:58 +02:00
filipriec
634a01f618 service search changed 2025-06-13 16:53:39 +02:00
filipriec
6abea062ba ui debug in status line 2025-06-13 15:26:45 +02:00
filipriec
f50887a326 outputting to the status line 2025-06-13 13:38:40 +02:00
filipriec
3c0af05a3c the search tui is not working yet 2025-06-11 22:08:23 +02:00
filipriec
c9131d4457 working but not properly displaying search results 2025-06-11 16:46:55 +02:00
filipriec
2af79a3ef2 search added, but unable to trigger it yet 2025-06-11 16:24:42 +02:00
filipriec
afd9228efa json in the otput of the tantivy 2025-06-11 14:07:22 +02:00
filipriec
495d77fda5 4 ngram tokenizer, not doing anything elsekeeping this as is 2025-06-10 23:56:31 +02:00
filipriec
679bb3b6ab search in common module, now fixing layer mixing issue 2025-06-10 13:47:18 +02:00
filipriec
350c522d19 better search but still has some flaws. It at least works, even tho its not perfect. Needs more testing, but im pretty happy with it rn, keeping it this way 2025-06-10 00:22:31 +02:00
filipriec
4760f42589 slovak language tokenized search 2025-06-09 16:36:18 +02:00
filipriec
50d15e321f automatic indexing is working perfectly well 2025-06-08 23:26:13 +02:00
filipriec
a3e7fd8f0a forgotten changes to the lib that are needed for a single port of two crates working separately 2025-06-08 22:40:46 +02:00
filipriec
645172747a we are now running search server at the same port as the whole backend service 2025-06-08 21:53:48 +02:00
filipriec
7c4ac1eebc search via tantivy on different grpc port works perfectly well now 2025-06-08 21:28:10 +02:00
filipriec
4b4301ad49 fixed now it all compiled successfuly 2025-06-08 20:14:44 +02:00
filipriec
b60e03eb70 search crate compiled, lets get to fixing all the other errors 2025-06-08 20:10:57 +02:00
filipriec
2c7bda3ff1 search crate created 2025-06-08 16:25:56 +02:00
filipriec
eeaaa3635b crucial dialog reloading bug fixed for good(hardest bug had a single line of code fix) 2025-06-08 10:53:46 +02:00
filipriec
e61cbb3956 features ui debug is now working perfectly well, it debugs the rerender flags 2025-06-08 09:26:56 +02:00
filipriec
f9841f2ef3 centralizing logic in the formstate 2025-06-08 00:00:37 +02:00
filipriec
dc232b2523 form is now working as expected 2025-06-07 15:25:35 +02:00
filipriec
b086b3e236 hardcoded firma is being removed part2 2025-06-07 15:12:00 +02:00
filipriec
387e1a0fe0 displaying data properly, fixing hardcoded backend to firma part one 2025-06-07 14:05:35 +02:00
filipriec
08e01d41f2 now properly not displaying in the frontend form fields that should be hidden from the user 2025-06-07 09:37:12 +02:00
filipriec
f5edf52571 working find palette now properly well 2025-06-07 09:16:43 +02:00
filipriec
02c62213c3 making select from the find file to work, not yet working, needs more redesign in how select is working 2025-06-06 23:44:29 +02:00
filipriec
d0722fbbbe working well now, creation of the columns 2025-06-06 20:18:51 +02:00
filipriec
4ec569342d hidden from the user now in the form 2025-06-03 18:47:14 +02:00
filipriec
9540d9ccb9 table definitions are now forbidden for user to allocated rust autoallocated table columns 2025-06-03 18:46:57 +02:00
filipriec
6b5cbe854b now working with the gen schema in the database 2025-06-02 12:39:23 +02:00
filipriec
59ed52814e compiled, needs other fixes 2025-06-02 12:08:16 +02:00
filipriec
3488ab4f6b hardcoded adresar to general form 2025-06-02 10:32:39 +02:00
filipriec
6e2fc5349b code cleanup 2025-05-31 23:02:09 +02:00
filipriec
ea88c2686d tabbing now adds / if there is nothing to tab to 2025-05-30 23:43:49 +02:00
filipriec
3df4baec92 tabbing now works perfectly well 2025-05-30 23:36:53 +02:00
filipriec
ff74e1aaa1 it works amazingly well now, we can select the table name via command line 2025-05-30 22:46:32 +02:00
filipriec
b0c865ab76 workig suggestion menu 2025-05-29 19:46:58 +02:00
filipriec
3dbc086f10 overriding overflows by using empty spaces as letters 2025-05-29 19:32:48 +02:00
filipriec
e9b4b34fb4 fixed height of the find file 2025-05-29 19:02:02 +02:00
filipriec
668eeee197 navigation in the menu but needs refactoring 2025-05-29 16:11:41 +02:00
filipriec
799d8471c9 open menu in command mode now implemented 2025-05-28 19:09:55 +02:00
filipriec
f77c16dec9 temp fix, before implementing C-x C-f 2025-05-28 15:53:33 +02:00
filipriec
45026cac6a table schema is gen now 2025-05-28 15:40:17 +02:00
filipriec
edf6ab5bca gen schema being created 2025-05-28 13:10:08 +02:00
filipriec
462b1f14e2 generated tables are now in gen schema, breaking change, needs crucial fixes NOW 2025-05-27 22:21:40 +02:00
filipriec
7a8f18b116 cargo fix 2025-05-26 22:28:58 +02:00
filipriec
d255e4abb6 proper postiion of the cursor when using sql 2025-05-26 20:53:05 +02:00
filipriec
b770240f0d better autocomplete 2025-05-26 20:43:58 +02:00
filipriec
43b064673b autocomplete is now powerful 2025-05-26 20:22:47 +02:00
filipriec
bf2726c151 tablenames added properly well 2025-05-26 19:51:48 +02:00
filipriec
f3cd921c76 we are suggesting properly table column names now 2025-05-26 19:42:23 +02:00
filipriec
913f6b6b64 broken autocomplete in the add_logic, but its usable, we are keeping it as is, there is nothing more we can do 2025-05-26 16:37:01 +02:00
filipriec
3463a52960 working autocomplete, need more fixes soon 2025-05-26 11:54:28 +02:00
filipriec
116db3566f intro buffer can be killed now also 2025-05-25 22:37:27 +02:00
filipriec
32210a5f7c killing of the buffer now works amazingly well 2025-05-25 22:24:26 +02:00
filipriec
d8f9372bbd killing buffers 2025-05-25 22:02:18 +02:00
filipriec
6e1997fd9d storage in the system is now storing log in details properly well 2025-05-25 21:33:24 +02:00
filipriec
4e7213d1aa automcomplete running and working now 2025-05-25 19:26:30 +02:00
filipriec
5afb427bb4 neccessary hardcode changes to fix the last changes introducing bug. general solution soon 2025-05-25 19:16:42 +02:00
filipriec
685361a11a server table structure response is now generalized 2025-05-25 18:57:13 +02:00
filipriec
bd7c97ca91 required table to access logic 2025-05-25 17:53:06 +02:00
filipriec
81235c67dc add script now has a proper way of doing things 2025-05-25 15:46:06 +02:00
filipriec
65e8e03224 better and better add script 2025-05-25 15:27:41 +02:00
filipriec
85eb3adec7 logic is being implemented properly well 2025-05-25 15:09:38 +02:00
filipriec
5d0f958a68 working cursor tracking in the add_table 2025-05-25 14:08:50 +02:00
filipriec
b82f50b76b movement in the add_table now fixed 2025-05-25 12:40:19 +02:00
filipriec
0ab11a9bf9 add table movement adjustements 2025-05-25 12:27:30 +02:00
filipriec
d28c310704 forbid jump from the last to the first and vice versa 2025-05-25 11:39:25 +02:00
filipriec
2e1d7fdf2b admin panel fixed completely 2025-05-25 11:26:19 +02:00
filipriec
82e96f7b86 vim or default mode workin properly now 2025-05-24 19:25:35 +02:00
filipriec
7229e2abbd weird highlight is gone 2025-05-24 18:53:06 +02:00
filipriec
4e35043da0 vim keybinings are now working properly well 2025-05-24 18:41:41 +02:00
filipriec
56fe1c2ccc text area working now perfectly well 2025-05-24 16:34:59 +02:00
filipriec
a874edf2a1 text area on focus is now big 2025-05-24 14:24:19 +02:00
filipriec
9d55ec3e43 h and l movements are now working 2025-05-23 16:59:12 +02:00
filipriec
05580ac978 better and better 2025-05-23 15:40:16 +02:00
filipriec
667eb4809d compiled but the readonly and edit mode is not working 2025-05-23 15:22:21 +02:00
filipriec
58fdaa8298 add logic, not working tho 2025-05-23 13:34:49 +02:00
filipriec
5478a2ac27 server changes for the ID in the tree 2025-05-23 13:34:39 +02:00
filipriec
ad37990da9 server is being licensed as AGPL instead of GPL 2025-05-21 10:29:22 +02:00
filipriec
66824030f2 refresh of the admin panel after adding the table 2025-04-23 12:31:47 +02:00
filipriec
90ca8cf97c working dialog is now at the correct place 2025-04-23 12:13:56 +02:00
filipriec
3c8ea28da1 dialog on add table save working 2025-04-23 12:04:54 +02:00
filipriec
5c352eb863 tracing running only when enabled 2025-04-23 11:29:00 +02:00
filipriec
8c312bc163 tracing on add_table 2025-04-23 11:02:17 +02:00
filipriec
6fa8b06063 grpc post request to the table definition from add table, not working, major bug, needs debugging to make it work 2025-04-22 23:22:59 +02:00
filipriec
2992f122bc PROTOBUF CHANGED I HOPE IT WAS ONLY A MISTAKE OTHERWISE IM SCREWING SOMETHING UP AND I HAVE NO CLUE WHAT 2025-04-22 22:38:52 +02:00
filipriec
e507993065 ui imporvements 2025-04-22 21:21:49 +02:00
filipriec
6f22aad6f4 working perfectly well admin panel for admin 2025-04-22 18:17:41 +02:00
filipriec
097264040f admin_panel for admins have now some adjustements 2025-04-22 18:03:22 +02:00
filipriec
2c03ee6af0 add_table ready to be used as a post request, only small changes are needed 2025-04-22 17:36:00 +02:00
filipriec
ec596b2ada indexing now works amazingly well 2025-04-22 16:03:16 +02:00
filipriec
b01ba0b2d9 cargo fix on the server 2025-04-19 19:00:14 +02:00
filipriec
f74a6ef093 upgraded to tonic 0.13 2025-04-19 18:55:26 +02:00
filipriec
ee687fafbe upgrading and updating my repo 2025-04-19 18:36:26 +02:00
filipriec
60ba17cfea TCP connection creation overhead fixed by cloning once created TCP connection. Huge performance gain on login and register. Utilizing gRPC 2025-04-19 15:54:58 +02:00
filipriec
8b3aa5891e sidebar redesigned correctly and properly 2025-04-19 00:53:57 +02:00
filipriec
3ff9399b81 cargo fix 2025-04-18 23:27:14 +02:00
filipriec
d18f7862ab login refactored 2025-04-18 23:26:48 +02:00
filipriec
dc6c1ce43c refactor happend and its perfectly fine 2025-04-18 23:16:22 +02:00
filipriec
8d1adccec6 async registration working 2025-04-18 22:36:30 +02:00
filipriec
420ce71fb2 asnyc gRPC call that needs nonblocking waiting operation with redraws docs describing setup on what to do 2025-04-18 22:15:08 +02:00
filipriec
8e5a269ff0 finally working amazingly well 2025-04-18 22:09:07 +02:00
filipriec
f357d6f0ee working blocked constant redraws 2025-04-18 21:43:54 +02:00
filipriec
a0467d17a8 cleanup 2025-04-18 21:11:49 +02:00
filipriec
ef3ecfc73f we compiled 2025-04-18 21:04:36 +02:00
filipriec
d3fcb23e22 fixing, nothing works lmao 2025-04-18 20:48:39 +02:00
filipriec
5a029283a1 anyhow used 2025-04-18 19:04:05 +02:00
filipriec
09ccad2bd4 time for changing it all 2025-04-18 18:11:12 +02:00
filipriec
bdcc10bd40 auth verification of emptiness 2025-04-18 17:08:38 +02:00
filipriec
2a1fafc3f9 warnings fixed 2025-04-18 16:17:02 +02:00
filipriec
6010b9a0af fixing warnings 2025-04-18 15:50:47 +02:00
filipriec
11e8f87fe6 cargo fix 2025-04-18 14:59:34 +02:00
filipriec
14b81cba19 fixed, removed log library 2025-04-18 14:58:05 +02:00
filipriec
2b37de3b4d login waiting dialog works, THIS COMMIT NEEDS TO BE REFACTORED 2025-04-18 14:44:04 +02:00
filipriec
73d9a6367c canvas edit mode movement fixed 2025-04-18 13:10:39 +02:00
filipriec
c90233b56f dialog in add_table is now working properly well 2025-04-18 12:29:29 +02:00
filipriec
39dcf38462 add_table dialog is now working properly well 2025-04-18 12:20:08 +02:00
filipriec
efa27cd2dd highlight restored 2025-04-18 11:35:15 +02:00
filipriec
ca231964f2 fullscreen on enter for add_table 2025-04-18 11:31:50 +02:00
filipriec
2bb83cb990 gui changes 2025-04-18 11:29:11 +02:00
filipriec
305bcfcf62 deletion of the selected works 2025-04-18 11:15:15 +02:00
filipriec
92a9011f27 buttons are only border and text colors now in add_table 2025-04-18 11:04:13 +02:00
filipriec
e64cebdfc2 deselect have no highlight now 2025-04-18 10:52:18 +02:00
filipriec
0db426d278 h l movement in add_table fixed for now 2025-04-18 10:48:52 +02:00
filipriec
6bfef1c7a0 exit in the general mode is on esc and select is not escaping in the add_table anymore 2025-04-18 10:37:52 +02:00
filipriec
f50fe788cb scrolling in the add table doesnt highlight first item anymore 2025-04-18 09:27:28 +02:00
filipriec
4db78ecf1b working properly well to distinguish enter in the edit mode now 2025-04-18 00:15:34 +02:00
filipriec
f22dd7749f proper scroll behaviour on the page now 2025-04-17 23:37:58 +02:00
filipriec
75af0c3be1 the profile name is now passed from admin panel to the add table page 2025-04-17 21:59:19 +02:00
filipriec
6f36b84f85 delete selected button now in the add table page working 2025-04-17 21:21:37 +02:00
filipriec
e723198b72 best ever, working as intended 2025-04-17 21:07:07 +02:00
filipriec
8f74febff1 combined, full width need a table name that is missing now 2025-04-17 20:41:48 +02:00
filipriec
d9bd6f8e1d working, but full width is not working, lets combine it with the full width now 2025-04-17 20:17:21 +02:00
filipriec
bf55417901 professional layout working 2025-04-17 19:50:48 +02:00
filipriec
9511970a1a removed placeholder 2025-04-17 19:12:28 +02:00
filipriec
5c8557b369 adding stuff to the column 2025-04-17 19:06:54 +02:00
filipriec
5e47c53fcf canvas required fields 2025-04-17 18:28:06 +02:00
filipriec
f7493a8bc4 canvas properly updating table name 2025-04-17 18:17:01 +02:00
filipriec
4f39b93edd logic of the add button, needs redesign 2025-04-17 16:48:03 +02:00
filipriec
ff8b4eb0f6 canvas now working properly well 2025-04-17 15:40:07 +02:00
filipriec
e921862a7f compiled, still not working for canvas 2025-04-17 14:41:09 +02:00
filipriec
4d7177f15a mistake fixed 2025-04-17 14:21:39 +02:00
filipriec
c5d7f56399 readonly and edit functionality to add table 2025-04-17 14:14:36 +02:00
filipriec
57f789290d needs improvements but at least it looks like it exists 2025-04-17 11:53:31 +02:00
filipriec
f34317e504 from template to the working page 2025-04-17 11:11:33 +02:00
filipriec
8159a84447 movement 2025-04-16 23:31:28 +02:00
filipriec
f4db0e384c add table page1 2025-04-16 22:23:30 +02:00
filipriec
69953401b1 NEW PAGE ADD TABLE 2025-04-16 22:07:07 +02:00
filipriec
93a3c246c6 buttons are now added in the admin panel 2025-04-16 21:43:21 +02:00
filipriec
6505e18b0b working admin panel, needs to do buttons for navigation next 2025-04-16 21:22:34 +02:00
filipriec
51ab73014f removing debug statement 2025-04-16 19:19:15 +02:00
filipriec
05d9e6e46b working selection in the admin panel for the admin perfectly well 2025-04-16 19:15:05 +02:00
filipriec
8ea9965ee3 temp debug 2025-04-16 19:10:02 +02:00
filipriec
486df65aa3 better admin panel, main problem not fixed, needs fix now 2025-04-16 18:53:09 +02:00
filipriec
8044696d7c needed fix done 2025-04-16 18:33:00 +02:00
filipriec
04a7d86636 better movement 2025-04-16 16:30:11 +02:00
filipriec
d0e2f31ce8 admin panel improvements 2025-04-16 14:11:52 +02:00
filipriec
50fcb09758 admin panel admin width fixed 2025-04-16 13:58:34 +02:00
filipriec
6d3c09d57a CRUCIAL bug fixed in the admin panel non admin user 2025-04-16 13:43:44 +02:00
filipriec
cc994fb940 admin panel for admin 2025-04-16 13:21:59 +02:00
filipriec
eee12513dd admin panel from scratch 2025-04-16 12:56:57 +02:00
filipriec
055b6a0a43 admin panel bombarded like never before 2025-04-16 12:47:33 +02:00
filipriec
26b899df16 fixed highlight logic 2025-04-16 09:48:39 +02:00
filipriec
afc8e1a1e5 highlight mode to full line highlightmode 2025-04-16 09:08:40 +02:00
filipriec
b6c4d3308d its now using enum fully for the highlight mode 2025-04-16 00:11:41 +02:00
filipriec
af4567aa3d highlight is now working properly well, can keep on going 2025-04-15 23:46:57 +02:00
filipriec
415bc2044d highlight through many lines working 2025-04-15 23:07:55 +02:00
filipriec
91ad2b0caf FIXED CRUCIAL BUG of two same shortcuts defined in the config 2025-04-15 22:28:12 +02:00
filipriec
bc6471fa54 misname fixed, highlight kinda working 2025-04-15 21:38:32 +02:00
filipriec
0704668d8d mistakes in config.toml fixed, needs more fixes before the real implementation 2025-04-15 21:22:13 +02:00
filipriec
2e9f8815d2 HIGHLIGHT MODE 2025-04-15 21:17:04 +02:00
filipriec
f4689125e0 minor changes 2025-04-15 20:28:31 +02:00
filipriec
bbba67a253 buffer for form fix performed 2025-04-15 20:19:30 +02:00
filipriec
800b857e53 buffers are in the layers now 2025-04-15 19:42:12 +02:00
filipriec
921059bed8 buffer logic going hard, we are killing buffers from login and register now 2025-04-15 18:56:11 +02:00
filipriec
e8b585dc07 killing of the buffers now works 2025-04-15 18:43:36 +02:00
filipriec
afce27184a better defaults 2025-04-15 18:37:27 +02:00
filipriec
8d41beef0b killing of the buffer working 2025-04-15 18:33:42 +02:00
filipriec
5e482cd77b buffer close, not kill implemented yet 2025-04-15 18:29:59 +02:00
filipriec
09068fd4e5 FPS counter added 2025-04-15 18:11:26 +02:00
filipriec
d8c2b9089b fixes are now in place properly well 2025-04-15 17:52:58 +02:00
filipriec
bb577bc276 now buffer handling is global but not allowed from edit mode 2025-04-15 16:23:26 +02:00
filipriec
6267e3d593 working switching of the buffers properly well now 2025-04-15 16:03:14 +02:00
filipriec
c091a39802 compiled 2025-04-15 14:12:42 +02:00
filipriec
f94006dd08 buffer its independent, needs fixes 2025-04-15 13:57:17 +02:00
filipriec
f42790980d perfectly working buffer now 2025-04-15 00:18:42 +02:00
filipriec
779683de4b buffer movement is now working as I would only wish it would. Needs layers implementation, will do in the future 2025-04-15 00:10:28 +02:00
filipriec
aa8887318f history of buffers implemented now 2025-04-14 23:58:58 +02:00
filipriec
3ad8dc6490 better buffer list 2025-04-14 23:35:56 +02:00
filipriec
20f9fae141 buffer working 2025-04-14 22:34:22 +02:00
filipriec
ec062bbf24 its on the top of the screen now 2025-04-14 22:17:24 +02:00
filipriec
8745c9ea2f sidebar fixed bugs and buffer implementation 1 2025-04-14 22:14:01 +02:00
filipriec
0917654361 admin panel now distinguish admin and other roles of the user 2025-04-14 21:04:55 +02:00
filipriec
d892d1cfa0 role restricted admin panel 2025-04-14 20:50:02 +02:00
filipriec
e31138c250 DONE we have now intro state separate from others completely 2025-04-14 20:00:02 +02:00
filipriec
7cbe5ce8be intro movement fixed 2025-04-14 16:25:54 +02:00
filipriec
1c31d4cd1c moved introstate into the state folder 2025-04-14 16:24:21 +02:00
filipriec
990ec9317f admin panel tiny improvement. STARTING OF ADMIN PANEL BUILDING 2025-04-14 15:27:26 +02:00
filipriec
718ceac17e cargo fix 2025-04-14 15:04:24 +02:00
filipriec
d154ba6b89 we compiled for now 2025-04-14 14:28:36 +02:00
filipriec
f2a63476b3 continuation of the fixes 2025-04-14 14:05:20 +02:00
filipriec
adcd3b37fa fixing this 2025-04-14 13:23:09 +02:00
filipriec
71dabc1e37 very bald changes, still destroyed 2025-04-14 12:07:15 +02:00
filipriec
2d724876eb going directly into adminstate from appstate for the admin page. DESTROYED 2025-04-14 11:24:56 +02:00
filipriec
1927d1fa4d appstate moved to its folder also 2025-04-13 22:52:15 +02:00
filipriec
d995fab0e4 moved canvasstate to the pages folder 2025-04-13 22:45:26 +02:00
filipriec
b4135c1626 attempt for dropdown generalization, minor change really 2025-04-13 22:14:34 +02:00
filipriec
3d0a9f2082 we successfully compiled and wen from auth state to login state and auth state 2025-04-13 17:47:00 +02:00
filipriec
1dd5f685a6 authstate splitting into login state and auth state, quite before maddness 2025-04-13 17:06:59 +02:00
filipriec
c1c4394f94 ... at the end of the dialog truncation 2025-04-13 14:58:41 +02:00
filipriec
16d9fcdadc only important stuff in the response of the login 2025-04-13 14:09:57 +02:00
filipriec
5b1db01fe6 displaying username and username contained in the jwt now 2025-04-13 14:04:30 +02:00
filipriec
e856e9d6c7 response contains username but jwt is holding username also 2025-04-13 13:45:22 +02:00
filipriec
ad2c783870 improvements on the register.rs 2025-04-13 00:09:12 +02:00
filipriec
5e101bef14 tab is triggering the suggestion dropdown menu, but ctrl+n and ctrl+p only cycle through it 2025-04-12 23:56:14 +02:00
filipriec
149949ad99 better dropdown gui 2025-04-12 22:42:06 +02:00
filipriec
741cc952fa fixed more dropdown menu 2025-04-12 18:06:19 +02:00
filipriec
d2bb7a0bf4 forgotten cargo lock 2025-04-12 17:48:35 +02:00
filipriec
d4d5bcec9e width for the dropdown fixed 2025-04-12 17:41:37 +02:00
filipriec
b4053a7b94 working perfectly well 2025-04-12 17:34:17 +02:00
filipriec
7b27d00972 working suggestions but position is wrong 2025-04-12 16:22:07 +02:00
filipriec
f4d234089f much better, still not it 2025-04-12 15:52:12 +02:00
filipriec
50a329fc0d suggestions on tab, still not working yet 2025-04-12 15:31:29 +02:00
filipriec
6d6df7ca5c still not working autocomplete 2025-04-12 00:13:28 +02:00
filipriec
6b16068706 moved to auth_e functions for dropdown from edit mode file 2025-04-11 23:42:18 +02:00
filipriec
024a0de4af completion not working yet 2025-04-11 22:54:44 +02:00
filipriec
e145d63ba6 fixing autocomplete, bombarded code, nothing in here 2025-04-11 22:39:09 +02:00
filipriec
e7e8b0b3f6 reg proper movement 2025-04-11 21:42:59 +02:00
filipriec
a7389db674 trigger dropdown, not working at all, needs proper implementation, ready for debug 2025-04-11 15:40:50 +02:00
filipriec
cf1aa4fd2a register form now has optional field role 2025-04-11 13:51:05 +02:00
filipriec
0fd2a589eb register with optional role in the post request 2025-04-11 13:37:57 +02:00
filipriec
944131d5a6 from 4e01740a61 till now, fully integrated working register 2025-04-11 08:52:01 +02:00
filipriec
eff46ac9bf working read only mode in login and register properly workig now 2025-04-11 08:50:54 +02:00
filipriec
14f9b254b2 read only mode needs adjustement to 4 fields from 2 2025-04-10 23:04:25 +02:00
filipriec
337d6050ff common mode register fix 2025-04-10 22:18:01 +02:00
filipriec
d36348b84f gRPC implementation of a registration working 2025-04-10 21:14:32 +02:00
filipriec
dfb6f5b375 registration auth services added 2025-04-10 21:00:41 +02:00
filipriec
a9089bc2ff displaying register 2025-04-10 20:01:03 +02:00
filipriec
5f5d690ff3 compiled time to render register properly well 2025-04-10 19:32:49 +02:00
filipriec
431882ece9 register 2 2025-04-10 19:27:04 +02:00
filipriec
b51e76e366 register on the way 2025-04-10 18:50:54 +02:00
filipriec
4e01740a61 fixed exit in the login dialog 2025-04-10 16:34:29 +02:00
filipriec
e729ed9df3 compiled 2025-04-10 16:05:06 +02:00
filipriec
6b241304fb dialog login functionality 2025-04-10 15:36:43 +02:00
filipriec
3ed8764087 cargo fix 2025-04-10 14:45:16 +02:00
filipriec
df61b245ab back to main 2025-04-10 14:42:11 +02:00
filipriec
7a364d654c repaired 2025-04-10 14:22:30 +02:00
filipriec
0d1a0be1a0 dialog movement fixed 2025-04-10 14:13:39 +02:00
filipriec
5da9f5aaf4 better, but more dialog logic is needed 2025-04-10 13:45:36 +02:00
filipriec
2bec0f5850 dialog 2 2025-04-10 13:40:24 +02:00
filipriec
5bc28ec38b dialog 2025-04-10 13:37:15 +02:00
filipriec
8c7a0a1ec0 we compiled centralized system for select 2025-04-10 12:13:04 +02:00
filipriec
a8eef8107b cursor fixed 2025-04-07 23:39:27 +02:00
filipriec
75cd942f39 fixed and now fully functional 2025-04-07 23:06:58 +02:00
filipriec
fc04af148d minor error fix 2025-04-07 22:55:07 +02:00
filipriec
d7d7fd614b fixing more errors, last to go 2025-04-07 22:46:00 +02:00
filipriec
10e9c3ead0 minor changes 2025-04-07 21:32:02 +02:00
filipriec
70678432c6 BREAKING CHANGES updating gRPC based on the enum now 2025-04-07 21:27:01 +02:00
filipriec
bb103fac6c minor changes 2025-04-07 17:36:59 +02:00
filipriec
0e1fc3f5fa cursor is disabled instead of hidden now 2025-04-07 17:23:42 +02:00
filipriec
7830ebdb3b cursor hidden if not active 2025-04-07 17:16:36 +02:00
filipriec
b061dd3395 BREAKING UPDATE COUNT REMOVED FROM THE MAIN LOOP 2025-04-07 16:27:40 +02:00
filipriec
a6f2fa8a88 button highlight now working perfectly well 2025-04-07 14:05:28 +02:00
filipriec
37f12ea6f0 working needs a small fix 2025-04-07 13:24:22 +02:00
filipriec
e29b576102 removed unused imports 2025-04-07 12:19:35 +02:00
filipriec
b1b3cf6136 changes for revert save in the readonly mode and not only the edit mode finished. 2025-04-07 12:18:03 +02:00
filipriec
173c4c98b8 feat: Prevent form navigation with unsaved changes 2025-04-07 12:03:17 +02:00
filipriec
5a3067c8e5 edit to read_only mode without save 2025-04-07 10:13:39 +02:00
filipriec
803c748738 only to the top and to the bottom in the canvas, cant jump around anymore 2025-04-06 23:16:15 +02:00
filipriec
5879b40e8c exit menu buttons upon success 2025-04-05 19:42:29 +02:00
filipriec
c3decdac13 working dialog now better 2025-04-05 19:37:51 +02:00
filipriec
1baf89dde6 login with dialog menu 2025-04-05 17:52:37 +02:00
filipriec
0219dc0ede border fixed 2025-04-05 17:34:26 +02:00
filipriec
1c662888c6 minor changes 2025-04-05 17:21:07 +02:00
filipriec
7fd1c1e41d working inputting characters 2025-04-05 13:34:38 +02:00
filipriec
1eaa716f42 working now, able to switch between form_e and auth_e 2025-04-05 00:42:56 +02:00
filipriec
704bb7401a copied form_e to auth_e 2025-04-04 23:37:19 +02:00
filipriec
b0c3fdbdc6 added in here src/functions/modes/read_only/auth_ro.rs missing functions, not working tho 2025-04-04 23:34:56 +02:00
filipriec
1d0ceb7045 minor changes 2025-04-04 23:28:30 +02:00
filipriec
ce85a050d3 command mode handler moved 2025-04-04 22:34:04 +02:00
filipriec
94ecbcd639 moved common to common_mode 2025-04-04 20:49:12 +02:00
filipriec
ef1d8bcc9c fully moved now 2025-04-04 20:39:23 +02:00
filipriec
1a7bbdc541 moving back to tui nonmode functions for now 2025-04-04 20:37:02 +02:00
filipriec
3dc5a89459 splitting modes into functions and how it works vs logic to handle it all 2025-04-03 23:23:25 +02:00
filipriec
9f6268dbc9 minor change 2025-04-03 23:00:34 +02:00
filipriec
2e912dd261 moved to form_ro 2025-04-03 22:12:20 +02:00
filipriec
6d6fff474f switching now execute action functions left to right 2025-04-03 20:04:01 +02:00
filipriec
e725c70570 splitting read_only mode 2025-04-03 19:50:35 +02:00
filipriec
07aeecbbfc functions created and now working, lets move modes functions here now 2025-04-03 19:43:59 +02:00
filipriec
9bfac04c44 modes functions dir 2025-04-03 12:34:37 +02:00
filipriec
6acf0d6378 new function dir for functions 2025-04-03 12:32:53 +02:00
filipriec
1a09624242 e fixed 2025-04-02 22:26:35 +02:00
filipriec
9e36385e63 decimated edit mode also 2025-04-02 21:13:18 +02:00
filipriec
43edadde0c trying restoring functionality we are not in there correctly yet 2025-04-02 21:06:49 +02:00
374 changed files with 53503 additions and 8318 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

5
.gitignore vendored
View File

@@ -1,2 +1,7 @@
/target
.env
/tantivy_indexes
server/tantivy_indexes
steel_decimal/tests/property_tests.proptest-regressions
.direnv/
canvas/*.toml

1884
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,55 @@
[workspace]
members = ["client", "server", "common"]
members = ["client", "server", "common", "search", "canvas"]
resolver = "2"
[workspace.package]
# TODO: idk how to do the name, fix later
# name = "Multieko2"
version = "0.2.5"
# name = "komp_ac"
version = "0.4.2"
edition = "2021"
license = "GPL-3.0-or-later"
authors = ["Filip Priečinský <filippriec@gmail.com>"]
description = "Poriadny uctovnicky software."
readme = "README.md"
repository = "https://gitlab.com/filipriec/multieko2"
repository = "https://gitlab.com/filipriec/komp_ac"
categories = ["command-line-interface"]
# [workspace.metadata]
# TODO:
# documentation = "https://docs.rs/accounting-client"`
# documentation = "https://docs.rs/accounting-client"
[workspace.dependencies]
# Async and gRPC
tokio = { version = "1.44.2", features = ["full"] }
tonic = "0.13.0"
prost = "0.13.5"
async-trait = "0.1.88"
prost-types = "0.13.0"
# Data Handling & Serialization
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
time = "0.3.41"
# Utilities & Error Handling
anyhow = "1.0.98"
dotenvy = "0.15.7"
lazy_static = "1.5.0"
tracing = "0.1.41"
# Search crate
tantivy = "0.24.1"
# Steel_decimal crate
rust_decimal = { version = "1.37.2", features = ["maths", "serde"] }
rust_decimal_macros = "1.37.1"
thiserror = "2.0.12"
regex = "1.11.1"
# Canvas crate
ratatui = { version = "0.29.0", features = ["crossterm"] }
crossterm = "0.28.1"
toml = "0.8.20"
unicode-width = "0.2.0"
common = { path = "./common" }

View File

@@ -1,5 +1,7 @@
# Hey
This is only work in progress, until release 1.0.0 this is for development use cases only.
I run development like this:
Server:
@@ -12,3 +14,12 @@ Client:
cargo watch -x 'run --package client -- client'
```
Client with tracing:
```
ENABLE_TRACING=1 RUST_LOG=client=debug cargo watch -x 'run --package client -- client'
```
Client with debug that cant be traced
```
cargo run --package client --features ui-debug -- client
```

334
canvas/CANVAS_MIGRATION.md Normal file
View File

@@ -0,0 +1,334 @@
# Canvas Library Migration Guide
## Overview
This guide covers the migration from the legacy canvas library structure to the new clean, modular architecture. The new design separates core canvas functionality from autocomplete features, providing better type safety and maintainability.
## Key Changes
### 1. **Modular Architecture**
```
# Old Structure (LEGACY)
src/
├── state.rs # Mixed canvas + autocomplete
├── actions/edit.rs # Mixed concerns
├── gui/render.rs # Everything together
└── suggestions.rs # Legacy file
# New Structure (CLEAN)
src/
├── canvas/ # Core canvas functionality
│ ├── state.rs # CanvasState trait only
│ ├── actions/edit.rs # Canvas actions only
│ └── gui.rs # Canvas rendering
├── autocomplete/ # Rich autocomplete features
│ ├── state.rs # AutocompleteCanvasState trait
│ ├── types.rs # SuggestionItem, AutocompleteState
│ ├── actions.rs # Autocomplete actions
│ └── gui.rs # Autocomplete dropdown rendering
└── dispatcher.rs # Action routing
```
### 2. **Trait Separation**
- **CanvasState**: Core form functionality (navigation, input, validation)
- **AutocompleteCanvasState**: Optional rich autocomplete features
### 3. **Rich Suggestions**
Replaced simple string suggestions with typed, rich suggestion objects.
## Migration Steps
### Step 1: Update Import Paths
**Find and Replace these imports:**
```rust
# OLD IMPORTS
use canvas::CanvasState;
use canvas::CanvasAction;
use canvas::ActionContext;
use canvas::HighlightState;
use canvas::CanvasTheme;
use canvas::ActionDispatcher;
use canvas::ActionResult;
# NEW IMPORTS
use canvas::canvas::CanvasState;
use canvas::canvas::CanvasAction;
use canvas::canvas::ActionContext;
use canvas::canvas::HighlightState;
use canvas::canvas::CanvasTheme;
use canvas::dispatcher::ActionDispatcher;
use canvas::canvas::ActionResult;
```
**Complex imports:**
```rust
# OLD
use canvas::{CanvasAction, ActionDispatcher, ActionResult};
# NEW
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
```
### Step 2: Clean Up State Implementation
**Remove legacy methods from your CanvasState implementation:**
```rust
impl CanvasState for YourFormState {
// Keep all the core methods:
fn current_field(&self) -> usize { /* ... */ }
fn get_current_input(&self) -> &str { /* ... */ }
// ... etc
// ❌ REMOVE these legacy methods:
// fn get_suggestions(&self) -> Option<&[String]>
// fn get_selected_suggestion_index(&self) -> Option<usize>
// fn set_selected_suggestion_index(&mut self, index: Option<usize>)
// fn activate_suggestions(&mut self, suggestions: Vec<String>)
// fn deactivate_suggestions(&mut self)
}
```
### Step 3: Implement Rich Autocomplete (Optional)
**If you want rich autocomplete features:**
```rust
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem, AutocompleteState};
impl AutocompleteCanvasState for YourFormState {
type SuggestionData = YourDataType; // e.g., Hit, CustomRecord, etc.
fn supports_autocomplete(&self, field_index: usize) -> bool {
// Define which fields support autocomplete
matches!(field_index, 2 | 3 | 5) // Example: only certain fields
}
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
Some(&self.autocomplete)
}
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
Some(&mut self.autocomplete)
}
}
```
**Add autocomplete field to your state:**
```rust
pub struct YourFormState {
// ... existing fields
pub autocomplete: AutocompleteState<YourDataType>,
}
```
### Step 4: Migrate Suggestions
**Old way (simple strings):**
```rust
let suggestions = vec!["John".to_string(), "Jane".to_string()];
form_state.activate_suggestions(suggestions);
```
**New way (rich objects):**
```rust
let suggestions = vec![
SuggestionItem::new(
hit1, // Your data object
"John Doe (Manager) | ID: 123".to_string(), // What user sees
"123".to_string(), // What gets stored
),
SuggestionItem::simple(hit2, "Jane".to_string()), // Simple version
];
form_state.set_autocomplete_suggestions(suggestions);
```
### Step 5: Update Rendering
**Old rendering:**
```rust
// Manual autocomplete rendering
if form_state.autocomplete_active {
render_autocomplete_dropdown(/* ... */);
}
```
**New rendering:**
```rust
// Canvas handles everything
use canvas::canvas::render_canvas;
let active_field_rect = render_canvas(f, area, form_state, theme, edit_mode, highlight_state);
// Optional: Rich autocomplete (if implementing AutocompleteCanvasState)
if form_state.is_autocomplete_active() {
if let Some(autocomplete_state) = form_state.autocomplete_state() {
canvas::autocomplete::render_autocomplete_dropdown(
f, f.area(), active_field_rect.unwrap(), theme, autocomplete_state
);
}
}
```
### Step 6: Update Method Calls
**Replace legacy method calls:**
```rust
# OLD
form_state.deactivate_suggestions();
# NEW - Option A: Add your own method
impl YourFormState {
pub fn deactivate_autocomplete(&mut self) {
self.autocomplete_active = false;
self.autocomplete_suggestions.clear();
self.selected_suggestion_index = None;
}
}
form_state.deactivate_autocomplete();
# NEW - Option B: Use rich autocomplete trait
form_state.deactivate_autocomplete(); // If implementing AutocompleteCanvasState
```
## Benefits of New Architecture
### 1. **Clean Separation of Concerns**
- Canvas: Form rendering, navigation, input handling
- Autocomplete: Rich suggestions, dropdown management, async loading
### 2. **Type Safety**
```rust
// Old: Stringly typed
let suggestions: Vec<String> = vec!["user1".to_string()];
// New: Fully typed with your domain objects
let suggestions: Vec<SuggestionItem<UserRecord>> = vec![
SuggestionItem::new(user_record, display_text, stored_value)
];
```
### 3. **Rich UX Capabilities**
- **Display vs Storage**: Show "John Doe (Manager)" but store user ID
- **Loading States**: Built-in spinner/loading indicators
- **Async Support**: Designed for async suggestion fetching
- **Display Overrides**: Show friendly text while storing normalized data
### 4. **Future-Proof**
- Easy to add new autocomplete features
- Canvas features don't interfere with autocomplete
- Modular: Use only what you need
## Advanced Features
### Display Overrides
Perfect for foreign key relationships:
```rust
// User selects "John Doe (Manager) | ID: 123"
// Field stores: "123" (for database)
// User sees: "John Doe" (friendly display)
impl CanvasState for FormState {
fn get_display_value_for_field(&self, index: usize) -> &str {
if let Some(display_text) = self.link_display_map.get(&index) {
return display_text.as_str(); // Shows "John Doe"
}
self.inputs().get(index).map(|s| s.as_str()).unwrap_or("") // Shows "123"
}
fn has_display_override(&self, index: usize) -> bool {
self.link_display_map.contains_key(&index)
}
}
```
### Progressive Enhancement
Start simple, add features when needed:
```rust
// Week 1: Basic usage
SuggestionItem::simple(data, "John".to_string());
// Week 5: Rich display
SuggestionItem::new(data, "John Doe (Manager)".to_string(), "John".to_string());
// Week 10: Store IDs, show names
SuggestionItem::new(user, "John Doe (Manager)".to_string(), "123".to_string());
```
## Breaking Changes Summary
1. **Import paths changed**: Add `canvas::` or `dispatcher::` prefixes
2. **Legacy suggestion methods removed**: Replace with rich autocomplete or custom methods
3. **No more simple suggestions**: Use `SuggestionItem` for typed suggestions
4. **Trait split**: `AutocompleteCanvasState` is now separate and optional
## Troubleshooting
### Common Compilation Errors
**Error**: `no method named 'get_suggestions' found`
**Fix**: Remove legacy method from `CanvasState` implementation
**Error**: `no 'CanvasState' in the root`
**Fix**: Change `use canvas::CanvasState` to `use canvas::canvas::CanvasState`
**Error**: `trait bound 'FormState: CanvasState' is not satisfied`
**Fix**: Make sure your state properly implements the new `CanvasState` trait
### Migration Checklist
- [ ] Updated all import paths
- [ ] Removed legacy methods from CanvasState implementation
- [ ] Added custom autocomplete methods if needed
- [ ] Updated suggestion usage to SuggestionItem
- [ ] Updated rendering calls
- [ ] Tested form functionality
- [ ] Tested autocomplete functionality (if using)
## Example: Complete Migration
**Before:**
```rust
use canvas::{CanvasState, CanvasAction};
impl CanvasState for FormState {
fn get_suggestions(&self) -> Option<&[String]> { /* ... */ }
fn deactivate_suggestions(&mut self) { /* ... */ }
// ... other methods
}
```
**After:**
```rust
use canvas::canvas::{CanvasState, CanvasAction};
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem};
impl CanvasState for FormState {
// Only core canvas methods, no suggestion methods
fn current_field(&self) -> usize { /* ... */ }
fn get_current_input(&self) -> &str { /* ... */ }
// ... other core methods only
}
impl AutocompleteCanvasState for FormState {
type SuggestionData = Hit;
fn supports_autocomplete(&self, field_index: usize) -> bool {
self.fields[field_index].is_link
}
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
Some(&self.autocomplete)
}
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
Some(&mut self.autocomplete)
}
}
```
This migration results in cleaner, more maintainable, and more powerful code!

31
canvas/Cargo.toml Normal file
View File

@@ -0,0 +1,31 @@
[package]
name = "canvas"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description.workspace = true
readme.workspace = true
repository.workspace = true
categories.workspace = true
[dependencies]
common = { path = "../common" }
ratatui = { workspace = true, optional = true }
crossterm = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
toml = { workspace = true }
serde = { workspace = true }
unicode-width.workspace = true
thiserror = { workspace = true }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
[dev-dependencies]
tokio-test = "0.4.4"
[features]
default = []
gui = ["ratatui"]

337
canvas/README.md Normal file
View File

@@ -0,0 +1,337 @@
# Canvas 🎨
A reusable, type-safe canvas system for building form-based TUI applications with vim-like modal editing.
## ✨ Features
- **Type-Safe Actions**: No more string-based action names - everything is compile-time checked
- **Generic Design**: Implement `CanvasState` once, get navigation, editing, and suggestions for free
- **Vim-Like Experience**: Modal editing with familiar keybindings
- **Suggestion System**: Built-in autocomplete and suggestions support
- **Framework Agnostic**: Works with any TUI framework or raw terminal handling
- **Async Ready**: Full async/await support for modern Rust applications
- **Batch Operations**: Execute multiple actions atomically
- **Extensible**: Custom actions and feature-specific handling
## 🚀 Quick Start
Add to your `Cargo.toml`:
```toml
cargo add canvas
```
Implement the `CanvasState` trait:
```rust
use canvas::prelude::*;
#[derive(Debug)]
struct LoginForm {
current_field: usize,
cursor_pos: usize,
username: String,
password: String,
has_changes: bool,
}
impl CanvasState for LoginForm {
fn current_field(&self) -> usize { self.current_field }
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
fn get_current_input(&self) -> &str {
match self.current_field {
0 => &self.username,
1 => &self.password,
_ => "",
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_field {
0 => &mut self.username,
1 => &mut self.password,
_ => unreachable!(),
}
}
fn inputs(&self) -> Vec<&String> { vec![&self.username, &self.password] }
fn fields(&self) -> Vec<&str> { vec!["Username", "Password"] }
fn has_unsaved_changes(&self) -> bool { self.has_changes }
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
}
```
Use the type-safe action dispatcher:
```rust
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut form = LoginForm::new();
let mut ideal_cursor = 0;
// Type a character - compile-time safe!
ActionDispatcher::dispatch(
CanvasAction::InsertChar('h'),
&mut form,
&mut ideal_cursor,
).await?;
// Move to next field
ActionDispatcher::dispatch(
CanvasAction::NextField,
&mut form,
&mut ideal_cursor,
).await?;
// Batch operations
let actions = vec![
CanvasAction::InsertChar('p'),
CanvasAction::InsertChar('a'),
CanvasAction::InsertChar('s'),
CanvasAction::InsertChar('s'),
];
ActionDispatcher::dispatch_batch(actions, &mut form, &mut ideal_cursor).await?;
Ok(())
}
```
## 🎯 Type-Safe Actions
The Canvas system uses strongly-typed actions instead of error-prone strings:
```rust
// ✅ Type-safe - impossible to make typos
ActionDispatcher::dispatch(CanvasAction::MoveLeft, &mut form, &mut cursor).await?;
// ❌ Old way - runtime errors waiting to happen
execute_edit_action("move_left", key, &mut form, &mut cursor).await?;
execute_edit_action("move_leftt", key, &mut form, &mut cursor).await?; // Oops!
```
### Available Actions
```rust
pub enum CanvasAction {
// Character input
InsertChar(char),
// Deletion
DeleteBackward,
DeleteForward,
// Movement
MoveLeft, MoveRight, MoveUp, MoveDown,
MoveLineStart, MoveLineEnd,
MoveWordNext, MoveWordPrev,
// Navigation
NextField, PrevField,
MoveFirstLine, MoveLastLine,
// Suggestions
SuggestionUp, SuggestionDown,
SelectSuggestion, ExitSuggestions,
// Extensibility
Custom(String),
}
```
## 🔧 Advanced Features
### Suggestions and Autocomplete
```rust
impl CanvasState for MyForm {
fn get_suggestions(&self) -> Option<&[String]> {
if self.suggestions.is_active {
Some(&self.suggestions.suggestions)
} else {
None
}
}
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
match action {
CanvasAction::InsertChar('@') => {
// Trigger email suggestions
let suggestions = vec![
format!("{}@gmail.com", self.username),
format!("{}@company.com", self.username),
];
self.activate_suggestions(suggestions);
None // Let generic handler insert the '@'
}
CanvasAction::SelectSuggestion => {
if let Some(suggestion) = self.suggestions.get_selected() {
*self.get_current_input_mut() = suggestion.clone();
self.deactivate_autocomplete();
Some("Applied suggestion".to_string())
}
None
}
_ => None,
}
}
}
```
### Custom Actions
```rust
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(cmd) => match cmd.as_str() {
"uppercase" => {
*self.get_current_input_mut() = self.get_current_input().to_uppercase();
Some("Converted to uppercase".to_string())
}
"validate_email" => {
if self.get_current_input().contains('@') {
Some("Email is valid".to_string())
} else {
Some("Invalid email format".to_string())
}
}
_ => None,
},
_ => None,
}
}
```
### Integration with TUI Frameworks
Canvas is framework-agnostic and works with any TUI library:
```rust
// Works with crossterm (see examples)
// Works with termion
// Works with ratatui/tui-rs
// Works with cursive
// Works with raw terminal I/O
```
## 🏗️ Architecture
Canvas follows a clean, layered architecture:
```
┌─────────────────────────────────────┐
│ Your Application │
├─────────────────────────────────────┤
│ ActionDispatcher │ ← High-level API
├─────────────────────────────────────┤
│ CanvasAction (Type-Safe) │ ← Type safety layer
├─────────────────────────────────────┤
│ Action Handlers │ ← Core logic
├─────────────────────────────────────┤
│ CanvasState Trait │ ← Your implementation
└─────────────────────────────────────┘
```
## 🤝 Why Canvas?
### Before Canvas
```rust
// ❌ Error-prone string actions
execute_action("move_left", key, state)?;
execute_action("move_leftt", key, state)?; // Runtime error!
// ❌ Duplicate navigation logic everywhere
impl MyLoginForm { /* navigation code */ }
impl MyConfigForm { /* same navigation code */ }
impl MyDataForm { /* same navigation code again */ }
// ❌ Manual cursor and field management
if key == Key::Tab {
current_field = (current_field + 1) % fields.len();
cursor_pos = cursor_pos.min(current_input.len());
}
```
### With Canvas
```rust
// ✅ Type-safe actions
ActionDispatcher::dispatch(CanvasAction::MoveLeft, state, cursor)?;
// Typos are impossible - won't compile!
// ✅ Implement once, use everywhere
impl CanvasState for MyForm { /* minimal implementation */ }
// All navigation, editing, suggestions work automatically!
// ✅ High-level operations
ActionDispatcher::dispatch_batch(actions, state, cursor)?;
```
## 📖 Documentation
- **API Docs**: `cargo doc --open`
- **Examples**: See `examples/` directory
- **Migration Guide**: See `CANVAS_MIGRATION.md`
## 🔄 Migration from String-Based Actions
Canvas provides backwards compatibility during migration:
```rust
// Legacy support (deprecated)
execute_edit_action("move_left", key, state, cursor).await?;
// New type-safe way
ActionDispatcher::dispatch(CanvasAction::MoveLeft, state, cursor).await?;
```
## 🧪 Testing
```bash
# Run all tests
cargo test
# Run specific example
cargo run --example simple_login
# Check type safety
cargo check
```
## 📋 Requirements
- Rust 1.70+
- Terminal with cursor support
- Optional: async runtime (tokio) for examples
## 🤔 FAQ
**Q: Does Canvas work with [my TUI framework]?**
A: Yes! Canvas is framework-agnostic. Just implement `CanvasState` and handle the key events.
**Q: Can I extend Canvas with custom actions?**
A: Absolutely! Use `CanvasAction::Custom("my_action")` or implement `handle_feature_action`.
**Q: Is Canvas suitable for complex forms?**
A: Yes! See the `config_screen` example for validation, suggestions, and multi-field forms.
**Q: How do I migrate from string-based actions?**
A: Canvas provides backwards compatibility. Migrate incrementally using the type-safe APIs.
## 📄 License
Licensed under either of:
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
- MIT License ([LICENSE-MIT](LICENSE-MIT))
at your option.
## 🙏 Contributing
Will write here something later on, too busy rn
---
Built with ❤️ for the Rust TUI community

View File

@@ -0,0 +1,21 @@
// examples/generate_template.rs
use canvas::config::CanvasConfig;
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() > 1 && args[1] == "clean" {
// Generate clean template with 80% active code
let template = CanvasConfig::generate_clean_template();
println!("{}", template);
} else {
// Generate verbose template with descriptions (default)
let template = CanvasConfig::generate_template();
println!("{}", template);
}
}
// Usage:
// cargo run --example generate_template > canvas_config.toml
// cargo run --example generate_template clean > canvas_config_clean.toml

View File

@@ -0,0 +1,620 @@
// examples/integration_patterns.rs
//! Advanced integration patterns showing how Canvas works with:
//! - State management patterns
//! - Event-driven architectures
//! - Validation systems
//! - Custom rendering
//!
//! Run with: cargo run --example integration_patterns
use canvas::prelude::*;
use std::collections::HashMap;
#[tokio::main]
async fn main() {
println!("🔧 Canvas Integration Patterns");
println!("==============================\n");
// Pattern 1: State machine integration
state_machine_example().await;
// Pattern 2: Event-driven architecture
event_driven_example().await;
// Pattern 3: Validation pipeline
validation_pipeline_example().await;
// Pattern 4: Multi-form orchestration
multi_form_example().await;
}
// Pattern 1: Canvas with state machine
async fn state_machine_example() {
println!("🔄 Pattern 1: State Machine Integration");
#[derive(Debug, Clone, PartialEq)]
enum FormState {
Initial,
Editing,
Validating,
Submitting,
Success,
Error(String),
}
#[derive(Debug)]
struct StateMachineForm {
// Canvas state
current_field: usize,
cursor_pos: usize,
username: String,
password: String,
has_changes: bool,
// State machine
state: FormState,
}
impl StateMachineForm {
fn new() -> Self {
Self {
current_field: 0,
cursor_pos: 0,
username: String::new(),
password: String::new(),
has_changes: false,
state: FormState::Initial,
}
}
fn transition_to(&mut self, new_state: FormState) -> String {
let old_state = self.state.clone();
self.state = new_state;
format!("State transition: {:?} -> {:?}", old_state, self.state)
}
fn can_submit(&self) -> bool {
matches!(self.state, FormState::Editing) &&
!self.username.trim().is_empty() &&
!self.password.trim().is_empty()
}
}
impl CanvasState for StateMachineForm {
fn current_field(&self) -> usize { self.current_field }
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
fn set_current_field(&mut self, index: usize) { self.current_field = index.min(1); }
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
fn get_current_input(&self) -> &str {
match self.current_field {
0 => &self.username,
1 => &self.password,
_ => "",
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_field {
0 => &mut self.username,
1 => &mut self.password,
_ => unreachable!(),
}
}
fn inputs(&self) -> Vec<&String> { vec![&self.username, &self.password] }
fn fields(&self) -> Vec<&str> { vec!["Username", "Password"] }
fn has_unsaved_changes(&self) -> bool { self.has_changes }
fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_changes = changed;
// Transition to editing state when user starts typing
if changed && self.state == FormState::Initial {
self.state = FormState::Editing;
}
}
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(cmd) => match cmd.as_str() {
"submit" => {
if self.can_submit() {
let msg = self.transition_to(FormState::Submitting);
// Simulate submission
self.state = FormState::Success;
Some(format!("{} -> Form submitted successfully", msg))
} else {
let msg = self.transition_to(FormState::Error("Invalid form data".to_string()));
Some(msg)
}
}
"reset" => {
self.username.clear();
self.password.clear();
self.has_changes = false;
Some(self.transition_to(FormState::Initial))
}
_ => None,
},
_ => None,
}
}
}
let mut form = StateMachineForm::new();
let mut ideal_cursor = 0;
println!(" Initial state: {:?}", form.state);
// Type some text to trigger state change
let _result = ActionDispatcher::dispatch(
CanvasAction::InsertChar('u'),
&mut form,
&mut ideal_cursor,
).await.unwrap();
println!(" After typing: {:?}", form.state);
// Try to submit (should fail)
let result = ActionDispatcher::dispatch(
CanvasAction::Custom("submit".to_string()),
&mut form,
&mut ideal_cursor,
).await.unwrap();
println!(" Submit result: {}", result.message().unwrap_or(""));
println!(" ✅ State machine integration works!\n");
}
// Pattern 2: Event-driven architecture
async fn event_driven_example() {
println!("📡 Pattern 2: Event-Driven Architecture");
#[derive(Debug, Clone)]
enum FormEvent {
FieldChanged { field: usize, old_value: String, new_value: String },
ValidationTriggered { field: usize, is_valid: bool },
ActionExecuted { action: String, success: bool },
}
#[derive(Debug)]
struct EventDrivenForm {
current_field: usize,
cursor_pos: usize,
email: String,
has_changes: bool,
events: Vec<FormEvent>,
}
impl EventDrivenForm {
fn new() -> Self {
Self {
current_field: 0,
cursor_pos: 0,
email: String::new(),
has_changes: false,
events: Vec::new(),
}
}
fn emit_event(&mut self, event: FormEvent) {
println!(" 📡 Event: {:?}", event);
self.events.push(event);
}
fn validate_email(&self) -> bool {
self.email.contains('@') && self.email.contains('.')
}
}
impl CanvasState for EventDrivenForm {
fn current_field(&self) -> usize { self.current_field }
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
fn get_current_input(&self) -> &str { &self.email }
fn get_current_input_mut(&mut self) -> &mut String { &mut self.email }
fn inputs(&self) -> Vec<&String> { vec![&self.email] }
fn fields(&self) -> Vec<&str> { vec!["Email"] }
fn has_unsaved_changes(&self) -> bool { self.has_changes }
fn set_has_unsaved_changes(&mut self, changed: bool) {
if changed != self.has_changes {
let old_value = if self.has_changes { "modified" } else { "unmodified" };
let new_value = if changed { "modified" } else { "unmodified" };
self.emit_event(FormEvent::FieldChanged {
field: self.current_field,
old_value: old_value.to_string(),
new_value: new_value.to_string(),
});
}
self.has_changes = changed;
}
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(cmd) => match cmd.as_str() {
"validate" => {
let is_valid = self.validate_email();
self.emit_event(FormEvent::ValidationTriggered {
field: self.current_field,
is_valid,
});
self.emit_event(FormEvent::ActionExecuted {
action: "validate".to_string(),
success: true,
});
if is_valid {
Some("Email is valid!".to_string())
} else {
Some("Email is invalid".to_string())
}
}
_ => None,
},
_ => None,
}
}
}
let mut form = EventDrivenForm::new();
let mut ideal_cursor = 0;
// Type an email address
let email = "user@example.com";
for c in email.chars() {
ActionDispatcher::dispatch(
CanvasAction::InsertChar(c),
&mut form,
&mut ideal_cursor,
).await.unwrap();
}
// Validate the email
let result = ActionDispatcher::dispatch(
CanvasAction::Custom("validate".to_string()),
&mut form,
&mut ideal_cursor,
).await.unwrap();
println!(" Final email: {}", form.email);
println!(" Validation result: {}", result.message().unwrap_or(""));
println!(" Total events captured: {}", form.events.len());
println!(" ✅ Event-driven architecture works!\n");
}
// Pattern 3: Validation pipeline
async fn validation_pipeline_example() {
println!("✅ Pattern 3: Validation Pipeline");
type ValidationRule = Box<dyn Fn(&str) -> Result<(), String>>;
// Custom Debug implementation since function pointers don't implement Debug
struct ValidatedForm {
current_field: usize,
cursor_pos: usize,
password: String,
has_changes: bool,
validators: HashMap<usize, Vec<ValidationRule>>,
}
impl std::fmt::Debug for ValidatedForm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ValidatedForm")
.field("current_field", &self.current_field)
.field("cursor_pos", &self.cursor_pos)
.field("password", &self.password)
.field("has_changes", &self.has_changes)
.field("validators", &format!("HashMap with {} entries", self.validators.len()))
.finish()
}
}
impl ValidatedForm {
fn new() -> Self {
let mut validators: HashMap<usize, Vec<ValidationRule>> = HashMap::new();
// Password validators
let mut password_validators: Vec<ValidationRule> = Vec::new();
password_validators.push(Box::new(|value| {
if value.len() < 8 {
Err("Password must be at least 8 characters".to_string())
} else {
Ok(())
}
}));
password_validators.push(Box::new(|value| {
if !value.chars().any(|c| c.is_uppercase()) {
Err("Password must contain at least one uppercase letter".to_string())
} else {
Ok(())
}
}));
password_validators.push(Box::new(|value| {
if !value.chars().any(|c| c.is_numeric()) {
Err("Password must contain at least one number".to_string())
} else {
Ok(())
}
}));
validators.insert(0, password_validators);
Self {
current_field: 0,
cursor_pos: 0,
password: String::new(),
has_changes: false,
validators,
}
}
fn validate_field(&self, field_index: usize) -> Vec<String> {
let mut errors = Vec::new();
if let Some(validators) = self.validators.get(&field_index) {
let value = match field_index {
0 => &self.password,
_ => return errors,
};
for validator in validators {
if let Err(error) = validator(value) {
errors.push(error);
}
}
}
errors
}
}
impl CanvasState for ValidatedForm {
fn current_field(&self) -> usize { self.current_field }
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
fn get_current_input(&self) -> &str { &self.password }
fn get_current_input_mut(&mut self) -> &mut String { &mut self.password }
fn inputs(&self) -> Vec<&String> { vec![&self.password] }
fn fields(&self) -> Vec<&str> { vec!["Password"] }
fn has_unsaved_changes(&self) -> bool { self.has_changes }
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(cmd) => match cmd.as_str() {
"validate" => {
let errors = self.validate_field(self.current_field);
if errors.is_empty() {
Some("Password meets all requirements!".to_string())
} else {
Some(format!("Validation errors: {}", errors.join(", ")))
}
}
_ => None,
},
_ => None,
}
}
}
let mut form = ValidatedForm::new();
let mut ideal_cursor = 0;
// Test with weak password
let weak_password = "abc";
for c in weak_password.chars() {
ActionDispatcher::dispatch(
CanvasAction::InsertChar(c),
&mut form,
&mut ideal_cursor,
).await.unwrap();
}
let result = ActionDispatcher::dispatch(
CanvasAction::Custom("validate".to_string()),
&mut form,
&mut ideal_cursor,
).await.unwrap();
println!(" Weak password '{}': {}", form.password, result.message().unwrap_or(""));
// Clear and test with strong password
form.password.clear();
form.cursor_pos = 0;
let strong_password = "StrongPass123";
for c in strong_password.chars() {
ActionDispatcher::dispatch(
CanvasAction::InsertChar(c),
&mut form,
&mut ideal_cursor,
).await.unwrap();
}
let result = ActionDispatcher::dispatch(
CanvasAction::Custom("validate".to_string()),
&mut form,
&mut ideal_cursor,
).await.unwrap();
println!(" Strong password '{}': {}", form.password, result.message().unwrap_or(""));
println!(" ✅ Validation pipeline works!\n");
}
// Pattern 4: Multi-form orchestration
async fn multi_form_example() {
println!("🎭 Pattern 4: Multi-Form Orchestration");
#[derive(Debug)]
struct PersonalInfoForm {
current_field: usize,
cursor_pos: usize,
name: String,
age: String,
has_changes: bool,
}
#[derive(Debug)]
struct ContactInfoForm {
current_field: usize,
cursor_pos: usize,
email: String,
phone: String,
has_changes: bool,
}
// Implement CanvasState for both forms
impl CanvasState for PersonalInfoForm {
fn current_field(&self) -> usize { self.current_field }
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
fn set_current_field(&mut self, index: usize) { self.current_field = index.min(1); }
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
fn get_current_input(&self) -> &str {
match self.current_field {
0 => &self.name,
1 => &self.age,
_ => "",
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_field {
0 => &mut self.name,
1 => &mut self.age,
_ => unreachable!(),
}
}
fn inputs(&self) -> Vec<&String> { vec![&self.name, &self.age] }
fn fields(&self) -> Vec<&str> { vec!["Name", "Age"] }
fn has_unsaved_changes(&self) -> bool { self.has_changes }
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
}
impl CanvasState for ContactInfoForm {
fn current_field(&self) -> usize { self.current_field }
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
fn set_current_field(&mut self, index: usize) { self.current_field = index.min(1); }
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
fn get_current_input(&self) -> &str {
match self.current_field {
0 => &self.email,
1 => &self.phone,
_ => "",
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_field {
0 => &mut self.email,
1 => &mut self.phone,
_ => unreachable!(),
}
}
fn inputs(&self) -> Vec<&String> { vec![&self.email, &self.phone] }
fn fields(&self) -> Vec<&str> { vec!["Email", "Phone"] }
fn has_unsaved_changes(&self) -> bool { self.has_changes }
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
}
// Form orchestrator
#[derive(Debug)]
struct FormOrchestrator {
personal_form: PersonalInfoForm,
contact_form: ContactInfoForm,
current_form: usize, // 0 = personal, 1 = contact
}
impl FormOrchestrator {
fn new() -> Self {
Self {
personal_form: PersonalInfoForm {
current_field: 0,
cursor_pos: 0,
name: String::new(),
age: String::new(),
has_changes: false,
},
contact_form: ContactInfoForm {
current_field: 0,
cursor_pos: 0,
email: String::new(),
phone: String::new(),
has_changes: false,
},
current_form: 0,
}
}
async fn execute_action(&mut self, action: CanvasAction) -> ActionResult {
let mut ideal_cursor = 0;
match self.current_form {
0 => ActionDispatcher::dispatch(action, &mut self.personal_form, &mut ideal_cursor).await.unwrap(),
1 => ActionDispatcher::dispatch(action, &mut self.contact_form, &mut ideal_cursor).await.unwrap(),
_ => ActionResult::error("Invalid form index"),
}
}
fn switch_form(&mut self) -> String {
self.current_form = (self.current_form + 1) % 2;
match self.current_form {
0 => "Switched to Personal Info form".to_string(),
1 => "Switched to Contact Info form".to_string(),
_ => "Unknown form".to_string(),
}
}
fn current_form_name(&self) -> &str {
match self.current_form {
0 => "Personal Info",
1 => "Contact Info",
_ => "Unknown",
}
}
}
let mut orchestrator = FormOrchestrator::new();
println!(" Current form: {}", orchestrator.current_form_name());
// Fill personal info
for &c in &['J', 'o', 'h', 'n'] {
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
}
orchestrator.execute_action(CanvasAction::NextField).await;
for &c in &['2', '5'] {
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
}
println!(" Personal form - Name: '{}', Age: '{}'",
orchestrator.personal_form.name,
orchestrator.personal_form.age);
// Switch to contact form
let switch_msg = orchestrator.switch_form();
println!(" {}", switch_msg);
// Fill contact info
for &c in &['j', 'o', 'h', 'n', '@', 'e', 'x', 'a', 'm', 'p', 'l', 'e', '.', 'c', 'o', 'm'] {
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
}
orchestrator.execute_action(CanvasAction::NextField).await;
for &c in &['5', '5', '5', '-', '1', '2', '3', '4'] {
orchestrator.execute_action(CanvasAction::InsertChar(c)).await;
}
println!(" Contact form - Email: '{}', Phone: '{}'",
orchestrator.contact_form.email,
orchestrator.contact_form.phone);
}

View File

@@ -0,0 +1,133 @@
// src/autocomplete/actions.rs
use crate::canvas::state::{CanvasState, ActionContext};
use crate::autocomplete::state::AutocompleteCanvasState;
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::dispatcher::ActionDispatcher; // NEW: Use dispatcher directly
use crate::config::CanvasConfig;
use anyhow::Result;
/// Version for states that implement rich autocomplete
pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + AutocompleteCanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> Result<ActionResult> {
// 1. Try feature-specific handler first
let context = ActionContext {
key_code: None,
ideal_cursor_column: *ideal_cursor_column,
current_input: state.get_current_input().to_string(),
current_field: state.current_field(),
};
if let Some(result) = handle_rich_autocomplete_action(action.clone(), state, &context) {
return Ok(result);
}
// 2. Handle generic actions using the new dispatcher directly
let result = ActionDispatcher::dispatch_with_config(action.clone(), state, ideal_cursor_column, config).await?;
// 3. AUTO-TRIGGER LOGIC: Check if we should activate/deactivate autocomplete
if let Some(cfg) = config {
println!("{:?}, {}", action, cfg.should_auto_trigger_autocomplete());
if cfg.should_auto_trigger_autocomplete() {
println!("AUTO-TRIGGER");
match action {
CanvasAction::InsertChar(_) => {
println!("AUTO-T on Ins");
let current_field = state.current_field();
let current_input = state.get_current_input();
if state.supports_autocomplete(current_field)
&& !state.is_autocomplete_active()
&& current_input.len() >= 1
{
println!("ACT AUTOC");
state.activate_autocomplete();
}
}
CanvasAction::NextField | CanvasAction::PrevField => {
println!("AUTO-T on nav");
let current_field = state.current_field();
if state.supports_autocomplete(current_field) && !state.is_autocomplete_active() {
state.activate_autocomplete();
} else if !state.supports_autocomplete(current_field) && state.is_autocomplete_active() {
state.deactivate_autocomplete();
}
}
_ => {} // No auto-trigger for other actions
}
}
}
Ok(result)
}
/// Handle rich autocomplete actions for AutocompleteCanvasState
fn handle_rich_autocomplete_action<S: CanvasState + AutocompleteCanvasState>(
action: CanvasAction,
state: &mut S,
_context: &ActionContext,
) -> Option<ActionResult> {
match action {
CanvasAction::TriggerAutocomplete => {
let current_field = state.current_field();
if state.supports_autocomplete(current_field) {
state.activate_autocomplete();
Some(ActionResult::success_with_message("Autocomplete activated"))
} else {
Some(ActionResult::success_with_message("Autocomplete not supported for this field"))
}
}
CanvasAction::SuggestionUp => {
if state.is_autocomplete_ready() {
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
autocomplete_state.select_previous();
}
Some(ActionResult::success())
} else {
Some(ActionResult::success_with_message("No suggestions available"))
}
}
CanvasAction::SuggestionDown => {
if state.is_autocomplete_ready() {
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
autocomplete_state.select_next();
}
Some(ActionResult::success())
} else {
Some(ActionResult::success_with_message("No suggestions available"))
}
}
CanvasAction::SelectSuggestion => {
if state.is_autocomplete_ready() {
if let Some(msg) = state.apply_autocomplete_selection() {
Some(ActionResult::success_with_message(&msg))
} else {
Some(ActionResult::success_with_message("No suggestion selected"))
}
} else {
Some(ActionResult::success_with_message("No suggestions available"))
}
}
CanvasAction::ExitSuggestions => {
if state.is_autocomplete_active() {
state.deactivate_autocomplete();
Some(ActionResult::success_with_message("Exited autocomplete"))
} else {
Some(ActionResult::success())
}
}
_ => None, // Not a rich autocomplete action
}
}

View File

@@ -0,0 +1,191 @@
// canvas/src/autocomplete/gui.rs
#[cfg(feature = "gui")]
use ratatui::{
layout::{Alignment, Rect},
style::{Modifier, Style},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Frame,
};
use crate::autocomplete::types::AutocompleteState;
#[cfg(feature = "gui")]
use crate::canvas::theme::CanvasTheme;
#[cfg(feature = "gui")]
use unicode_width::UnicodeWidthStr;
/// Render autocomplete dropdown - call this AFTER rendering canvas
#[cfg(feature = "gui")]
pub fn render_autocomplete_dropdown<T: CanvasTheme>(
f: &mut Frame,
frame_area: Rect,
input_rect: Rect,
theme: &T,
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
) {
if !autocomplete_state.is_active {
return;
}
if autocomplete_state.is_loading {
render_loading_indicator(f, frame_area, input_rect, theme);
} else if !autocomplete_state.suggestions.is_empty() {
render_suggestions_dropdown(f, frame_area, input_rect, theme, autocomplete_state);
}
}
/// Show loading spinner/text
#[cfg(feature = "gui")]
fn render_loading_indicator<T: CanvasTheme>(
f: &mut Frame,
frame_area: Rect,
input_rect: Rect,
theme: &T,
) {
let loading_text = "Loading suggestions...";
let loading_width = loading_text.width() as u16 + 4; // +4 for borders and padding
let loading_height = 3;
let dropdown_area = calculate_dropdown_position(
input_rect,
frame_area,
loading_width,
loading_height,
);
let loading_block = Block::default()
.style(Style::default().bg(theme.bg()));
let loading_paragraph = Paragraph::new(loading_text)
.block(loading_block)
.style(Style::default().fg(theme.fg()))
.alignment(Alignment::Center);
f.render_widget(loading_paragraph, dropdown_area);
}
/// Show actual suggestions list
#[cfg(feature = "gui")]
fn render_suggestions_dropdown<T: CanvasTheme>(
f: &mut Frame,
frame_area: Rect,
input_rect: Rect,
theme: &T,
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
) {
let display_texts: Vec<&str> = autocomplete_state.suggestions
.iter()
.map(|item| item.display_text.as_str())
.collect();
let dropdown_dimensions = calculate_dropdown_dimensions(&display_texts);
let dropdown_area = calculate_dropdown_position(
input_rect,
frame_area,
dropdown_dimensions.width,
dropdown_dimensions.height,
);
// Background
let dropdown_block = Block::default()
.style(Style::default().bg(theme.bg()));
// List items
let items = create_suggestion_list_items(
&display_texts,
autocomplete_state.selected_index,
dropdown_dimensions.width,
theme,
);
let list = List::new(items).block(dropdown_block);
let mut list_state = ListState::default();
list_state.select(autocomplete_state.selected_index);
f.render_stateful_widget(list, dropdown_area, &mut list_state);
}
/// Calculate dropdown size based on suggestions - updated to match client dimensions
#[cfg(feature = "gui")]
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
let max_width = display_texts
.iter()
.map(|text| text.width())
.max()
.unwrap_or(0) as u16;
let horizontal_padding = 2; // Changed from 4 to 2 to match client
let width = (max_width + horizontal_padding).max(10); // Changed from 12 to 10 to match client
let height = (display_texts.len() as u16).min(5); // Removed +2 since no borders
DropdownDimensions { width, height }
}
/// Position dropdown to stay in bounds
#[cfg(feature = "gui")]
fn calculate_dropdown_position(
input_rect: Rect,
frame_area: Rect,
dropdown_width: u16,
dropdown_height: u16,
) -> Rect {
let mut dropdown_area = Rect {
x: input_rect.x,
y: input_rect.y + 1, // below input field
width: dropdown_width,
height: dropdown_height,
};
// Keep in bounds
if dropdown_area.bottom() > frame_area.height {
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
}
if dropdown_area.right() > frame_area.width {
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
}
dropdown_area.x = dropdown_area.x.max(0);
dropdown_area.y = dropdown_area.y.max(0);
dropdown_area
}
/// Create styled list items - updated to match client spacing
#[cfg(feature = "gui")]
fn create_suggestion_list_items<'a, T: CanvasTheme>(
display_texts: &'a [&'a str],
selected_index: Option<usize>,
dropdown_width: u16,
theme: &T,
) -> Vec<ListItem<'a>> {
let horizontal_padding = 2; // Changed from 4 to 2 to match client
let available_width = dropdown_width; // No border padding needed
display_texts
.iter()
.enumerate()
.map(|(i, text)| {
let is_selected = selected_index == Some(i);
let text_width = text.width() as u16;
let padding_needed = available_width.saturating_sub(text_width);
let padded_text = format!("{}{}", text, " ".repeat(padding_needed as usize));
ListItem::new(padded_text).style(if is_selected {
Style::default()
.fg(theme.bg())
.bg(theme.highlight())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg()).bg(theme.bg())
})
})
.collect()
}
/// Helper struct for dropdown dimensions
#[cfg(feature = "gui")]
struct DropdownDimensions {
width: u16,
height: u16,
}

View File

@@ -0,0 +1,10 @@
// src/autocomplete/mod.rs
pub mod types;
pub mod gui;
pub mod state;
pub mod actions;
// Re-export autocomplete types
pub use types::{SuggestionItem, AutocompleteState};
pub use state::AutocompleteCanvasState;
pub use actions::execute_canvas_action_with_autocomplete;

View File

@@ -0,0 +1,96 @@
// canvas/src/state.rs
use crate::canvas::state::CanvasState;
/// OPTIONAL extension trait for states that want rich autocomplete functionality.
/// Only implement this if you need the new autocomplete features.
pub trait AutocompleteCanvasState: CanvasState {
/// Associated type for suggestion data (e.g., Hit, String, CustomType)
type SuggestionData: Clone + Send + 'static;
/// Check if a field supports autocomplete
fn supports_autocomplete(&self, _field_index: usize) -> bool {
false // Default: no autocomplete support
}
/// Get autocomplete state (read-only)
fn autocomplete_state(&self) -> Option<&crate::autocomplete::AutocompleteState<Self::SuggestionData>> {
None // Default: no autocomplete state
}
/// Get autocomplete state (mutable)
fn autocomplete_state_mut(&mut self) -> Option<&mut crate::autocomplete::AutocompleteState<Self::SuggestionData>> {
None // Default: no autocomplete state
}
/// CLIENT API: Activate autocomplete for current field
fn activate_autocomplete(&mut self) {
let current_field = self.current_field(); // Get field first
if let Some(state) = self.autocomplete_state_mut() {
state.activate(current_field); // Then use it
}
}
/// CLIENT API: Deactivate autocomplete
fn deactivate_autocomplete(&mut self) {
if let Some(state) = self.autocomplete_state_mut() {
state.deactivate();
}
}
/// CLIENT API: Set suggestions (called after async fetch completes)
fn set_autocomplete_suggestions(&mut self, suggestions: Vec<crate::autocomplete::SuggestionItem<Self::SuggestionData>>) {
if let Some(state) = self.autocomplete_state_mut() {
state.set_suggestions(suggestions);
}
}
/// CLIENT API: Set loading state
fn set_autocomplete_loading(&mut self, loading: bool) {
if let Some(state) = self.autocomplete_state_mut() {
state.is_loading = loading;
}
}
/// Check if autocomplete is currently active
fn is_autocomplete_active(&self) -> bool {
self.autocomplete_state()
.map(|state| state.is_active)
.unwrap_or(false)
}
/// Check if autocomplete is ready for interaction
fn is_autocomplete_ready(&self) -> bool {
self.autocomplete_state()
.map(|state| state.is_ready())
.unwrap_or(false)
}
/// INTERNAL: Apply selected autocomplete value to current field
fn apply_autocomplete_selection(&mut self) -> Option<String> {
// First, get the selected value and display text (if any)
let selection_info = if let Some(state) = self.autocomplete_state() {
state.get_selected().map(|selected| {
(selected.value_to_store.clone(), selected.display_text.clone())
})
} else {
None
};
// Apply the selection if we have one
if let Some((value, display)) = selection_info {
// Apply the value to current field
*self.get_current_input_mut() = value;
self.set_has_unsaved_changes(true);
// Deactivate autocomplete
if let Some(state_mut) = self.autocomplete_state_mut() {
state_mut.deactivate();
}
Some(format!("Selected: {}", display))
} else {
None
}
}
}

View File

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

View File

@@ -0,0 +1,203 @@
// src/canvas/actions/handlers/edit.rs
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use crate::config::CanvasConfig;
use anyhow::Result;
const FOR_EDIT_MODE: bool = true; // Edit mode flag
/// Handle actions in edit mode with edit-specific cursor behavior
pub async fn handle_edit_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> Result<ActionResult> {
match action {
CanvasAction::InsertChar(c) => {
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 => {
let cursor_pos = state.current_cursor_pos();
if cursor_pos > 0 {
let input = state.get_current_input_mut();
input.remove(cursor_pos - 1);
state.set_current_cursor_pos(cursor_pos - 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos - 1;
}
Ok(ActionResult::success())
}
CanvasAction::DeleteForward => {
let cursor_pos = state.current_cursor_pos();
let input = state.get_current_input_mut();
if cursor_pos < input.len() {
input.remove(cursor_pos);
state.set_has_unsaved_changes(true);
}
Ok(ActionResult::success())
}
CanvasAction::MoveLeft => {
let 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 => {
// For single-line fields, move to previous field
let current_field = state.current_field();
if current_field > 0 {
state.set_current_field(current_field - 1);
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 => {
// For single-line fields, move to next field
let current_field = state.current_field();
let total_fields = state.fields().len();
if current_field < total_fields - 1 {
state.set_current_field(current_field + 1);
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::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::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())
}
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())
}
CanvasAction::NextField | CanvasAction::PrevField => {
let current_field = state.current_field();
let total_fields = state.fields().len();
let new_field = match action {
CanvasAction::NextField => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
(current_field + 1) % total_fields
} else {
(current_field + 1).min(total_fields - 1)
}
}
CanvasAction::PrevField => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
if current_field == 0 { total_fields - 1 } else { current_field - 1 }
} else {
current_field.saturating_sub(1)
}
}
_ => unreachable!(),
};
state.set_current_field(new_field);
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

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

View File

@@ -0,0 +1,10 @@
// src/canvas/actions/handlers/mod.rs
pub mod edit;
pub mod readonly;
pub mod highlight;
// Re-export handler functions
pub use edit::handle_edit_action;
pub use readonly::handle_readonly_action;
pub use highlight::handle_highlight_action;

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
// src/canvas/actions/movement/char.rs
/// Calculate new position when moving left
pub fn move_left(current_pos: usize) -> usize {
current_pos.saturating_sub(1)
}
/// Calculate new position when moving right
pub fn move_right(current_pos: usize, text: &str, for_edit_mode: bool) -> usize {
if text.is_empty() {
return current_pos;
}
if for_edit_mode {
// Edit mode: can move past end of text
(current_pos + 1).min(text.len())
} else {
// Read-only/highlight mode: stays within text bounds
if current_pos < text.len().saturating_sub(1) {
current_pos + 1
} else {
current_pos
}
}
}
/// Check if cursor position is valid for the given mode
pub fn is_valid_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> bool {
if text.is_empty() {
return pos == 0;
}
if for_edit_mode {
pos <= text.len()
} else {
pos < text.len()
}
}
/// Clamp cursor position to valid bounds for the given mode
pub fn clamp_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> usize {
if text.is_empty() {
0
} else if for_edit_mode {
pos.min(text.len())
} else {
pos.min(text.len().saturating_sub(1))
}
}

View File

@@ -0,0 +1,32 @@
// src/canvas/actions/movement/line.rs
/// Calculate cursor position for line start
pub fn line_start_position() -> usize {
0
}
/// Calculate cursor position for line end
pub fn line_end_position(text: &str, for_edit_mode: bool) -> usize {
if text.is_empty() {
0
} else if for_edit_mode {
// Edit mode: cursor can go past end of text
text.len()
} else {
// Read-only/highlight mode: cursor stays on last character
text.len().saturating_sub(1)
}
}
/// Calculate safe cursor position when switching fields
pub fn safe_cursor_position(text: &str, ideal_column: usize, for_edit_mode: bool) -> usize {
if text.is_empty() {
0
} else if for_edit_mode {
// Edit mode: cursor can go past end
ideal_column.min(text.len())
} else {
// Read-only/highlight mode: cursor stays within text
ideal_column.min(text.len().saturating_sub(1))
}
}

View File

@@ -0,0 +1,10 @@
// src/canvas/actions/movement/mod.rs
pub mod word;
pub mod line;
pub mod char;
// Re-export commonly used functions
pub use word::{find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end};
pub use line::{line_start_position, line_end_position, safe_cursor_position};
pub use char::{move_left, move_right, is_valid_cursor_position, clamp_cursor_position};

View File

@@ -0,0 +1,146 @@
// src/canvas/actions/movement/word.rs
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
/// Find the start of the next word from the current position
pub fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let current_pos = current_pos.min(chars.len());
if current_pos == chars.len() {
return current_pos;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
// Skip current word/token
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
// Skip whitespace
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
/// Find the end of the current or next word
pub fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
let current_type = get_char_type(chars[pos]);
// If we're not on whitespace, move to end of current word
if current_type != CharType::Whitespace {
while pos < len && get_char_type(chars[pos]) == current_type {
pos += 1;
}
return pos.saturating_sub(1);
}
// If we're on whitespace, find next word and go to its end
pos = find_next_word_start(text, pos);
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
/// Find the start of the previous word
pub fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
// Skip whitespace backwards
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
// Move to start of word
if get_char_type(chars[pos]) != CharType::Whitespace {
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
0
} else {
pos
}
}
/// Find the end of the previous word
pub fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
// Skip whitespace backwards
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
// Skip whitespace before this word
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}

View File

@@ -0,0 +1,108 @@
// src/canvas/actions/types.rs
#[derive(Debug, Clone, PartialEq)]
pub enum CanvasAction {
// Character input
InsertChar(char),
// Deletion
DeleteBackward,
DeleteForward,
// Basic cursor movement
MoveLeft,
MoveRight,
MoveUp,
MoveDown,
// Line movement
MoveLineStart,
MoveLineEnd,
MoveFirstLine,
MoveLastLine,
// Word movement
MoveWordNext,
MoveWordEnd,
MoveWordPrev,
MoveWordEndPrev,
// Field navigation
NextField,
PrevField,
// Autocomplete actions
TriggerAutocomplete,
SuggestionUp,
SuggestionDown,
SelectSuggestion,
ExitSuggestions,
// Custom actions
Custom(String),
}
impl CanvasAction {
/// Convert string action name to CanvasAction enum (config-driven)
pub fn from_string(action: &str) -> Self {
match action {
"delete_char_backward" => Self::DeleteBackward,
"delete_char_forward" => Self::DeleteForward,
"move_left" => Self::MoveLeft,
"move_right" => Self::MoveRight,
"move_up" => Self::MoveUp,
"move_down" => Self::MoveDown,
"move_line_start" => Self::MoveLineStart,
"move_line_end" => Self::MoveLineEnd,
"move_first_line" => Self::MoveFirstLine,
"move_last_line" => Self::MoveLastLine,
"move_word_next" => Self::MoveWordNext,
"move_word_end" => Self::MoveWordEnd,
"move_word_prev" => Self::MoveWordPrev,
"move_word_end_prev" => Self::MoveWordEndPrev,
"next_field" => Self::NextField,
"prev_field" => Self::PrevField,
"trigger_autocomplete" => Self::TriggerAutocomplete,
"suggestion_up" => Self::SuggestionUp,
"suggestion_down" => Self::SuggestionDown,
"select_suggestion" => Self::SelectSuggestion,
"exit_suggestions" => Self::ExitSuggestions,
_ => Self::Custom(action.to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ActionResult {
Success(Option<String>),
HandledByFeature(String),
RequiresContext(String),
Error(String),
}
impl ActionResult {
pub fn success() -> Self {
Self::Success(None)
}
pub fn success_with_message(msg: &str) -> Self {
Self::Success(Some(msg.to_string()))
}
pub fn error(msg: &str) -> Self {
Self::Error(msg.into())
}
pub fn is_success(&self) -> bool {
matches!(self, Self::Success(_) | Self::HandledByFeature(_))
}
pub fn message(&self) -> Option<&str> {
match self {
Self::Success(msg) => msg.as_deref(),
Self::HandledByFeature(msg) => Some(msg),
Self::RequiresContext(msg) => Some(msg),
Self::Error(msg) => Some(msg),
}
}
}

338
canvas/src/canvas/gui.rs Normal file
View File

@@ -0,0 +1,338 @@
// canvas/src/canvas/gui.rs
#[cfg(feature = "gui")]
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, BorderType, Paragraph},
Frame,
};
use crate::canvas::state::CanvasState;
use crate::canvas::modes::HighlightState;
#[cfg(feature = "gui")]
use crate::canvas::theme::CanvasTheme;
#[cfg(feature = "gui")]
use std::cmp::{max, min};
/// Render ONLY the canvas form fields - no autocomplete
#[cfg(feature = "gui")]
pub fn render_canvas<T: CanvasTheme>(
f: &mut Frame,
area: Rect,
form_state: &impl CanvasState,
theme: &T,
is_edit_mode: bool,
highlight_state: &HighlightState,
) -> Option<Rect> {
let fields: Vec<&str> = form_state.fields();
let current_field_idx = form_state.current_field();
let inputs: Vec<&String> = form_state.inputs();
render_canvas_fields(
f,
area,
&fields,
&current_field_idx,
&inputs,
theme,
is_edit_mode,
highlight_state,
form_state.current_cursor_pos(),
form_state.has_unsaved_changes(),
|i| form_state.get_display_value_for_field(i).to_string(),
|i| form_state.has_display_override(i),
)
}
/// Core canvas field rendering
#[cfg(feature = "gui")]
fn render_canvas_fields<T: CanvasTheme, F1, F2>(
f: &mut Frame,
area: Rect,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
theme: &T,
is_edit_mode: bool,
highlight_state: &HighlightState,
current_cursor_pos: usize,
has_unsaved_changes: bool,
get_display_value: F1,
has_display_override: F2,
) -> Option<Rect>
where
F1: Fn(usize) -> String,
F2: Fn(usize) -> bool,
{
// Create layout
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(area);
// Border style based on state
let border_style = if has_unsaved_changes {
Style::default().fg(theme.warning())
} else if is_edit_mode {
Style::default().fg(theme.accent())
} else {
Style::default().fg(theme.secondary())
};
// Input container
let input_container = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.style(Style::default().bg(theme.bg()));
let input_block = Rect {
x: columns[1].x,
y: columns[1].y,
width: columns[1].width,
height: fields.len() as u16 + 2,
};
f.render_widget(&input_container, input_block);
// Input area layout
let input_area = input_container.inner(input_block);
let input_rows = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(1); fields.len()])
.split(input_area);
// Render field labels
render_field_labels(f, columns[0], input_block, fields, theme);
// Render field values and return active field rect
render_field_values(
f,
input_rows.to_vec(), // Fix: Convert Rc<[Rect]> to Vec<Rect>
inputs,
current_field_idx,
theme,
highlight_state,
current_cursor_pos,
get_display_value,
has_display_override,
)
}
/// Render field labels
#[cfg(feature = "gui")]
fn render_field_labels<T: CanvasTheme>(
f: &mut Frame,
label_area: Rect,
input_block: Rect,
fields: &[&str],
theme: &T,
) {
for (i, field) in fields.iter().enumerate() {
let label = Paragraph::new(Line::from(Span::styled(
format!("{}:", field),
Style::default().fg(theme.fg()),
)));
f.render_widget(
label,
Rect {
x: label_area.x,
y: input_block.y + 1 + i as u16,
width: label_area.width,
height: 1,
},
);
}
}
/// Render field values with highlighting
#[cfg(feature = "gui")]
fn render_field_values<T: CanvasTheme, F1, F2>(
f: &mut Frame,
input_rows: Vec<Rect>,
inputs: &[&String],
current_field_idx: &usize,
theme: &T,
highlight_state: &HighlightState,
current_cursor_pos: usize,
get_display_value: F1,
has_display_override: F2,
) -> Option<Rect>
where
F1: Fn(usize) -> String,
F2: Fn(usize) -> bool,
{
let mut active_field_input_rect = None;
for (i, _input) in inputs.iter().enumerate() {
let is_active = i == *current_field_idx;
let text = get_display_value(i);
// Apply highlighting
let line = apply_highlighting(
&text,
i,
current_field_idx,
current_cursor_pos,
highlight_state,
theme,
is_active,
);
let input_display = Paragraph::new(line).alignment(Alignment::Left);
f.render_widget(input_display, input_rows[i]);
// Set cursor for active field
if is_active {
active_field_input_rect = Some(input_rows[i]);
set_cursor_position(f, input_rows[i], &text, current_cursor_pos, has_display_override(i));
}
}
active_field_input_rect
}
/// Apply highlighting based on highlight state
#[cfg(feature = "gui")]
fn apply_highlighting<'a, T: CanvasTheme>(
text: &'a str,
field_index: usize,
current_field_idx: &usize,
current_cursor_pos: usize,
highlight_state: &HighlightState,
theme: &T,
is_active: bool,
) -> Line<'a> {
let text_len = text.chars().count();
match highlight_state {
HighlightState::Off => {
Line::from(Span::styled(
text,
if is_active {
Style::default().fg(theme.highlight())
} else {
Style::default().fg(theme.fg())
},
))
}
HighlightState::Characterwise { anchor } => {
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, is_active)
}
HighlightState::Linewise { anchor_line } => {
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, is_active)
}
}
}
/// Apply characterwise highlighting
#[cfg(feature = "gui")]
fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
text: &'a str,
text_len: usize,
field_index: usize,
current_field_idx: &usize,
current_cursor_pos: usize,
anchor: &(usize, usize),
theme: &T,
is_active: bool,
) -> Line<'a> {
let (anchor_field, anchor_char) = *anchor;
let start_field = min(anchor_field, *current_field_idx);
let end_field = max(anchor_field, *current_field_idx);
let highlight_style = Style::default()
.fg(theme.highlight())
.bg(theme.highlight_bg())
.add_modifier(Modifier::BOLD);
let normal_style_in_highlight = Style::default().fg(theme.highlight());
let normal_style_outside = Style::default().fg(theme.fg());
if field_index >= start_field && field_index <= end_field {
if start_field == end_field {
let (start_char, end_char) = if anchor_field == *current_field_idx {
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
} else if anchor_field < *current_field_idx {
(anchor_char, current_cursor_pos)
} else {
(current_cursor_pos, anchor_char)
};
let clamped_start = start_char.min(text_len);
let clamped_end = end_char.min(text_len);
let before: String = text.chars().take(clamped_start).collect();
let highlighted: String = text.chars()
.skip(clamped_start)
.take(clamped_end.saturating_sub(clamped_start) + 1)
.collect();
let after: String = text.chars().skip(clamped_end + 1).collect();
Line::from(vec![
Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight),
])
} else {
// Multi-field selection
Line::from(Span::styled(text, highlight_style))
}
} else {
Line::from(Span::styled(
text,
if is_active { normal_style_in_highlight } else { normal_style_outside }
))
}
}
/// Apply linewise highlighting
#[cfg(feature = "gui")]
fn apply_linewise_highlighting<'a, T: CanvasTheme>(
text: &'a str,
field_index: usize,
current_field_idx: &usize,
anchor_line: &usize,
theme: &T,
is_active: bool,
) -> Line<'a> {
let start_field = min(*anchor_line, *current_field_idx);
let end_field = max(*anchor_line, *current_field_idx);
let highlight_style = Style::default()
.fg(theme.highlight())
.bg(theme.highlight_bg())
.add_modifier(Modifier::BOLD);
let normal_style_in_highlight = Style::default().fg(theme.highlight());
let normal_style_outside = Style::default().fg(theme.fg());
if field_index >= start_field && field_index <= end_field {
Line::from(Span::styled(text, highlight_style))
} else {
Line::from(Span::styled(
text,
if is_active { normal_style_in_highlight } else { normal_style_outside }
))
}
}
/// Set cursor position
#[cfg(feature = "gui")]
fn set_cursor_position(
f: &mut Frame,
field_rect: Rect,
text: &str,
current_cursor_pos: usize,
has_display_override: bool,
) {
let cursor_x = if has_display_override {
field_rect.x + text.chars().count() as u16
} else {
field_rect.x + current_cursor_pos as u16
};
let cursor_y = field_rect.y;
f.set_cursor_position((cursor_x, cursor_y));
}

20
canvas/src/canvas/mod.rs Normal file
View File

@@ -0,0 +1,20 @@
// src/canvas/mod.rs
pub mod actions;
pub mod gui;
pub mod modes;
pub mod state;
pub mod theme;
// Re-export commonly used canvas types
pub use actions::{CanvasAction, ActionResult};
pub use modes::{AppMode, ModeManager, HighlightState};
pub use state::{CanvasState, ActionContext};
// Re-export the main entry point
pub use crate::dispatcher::execute_canvas_action;
#[cfg(feature = "gui")]
pub use theme::CanvasTheme;
#[cfg(feature = "gui")]
pub use gui::render_canvas;

View File

@@ -0,0 +1,15 @@
// src/state/app/highlight.rs
// canvas/src/modes/highlight.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HighlightState {
Off,
Characterwise { anchor: (usize, usize) }, // (field_index, char_position)
Linewise { anchor_line: usize }, // field_index
}
impl Default for HighlightState {
fn default() -> Self {
HighlightState::Off
}
}

View File

@@ -0,0 +1,33 @@
// src/modes/handlers/mode_manager.rs
// canvas/src/modes/manager.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
General, // For intro and admin screens
ReadOnly, // Canvas read-only mode
Edit, // Canvas edit mode
Highlight, // Canvas highlight/visual mode
Command, // Command mode overlay
}
pub struct ModeManager;
impl ModeManager {
// Mode transition rules
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
!matches!(current_mode, AppMode::Edit)
}
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::ReadOnly)
}
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
}
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::ReadOnly)
}
}

View File

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

View File

@@ -0,0 +1,56 @@
// src/canvas/state.rs
use crate::canvas::actions::CanvasAction;
use crate::canvas::modes::AppMode;
/// Context passed to feature-specific action handlers
#[derive(Debug)]
pub struct ActionContext {
pub key_code: Option<crossterm::event::KeyCode>, // Kept for backwards compatibility
pub ideal_cursor_column: usize,
pub current_input: String,
pub current_field: usize,
}
/// Core trait that any form-like state must implement to work with the canvas system.
/// This enables the same mode behaviors (edit, read-only, highlight) to work across
/// any implementation - login forms, data entry forms, configuration screens, etc.
pub trait CanvasState {
// --- Core Navigation ---
fn current_field(&self) -> usize;
fn current_cursor_pos(&self) -> usize;
fn set_current_field(&mut self, index: usize);
fn set_current_cursor_pos(&mut self, pos: usize);
// --- Mode Information ---
fn current_mode(&self) -> AppMode;
// --- Data Access ---
fn get_current_input(&self) -> &str;
fn get_current_input_mut(&mut self) -> &mut String;
fn inputs(&self) -> Vec<&String>;
fn fields(&self) -> Vec<&str>;
// --- State Management ---
fn has_unsaved_changes(&self) -> bool;
fn set_has_unsaved_changes(&mut self, changed: bool);
// --- Feature-specific action handling ---
/// Feature-specific action handling (Type-safe)
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
None // Default: no feature-specific handling
}
// --- Display Overrides (for links, computed values, etc.) ---
fn get_display_value_for_field(&self, index: usize) -> &str {
self.inputs()
.get(index)
.map(|s| s.as_str())
.unwrap_or("")
}
fn has_display_override(&self, _index: usize) -> bool {
false
}
}

View File

@@ -0,0 +1,17 @@
// canvas/src/gui/theme.rs
#[cfg(feature = "gui")]
use ratatui::style::Color;
/// Theme trait that must be implemented by applications using the canvas GUI
#[cfg(feature = "gui")]
pub trait CanvasTheme {
fn bg(&self) -> Color;
fn fg(&self) -> Color;
fn border(&self) -> Color;
fn accent(&self) -> Color;
fn secondary(&self) -> Color;
fn highlight(&self) -> Color;
fn highlight_bg(&self) -> Color;
fn warning(&self) -> Color;
}

363
canvas/src/config/config.rs Normal file
View File

@@ -0,0 +1,363 @@
// canvas/src/config.rs
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crossterm::event::{KeyCode, KeyModifiers};
use anyhow::{Context, Result};
use super::registry::{ActionRegistry, ActionSpec, ModeRegistry};
use super::validation::{ConfigValidator, ValidationError, ValidationResult, ValidationWarning};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasConfig {
#[serde(default)]
pub keybindings: CanvasKeybindings,
#[serde(default)]
pub behavior: CanvasBehavior,
#[serde(default)]
pub appearance: CanvasAppearance,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CanvasKeybindings {
#[serde(default)]
pub read_only: HashMap<String, Vec<String>>,
#[serde(default)]
pub edit: HashMap<String, Vec<String>>,
#[serde(default)]
pub suggestions: HashMap<String, Vec<String>>,
#[serde(default)]
pub global: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasBehavior {
#[serde(default = "default_wrap_around")]
pub wrap_around_fields: bool,
#[serde(default = "default_auto_save")]
pub auto_save_on_field_change: bool,
#[serde(default = "default_word_chars")]
pub word_chars: String,
#[serde(default = "default_suggestion_limit")]
pub max_suggestions: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasAppearance {
#[serde(default = "default_cursor_style")]
pub cursor_style: String, // "block", "bar", "underline"
#[serde(default = "default_show_field_numbers")]
pub show_field_numbers: bool,
#[serde(default = "default_highlight_current_field")]
pub highlight_current_field: bool,
}
// Default values
fn default_wrap_around() -> bool { true }
fn default_auto_save() -> bool { false }
fn default_word_chars() -> String { "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_".to_string() }
fn default_suggestion_limit() -> usize { 10 }
fn default_cursor_style() -> String { "block".to_string() }
fn default_show_field_numbers() -> bool { false }
fn default_highlight_current_field() -> bool { true }
impl Default for CanvasBehavior {
fn default() -> Self {
Self {
wrap_around_fields: default_wrap_around(),
auto_save_on_field_change: default_auto_save(),
word_chars: default_word_chars(),
max_suggestions: default_suggestion_limit(),
}
}
}
impl Default for CanvasAppearance {
fn default() -> Self {
Self {
cursor_style: default_cursor_style(),
show_field_numbers: default_show_field_numbers(),
highlight_current_field: default_highlight_current_field(),
}
}
}
impl Default for CanvasConfig {
fn default() -> Self {
Self {
keybindings: CanvasKeybindings::with_vim_defaults(),
behavior: CanvasBehavior::default(),
appearance: CanvasAppearance::default(),
}
}
}
impl CanvasKeybindings {
pub fn with_vim_defaults() -> Self {
let mut keybindings = Self::default();
// Read-only mode (vim-style navigation)
keybindings.read_only.insert("move_left".to_string(), vec!["h".to_string()]);
keybindings.read_only.insert("move_right".to_string(), vec!["l".to_string()]);
keybindings.read_only.insert("move_up".to_string(), vec!["k".to_string()]);
keybindings.read_only.insert("move_down".to_string(), vec!["j".to_string()]);
keybindings.read_only.insert("move_word_next".to_string(), vec!["w".to_string()]);
keybindings.read_only.insert("move_word_end".to_string(), vec!["e".to_string()]);
keybindings.read_only.insert("move_word_prev".to_string(), vec!["b".to_string()]);
keybindings.read_only.insert("move_word_end_prev".to_string(), vec!["ge".to_string()]);
keybindings.read_only.insert("move_line_start".to_string(), vec!["0".to_string()]);
keybindings.read_only.insert("move_line_end".to_string(), vec!["$".to_string()]);
keybindings.read_only.insert("move_first_line".to_string(), vec!["gg".to_string()]);
keybindings.read_only.insert("move_last_line".to_string(), vec!["G".to_string()]);
keybindings.read_only.insert("next_field".to_string(), vec!["Tab".to_string()]);
keybindings.read_only.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
// Edit mode
keybindings.edit.insert("delete_char_backward".to_string(), vec!["Backspace".to_string()]);
keybindings.edit.insert("delete_char_forward".to_string(), vec!["Delete".to_string()]);
keybindings.edit.insert("move_left".to_string(), vec!["Left".to_string()]);
keybindings.edit.insert("move_right".to_string(), vec!["Right".to_string()]);
keybindings.edit.insert("move_up".to_string(), vec!["Up".to_string()]);
keybindings.edit.insert("move_down".to_string(), vec!["Down".to_string()]);
keybindings.edit.insert("move_line_start".to_string(), vec!["Home".to_string()]);
keybindings.edit.insert("move_line_end".to_string(), vec!["End".to_string()]);
keybindings.edit.insert("move_word_next".to_string(), vec!["Ctrl+Right".to_string()]);
keybindings.edit.insert("move_word_prev".to_string(), vec!["Ctrl+Left".to_string()]);
keybindings.edit.insert("next_field".to_string(), vec!["Tab".to_string()]);
keybindings.edit.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
// Suggestions
keybindings.suggestions.insert("suggestion_up".to_string(), vec!["Up".to_string(), "Ctrl+p".to_string()]);
keybindings.suggestions.insert("suggestion_down".to_string(), vec!["Down".to_string(), "Ctrl+n".to_string()]);
keybindings.suggestions.insert("select_suggestion".to_string(), vec!["Enter".to_string(), "Tab".to_string()]);
keybindings.suggestions.insert("exit_suggestions".to_string(), vec!["Esc".to_string()]);
// Global (works in both modes)
keybindings.global.insert("move_up".to_string(), vec!["Up".to_string()]);
keybindings.global.insert("move_down".to_string(), vec!["Down".to_string()]);
keybindings
}
pub fn with_emacs_defaults() -> Self {
let mut keybindings = Self::default();
// Emacs-style bindings
keybindings.read_only.insert("move_left".to_string(), vec!["Ctrl+b".to_string()]);
keybindings.read_only.insert("move_right".to_string(), vec!["Ctrl+f".to_string()]);
keybindings.read_only.insert("move_up".to_string(), vec!["Ctrl+p".to_string()]);
keybindings.read_only.insert("move_down".to_string(), vec!["Ctrl+n".to_string()]);
keybindings.read_only.insert("move_word_next".to_string(), vec!["Alt+f".to_string()]);
keybindings.read_only.insert("move_word_prev".to_string(), vec!["Alt+b".to_string()]);
keybindings.read_only.insert("move_line_start".to_string(), vec!["Ctrl+a".to_string()]);
keybindings.read_only.insert("move_line_end".to_string(), vec!["Ctrl+e".to_string()]);
keybindings.edit.insert("delete_char_backward".to_string(), vec!["Ctrl+h".to_string(), "Backspace".to_string()]);
keybindings.edit.insert("delete_char_forward".to_string(), vec!["Ctrl+d".to_string(), "Delete".to_string()]);
keybindings
}
}
impl CanvasConfig {
/// NEW: Load and validate configuration
pub fn load() -> Self {
match Self::load_and_validate() {
Ok(config) => config,
Err(e) => {
eprintln!("⚠️ Canvas config validation failed: {}", e);
eprintln!(" Using vim defaults. Run CanvasConfig::generate_template() for help.");
Self::default()
}
}
}
/// NEW: Load configuration with validation
pub fn load_and_validate() -> Result<Self> {
// Try to load canvas_config.toml from current directory
let config = if let Ok(config) = Self::from_file(std::path::Path::new("canvas_config.toml")) {
config
} else {
// Fallback to vim defaults
Self::default()
};
// Validate the configuration
let validator = ConfigValidator::new();
let validation_result = validator.validate_keybindings(&config.keybindings);
if !validation_result.is_valid {
// Print validation errors
validator.print_validation_result(&validation_result);
// Create error with suggestions
let error_msg = format!(
"Configuration validation failed with {} errors",
validation_result.errors.len()
);
return Err(anyhow::anyhow!(error_msg));
}
// Print warnings if any
if !validation_result.warnings.is_empty() {
validator.print_validation_result(&validation_result);
}
Ok(config)
}
/// NEW: Generate a complete configuration template
pub fn generate_template() -> String {
let registry = ActionRegistry::new();
registry.generate_config_template()
}
/// NEW: Generate a clean, minimal configuration template
pub fn generate_clean_template() -> String {
let registry = ActionRegistry::new();
registry.generate_clean_template()
}
/// NEW: Validate current configuration
pub fn validate(&self) -> ValidationResult {
let validator = ConfigValidator::new();
validator.validate_keybindings(&self.keybindings)
}
/// NEW: Print validation results for current config
pub fn print_validation(&self) {
let validator = ConfigValidator::new();
let result = validator.validate_keybindings(&self.keybindings);
validator.print_validation_result(&result);
}
/// NEW: Generate config for missing required actions
pub fn generate_missing_config(&self) -> String {
let validator = ConfigValidator::new();
validator.generate_missing_config(&self.keybindings)
}
/// Load from TOML string
pub fn from_toml(toml_str: &str) -> Result<Self> {
toml::from_str(toml_str)
.with_context(|| "Failed to parse canvas config TOML")
}
/// Load from file
pub fn from_file(path: &std::path::Path) -> Result<Self> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {:?}", path))?;
Self::from_toml(&contents)
}
/// NEW: Check if autocomplete should auto-trigger (simple logic)
pub fn should_auto_trigger_autocomplete(&self) -> bool {
// If trigger_autocomplete keybinding exists anywhere, use manual mode only
// If no trigger_autocomplete keybinding, use auto-trigger mode
!self.has_trigger_autocomplete_keybinding()
}
/// NEW: Check if user has configured manual trigger keybinding
pub fn has_trigger_autocomplete_keybinding(&self) -> bool {
self.keybindings.edit.contains_key("trigger_autocomplete") ||
self.keybindings.read_only.contains_key("trigger_autocomplete") ||
self.keybindings.global.contains_key("trigger_autocomplete")
}
// ... rest of your existing methods stay the same ...
/// Get action for key in read-only mode
pub fn get_read_only_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_in_mode(&self.keybindings.read_only, key, modifiers)
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
}
/// Get action for key in edit mode
pub fn get_edit_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_in_mode(&self.keybindings.edit, key, modifiers)
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
}
/// Get action for key in suggestions mode
pub fn get_suggestion_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_in_mode(&self.keybindings.suggestions, key, modifiers)
}
/// Get action for key (mode-aware)
pub fn get_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers, is_edit_mode: bool, has_suggestions: bool) -> Option<&str> {
// Suggestions take priority when active
if has_suggestions {
if let Some(action) = self.get_suggestion_action(key, modifiers) {
return Some(action);
}
}
// Then check mode-specific
if is_edit_mode {
self.get_edit_action(key, modifiers)
} else {
self.get_read_only_action(key, modifiers)
}
}
// ... keep all your existing private methods ...
fn get_action_in_mode<'a>(&self, mode_bindings: &'a HashMap<String, Vec<String>>, key: KeyCode, modifiers: KeyModifiers) -> Option<&'a str> {
for (action, bindings) in mode_bindings {
for binding in bindings {
if self.matches_keybinding(binding, key, modifiers) {
return Some(action);
}
}
}
None
}
fn matches_keybinding(&self, binding: &str, key: KeyCode, modifiers: KeyModifiers) -> bool {
// ... keep all your existing key matching logic ...
// (This is a very long method, so I'm just indicating to keep it as-is)
// Your existing implementation here...
true // placeholder - use your actual implementation
}
/// Convenience method to create vim preset
pub fn vim_preset() -> Self {
Self {
keybindings: CanvasKeybindings::with_vim_defaults(),
behavior: CanvasBehavior::default(),
appearance: CanvasAppearance::default(),
}
}
/// Convenience method to create emacs preset
pub fn emacs_preset() -> Self {
Self {
keybindings: CanvasKeybindings::with_emacs_defaults(),
behavior: CanvasBehavior::default(),
appearance: CanvasAppearance::default(),
}
}
/// Debug method to print loaded keybindings
pub fn debug_keybindings(&self) {
println!("📋 Canvas keybindings loaded:");
println!(" Read-only: {} actions", self.keybindings.read_only.len());
println!(" Edit: {} actions", self.keybindings.edit.len());
println!(" Suggestions: {} actions", self.keybindings.suggestions.len());
println!(" Global: {} actions", self.keybindings.global.len());
// NEW: Show validation status
let validation = self.validate();
if validation.is_valid {
println!(" ✅ Configuration is valid");
} else {
println!(" ❌ Configuration has {} errors", validation.errors.len());
}
if !validation.warnings.is_empty() {
println!(" ⚠️ Configuration has {} warnings", validation.warnings.len());
}
}
}
// Re-export for convenience
pub use crate::canvas::actions::CanvasAction;
pub use crate::dispatcher::ActionDispatcher;

10
canvas/src/config/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
// src/config/mod.rs
mod registry;
mod config;
mod validation;
// Re-export everything from the main config module
pub use registry::*;
pub use validation::*;
pub use config::*;

View File

@@ -0,0 +1,451 @@
// src/config/registry.rs
use std::collections::HashMap;
use crate::canvas::modes::AppMode;
#[derive(Debug, Clone)]
pub struct ActionSpec {
pub name: String,
pub description: String,
pub examples: Vec<String>,
pub mode_specific: bool, // true if different behavior per mode
}
#[derive(Debug, Clone)]
pub struct ModeRegistry {
pub required: HashMap<String, ActionSpec>,
pub optional: HashMap<String, ActionSpec>,
pub auto_handled: Vec<String>, // Never appear in config
}
#[derive(Debug, Clone)]
pub struct ActionRegistry {
pub edit_mode: ModeRegistry,
pub readonly_mode: ModeRegistry,
pub suggestions: ModeRegistry,
pub global: ModeRegistry,
}
impl ActionRegistry {
pub fn new() -> Self {
Self {
edit_mode: Self::edit_mode_registry(),
readonly_mode: Self::readonly_mode_registry(),
suggestions: Self::suggestions_registry(),
global: Self::global_registry(),
}
}
fn edit_mode_registry() -> ModeRegistry {
let mut required = HashMap::new();
let mut optional = HashMap::new();
// REQUIRED - These MUST be configured
required.insert("move_left".to_string(), ActionSpec {
name: "move_left".to_string(),
description: "Move cursor one position to the left".to_string(),
examples: vec!["Left".to_string(), "h".to_string()],
mode_specific: false,
});
required.insert("move_right".to_string(), ActionSpec {
name: "move_right".to_string(),
description: "Move cursor one position to the right".to_string(),
examples: vec!["Right".to_string(), "l".to_string()],
mode_specific: false,
});
required.insert("move_up".to_string(), ActionSpec {
name: "move_up".to_string(),
description: "Move to previous field or line".to_string(),
examples: vec!["Up".to_string(), "k".to_string()],
mode_specific: false,
});
required.insert("move_down".to_string(), ActionSpec {
name: "move_down".to_string(),
description: "Move to next field or line".to_string(),
examples: vec!["Down".to_string(), "j".to_string()],
mode_specific: false,
});
required.insert("delete_char_backward".to_string(), ActionSpec {
name: "delete_char_backward".to_string(),
description: "Delete character before cursor".to_string(),
examples: vec!["Backspace".to_string()],
mode_specific: false,
});
required.insert("next_field".to_string(), ActionSpec {
name: "next_field".to_string(),
description: "Move to next input field".to_string(),
examples: vec!["Tab".to_string(), "Enter".to_string()],
mode_specific: false,
});
required.insert("prev_field".to_string(), ActionSpec {
name: "prev_field".to_string(),
description: "Move to previous input field".to_string(),
examples: vec!["Shift+Tab".to_string()],
mode_specific: false,
});
// OPTIONAL - These can be configured or omitted
optional.insert("move_word_next".to_string(), ActionSpec {
name: "move_word_next".to_string(),
description: "Move cursor to start of next word".to_string(),
examples: vec!["Ctrl+Right".to_string(), "w".to_string()],
mode_specific: false,
});
optional.insert("move_word_prev".to_string(), ActionSpec {
name: "move_word_prev".to_string(),
description: "Move cursor to start of previous word".to_string(),
examples: vec!["Ctrl+Left".to_string(), "b".to_string()],
mode_specific: false,
});
optional.insert("move_word_end".to_string(), ActionSpec {
name: "move_word_end".to_string(),
description: "Move cursor to end of current/next word".to_string(),
examples: vec!["e".to_string()],
mode_specific: false,
});
optional.insert("move_word_end_prev".to_string(), ActionSpec {
name: "move_word_end_prev".to_string(),
description: "Move cursor to end of previous word".to_string(),
examples: vec!["ge".to_string()],
mode_specific: false,
});
optional.insert("move_line_start".to_string(), ActionSpec {
name: "move_line_start".to_string(),
description: "Move cursor to beginning of line".to_string(),
examples: vec!["Home".to_string(), "0".to_string()],
mode_specific: false,
});
optional.insert("move_line_end".to_string(), ActionSpec {
name: "move_line_end".to_string(),
description: "Move cursor to end of line".to_string(),
examples: vec!["End".to_string(), "$".to_string()],
mode_specific: false,
});
optional.insert("move_first_line".to_string(), ActionSpec {
name: "move_first_line".to_string(),
description: "Move to first field".to_string(),
examples: vec!["Ctrl+Home".to_string(), "gg".to_string()],
mode_specific: false,
});
optional.insert("move_last_line".to_string(), ActionSpec {
name: "move_last_line".to_string(),
description: "Move to last field".to_string(),
examples: vec!["Ctrl+End".to_string(), "G".to_string()],
mode_specific: false,
});
optional.insert("delete_char_forward".to_string(), ActionSpec {
name: "delete_char_forward".to_string(),
description: "Delete character after cursor".to_string(),
examples: vec!["Delete".to_string()],
mode_specific: false,
});
ModeRegistry {
required,
optional,
auto_handled: vec![
"insert_char".to_string(), // Any printable character
],
}
}
fn readonly_mode_registry() -> ModeRegistry {
let mut required = HashMap::new();
let mut optional = HashMap::new();
// REQUIRED - Navigation is essential in read-only mode
required.insert("move_left".to_string(), ActionSpec {
name: "move_left".to_string(),
description: "Move cursor one position to the left".to_string(),
examples: vec!["h".to_string(), "Left".to_string()],
mode_specific: true,
});
required.insert("move_right".to_string(), ActionSpec {
name: "move_right".to_string(),
description: "Move cursor one position to the right".to_string(),
examples: vec!["l".to_string(), "Right".to_string()],
mode_specific: true,
});
required.insert("move_up".to_string(), ActionSpec {
name: "move_up".to_string(),
description: "Move to previous field".to_string(),
examples: vec!["k".to_string(), "Up".to_string()],
mode_specific: true,
});
required.insert("move_down".to_string(), ActionSpec {
name: "move_down".to_string(),
description: "Move to next field".to_string(),
examples: vec!["j".to_string(), "Down".to_string()],
mode_specific: true,
});
// OPTIONAL - Advanced navigation
optional.insert("move_word_next".to_string(), ActionSpec {
name: "move_word_next".to_string(),
description: "Move cursor to start of next word".to_string(),
examples: vec!["w".to_string()],
mode_specific: true,
});
optional.insert("move_word_prev".to_string(), ActionSpec {
name: "move_word_prev".to_string(),
description: "Move cursor to start of previous word".to_string(),
examples: vec!["b".to_string()],
mode_specific: true,
});
optional.insert("move_word_end".to_string(), ActionSpec {
name: "move_word_end".to_string(),
description: "Move cursor to end of current/next word".to_string(),
examples: vec!["e".to_string()],
mode_specific: true,
});
optional.insert("move_word_end_prev".to_string(), ActionSpec {
name: "move_word_end_prev".to_string(),
description: "Move cursor to end of previous word".to_string(),
examples: vec!["ge".to_string()],
mode_specific: true,
});
optional.insert("move_line_start".to_string(), ActionSpec {
name: "move_line_start".to_string(),
description: "Move cursor to beginning of line".to_string(),
examples: vec!["0".to_string()],
mode_specific: true,
});
optional.insert("move_line_end".to_string(), ActionSpec {
name: "move_line_end".to_string(),
description: "Move cursor to end of line".to_string(),
examples: vec!["$".to_string()],
mode_specific: true,
});
optional.insert("move_first_line".to_string(), ActionSpec {
name: "move_first_line".to_string(),
description: "Move to first field".to_string(),
examples: vec!["gg".to_string()],
mode_specific: true,
});
optional.insert("move_last_line".to_string(), ActionSpec {
name: "move_last_line".to_string(),
description: "Move to last field".to_string(),
examples: vec!["G".to_string()],
mode_specific: true,
});
optional.insert("next_field".to_string(), ActionSpec {
name: "next_field".to_string(),
description: "Move to next input field".to_string(),
examples: vec!["Tab".to_string()],
mode_specific: true,
});
optional.insert("prev_field".to_string(), ActionSpec {
name: "prev_field".to_string(),
description: "Move to previous input field".to_string(),
examples: vec!["Shift+Tab".to_string()],
mode_specific: true,
});
ModeRegistry {
required,
optional,
auto_handled: vec![], // Read-only mode has no auto-handled actions
}
}
fn suggestions_registry() -> ModeRegistry {
let mut required = HashMap::new();
// REQUIRED - Essential for suggestion navigation
required.insert("suggestion_up".to_string(), ActionSpec {
name: "suggestion_up".to_string(),
description: "Move selection to previous suggestion".to_string(),
examples: vec!["Up".to_string(), "Ctrl+p".to_string()],
mode_specific: false,
});
required.insert("suggestion_down".to_string(), ActionSpec {
name: "suggestion_down".to_string(),
description: "Move selection to next suggestion".to_string(),
examples: vec!["Down".to_string(), "Ctrl+n".to_string()],
mode_specific: false,
});
required.insert("select_suggestion".to_string(), ActionSpec {
name: "select_suggestion".to_string(),
description: "Select the currently highlighted suggestion".to_string(),
examples: vec!["Enter".to_string(), "Tab".to_string()],
mode_specific: false,
});
required.insert("exit_suggestions".to_string(), ActionSpec {
name: "exit_suggestions".to_string(),
description: "Close suggestions without selecting".to_string(),
examples: vec!["Esc".to_string()],
mode_specific: false,
});
ModeRegistry {
required,
optional: HashMap::new(),
auto_handled: vec![],
}
}
fn global_registry() -> ModeRegistry {
let mut optional = HashMap::new();
// OPTIONAL - Global overrides
optional.insert("move_up".to_string(), ActionSpec {
name: "move_up".to_string(),
description: "Global override for up movement".to_string(),
examples: vec!["Up".to_string()],
mode_specific: false,
});
optional.insert("move_down".to_string(), ActionSpec {
name: "move_down".to_string(),
description: "Global override for down movement".to_string(),
examples: vec!["Down".to_string()],
mode_specific: false,
});
ModeRegistry {
required: HashMap::new(),
optional,
auto_handled: vec![],
}
}
pub fn get_mode_registry(&self, mode: &str) -> &ModeRegistry {
match mode {
"edit" => &self.edit_mode,
"read_only" => &self.readonly_mode,
"suggestions" => &self.suggestions,
"global" => &self.global,
_ => &self.global, // fallback
}
}
pub fn all_known_actions(&self) -> Vec<String> {
let mut actions = Vec::new();
for registry in [&self.edit_mode, &self.readonly_mode, &self.suggestions, &self.global] {
actions.extend(registry.required.keys().cloned());
actions.extend(registry.optional.keys().cloned());
}
actions.sort();
actions.dedup();
actions
}
pub fn generate_config_template(&self) -> String {
let mut template = String::new();
template.push_str("# Canvas Library Configuration Template\n");
template.push_str("# Generated automatically - customize as needed\n\n");
template.push_str("[keybindings.edit]\n");
template.push_str("# REQUIRED ACTIONS - These must be configured\n");
for (name, spec) in &self.edit_mode.required {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("{} = {:?}\n\n", name, spec.examples));
}
template.push_str("# OPTIONAL ACTIONS - Configure these if you want them enabled\n");
for (name, spec) in &self.edit_mode.optional {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("# {} = {:?}\n\n", name, spec.examples));
}
template.push_str("[keybindings.read_only]\n");
template.push_str("# REQUIRED ACTIONS - These must be configured\n");
for (name, spec) in &self.readonly_mode.required {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("{} = {:?}\n\n", name, spec.examples));
}
template.push_str("# OPTIONAL ACTIONS - Configure these if you want them enabled\n");
for (name, spec) in &self.readonly_mode.optional {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("# {} = {:?}\n\n", name, spec.examples));
}
template.push_str("[keybindings.suggestions]\n");
template.push_str("# REQUIRED ACTIONS - These must be configured\n");
for (name, spec) in &self.suggestions.required {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("{} = {:?}\n\n", name, spec.examples));
}
template
}
pub fn generate_clean_template(&self) -> String {
let mut template = String::new();
// Edit Mode
template.push_str("[keybindings.edit]\n");
template.push_str("# Required\n");
for (name, spec) in &self.edit_mode.required {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
template.push_str("# Optional\n");
for (name, spec) in &self.edit_mode.optional {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
template.push('\n');
// Read-Only Mode
template.push_str("[keybindings.read_only]\n");
template.push_str("# Required\n");
for (name, spec) in &self.readonly_mode.required {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
template.push_str("# Optional\n");
for (name, spec) in &self.readonly_mode.optional {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
template.push('\n');
// Suggestions Mode
template.push_str("[keybindings.suggestions]\n");
template.push_str("# Required\n");
for (name, spec) in &self.suggestions.required {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
template.push('\n');
// Global (all optional)
if !self.global.optional.is_empty() {
template.push_str("[keybindings.global]\n");
template.push_str("# Optional\n");
for (name, spec) in &self.global.optional {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
}
template
}
}

View File

@@ -0,0 +1,279 @@
// src/config/validation.rs
use std::collections::HashMap;
use thiserror::Error;
use crate::config::registry::{ActionRegistry, ModeRegistry};
use crate::config::CanvasKeybindings;
#[derive(Error, Debug)]
pub enum ValidationError {
#[error("Missing required action '{action}' in {mode} mode")]
MissingRequired {
action: String,
mode: String,
suggestion: String,
},
#[error("Unknown action '{action}' in {mode} mode")]
UnknownAction {
action: String,
mode: String,
similar: Vec<String>,
},
#[error("Multiple validation errors")]
Multiple(Vec<ValidationError>),
}
#[derive(Debug)]
pub struct ValidationWarning {
pub message: String,
pub suggestion: Option<String>,
}
#[derive(Debug)]
pub struct ValidationResult {
pub errors: Vec<ValidationError>,
pub warnings: Vec<ValidationWarning>,
pub is_valid: bool,
}
impl ValidationResult {
pub fn new() -> Self {
Self {
errors: Vec::new(),
warnings: Vec::new(),
is_valid: true,
}
}
pub fn add_error(&mut self, error: ValidationError) {
self.errors.push(error);
self.is_valid = false;
}
pub fn add_warning(&mut self, warning: ValidationWarning) {
self.warnings.push(warning);
}
pub fn merge(&mut self, other: ValidationResult) {
self.errors.extend(other.errors);
self.warnings.extend(other.warnings);
if !other.is_valid {
self.is_valid = false;
}
}
}
pub struct ConfigValidator {
registry: ActionRegistry,
}
impl ConfigValidator {
pub fn new() -> Self {
Self {
registry: ActionRegistry::new(),
}
}
pub fn validate_keybindings(&self, keybindings: &CanvasKeybindings) -> ValidationResult {
let mut result = ValidationResult::new();
// Validate each mode
result.merge(self.validate_mode_bindings(
"edit",
&keybindings.edit,
self.registry.get_mode_registry("edit")
));
result.merge(self.validate_mode_bindings(
"read_only",
&keybindings.read_only,
self.registry.get_mode_registry("read_only")
));
result.merge(self.validate_mode_bindings(
"suggestions",
&keybindings.suggestions,
self.registry.get_mode_registry("suggestions")
));
result.merge(self.validate_mode_bindings(
"global",
&keybindings.global,
self.registry.get_mode_registry("global")
));
result
}
fn validate_mode_bindings(
&self,
mode_name: &str,
bindings: &HashMap<String, Vec<String>>,
registry: &ModeRegistry
) -> ValidationResult {
let mut result = ValidationResult::new();
// Check for missing required actions
for (action_name, spec) in &registry.required {
if !bindings.contains_key(action_name) {
result.add_error(ValidationError::MissingRequired {
action: action_name.clone(),
mode: mode_name.to_string(),
suggestion: format!(
"Add to config: {} = {:?}",
action_name,
spec.examples
),
});
}
}
// Check for unknown actions
let all_known: std::collections::HashSet<_> = registry.required.keys()
.chain(registry.optional.keys())
.collect();
for action_name in bindings.keys() {
if !all_known.contains(action_name) {
let similar = self.find_similar_actions(action_name, &all_known);
result.add_error(ValidationError::UnknownAction {
action: action_name.clone(),
mode: mode_name.to_string(),
similar,
});
}
}
// Check for empty keybinding arrays
for (action_name, key_list) in bindings {
if key_list.is_empty() {
result.add_warning(ValidationWarning {
message: format!(
"Action '{}' in {} mode has empty keybinding list",
action_name, mode_name
),
suggestion: Some(format!(
"Either add keybindings or remove the action from config"
)),
});
}
}
// Warn about auto-handled actions that shouldn't be in config
for auto_action in &registry.auto_handled {
if bindings.contains_key(auto_action) {
result.add_warning(ValidationWarning {
message: format!(
"Action '{}' in {} mode is auto-handled and shouldn't be in config",
auto_action, mode_name
),
suggestion: Some(format!(
"Remove '{}' from config - it's handled automatically",
auto_action
)),
});
}
}
result
}
fn find_similar_actions(&self, action: &str, known_actions: &std::collections::HashSet<&String>) -> Vec<String> {
let mut similar = Vec::new();
for known in known_actions {
if self.is_similar(action, known) {
similar.push(known.to_string());
}
}
similar.sort();
similar.truncate(3); // Limit to 3 suggestions
similar
}
fn is_similar(&self, a: &str, b: &str) -> bool {
// Simple similarity check - could be improved with proper edit distance
let a_lower = a.to_lowercase();
let b_lower = b.to_lowercase();
// Check if one contains the other
if a_lower.contains(&b_lower) || b_lower.contains(&a_lower) {
return true;
}
// Check for common prefixes
let common_prefixes = ["move_", "delete_", "suggestion_"];
for prefix in &common_prefixes {
if a_lower.starts_with(prefix) && b_lower.starts_with(prefix) {
return true;
}
}
false
}
pub fn print_validation_result(&self, result: &ValidationResult) {
if result.is_valid && result.warnings.is_empty() {
println!("✅ Canvas configuration is valid!");
return;
}
if !result.errors.is_empty() {
println!("❌ Canvas configuration has errors:");
for error in &result.errors {
match error {
ValidationError::MissingRequired { action, mode, suggestion } => {
println!(" • Missing required action '{}' in {} mode", action, mode);
println!(" 💡 {}", suggestion);
}
ValidationError::UnknownAction { action, mode, similar } => {
println!(" • Unknown action '{}' in {} mode", action, mode);
if !similar.is_empty() {
println!(" 💡 Did you mean: {}", similar.join(", "));
}
}
ValidationError::Multiple(_) => {
println!(" • Multiple errors occurred");
}
}
println!();
}
}
if !result.warnings.is_empty() {
println!("⚠️ Canvas configuration has warnings:");
for warning in &result.warnings {
println!("{}", warning.message);
if let Some(suggestion) = &warning.suggestion {
println!(" 💡 {}", suggestion);
}
println!();
}
}
if !result.is_valid {
println!("🔧 To generate a config template, use:");
println!(" CanvasConfig::generate_template()");
}
}
pub fn generate_missing_config(&self, keybindings: &CanvasKeybindings) -> String {
let mut config = String::new();
let validation = self.validate_keybindings(keybindings);
for error in &validation.errors {
if let ValidationError::MissingRequired { action, mode, suggestion } = error {
if config.is_empty() {
config.push_str(&format!("# Missing required actions for canvas\n\n"));
config.push_str(&format!("[keybindings.{}]\n", mode));
}
config.push_str(&format!("{}\n", suggestion));
}
}
config
}
}

110
canvas/src/dispatcher.rs Normal file
View File

@@ -0,0 +1,110 @@
// src/dispatcher.rs
use crate::canvas::state::{CanvasState, ActionContext};
use crate::canvas::actions::{CanvasAction, ActionResult};
use crate::canvas::actions::handlers::{handle_edit_action, handle_readonly_action, handle_highlight_action};
use crate::canvas::modes::AppMode;
use crate::config::CanvasConfig;
use crossterm::event::{KeyCode, KeyModifiers};
/// Main entry point for executing canvas actions
pub async fn execute_canvas_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> anyhow::Result<ActionResult> {
ActionDispatcher::dispatch_with_config(action, state, ideal_cursor_column, config).await
}
/// High-level action dispatcher that routes actions to mode-specific handlers
pub struct ActionDispatcher;
impl ActionDispatcher {
/// Dispatch any action to the appropriate mode handler
pub async fn dispatch<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> anyhow::Result<ActionResult> {
let config = CanvasConfig::load();
Self::dispatch_with_config(action, state, ideal_cursor_column, Some(&config)).await
}
/// Dispatch action with provided config
pub async fn dispatch_with_config<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> anyhow::Result<ActionResult> {
// Check for feature-specific handling first
let context = ActionContext {
key_code: None,
ideal_cursor_column: *ideal_cursor_column,
current_input: state.get_current_input().to_string(),
current_field: state.current_field(),
};
if let Some(result) = state.handle_feature_action(&action, &context) {
return Ok(ActionResult::HandledByFeature(result));
}
// Route to mode-specific handler
match state.current_mode() {
AppMode::Edit => {
handle_edit_action(action, state, ideal_cursor_column, config).await
}
AppMode::ReadOnly => {
handle_readonly_action(action, state, ideal_cursor_column, config).await
}
AppMode::Highlight => {
handle_highlight_action(action, state, ideal_cursor_column, config).await
}
AppMode::General | AppMode::Command => {
// These modes might not handle canvas actions directly
Ok(ActionResult::success_with_message("Mode does not handle canvas actions"))
}
}
}
/// Quick action dispatch from KeyCode using config
pub async fn dispatch_key<S: CanvasState>(
key: KeyCode,
modifiers: KeyModifiers,
state: &mut S,
ideal_cursor_column: &mut usize,
is_edit_mode: bool,
has_suggestions: bool,
) -> anyhow::Result<Option<ActionResult>> {
let config = CanvasConfig::load();
if let Some(action_name) = config.get_action_for_key(key, modifiers, is_edit_mode, has_suggestions) {
let action = CanvasAction::from_string(action_name);
let result = Self::dispatch_with_config(action, state, ideal_cursor_column, Some(&config)).await?;
Ok(Some(result))
} else {
Ok(None)
}
}
/// Batch dispatch multiple actions
pub async fn dispatch_batch<S: CanvasState>(
actions: Vec<CanvasAction>,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> anyhow::Result<Vec<ActionResult>> {
let mut results = Vec::new();
for action in actions {
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
let is_success = result.is_success();
results.push(result);
// Stop on first error
if !is_success {
break;
}
}
Ok(results)
}
}

11
canvas/src/lib.rs Normal file
View File

@@ -0,0 +1,11 @@
// src/lib.rs
pub mod canvas;
pub mod autocomplete;
pub mod config;
pub mod dispatcher;
// Re-export the main API for easy access
pub use dispatcher::{execute_canvas_action, ActionDispatcher};
pub use canvas::actions::{CanvasAction, ActionResult};
pub use canvas::state::{CanvasState, ActionContext};
pub use canvas::modes::{AppMode, HighlightState, ModeManager};

View File

@@ -5,16 +5,36 @@ edition.workspace = true
license.workspace = true
[dependencies]
anyhow = { workspace = true }
async-trait = "0.1.88"
common = { path = "../common" }
canvas = { path = "../canvas", features = ["gui"] }
crossterm = "0.28.1"
ratatui = { workspace = true }
crossterm = { workspace = true }
prost-types = { workspace = true }
dirs = "6.0.0"
dotenvy = "0.15.7"
lazy_static = "1.5.0"
prost = "0.13.5"
ratatui = "0.29.0"
serde = { version = "1.0.218", features = ["derive"] }
tokio = { version = "1.43.0", features = ["full", "macros"] }
toml = "0.8.20"
tonic = "0.12.3"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
time = "0.3.41"
tokio = { version = "1.44.2", features = ["full", "macros"] }
toml = { workspace = true }
tonic = "0.13.0"
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
unicode-segmentation = "1.12.0"
unicode-width.workspace = true
[features]
default = []
ui-debug = []
[dev-dependencies]
rstest = "0.25.0"
tokio-test = "0.4.4"
uuid = { version = "1.17.0", features = ["v4"] }
futures = "0.3.31"

58
client/canvas_config.toml Normal file
View File

@@ -0,0 +1,58 @@
# canvas_config.toml - Complete Canvas Configuration
[behavior]
wrap_around_fields = true
auto_save_on_field_change = false
word_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
max_suggestions = 6
[appearance]
cursor_style = "block" # "block", "bar", "underline"
show_field_numbers = false
highlight_current_field = true
# Read-only mode keybindings (vim-style)
[keybindings.read_only]
move_left = ["h"]
move_right = ["l"]
move_up = ["k"]
move_down = ["j"]
move_word_next = ["w"]
move_word_end = ["e"]
move_word_prev = ["b"]
move_word_end_prev = ["ge"]
move_line_start = ["0"]
move_line_end = ["$"]
move_first_line = ["gg"]
move_last_line = ["shift+g"]
next_field = ["Tab"]
prev_field = ["Shift+Tab"]
# Edit mode keybindings
[keybindings.edit]
delete_char_backward = ["Backspace"]
delete_char_forward = ["Delete"]
move_left = ["Left"]
move_right = ["Right"]
move_up = ["Up"]
move_down = ["Down"]
move_line_start = ["Home"]
move_line_end = ["End"]
move_word_next = ["Ctrl+Right"]
move_word_prev = ["Ctrl+Left"]
next_field = ["Tab"]
prev_field = ["Shift+Tab"]
trigger_autocomplete = ["Ctrl+p"]
# Suggestion/autocomplete keybindings
[keybindings.suggestions]
suggestion_up = ["Up", "Ctrl+p"]
suggestion_down = ["Down", "Ctrl+n"]
select_suggestion = ["Enter", "Tab"]
exit_suggestions = ["Esc"]
trigger_autocomplete = ["Tab"]
# Global keybindings (work in both modes)
[keybindings.global]
move_up = ["Up"]
move_down = ["Down"]

View File

@@ -2,6 +2,9 @@
[keybindings]
enter_command_mode = [":", "ctrl+;"]
next_buffer = ["space+b+n"]
previous_buffer = ["space+b+p"]
close_buffer = ["space+b+d"]
[keybindings.general]
move_up = ["k", "Up"]
@@ -10,20 +13,23 @@ next_option = ["l", "Right"]
previous_option = ["h", "Left"]
select = ["Enter"]
toggle_sidebar = ["ctrl+t"]
toggle_buffer_list = ["ctrl+b"]
next_field = ["Tab"]
prev_field = ["Shift+Tab"]
exit_table_scroll = ["esc"]
open_search = ["ctrl+f"]
[keybindings.common]
save = ["ctrl+s"]
quit = ["ctrl+q"]
# !!!change to space b r in the future and from edit mode
revert = ["ctrl+r"]
force_quit = ["ctrl+shift+q"]
save_and_quit = ["ctrl+shift+s"]
move_up = ["Up"]
move_down = ["Down"]
toggle_sidebar = ["ctrl+t"]
toggle_buffer_list = ["ctrl+b"]
revert = ["space+b+r"]
# MODE SPECIFIC
# READ ONLY MODE
@@ -33,27 +39,26 @@ enter_edit_mode_after = ["a"]
previous_entry = ["left","q"]
next_entry = ["right","1"]
move_left = ["h"]
move_right = ["l"]
move_up = ["k"]
move_down = ["j"]
move_word_next = ["w"]
move_word_end = ["e"]
move_word_prev = ["b"]
move_word_end_prev = ["ge"]
move_line_start = ["0"]
move_line_end = ["$"]
move_first_line = ["gg"]
move_last_line = ["x"]
enter_highlight_mode = ["v"]
enter_highlight_mode_linewise = ["ctrl+v"]
[keybindings.highlight]
exit_highlight_mode = ["esc"]
enter_highlight_mode_linewise = ["ctrl+v"]
[keybindings.edit]
exit_edit_mode = ["esc","ctrl+e"]
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
# exit_edit_mode = ["esc","ctrl+e"]
# exit_suggestion_mode = ["esc"]
# select_suggestion = ["enter"]
# next_field = ["enter"]
enter_decider = ["enter"]
prev_field = ["shift+enter"]
exit = ["esc", "ctrl+e"]
delete_char_forward = ["delete"]
delete_char_backward = ["backspace"]
next_field = ["tab", "enter"]
prev_field = ["shift+tab", "backtab"]
move_left = ["left"]
move_right = ["right"]
suggestion_down = ["ctrl+n", "tab"]
suggestion_up = ["ctrl+p", "shift+tab"]
[keybindings.command]
exit_command_mode = ["ctrl+g", "esc"]
@@ -64,6 +69,10 @@ quit = ["q"]
force_quit = ["q!"]
save_and_quit = ["wq"]
revert = ["r"]
find_file_palette_toggle = ["ff"]
[editor]
keybinding_mode = "vim" # Options: "default", "vim", "emacs"
[colors]
theme = "dark"

View File

@@ -0,0 +1,124 @@
## How Canvas Library Custom Functionality Works
### 1. **The Canvas Library Calls YOUR Custom Code First**
When you call `ActionDispatcher::dispatch()`, here's what happens:
```rust
// Inside canvas library (canvas/src/actions/edit.rs):
pub async fn execute_canvas_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
// 1. FIRST: Canvas library calls YOUR custom handler
if let Some(result) = state.handle_feature_action(&action, &context) {
return Ok(ActionResult::HandledByFeature(result)); // YOUR code handled it
}
// 2. ONLY IF your code returns None: Canvas handles generic actions
handle_generic_canvas_action(action, state, ideal_cursor_column).await
}
```
### 2. **Your Extension Point: `handle_feature_action`**
You add custom functionality by implementing `handle_feature_action` in your states:
```rust
// In src/state/pages/auth.rs
impl CanvasState for LoginState {
// ... other methods ...
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
match action {
// Custom login-specific actions
CanvasAction::Custom(action_str) if action_str == "submit_login" => {
if self.username.is_empty() || self.password.is_empty() {
Some("Please fill in all required fields".to_string())
} else {
// Trigger login process
Some(format!("Logging in user: {}", self.username))
}
}
CanvasAction::Custom(action_str) if action_str == "clear_form" => {
self.username.clear();
self.password.clear();
self.set_has_unsaved_changes(false);
Some("Login form cleared".to_string())
}
// Custom behavior for standard actions
CanvasAction::NextField => {
// Custom validation when moving between fields
if self.current_field == 0 && self.username.is_empty() {
Some("Username cannot be empty".to_string())
} else {
None // Let canvas library handle the normal field movement
}
}
// Let canvas library handle everything else
_ => None,
}
}
}
```
### 3. **Multiple Ways to Add Custom Functionality**
#### A) **Custom Actions via Config**
```toml
# In config.toml
[keybindings.edit]
submit_login = ["ctrl+enter"]
clear_form = ["ctrl+r"]
```
#### B) **Override Standard Actions**
```rust
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
match action {
CanvasAction::InsertChar('p') if self.current_field == 1 => {
// Custom behavior when typing 'p' in password field
Some("Password field - use secure input".to_string())
}
_ => None, // Let canvas handle normally
}
}
```
#### C) **Context-Aware Logic**
```rust
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
match action {
CanvasAction::MoveDown => {
// Custom logic based on current state
if context.current_field == 1 && context.current_input.len() < 8 {
Some("Password should be at least 8 characters".to_string())
} else {
None // Normal field movement
}
}
_ => None,
}
}
```
## The Canvas Library Philosophy
**Canvas Library = Generic behavior + Your extension points**
-**Canvas handles**: Character insertion, cursor movement, field navigation, etc.
-**You handle**: Validation, submission, clearing, app-specific logic
-**You decide**: Return `Some(message)` to override, `None` to use canvas default
## Summary
You **don't communicate with the library elsewhere**. Instead:
1. **Canvas library calls your code first** via `handle_feature_action`
2. **Your code decides** whether to handle the action or let canvas handle it
3. **Canvas library handles** generic form behavior when you return `None`

View File

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

View File

@@ -0,0 +1,318 @@
// src/components/admin/add_logic.rs
use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::app::state::AppState;
use crate::state::pages::add_logic::{AddLogicFocus, AddLogicState};
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState}; // Use canvas library
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
use crate::config::binds::config::EditorKeybindingMode;
// Helper function to convert between HighlightState types
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
match local {
HighlightState::Off => CanvasHighlightState::Off,
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
}
}
pub fn render_add_logic(
f: &mut Frame,
area: Rect,
theme: &Theme,
app_state: &AppState,
add_logic_state: &mut AddLogicState,
is_edit_mode: bool,
highlight_state: &HighlightState,
) {
let main_block = Block::default()
.title(" Add New Logic Script ")
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.bg));
let inner_area = main_block.inner(area);
f.render_widget(main_block, area);
// Handle full-screen script editing
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
let border_style_color = if is_edit_mode { theme.highlight } else { theme.secondary };
let border_style = Style::default().fg(border_style_color);
editor_ref.set_cursor_line_style(Style::default());
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
let script_title_hint = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(&add_logic_state.vim_state);
format!("Script {}", vim_mode_status)
}
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
if is_edit_mode {
"Script (Editing)".to_string()
} else {
"Script".to_string()
}
}
};
editor_ref.set_block(
Block::default()
.title(Span::styled(script_title_hint, Style::default().fg(theme.fg)))
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style),
);
f.render_widget(&*editor_ref, inner_area);
// Drop the editor borrow before accessing autocomplete state
drop(editor_ref);
// === SCRIPT EDITOR AUTOCOMPLETE RENDERING ===
if add_logic_state.script_editor_autocomplete_active && !add_logic_state.script_editor_suggestions.is_empty() {
// Get the current cursor position from textarea
let current_cursor = {
let editor_borrow = add_logic_state.script_content_editor.borrow();
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
};
let (cursor_line, cursor_col) = current_cursor;
// Account for TextArea's block borders (1 for each side)
let block_offset_x = 1;
let block_offset_y = 1;
// Position autocomplete at current cursor position
// Add 1 to column to position dropdown right after the cursor
let autocomplete_x = cursor_col + 1;
let autocomplete_y = cursor_line;
let input_rect = Rect {
x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)),
y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)),
width: 1, // Minimum width for positioning
height: 1,
};
// Render autocomplete dropdown
autocomplete::render_autocomplete_dropdown(
f,
input_rect,
f.area(), // Full frame area for clamping
theme,
&add_logic_state.script_editor_suggestions,
add_logic_state.script_editor_selected_suggestion_index,
);
}
return; // Exit early for fullscreen mode
}
// Regular layout with preview
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Top info
Constraint::Length(9), // Canvas for 3 inputs (each 1 line + 1 padding = 2 lines * 3 + 2 border = 8, +1 for good measure)
Constraint::Min(5), // Script preview
Constraint::Length(3), // Buttons
])
.split(inner_area);
let top_info_area = main_chunks[0];
let canvas_area = main_chunks[1];
let script_content_area = main_chunks[2];
let buttons_area = main_chunks[3];
// Top info
let profile_text = Paragraph::new(vec![
Line::from(Span::styled(
format!("Profile: {}", add_logic_state.profile_name),
Style::default().fg(theme.fg),
)),
Line::from(Span::styled(
format!(
"Table: {}",
add_logic_state
.selected_table_name
.clone()
.unwrap_or_else(|| add_logic_state.selected_table_id
.map(|id| format!("ID {}", id))
.unwrap_or_else(|| "Global (Not Selected)".to_string()))
),
Style::default().fg(theme.fg),
)),
])
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(theme.secondary)),
);
f.render_widget(profile_text, top_info_area);
// Canvas - USING CANVAS LIBRARY
let focus_on_canvas_inputs = matches!(
add_logic_state.current_focus,
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription
);
let canvas_highlight_state = convert_highlight_state(highlight_state);
let active_field_rect = render_canvas(
f,
canvas_area,
add_logic_state, // AddLogicState implements CanvasState
theme, // Theme implements CanvasTheme
is_edit_mode && focus_on_canvas_inputs,
&canvas_highlight_state,
);
// --- Render Autocomplete for Target Column ---
// `is_edit_mode` here refers to the general edit mode of the EventHandler
if is_edit_mode && add_logic_state.current_field() == 1 { // Target Column field
if add_logic_state.in_target_column_suggestion_mode && add_logic_state.show_target_column_suggestions {
if !add_logic_state.target_column_suggestions.is_empty() {
if let Some(input_rect) = active_field_rect {
autocomplete::render_autocomplete_dropdown(
f,
input_rect,
f.area(), // Full frame area for clamping
theme,
&add_logic_state.target_column_suggestions,
add_logic_state.selected_target_column_suggestion_index,
);
}
}
}
}
// Script content preview
{
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
editor_ref.set_cursor_line_style(Style::default());
let is_script_preview_focused = add_logic_state.current_focus == AddLogicFocus::ScriptContentPreview;
if is_script_preview_focused {
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
} else {
let underscore_cursor_style = Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(theme.secondary);
editor_ref.set_cursor_style(underscore_cursor_style);
}
let border_style_color = if is_script_preview_focused {
theme.highlight
} else {
theme.secondary
};
let title_text = "Script Preview"; // Title doesn't need to change based on focus here
let title_style = if is_script_preview_focused {
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
editor_ref.set_block(
Block::default()
.title(Span::styled(title_text, title_style))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_style_color)),
);
f.render_widget(&*editor_ref, script_content_area);
}
// Buttons
let get_button_style = |button_focus: AddLogicFocus, current_focus_state: AddLogicFocus| {
let is_focused = current_focus_state == button_focus;
let base_style = Style::default().fg(if is_focused {
theme.highlight
} else {
theme.secondary
});
if is_focused {
base_style.add_modifier(Modifier::BOLD)
} else {
base_style
}
};
let get_button_border_style = |is_focused: bool, current_theme: &Theme| {
if is_focused {
Style::default().fg(current_theme.highlight)
} else {
Style::default().fg(current_theme.secondary)
}
};
let button_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.split(buttons_area);
let save_button = Paragraph::new(" Save Logic ")
.style(get_button_style(
AddLogicFocus::SaveButton,
add_logic_state.current_focus,
))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_button_border_style(
add_logic_state.current_focus == AddLogicFocus::SaveButton,
theme,
)),
);
f.render_widget(save_button, button_chunks[0]);
let cancel_button = Paragraph::new(" Cancel ")
.style(get_button_style(
AddLogicFocus::CancelButton,
add_logic_state.current_focus,
))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_button_border_style(
add_logic_state.current_focus == AddLogicFocus::CancelButton,
theme,
)),
);
f.render_widget(cancel_button, button_chunks[1]);
// Dialog
if app_state.ui.dialog.dialog_show {
dialog::render_dialog(
f,
f.area(),
theme,
&app_state.ui.dialog.dialog_title,
&app_state.ui.dialog.dialog_message,
&app_state.ui.dialog.dialog_buttons,
app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
);
}
}

View File

@@ -0,0 +1,577 @@
// src/components/admin/add_table.rs
use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::app::state::AppState;
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table},
Frame,
};
use crate::components::common::dialog;
// Helper function to convert between HighlightState types
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
match local {
HighlightState::Off => CanvasHighlightState::Off,
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
}
}
/// Renders the Add New Table page layout, structuring the display of table information,
/// input fields, and action buttons. Adapts layout based on terminal width.
pub fn render_add_table(
f: &mut Frame,
area: Rect,
theme: &Theme,
app_state: &AppState,
add_table_state: &mut AddTableState,
is_edit_mode: bool, // Determines if canvas inputs are in edit mode
highlight_state: &HighlightState, // For text highlighting in canvas
) {
// --- Configuration ---
// Threshold width to switch between wide and narrow layouts
const NARROW_LAYOUT_THRESHOLD: u16 = 120; // Adjust this value as needed
// --- State Checks ---
let focus_on_canvas_inputs = matches!(
add_table_state.current_focus,
AddTableFocus::InputTableName
| AddTableFocus::InputColumnName
| AddTableFocus::InputColumnType
);
// --- Main Page Block ---
let main_block = Block::default()
.title(" Add New Table ")
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.bg));
let inner_area = main_block.inner(area);
f.render_widget(main_block, area);
// --- Fullscreen Columns Table Check (Narrow Screens Only) ---
if area.width < NARROW_LAYOUT_THRESHOLD && add_table_state.current_focus == AddTableFocus::InsideColumnsTable {
// Render ONLY the columns table taking the full inner area
let columns_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
let column_rows: Vec<Row<'_>> = add_table_state
.columns
.iter()
.map(|col_def| {
Row::new(vec![
Cell::from(if col_def.selected { "[*]" } else { "[ ]" }),
Cell::from(col_def.name.clone()),
Cell::from(col_def.data_type.clone()),
])
.style(Style::default().fg(theme.fg))
})
.collect();
let header_cells = ["Sel", "Name", "Type"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
let header = Row::new(header_cells).height(1).bottom_margin(1);
let columns_table = Table::new(column_rows, [Constraint::Length(5), Constraint::Percentage(50), Constraint::Percentage(50)])
.header(header)
.block(
Block::default()
.title(Span::styled(" Columns (Fullscreen) ", theme.fg)) // Indicate fullscreen
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(columns_border_style),
)
.row_highlight_style(
Style::default()
.add_modifier(Modifier::REVERSED)
.fg(theme.highlight),
)
.highlight_symbol(" > "); // Use the inside symbol
f.render_stateful_widget(columns_table, inner_area, &mut add_table_state.column_table_state);
return; // IMPORTANT: Stop rendering here for fullscreen mode
}
// --- Fullscreen Indexes Table Check ---
if add_table_state.current_focus == AddTableFocus::InsideIndexesTable { // Remove width check
// Render ONLY the indexes table taking the full inner area
let indexes_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
let index_rows: Vec<Row<'_>> = add_table_state
.indexes
.iter()
.map(|index_def| {
Row::new(vec![
Cell::from(if index_def.selected { "[*]" } else { "[ ]" }),
Cell::from(index_def.name.clone()),
])
.style(Style::default().fg(theme.fg))
})
.collect();
let index_header_cells = ["Sel", "Column Name"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
let index_header = Row::new(index_header_cells).height(1).bottom_margin(1);
let indexes_table = Table::new(index_rows, [Constraint::Length(5), Constraint::Percentage(95)])
.header(index_header)
.block(
Block::default()
.title(Span::styled(" Indexes (Fullscreen) ", theme.fg)) // Indicate fullscreen
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(indexes_border_style),
)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
.highlight_symbol(" > "); // Use the inside symbol
f.render_stateful_widget(indexes_table, inner_area, &mut add_table_state.index_table_state);
return; // IMPORTANT: Stop rendering here for fullscreen mode
}
// --- Fullscreen Links Table Check ---
if add_table_state.current_focus == AddTableFocus::InsideLinksTable {
// Render ONLY the links table taking the full inner area
let links_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
let link_rows: Vec<Row<'_>> = add_table_state
.links
.iter()
.map(|link_def| {
Row::new(vec![
Cell::from(if link_def.selected { "[*]" } else { "[ ]" }), // Selection first
Cell::from(link_def.linked_table_name.clone()), // Table name second
])
.style(Style::default().fg(theme.fg))
})
.collect();
let link_header_cells = ["Sel", "Available Table"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
let link_header = Row::new(link_header_cells).height(1).bottom_margin(1);
let links_table = Table::new(link_rows, [Constraint::Length(5), Constraint::Percentage(95)])
.header(link_header)
.block(
Block::default()
.title(Span::styled(" Links (Fullscreen) ", theme.fg)) // Indicate fullscreen
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(links_border_style),
)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
.highlight_symbol(" > "); // Use the inside symbol
f.render_stateful_widget(links_table, inner_area, &mut add_table_state.link_table_state);
return; // IMPORTANT: Stop rendering here for fullscreen mode
}
// --- Area Variable Declarations ---
let top_info_area: Rect;
let columns_area: Rect;
let canvas_area: Rect;
let add_button_area: Rect;
let indexes_area: Rect;
let links_area: Rect;
let bottom_buttons_area: Rect;
// --- Layout Decision ---
if area.width >= NARROW_LAYOUT_THRESHOLD {
// --- WIDE Layout (Based on first screenshot) ---
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Top Info (Profile/Table Name) - Increased to 3 lines
Constraint::Min(10), // Middle Area (Columns | Right Pane)
Constraint::Length(3), // Bottom Buttons
])
.split(inner_area);
top_info_area = main_chunks[0];
let middle_area = main_chunks[1];
bottom_buttons_area = main_chunks[2];
// Split Middle Horizontally
let middle_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50), // Left: Columns Table
Constraint::Percentage(50), // Right: Inputs etc.
])
.split(middle_area);
columns_area = middle_chunks[0];
let right_pane_area = middle_chunks[1];
// Split Right Pane Vertically
let right_pane_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5), // Input Canvas Area
Constraint::Length(3), // Add Button Area
Constraint::Min(5), // Indexes & Links Area
])
.split(right_pane_area);
canvas_area = right_pane_chunks[0];
add_button_area = right_pane_chunks[1];
let indexes_links_area = right_pane_chunks[2];
// Split Indexes/Links Horizontally
let indexes_links_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50), // Indexes Table
Constraint::Percentage(50), // Links Table
])
.split(indexes_links_area);
indexes_area = indexes_links_chunks[0];
links_area = indexes_links_chunks[1];
// --- Top Info Rendering (Wide - 2 lines) ---
let profile_text = Paragraph::new(vec![
Line::from(Span::styled(
format!("Profile: {}", add_table_state.profile_name),
theme.fg,
)),
Line::from(Span::styled(
format!("Table name: {}", add_table_state.table_name),
theme.fg,
)),
])
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(theme.secondary)),
);
f.render_widget(profile_text, top_info_area);
} else {
// --- NARROW Layout (Based on second screenshot) ---
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Top: Profile & Table Name (Single Row)
Constraint::Length(5), // Column Definition Input Canvas Area
Constraint::Length(3), // Add Button Area
Constraint::Min(5), // Columns Table Area
Constraint::Min(5), // Indexes & Links Area
Constraint::Length(3), // Bottom: Save/Cancel Buttons
])
.split(inner_area);
top_info_area = main_chunks[0];
canvas_area = main_chunks[1];
add_button_area = main_chunks[2];
columns_area = main_chunks[3];
let indexes_links_area = main_chunks[4];
bottom_buttons_area = main_chunks[5];
// Split Indexes/Links Horizontally
let indexes_links_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50), // Indexes Table
Constraint::Percentage(50), // Links Table
])
.split(indexes_links_area);
indexes_area = indexes_links_chunks[0];
links_area = indexes_links_chunks[1];
// --- Top Info Rendering (Narrow - 1 line) ---
let top_info_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.split(top_info_area);
let profile_text = Paragraph::new(Span::styled(
format!("Profile: {}", add_table_state.profile_name),
theme.fg,
))
.alignment(Alignment::Left);
f.render_widget(profile_text, top_info_chunks[0]);
let table_name_text = Paragraph::new(Span::styled(
format!("Table: {}", add_table_state.table_name),
theme.fg,
))
.alignment(Alignment::Left);
f.render_widget(table_name_text, top_info_chunks[1]);
}
// --- Common Widget Rendering (Uses calculated areas) ---
// --- Columns Table Rendering ---
let columns_focused = matches!(add_table_state.current_focus, AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable);
let columns_border_style = if columns_focused {
Style::default().fg(theme.highlight)
} else {
Style::default().fg(theme.secondary)
};
let column_rows: Vec<Row<'_>> = add_table_state
.columns
.iter()
.map(|col_def| {
Row::new(vec![
Cell::from(if col_def.selected { "[*]" } else { "[ ]" }),
Cell::from(col_def.name.clone()),
Cell::from(col_def.data_type.clone()),
])
.style(Style::default().fg(theme.fg))
})
.collect();
let header_cells = ["Sel", "Name", "Type"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
let header = Row::new(header_cells).height(1).bottom_margin(1);
let columns_table = Table::new(
column_rows,
[ // Define constraints for 3 columns: Sel, Name, Type
Constraint::Length(5),
Constraint::Percentage(60),
Constraint::Percentage(35),
],
)
.header(header)
.block(
Block::default()
.title(Span::styled(" Columns ", theme.fg))
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(columns_border_style),
)
.row_highlight_style(
Style::default()
.add_modifier(Modifier::REVERSED)
.fg(theme.highlight),
)
.highlight_symbol(" > ");
f.render_stateful_widget(
columns_table,
columns_area,
&mut add_table_state.column_table_state,
);
// --- Canvas Rendering (Column Definition Input) - USING CANVAS LIBRARY ---
let canvas_highlight_state = convert_highlight_state(highlight_state);
let _active_field_rect = render_canvas(
f,
canvas_area,
add_table_state, // AddTableState implements CanvasState
theme, // Theme implements CanvasTheme
is_edit_mode && focus_on_canvas_inputs,
&canvas_highlight_state,
);
// --- Button Style Helpers ---
let get_button_style = |button_focus: AddTableFocus, current_focus| {
// Only handles text style (FG + Bold) now, no BG
let is_focused = current_focus == button_focus;
let base_style = Style::default().fg(if is_focused {
theme.highlight // Highlighted text color
} else {
theme.secondary // Normal text color
});
if is_focused {
base_style.add_modifier(Modifier::BOLD)
} else {
base_style
}
};
// Updated signature to accept bool and theme
let get_button_border_style = |is_focused: bool, theme: &Theme| {
if is_focused {
Style::default().fg(theme.highlight)
} else {
Style::default().fg(theme.secondary)
}
};
// --- Add Button Rendering ---
// Determine if the add button is focused
let is_add_button_focused = add_table_state.current_focus == AddTableFocus::AddColumnButton;
// Create the Add button Paragraph widget
let add_button = Paragraph::new(" Add ")
.style(get_button_style(AddTableFocus::AddColumnButton, add_table_state.current_focus)) // Use existing closure
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_button_border_style(is_add_button_focused, theme)), // Pass bool and theme
);
// Render the button in its designated area
f.render_widget(add_button, add_button_area);
// --- Indexes Table Rendering ---
let indexes_focused = matches!(add_table_state.current_focus, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable);
let indexes_border_style = if indexes_focused {
Style::default().fg(theme.highlight)
} else {
Style::default().fg(theme.secondary)
};
let index_rows: Vec<Row<'_>> = add_table_state
.indexes
.iter()
.map(|index_def| { // Use index_def now
Row::new(vec![
Cell::from(if index_def.selected { "[*]" } else { "[ ]" }), // Display selection
Cell::from(index_def.name.clone()),
])
.style(Style::default().fg(theme.fg))
})
.collect();
let index_header_cells = ["Sel", "Column Name"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
let index_header = Row::new(index_header_cells).height(1).bottom_margin(1);
let indexes_table =
Table::new(index_rows, [Constraint::Length(5), Constraint::Percentage(95)])
.header(index_header)
.block(
Block::default()
.title(Span::styled(" Indexes ", theme.fg))
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(indexes_border_style),
)
.row_highlight_style(
Style::default()
.add_modifier(Modifier::REVERSED)
.fg(theme.highlight),
)
.highlight_symbol(" > ");
f.render_stateful_widget(
indexes_table,
indexes_area,
&mut add_table_state.index_table_state,
);
// --- Links Table Rendering ---
let links_focused = matches!(add_table_state.current_focus, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable);
let links_border_style = if links_focused {
Style::default().fg(theme.highlight)
} else {
Style::default().fg(theme.secondary)
};
let link_rows: Vec<Row<'_>> = add_table_state
.links
.iter()
.map(|link_def| {
Row::new(vec![
Cell::from(if link_def.selected { "[*]" } else { "[ ]" }),
Cell::from(link_def.linked_table_name.clone()),
])
.style(Style::default().fg(theme.fg))
})
.collect();
let link_header_cells = ["Sel", "Available Table"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
let link_header = Row::new(link_header_cells).height(1).bottom_margin(1);
let links_table =
Table::new(link_rows, [Constraint::Length(5), Constraint::Percentage(95)])
.header(link_header)
.block(
Block::default()
.title(Span::styled(" Links ", theme.fg))
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(links_border_style),
)
.row_highlight_style(
Style::default()
.add_modifier(Modifier::REVERSED)
.fg(theme.highlight),
)
.highlight_symbol(" > ");
f.render_stateful_widget(
links_table,
links_area,
&mut add_table_state.link_table_state,
);
// --- Save/Cancel Buttons Rendering ---
let bottom_button_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33), // Save Button
Constraint::Percentage(34), // Delete Button
Constraint::Percentage(33), // Cancel Button
])
.split(bottom_buttons_area);
let save_button = Paragraph::new(" Save table ")
.style(get_button_style(
AddTableFocus::SaveButton,
add_table_state.current_focus,
))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_button_border_style(
add_table_state.current_focus == AddTableFocus::SaveButton, // Pass bool
theme,
)),
);
f.render_widget(save_button, bottom_button_chunks[0]);
let delete_button = Paragraph::new(" Delete Selected ")
.style(get_button_style(
AddTableFocus::DeleteSelectedButton,
add_table_state.current_focus,
))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_button_border_style(
add_table_state.current_focus == AddTableFocus::DeleteSelectedButton, // Pass bool
theme,
)),
);
f.render_widget(delete_button, bottom_button_chunks[1]);
let cancel_button = Paragraph::new(" Cancel ")
.style(get_button_style(
AddTableFocus::CancelButton,
add_table_state.current_focus,
))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_button_border_style(
add_table_state.current_focus == AddTableFocus::CancelButton, // Pass bool
theme,
)),
);
f.render_widget(cancel_button, bottom_button_chunks[2]);
// --- DIALOG ---
// Render the dialog overlay if it's active
if app_state.ui.dialog.dialog_show {
dialog::render_dialog(
f,
f.area(), // Render over the whole frame area
theme,
&app_state.ui.dialog.dialog_title,
&app_state.ui.dialog.dialog_message,
&app_state.ui.dialog.dialog_buttons,
app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
);
}
}

View File

@@ -1,118 +1,131 @@
// src/components/admin/admin_panel.rs
use crate::config::colors::themes::Theme;
use crate::state::pages::auth::AuthState;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
use ratatui::{
widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph},
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span, Text},
layout::{Alignment, Constraint, Direction, Layout, Rect},
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap},
Frame,
};
use common::proto::multieko2::table_definition::ProfileTreeResponse;
use crate::config::colors::themes::Theme;
use super::admin_panel_admin::render_admin_panel_admin;
pub struct AdminPanelState {
pub list_state: ListState,
pub profiles: Vec<String>,
pub fn render_admin_panel(
f: &mut Frame,
app_state: &AppState,
auth_state: &AuthState,
admin_state: &mut AdminState,
area: Rect,
theme: &Theme,
profile_tree: &ProfileTreeResponse,
selected_profile: &Option<String>,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent))
.style(Style::default().bg(theme.bg));
let inner_area = block.inner(area);
f.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(inner_area);
// Content
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(chunks[1]);
if auth_state.role.as_deref() != Some("admin") {
render_admin_panel_non_admin(
f,
admin_state,
&content_chunks,
theme,
profile_tree,
selected_profile,
);
} else {
render_admin_panel_admin(
f,
chunks[1],
app_state,
admin_state,
theme,
);
}
}
impl AdminPanelState {
pub fn new(profiles: Vec<String>) -> Self {
let mut list_state = ListState::default();
if !profiles.is_empty() {
list_state.select(Some(0));
}
Self { list_state, profiles }
}
pub fn next(&mut self) {
let i = self.list_state.selected().map_or(0, |i|
if i >= self.profiles.len() - 1 { 0 } else { i + 1 });
self.list_state.select(Some(i));
}
pub fn previous(&mut self) {
let i = self.list_state.selected().map_or(0, |i|
if i == 0 { self.profiles.len() - 1 } else { i - 1 });
self.list_state.select(Some(i));
}
pub fn render(
&mut self,
f: &mut Frame,
area: Rect,
theme: &Theme,
profile_tree: &ProfileTreeResponse,
selected_profile: &Option<String>,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent))
.style(Style::default().bg(theme.bg));
let inner_area = block.inner(area);
f.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(inner_area);
// Title
let title = Line::from(Span::styled("Admin Panel", Style::default().fg(theme.highlight)));
let title_widget = Paragraph::new(title).alignment(Alignment::Center);
f.render_widget(title_widget, chunks[0]);
// Content
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(chunks[1]);
// Profile list
let items: Vec<ListItem> = self.profiles.iter()
.map(|p| ListItem::new(Line::from(vec![
/// Renders the view for non-admin users (profile list and details).
fn render_admin_panel_non_admin(
f: &mut Frame,
admin_state: &AdminState,
content_chunks: &[Rect],
theme: &Theme,
profile_tree: &ProfileTreeResponse,
selected_profile: &Option<String>,
) {
// Profile list - Use data from admin_state
let items: Vec<ListItem> = admin_state
.profiles
.iter()
.map(|p| {
ListItem::new(Line::from(vec![
Span::styled(
if Some(p) == selected_profile.as_ref() { "" } else { " " },
Style::default().fg(theme.accent)
Style::default().fg(theme.accent),
),
Span::styled(p, Style::default().fg(theme.fg)),
])))
.collect();
]))
})
.collect();
let list = List::new(items)
.block(Block::default().title("Profiles"))
.highlight_style(Style::default().bg(theme.highlight).fg(theme.bg));
f.render_stateful_widget(list, content_chunks[0], &mut self.list_state);
let list = List::new(items)
.block(Block::default().title("Profiles"))
.highlight_style(Style::default().bg(theme.highlight).fg(theme.bg));
// Profile details
if let Some(profile) = self.list_state.selected()
.and_then(|i| profile_tree.profiles.get(i))
{
let mut text = Text::default();
text.lines.push(Line::from(vec![
Span::styled("Profile: ", Style::default().fg(theme.accent)),
Span::styled(&profile.name, Style::default().fg(theme.highlight)),
]));
let mut profile_list_state_clone = admin_state.profile_list_state.clone();
f.render_stateful_widget(list, content_chunks[0], &mut profile_list_state_clone);
text.lines.push(Line::from(""));
text.lines.push(Line::from(Span::styled("Tables:", Style::default().fg(theme.accent))));
for table in &profile.tables {
let mut line = vec![Span::styled(format!("├─ {}", table.name), theme.fg)];
if !table.depends_on.is_empty() {
line.push(Span::styled(
format!("{}", table.depends_on.join(", ")),
Style::default().fg(theme.secondary)
));
}
text.lines.push(Line::from(line));
// Profile details - Use selection info from admin_state
if let Some(profile) = admin_state
.get_selected_index()
.and_then(|i| profile_tree.profiles.get(i))
{
let mut text = Text::default();
text.lines.push(Line::from(vec![
Span::styled("Profile: ", Style::default().fg(theme.accent)),
Span::styled(&profile.name, Style::default().fg(theme.highlight)),
]));
text.lines.push(Line::from(""));
text.lines.push(Line::from(Span::styled(
"Tables:",
Style::default().fg(theme.accent),
)));
for table in &profile.tables {
let mut line = vec![Span::styled(format!("├─ {}", table.name), theme.fg)];
if !table.depends_on.is_empty() {
line.push(Span::styled(
format!("{}", table.depends_on.join(", ")),
Style::default().fg(theme.secondary),
));
}
let details_widget = Paragraph::new(text)
.block(Block::default().title("Details"));
f.render_widget(details_widget, content_chunks[1]);
text.lines.push(Line::from(line));
}
let details_widget = Paragraph::new(text)
.block(Block::default().title("Details"))
.wrap(Wrap { trim: true });
f.render_widget(details_widget, content_chunks[1]);
}
}

View File

@@ -0,0 +1,180 @@
// src/components/admin/admin_panel_admin.rs
use crate::config::colors::themes::Theme;
use crate::state::pages::admin::{AdminFocus, AdminState};
use crate::state::app::state::AppState;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph},
Frame,
};
pub fn render_admin_panel_admin(
f: &mut Frame,
area: Rect,
app_state: &AppState,
admin_state: &mut AdminState,
theme: &Theme,
) {
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)].as_ref())
.split(area);
let panes_area = main_chunks[0];
let buttons_area = main_chunks[1];
let pane_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25), // Profiles
Constraint::Percentage(40), // Tables
Constraint::Percentage(35), // Dependencies
].as_ref())
.split(panes_area);
let profiles_pane = pane_chunks[0];
let tables_pane = pane_chunks[1];
let deps_pane = pane_chunks[2];
// --- Profiles Pane (Left) ---
let profile_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::ProfilesPane | AdminFocus::InsideProfilesList);
let profile_border_style = if profile_pane_has_focus {
Style::default().fg(theme.highlight)
} else {
Style::default().fg(theme.border)
};
let profiles_block = Block::default().title(" Profiles ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(profile_border_style);
let profiles_inner_area = profiles_block.inner(profiles_pane);
f.render_widget(profiles_block, profiles_pane);
let profile_list_items: Vec<ListItem> = app_state.profile_tree.profiles.iter().enumerate().map(|(idx, profile)| {
let is_persistently_selected = admin_state.selected_profile_index == Some(idx);
let is_nav_highlighted = admin_state.profile_list_state.selected() == Some(idx) && admin_state.current_focus == AdminFocus::InsideProfilesList;
let prefix = if is_persistently_selected { "[*] " } else { "[ ] " };
let item_style = if is_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
else if is_persistently_selected { Style::default().fg(theme.accent) }
else { Style::default().fg(theme.fg) };
ListItem::new(Line::from(vec![Span::styled(prefix, item_style), Span::styled(&profile.name, item_style)]))
}).collect();
let profile_list = List::new(profile_list_items)
.highlight_style(if admin_state.current_focus == AdminFocus::InsideProfilesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() })
.highlight_symbol(if admin_state.current_focus == AdminFocus::InsideProfilesList { "> " } else { " " });
f.render_stateful_widget(profile_list, profiles_inner_area, &mut admin_state.profile_list_state);
// --- Tables Pane (Middle) ---
let table_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::Tables | AdminFocus::InsideTablesList);
let table_border_style = if table_pane_has_focus { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.border) };
let profile_to_display_tables_for_idx: Option<usize>;
if admin_state.current_focus == AdminFocus::InsideProfilesList {
profile_to_display_tables_for_idx = admin_state.profile_list_state.selected();
} else {
profile_to_display_tables_for_idx = admin_state.selected_profile_index
.or_else(|| admin_state.profile_list_state.selected());
}
let tables_pane_title_profile_name = profile_to_display_tables_for_idx
.and_then(|idx| app_state.profile_tree.profiles.get(idx))
.map_or("None Selected", |p| p.name.as_str());
let tables_block = Block::default().title(format!(" Tables (Profile: {}) ", tables_pane_title_profile_name)).borders(Borders::ALL).border_type(BorderType::Rounded).border_style(table_border_style);
let tables_inner_area = tables_block.inner(tables_pane);
f.render_widget(tables_block, tables_pane);
let table_list_items_for_display: Vec<ListItem> =
if let Some(profile_data_for_tables) = profile_to_display_tables_for_idx
.and_then(|idx| app_state.profile_tree.profiles.get(idx)) {
profile_data_for_tables.tables.iter().enumerate().map(|(idx, table)| {
let is_table_persistently_selected = admin_state.selected_table_index == Some(idx) &&
profile_to_display_tables_for_idx == admin_state.selected_profile_index;
let is_table_nav_highlighted = admin_state.table_list_state.selected() == Some(idx) &&
admin_state.current_focus == AdminFocus::InsideTablesList;
let prefix = if is_table_persistently_selected { "[*] " } else { "[ ] " };
let style = if is_table_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
else if is_table_persistently_selected { Style::default().fg(theme.accent) }
else { Style::default().fg(theme.fg) };
ListItem::new(Line::from(vec![Span::styled(prefix, style), Span::styled(&table.name, style)]))
}).collect()
} else {
vec![ListItem::new("Select a profile to see tables")]
};
let table_list = List::new(table_list_items_for_display)
.highlight_style(if admin_state.current_focus == AdminFocus::InsideTablesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() })
.highlight_symbol(if admin_state.current_focus == AdminFocus::InsideTablesList { "> " } else { " " });
f.render_stateful_widget(table_list, tables_inner_area, &mut admin_state.table_list_state);
// --- Dependencies Pane (Right) ---
let mut deps_pane_title_table_name = "N/A".to_string();
let dependencies_to_display: Vec<String>;
if admin_state.current_focus == AdminFocus::InsideTablesList {
// If navigating tables, show dependencies for the '>' highlighted table.
// The profile context is `profile_to_display_tables_for_idx` (from Tables pane logic).
if let Some(p_idx_for_current_tables) = profile_to_display_tables_for_idx {
if let Some(current_profile_showing_tables) = app_state.profile_tree.profiles.get(p_idx_for_current_tables) {
if let Some(table_nav_idx) = admin_state.table_list_state.selected() { // The '>' highlighted table
if let Some(navigated_table) = current_profile_showing_tables.tables.get(table_nav_idx) {
deps_pane_title_table_name = navigated_table.name.clone();
dependencies_to_display = navigated_table.depends_on.clone();
} else {
dependencies_to_display = Vec::new(); // Navigated table index out of bounds
}
} else {
dependencies_to_display = Vec::new(); // No table navigated with '>'
}
} else {
dependencies_to_display = Vec::new(); // Profile for tables out of bounds
}
} else {
dependencies_to_display = Vec::new(); // No profile active for table display
}
} else {
// Otherwise, show dependencies for the '[*]' persistently selected table & profile.
if let Some(p_idx) = admin_state.selected_profile_index { // Must be a persistently selected profile
if let Some(selected_profile) = app_state.profile_tree.profiles.get(p_idx) {
if let Some(t_idx) = admin_state.selected_table_index { // Must be a persistently selected table
if let Some(selected_table) = selected_profile.tables.get(t_idx) {
deps_pane_title_table_name = selected_table.name.clone();
dependencies_to_display = selected_table.depends_on.clone();
} else { dependencies_to_display = Vec::new(); }
} else { dependencies_to_display = Vec::new(); }
} else { dependencies_to_display = Vec::new(); }
} else { dependencies_to_display = Vec::new(); }
}
let deps_block = Block::default()
.title(format!(" Dependencies (Table: {}) ", deps_pane_title_table_name))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border));
let deps_inner_area = deps_block.inner(deps_pane);
f.render_widget(deps_block, deps_pane);
let mut deps_content = Text::default();
deps_content.lines.push(Line::from(Span::styled(
"Depends On:",
Style::default().fg(theme.accent),
)));
if !dependencies_to_display.is_empty() {
for dep in dependencies_to_display {
deps_content.lines.push(Line::from(Span::styled(format!("- {}", dep), theme.fg)));
}
} else {
deps_content.lines.push(Line::from(Span::styled(" None", theme.secondary)));
}
let deps_paragraph = Paragraph::new(deps_content);
f.render_widget(deps_paragraph, deps_inner_area);
// --- Buttons Row ---
let button_chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Percentage(33), Constraint::Percentage(34), Constraint::Percentage(33)].as_ref()).split(buttons_area);
let btn_base_style = Style::default().fg(theme.secondary);
let get_btn_style = |button_focus: AdminFocus| { if admin_state.current_focus == button_focus { btn_base_style.add_modifier(ratatui::style::Modifier::REVERSED) } else { btn_base_style } };
let btn1 = Paragraph::new("Add Logic").style(get_btn_style(AdminFocus::Button1)).alignment(Alignment::Center);
let btn2 = Paragraph::new("Add Table").style(get_btn_style(AdminFocus::Button2)).alignment(Alignment::Center);
let btn3 = Paragraph::new("Change Table").style(get_btn_style(AdminFocus::Button3)).alignment(Alignment::Center);
f.render_widget(btn1, button_chunks[0]);
f.render_widget(btn2, button_chunks[1]);
f.render_widget(btn3, button_chunks[2]);
}

View File

@@ -3,3 +3,4 @@ pub mod login;
pub mod register;
pub use login::*;
pub use register::*;

View File

@@ -2,24 +2,36 @@
use crate::{
config::colors::themes::Theme,
state::pages::auth::AuthState,
state::pages::auth::LoginState,
components::common::dialog,
state::state::AppState, // Add this import
state::app::state::AppState,
};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect, Margin},
style::{Style, Modifier, Color}, // Removed unused Color import
style::{Style, Modifier, Color},
widgets::{Block, BorderType, Borders, Paragraph},
Frame, // Removed unused Span import
Frame,
};
use crate::state::app::highlight::HighlightState;
use canvas::canvas::{render_canvas, HighlightState as CanvasHighlightState}; // Use canvas library's render function
// Helper function to convert between HighlightState types
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
match local {
HighlightState::Off => CanvasHighlightState::Off,
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
}
}
pub fn render_login(
f: &mut Frame,
area: Rect,
theme: &Theme,
state: &AuthState,
app_state: &AppState, // Add AppState parameter
login_state: &LoginState,
app_state: &AppState,
is_edit_mode: bool,
highlight_state: &HighlightState,
) {
// Main container
let block = Block::default()
@@ -46,41 +58,40 @@ pub fn render_login(
])
.split(inner_area);
// --- FORM RENDERING ---
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(if is_edit_mode {
Style::default().fg(theme.accent)
} else {
Style::default().fg(theme.border)
})
.style(Style::default().bg(theme.bg));
// Calculate inner area BEFORE rendering
let input_area = input_block.inner(chunks[0]);
f.render_widget(input_block, chunks[0]);
// Use the canvas renderer for fields
crate::components::handlers::canvas::render_canvas(
// --- FORM RENDERING (Using canvas library directly) ---
let canvas_highlight_state = convert_highlight_state(highlight_state);
render_canvas(
f,
input_area, // Use the pre-calculated area
state,
&["Username/Email", "Password"],
&state.current_field,
&[&state.username, &state.password],
theme,
chunks[0],
login_state, // LoginState implements CanvasState
theme, // Theme implements CanvasTheme
is_edit_mode,
&canvas_highlight_state,
);
// --- BUTTONS --- (Keep this unchanged)
// --- ERROR MESSAGE ---
if let Some(err) = &login_state.error_message {
f.render_widget(
Paragraph::new(err.as_str())
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center),
chunks[1],
);
}
// --- BUTTONS (unchanged) ---
let button_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[2]);
// Login Button
let login_active = !state.return_selected;
let login_button_index = 0;
let login_active = if app_state.ui.focus_outside_canvas {
app_state.focused_button_index== login_button_index
} else {
false
};
let mut login_style = Style::default().fg(theme.fg);
let mut login_border = Style::default().fg(theme.border);
if login_active {
@@ -102,7 +113,12 @@ pub fn render_login(
);
// Return Button
let return_active = state.return_selected;
let return_button_index = 1;
let return_active = if app_state.ui.focus_outside_canvas {
app_state.focused_button_index== return_button_index
} else {
false
};
let mut return_style = Style::default().fg(theme.fg);
let mut return_border = Style::default().fg(theme.border);
if return_active {
@@ -123,24 +139,17 @@ pub fn render_login(
button_chunks[1],
);
// Error message
if let Some(err) = &state.error_message {
f.render_widget(
Paragraph::new(err.as_str())
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center),
chunks[1],
);
}
if app_state.ui.dialog.show_dialog {
// --- DIALOG ---
if app_state.ui.dialog.dialog_show {
dialog::render_dialog(
f,
f.area(), // Use area() instead of deprecated size()
f.area(),
theme,
&app_state.ui.dialog.dialog_title,
&app_state.ui.dialog.dialog_message,
app_state.ui.dialog.dialog_button_active,
&app_state.ui.dialog.dialog_buttons,
app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
);
}
}

View File

@@ -0,0 +1,178 @@
// src/components/auth/register.rs
use crate::{
config::colors::themes::Theme,
state::pages::auth::RegisterState,
components::common::dialog,
state::app::state::AppState,
modes::handlers::mode_manager::AppMode,
};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect, Margin},
style::{Style, Modifier, Color},
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use crate::state::app::highlight::HighlightState;
use canvas::canvas::{render_canvas, HighlightState as CanvasHighlightState}; // Use canvas library's render function
use canvas::autocomplete::gui::render_autocomplete_dropdown;
use canvas::autocomplete::AutocompleteCanvasState;
// Helper function to convert between HighlightState types
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
match local {
HighlightState::Off => CanvasHighlightState::Off,
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
}
}
pub fn render_register(
f: &mut Frame,
area: Rect,
theme: &Theme,
state: &RegisterState,
app_state: &AppState,
is_edit_mode: bool,
highlight_state: &HighlightState,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(theme.border))
.title(" Register ")
.style(Style::default().bg(theme.bg));
f.render_widget(block, area);
let inner_area = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7), // Form (5 fields + padding)
Constraint::Length(1), // Help text line
Constraint::Length(1), // Error message
Constraint::Length(3), // Buttons
])
.split(inner_area);
// --- FORM RENDERING (Using canvas library directly) ---
let canvas_highlight_state = convert_highlight_state(highlight_state);
let input_rect = render_canvas(
f,
chunks[0],
state, // RegisterState implements CanvasState
theme, // Theme implements CanvasTheme
is_edit_mode,
&canvas_highlight_state,
);
// --- HELP TEXT ---
let help_text = Paragraph::new("* are optional fields")
.style(Style::default().fg(theme.fg))
.alignment(Alignment::Center);
f.render_widget(help_text, chunks[1]);
// --- ERROR MESSAGE ---
if let Some(err) = &state.error_message {
f.render_widget(
Paragraph::new(err.as_str())
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center),
chunks[2],
);
}
// --- BUTTONS ---
let button_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[3]);
// Register Button
let register_button_index = 0;
let register_active = if app_state.ui.focus_outside_canvas {
app_state.focused_button_index== register_button_index
} else {
false
};
let mut register_style = Style::default().fg(theme.fg);
let mut register_border = Style::default().fg(theme.border);
if register_active {
register_style = register_style.fg(theme.highlight).add_modifier(Modifier::BOLD);
register_border = register_border.fg(theme.accent);
}
f.render_widget(
Paragraph::new("Register")
.style(register_style)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(register_border),
),
button_chunks[0],
);
// Return Button
let return_button_index = 1;
let return_active = if app_state.ui.focus_outside_canvas {
app_state.focused_button_index== return_button_index
} else {
false
};
let mut return_style = Style::default().fg(theme.fg);
let mut return_border = Style::default().fg(theme.border);
if return_active {
return_style = return_style.fg(theme.highlight).add_modifier(Modifier::BOLD);
return_border = return_border.fg(theme.accent);
}
f.render_widget(
Paragraph::new("Return")
.style(return_style)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(return_border),
),
button_chunks[1],
);
// --- AUTOCOMPLETE DROPDOWN (Using canvas library directly) ---
if app_state.current_mode == AppMode::Edit {
if let Some(autocomplete_state) = state.autocomplete_state() {
if let Some(input_rect) = input_rect {
render_autocomplete_dropdown(
f,
f.area(), // Frame area
input_rect, // Current input field rect
theme, // Theme implements CanvasTheme
autocomplete_state,
);
}
}
}
// --- DIALOG ---
if app_state.ui.dialog.dialog_show {
dialog::render_dialog(
f,
f.area(),
theme,
&app_state.ui.dialog.dialog_title,
&app_state.ui.dialog.dialog_message,
&app_state.ui.dialog.dialog_buttons,
app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
);
}
}

View File

@@ -1,10 +1,18 @@
// src/components/common.rs
pub mod command_line;
pub mod status_line;
pub mod text_editor;
pub mod background;
pub mod dialog;
pub mod autocomplete;
pub mod search_palette;
pub mod find_file_palette;
pub use command_line::*;
pub use status_line::*;
pub use text_editor::*;
pub use background::*;
pub use dialog::*;
pub use autocomplete::*;
pub use search_palette::*;
pub use find_file_palette::*;

View File

@@ -0,0 +1,153 @@
// src/components/common/autocomplete.rs
use crate::config::colors::themes::Theme;
use crate::state::pages::form::FormState;
use common::proto::komp_ac::search::search_response::Hit;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, List, ListItem, ListState},
Frame,
};
use unicode_width::UnicodeWidthStr;
/// Renders an opaque dropdown list for simple string-based suggestions.
/// THIS IS THE RESTORED FUNCTION.
pub fn render_autocomplete_dropdown(
f: &mut Frame,
input_rect: Rect,
frame_area: Rect,
theme: &Theme,
suggestions: &[String],
selected_index: Option<usize>,
) {
if suggestions.is_empty() {
return;
}
let max_suggestion_width =
suggestions.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
let horizontal_padding: u16 = 2;
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
let dropdown_height = (suggestions.len() as u16).min(5);
let mut dropdown_area = Rect {
x: input_rect.x,
y: input_rect.y + 1,
width: dropdown_width,
height: dropdown_height,
};
if dropdown_area.bottom() > frame_area.height {
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
}
if dropdown_area.right() > frame_area.width {
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
}
dropdown_area.x = dropdown_area.x.max(0);
dropdown_area.y = dropdown_area.y.max(0);
let background_block =
Block::default().style(Style::default().bg(Color::DarkGray));
f.render_widget(background_block, dropdown_area);
let items: Vec<ListItem> = suggestions
.iter()
.enumerate()
.map(|(i, s)| {
let is_selected = selected_index == Some(i);
let s_width = s.width() as u16;
let padding_needed = dropdown_width.saturating_sub(s_width);
let padded_s =
format!("{}{}", s, " ".repeat(padding_needed as usize));
ListItem::new(padded_s).style(if is_selected {
Style::default()
.fg(theme.bg)
.bg(theme.highlight)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg).bg(Color::DarkGray)
})
})
.collect();
let list = List::new(items);
let mut list_state = ListState::default();
list_state.select(selected_index);
f.render_stateful_widget(list, dropdown_area, &mut list_state);
}
/// Renders an opaque dropdown list for rich `Hit`-based suggestions.
/// RENAMED from render_rich_autocomplete_dropdown
pub fn render_hit_autocomplete_dropdown(
f: &mut Frame,
input_rect: Rect,
frame_area: Rect,
theme: &Theme,
suggestions: &[Hit],
selected_index: Option<usize>,
form_state: &FormState,
) {
if suggestions.is_empty() {
return;
}
let display_names: Vec<String> = suggestions
.iter()
.map(|hit| form_state.get_display_name_for_hit(hit))
.collect();
let max_suggestion_width =
display_names.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
let horizontal_padding: u16 = 2;
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
let dropdown_height = (suggestions.len() as u16).min(5);
let mut dropdown_area = Rect {
x: input_rect.x,
y: input_rect.y + 1,
width: dropdown_width,
height: dropdown_height,
};
if dropdown_area.bottom() > frame_area.height {
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
}
if dropdown_area.right() > frame_area.width {
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
}
dropdown_area.x = dropdown_area.x.max(0);
dropdown_area.y = dropdown_area.y.max(0);
let background_block =
Block::default().style(Style::default().bg(Color::DarkGray));
f.render_widget(background_block, dropdown_area);
let items: Vec<ListItem> = display_names
.iter()
.enumerate()
.map(|(i, s)| {
let is_selected = selected_index == Some(i);
let s_width = s.width() as u16;
let padding_needed = dropdown_width.saturating_sub(s_width);
let padded_s =
format!("{}{}", s, " ".repeat(padding_needed as usize));
ListItem::new(padded_s).style(if is_selected {
Style::default()
.fg(theme.bg)
.bg(theme.highlight)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg).bg(Color::DarkGray)
})
})
.collect();
let list = List::new(items);
let mut list_state = ListState::default();
list_state.select(selected_index);
f.render_stateful_widget(list, dropdown_area, &mut list_state);
}

View File

@@ -1,4 +1,5 @@
// src/client/components/command_line.rs
// src/components/common/command_line.rs
use ratatui::{
widgets::{Block, Paragraph},
style::Style,
@@ -6,30 +7,63 @@ use ratatui::{
Frame,
};
use crate::config::colors::themes::Theme;
use unicode_width::UnicodeWidthStr; // Import for width calculation
pub fn render_command_line(f: &mut Frame, area: Rect, input: &str, active: bool, theme: &Theme, message: &str) {
let prompt = if active {
":"
} else {
""
pub fn render_command_line(
f: &mut Frame,
area: Rect,
input: &str, // This is event_handler.command_input
active: bool, // This is event_handler.command_mode
theme: &Theme,
message: &str, // This is event_handler.command_message
) {
// Original logic for determining display_text
let display_text = if !active {
// If not in normal command mode, but there's a message (e.g. from Find File palette closing)
// Or if command mode is off and message is empty (render minimally)
if message.is_empty() {
"".to_string() // Render an empty string, background will cover
} else {
message.to_string()
}
} else { // active is true (normal command mode)
let prompt = ":";
if message.is_empty() || message == ":" {
format!("{}{}", prompt, input)
} else {
if input.is_empty() { // If command was just executed, input is cleared, show message
message.to_string()
} else { // Show input and message
format!("{}{} | {}", prompt, input, message)
}
}
};
// Combine the prompt, input, and message
let display_text = if message.is_empty() {
format!("{}{}", prompt, input)
let content_width = UnicodeWidthStr::width(display_text.as_str());
let available_width = area.width as usize;
let padding_needed = available_width.saturating_sub(content_width);
let display_text_padded = if padding_needed > 0 {
format!("{}{}", display_text, " ".repeat(padding_needed))
} else {
format!("{}{} | {}", prompt, input, message)
// If text is too long, ratatui's Paragraph will handle truncation.
// We could also truncate here if specific behavior is needed:
// display_text.chars().take(available_width).collect::<String>()
display_text
};
let style = if active {
// Determine style based on active state, but apply to the whole paragraph
let text_style = if active {
Style::default().fg(theme.accent)
} else {
// If not active, but there's a message, use default foreground.
// If message is also empty, this style won't matter much for empty text.
Style::default().fg(theme.fg)
};
let paragraph = Paragraph::new(display_text)
.block(Block::default().style(Style::default().bg(theme.bg)))
.style(style);
let paragraph = Paragraph::new(display_text_padded)
.block(Block::default().style(Style::default().bg(theme.bg))) // Block ensures bg for whole area
.style(text_style); // Style for the text itself
f.render_widget(paragraph, area);
}

View File

@@ -1,101 +1,183 @@
// src/components/common/dialog.rs
use ratatui::{
layout::{Constraint, Direction, Layout, Rect, Margin},
style::{Modifier, Style},
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
text::{Text, Line, Span}
};
use ratatui::prelude::Alignment;
use crate::config::colors::themes::Theme;
use ratatui::{
layout::{Constraint, Direction, Layout, Margin, Rect},
prelude::Alignment,
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, Paragraph, Clear},
Frame,
};
use unicode_segmentation::UnicodeSegmentation; // For grapheme clusters
use unicode_width::UnicodeWidthStr; // For accurate width calculation
pub fn render_dialog(
f: &mut Frame,
area: Rect,
theme: &Theme,
title: &str,
message: &str,
is_active: bool,
dialog_title: &str,
dialog_message: &str,
dialog_buttons: &[String],
dialog_active_button_index: usize,
is_loading: bool,
) {
// Create a centered rect for the dialog
let dialog_area = centered_rect(60, 25, area);
// Calculate required height based on the actual number of lines in the message
let message_lines: Vec<_> = dialog_message.lines().collect();
let message_height = message_lines.len() as u16;
let button_row_height = if dialog_buttons.is_empty() { 0 } else { 3 };
let vertical_padding = 2; // Block borders (top/bottom)
let inner_vertical_margin = 2; // Margin inside block (top/bottom)
// Calculate required height based on actual message lines
let required_inner_height =
message_height + button_row_height + inner_vertical_margin;
let required_total_height = required_inner_height + vertical_padding;
// Use a fixed percentage width, clamped to min/max
let width_percentage: u16 = 60;
let dialog_width = (area.width * width_percentage / 100)
.max(20) // Minimum width
.min(area.width); // Maximum width
// Ensure height doesn't exceed available area
let dialog_height = required_total_height.min(area.height);
// Calculate centered area manually
let dialog_x = area.x + (area.width.saturating_sub(dialog_width)) / 2;
let dialog_y = area.y + (area.height.saturating_sub(dialog_height)) / 2;
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
// Clear the area first before drawing the dialog
f.render_widget(Clear, dialog_area);
// Main dialog container
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent))
.title(title)
.title(format!(" {} ", dialog_title)) // Add padding to title
.style(Style::default().bg(theme.bg));
f.render_widget(&block, dialog_area);
f.render_widget(block, dialog_area);
// Inner content area
let inner_area = block.inner(dialog_area).inner(Margin {
horizontal: 2,
vertical: 1,
// Calculate inner area *after* defining the block
let inner_area = dialog_area.inner(Margin {
horizontal: 2, // Left/Right padding inside border
vertical: 1, // Top/Bottom padding inside border
});
// Split into message and button areas
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), // Message content
Constraint::Length(3), // Button
])
.split(inner_area);
// Message text
let message_text = Text::from(message.lines().map(|l| Line::from(Span::styled(
l,
Style::default().fg(theme.fg)
))).collect::<Vec<_>>());
let message_paragraph = Paragraph::new(message_text)
.alignment(Alignment::Center);
f.render_widget(message_paragraph, chunks[0]);
// OK Button
let button_style = if is_active {
Style::default()
.fg(theme.highlight)
.add_modifier(Modifier::BOLD)
if is_loading {
// --- Loading State ---
let loading_text = Paragraph::new(dialog_message) // Use the message passed for loading
.style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC))
.alignment(Alignment::Center);
// Render loading message centered in the inner area
f.render_widget(loading_text, inner_area);
} else {
Style::default().fg(theme.fg)
};
// --- Normal State (Message + Buttons) ---
let button_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(theme.accent))
.style(Style::default().bg(theme.bg));
// Layout for Message and Buttons based on actual message height
let mut constraints = vec![
// Allocate space for message, ensuring at least 1 line height
Constraint::Length(message_height.max(1)), // Use actual calculated height
];
if button_row_height > 0 {
constraints.push(Constraint::Length(button_row_height));
}
f.render_widget(
Paragraph::new("OK")
.block(button_block)
.style(button_style)
.alignment(Alignment::Center),
chunks[1],
);
}
/// Helper function to center a rect with given percentage values
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner_area);
// Render Message
let available_width = inner_area.width as usize;
let ellipsis = "...";
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
let processed_lines: Vec<Line> = message_lines
.into_iter()
.map(|line| {
let line_width = UnicodeWidthStr::width(line);
if line_width > available_width {
// Truncate with ellipsis
let mut truncated_len = 0;
let mut current_width = 0;
for (idx, grapheme) in line.grapheme_indices(true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if current_width + grapheme_width
> available_width.saturating_sub(ellipsis_width)
{
break;
}
current_width += grapheme_width;
truncated_len = idx + grapheme.len();
}
let truncated_line =
format!("{}{}", &line[..truncated_len], ellipsis);
Line::from(Span::styled(
truncated_line,
Style::default().fg(theme.fg),
))
} else {
Line::from(Span::styled(line, Style::default().fg(theme.fg)))
}
})
.collect();
let message_paragraph =
Paragraph::new(Text::from(processed_lines)).alignment(Alignment::Center);
f.render_widget(message_paragraph, chunks[0]); // Render message in the first chunk
// Render Buttons if they exist and there's a chunk for them
if !dialog_buttons.is_empty() && chunks.len() > 1 {
let button_area = chunks[1];
let button_count = dialog_buttons.len();
let button_constraints = std::iter::repeat(Constraint::Ratio(
1,
button_count as u32,
))
.take(button_count)
.collect::<Vec<_>>();
let button_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(button_constraints)
.horizontal_margin(1) // Add space between buttons
.split(button_area);
for (i, button_label) in dialog_buttons.iter().enumerate() {
if i >= button_chunks.len() {
break;
}
let is_active = i == dialog_active_button_index;
let (button_style, border_style) = if is_active {
(
Style::default()
.fg(theme.highlight)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.accent),
)
} else {
(
Style::default().fg(theme.fg),
Style::default().fg(theme.border),
)
};
let button_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(border_style);
f.render_widget(
Paragraph::new(button_label.as_str())
.block(button_block)
.style(button_style)
.alignment(Alignment::Center),
button_chunks[i],
);
}
}
}
}

View File

@@ -0,0 +1,142 @@
// src/components/common/find_file_palette.rs
use crate::config::colors::themes::Theme;
use crate::modes::general::command_navigation::NavigationState; // Corrected path
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::Style,
widgets::{Block, List, ListItem, Paragraph},
Frame,
};
use unicode_width::UnicodeWidthStr;
const PALETTE_MAX_VISIBLE_OPTIONS: usize = 15;
const PADDING_CHAR: &str = " ";
pub fn render_find_file_palette(
f: &mut Frame,
area: Rect,
theme: &Theme,
navigation_state: &NavigationState,
) {
let palette_display_input = navigation_state.get_display_input(); // Use the new method
let num_total_filtered = navigation_state.filtered_options.len();
let current_selected_list_idx = navigation_state.selected_index;
let mut display_start_offset = 0;
if num_total_filtered > PALETTE_MAX_VISIBLE_OPTIONS {
if let Some(sel_idx) = current_selected_list_idx {
if sel_idx >= display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS {
display_start_offset = sel_idx - PALETTE_MAX_VISIBLE_OPTIONS + 1;
} else if sel_idx < display_start_offset {
display_start_offset = sel_idx;
}
display_start_offset = display_start_offset
.min(num_total_filtered.saturating_sub(PALETTE_MAX_VISIBLE_OPTIONS));
}
}
display_start_offset = display_start_offset.max(0);
let display_end_offset = (display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS)
.min(num_total_filtered);
// navigation_state.filtered_options is Vec<(usize, String)>
// We only need the String part for display.
let visible_options_slice: Vec<&String> = if num_total_filtered > 0 {
navigation_state.filtered_options
[display_start_offset..display_end_offset]
.iter()
.map(|(_, opt_str)| opt_str)
.collect()
} else {
Vec::new()
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // For palette input line
Constraint::Min(0), // For options list, take remaining space
])
.split(area);
// Ensure list_area height does not exceed PALETTE_MAX_VISIBLE_OPTIONS
let list_area_height = std::cmp::min(chunks[1].height, PALETTE_MAX_VISIBLE_OPTIONS as u16);
let final_list_area = Rect::new(chunks[1].x, chunks[1].y, chunks[1].width, list_area_height);
let input_area = chunks[0];
// let list_area = chunks[1]; // Use final_list_area
let prompt_prefix = match navigation_state.navigation_type {
crate::modes::general::command_navigation::NavigationType::FindFile => "Find File: ",
crate::modes::general::command_navigation::NavigationType::TableTree => "Table Path: ",
};
let base_prompt_text = format!("{}{}", prompt_prefix, palette_display_input);
let prompt_text_width = UnicodeWidthStr::width(base_prompt_text.as_str());
let input_area_width = input_area.width as usize;
let input_padding_needed =
input_area_width.saturating_sub(prompt_text_width);
let padded_prompt_text = if input_padding_needed > 0 {
format!(
"{}{}",
base_prompt_text,
PADDING_CHAR.repeat(input_padding_needed)
)
} else {
base_prompt_text
};
let input_paragraph = Paragraph::new(padded_prompt_text)
.style(Style::default().fg(theme.accent).bg(theme.bg));
f.render_widget(input_paragraph, input_area);
let mut display_list_items: Vec<ListItem> =
Vec::with_capacity(PALETTE_MAX_VISIBLE_OPTIONS);
for (idx_in_visible_slice, opt_str) in
visible_options_slice.iter().enumerate()
{
// The selected_index in navigation_state is relative to the full filtered_options list.
// We need to check if the current item (from the visible slice) corresponds to the selected_index.
let original_filtered_idx = display_start_offset + idx_in_visible_slice;
let is_selected =
current_selected_list_idx == Some(original_filtered_idx);
let style = if is_selected {
Style::default().fg(theme.bg).bg(theme.accent)
} else {
Style::default().fg(theme.fg).bg(theme.bg)
};
let opt_width = opt_str.width() as u16;
let list_item_width = final_list_area.width;
let padding_amount = list_item_width.saturating_sub(opt_width);
let padded_opt_str = format!(
"{}{}",
opt_str,
PADDING_CHAR.repeat(padding_amount as usize)
);
display_list_items.push(ListItem::new(padded_opt_str).style(style));
}
// Fill remaining lines in the list area to maintain fixed height appearance
let num_rendered_options = display_list_items.len();
if num_rendered_options < PALETTE_MAX_VISIBLE_OPTIONS && (final_list_area.height as usize) > num_rendered_options {
for _ in num_rendered_options..(final_list_area.height as usize) {
let empty_padded_str =
PADDING_CHAR.repeat(final_list_area.width as usize);
display_list_items.push(
ListItem::new(empty_padded_str)
.style(Style::default().fg(theme.bg).bg(theme.bg)),
);
}
}
let options_list_widget = List::new(display_list_items)
.block(Block::default().style(Style::default().bg(theme.bg)));
f.render_widget(options_list_widget, final_list_area);
}

View File

@@ -0,0 +1,121 @@
// src/components/common/search_palette.rs
use crate::config::colors::themes::Theme;
use crate::state::app::search::SearchState;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
/// Renders the search palette dialog over the main UI.
pub fn render_search_palette(
f: &mut Frame,
area: Rect,
theme: &Theme,
state: &SearchState,
) {
// --- Dialog Area Calculation ---
let height = (area.height as f32 * 0.7).min(30.0) as u16;
let width = (area.width as f32 * 0.6).min(100.0) as u16;
let dialog_area = Rect {
x: area.x + (area.width - width) / 2,
y: area.y + (area.height - height) / 4,
width,
height,
};
f.render_widget(Clear, dialog_area); // Clear background
let block = Block::default()
.title(format!(" Search in '{}' ", state.table_name))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent));
f.render_widget(block.clone(), dialog_area);
// --- Inner Layout (Input + Results) ---
let inner_chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3), // For input box
Constraint::Min(0), // For results list
])
.split(dialog_area);
// --- Render Input Box ---
let input_block = Block::default()
.title("Query")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border));
let input_text = Paragraph::new(state.input.as_str())
.block(input_block)
.style(Style::default().fg(theme.fg));
f.render_widget(input_text, inner_chunks[0]);
// Set cursor position
f.set_cursor_position((
inner_chunks[0].x + state.cursor_position as u16 + 1,
inner_chunks[0].y + 1,
));
// --- Render Results List ---
if state.is_loading {
let loading_p = Paragraph::new("Searching...")
.style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC));
f.render_widget(loading_p, inner_chunks[1]);
} else {
let list_items: Vec<ListItem> = state
.results
.iter()
.map(|hit| {
// Parse the JSON string to make it readable
let content_summary = match serde_json::from_str::<
serde_json::Value,
>(&hit.content_json)
{
Ok(json) => {
if let Some(obj) = json.as_object() {
// Create a summary from the first few non-null string values
obj.values()
.filter_map(|v| v.as_str())
.filter(|s| !s.is_empty())
.take(3)
.collect::<Vec<_>>()
.join(" | ")
} else {
"Non-object JSON".to_string()
}
}
Err(_) => "Invalid JSON content".to_string(),
};
let line = Line::from(vec![
Span::styled(
format!("{:<4.2} ", hit.score),
Style::default().fg(theme.accent),
),
Span::raw(content_summary),
]);
ListItem::new(line)
})
.collect();
let results_list = List::new(list_items)
.block(Block::default().title("Results"))
.highlight_style(
Style::default()
.bg(theme.highlight)
.fg(theme.bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
// We need a mutable ListState to render the selection
let mut list_state =
ratatui::widgets::ListState::default().with_selected(Some(state.selected_index));
f.render_stateful_widget(results_list, inner_chunks[1], &mut list_state);
}
}

View File

@@ -1,13 +1,16 @@
// src/client/components/handlers/status_line.rs
use ratatui::{
widgets::Paragraph,
style::Style,
layout::Rect,
Frame,
text::{Line, Span},
};
// client/src/components/common/status_line.rs
use crate::config::colors::themes::Theme;
use crate::state::app::state::AppState;
use ratatui::{
layout::Rect,
style::Style,
text::{Line, Span, Text},
widgets::Paragraph,
Frame,
};
use ratatui::widgets::Wrap;
use std::path::Path;
use unicode_width::UnicodeWidthStr;
pub fn render_status_line(
f: &mut Frame,
@@ -15,66 +18,138 @@ pub fn render_status_line(
current_dir: &str,
theme: &Theme,
is_edit_mode: bool,
current_fps: f64,
app_state: &AppState,
) {
// Program name and version
let program_info = format!("multieko2 v{}", env!("CARGO_PKG_VERSION"));
#[cfg(feature = "ui-debug")]
{
if let Some(debug_state) = &app_state.debug_state {
let paragraph = if debug_state.is_error {
// --- THIS IS THE CRITICAL LOGIC FOR ERRORS ---
// 1. Create a `Text` object, which can contain multiple lines.
let error_text = Text::from(debug_state.displayed_message.clone());
let mode_text = if is_edit_mode {
"[EDIT]"
} else {
"[READ-ONLY]"
};
// 2. Create a Paragraph from the Text and TELL IT TO WRAP.
Paragraph::new(error_text)
.wrap(Wrap { trim: true }) // This line makes the text break into new rows.
.style(Style::default().bg(theme.highlight).fg(theme.bg))
} else {
// --- This is for normal, single-line info messages ---
Paragraph::new(debug_state.displayed_message.as_str())
.style(Style::default().fg(theme.accent).bg(theme.bg))
};
f.render_widget(paragraph, area);
} else {
// Fallback for when debug state is None
let paragraph = Paragraph::new("").style(Style::default().bg(theme.bg));
f.render_widget(paragraph, area);
}
return; // Stop here and don't render the normal status line.
}
// Shorten the current directory path
let home_dir = dirs::home_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
// --- The normal status line rendering logic (unchanged) ---
let program_info = format!("komp_ac v{}", env!("CARGO_PKG_VERSION"));
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
let home_dir = dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let display_dir = if current_dir.starts_with(&home_dir) {
current_dir.replacen(&home_dir, "~", 1)
} else {
current_dir.to_string()
};
// Create the full status line text
let full_text = format!("{} | {} | {}", mode_text, display_dir, program_info);
// Check if the full text fits in the available width
let available_width = area.width as usize;
let mut display_text = if full_text.len() <= available_width {
// If it fits, use the full text
full_text
let mode_width = UnicodeWidthStr::width(mode_text);
let program_info_width = UnicodeWidthStr::width(program_info.as_str());
let fps_text = format!("{:.0} FPS", current_fps);
let fps_width = UnicodeWidthStr::width(fps_text.as_str());
let separator = " | ";
let separator_width = UnicodeWidthStr::width(separator);
let fixed_width_with_fps = mode_width
+ separator_width
+ separator_width
+ program_info_width
+ separator_width
+ fps_width;
let show_fps = fixed_width_with_fps <= available_width;
let remaining_width_for_dir = available_width.saturating_sub(
mode_width
+ separator_width
+ separator_width
+ program_info_width
+ (if show_fps {
separator_width + fps_width
} else {
0
}),
);
let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str())
<= remaining_width_for_dir
{
display_dir
} else {
// If it doesn't fit, prioritize mode and program info, and show only the directory name
let dir_name = Path::new(current_dir)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(current_dir);
format!("{} | {} | {}", mode_text, dir_name, program_info)
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
dir_name.to_string()
} else {
dir_name
.chars()
.take(remaining_width_for_dir)
.collect::<String>()
}
};
// If even the shortened version overflows, truncate it
if display_text.len() > available_width {
display_text = display_text.chars().take(available_width).collect();
let mut current_content_width = mode_width
+ separator_width
+ UnicodeWidthStr::width(dir_display_text_str.as_str())
+ separator_width
+ program_info_width;
if show_fps {
current_content_width += separator_width + fps_width;
}
// Create the status line text using Line and Span
let status_line = Line::from(vec![
let mut line_spans = vec![
Span::styled(mode_text, Style::default().fg(theme.accent)),
Span::styled(" | ", Style::default().fg(theme.border)),
Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(
display_text.split(" | ").nth(1).unwrap_or(""), // Directory part
dir_display_text_str.as_str(),
Style::default().fg(theme.fg),
),
Span::styled(" | ", Style::default().fg(theme.border)),
Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(
program_info,
Style::default()
.fg(theme.secondary)
.add_modifier(ratatui::style::Modifier::BOLD),
program_info.as_str(),
Style::default().fg(theme.secondary),
),
]);
];
// Render the status line
let paragraph = Paragraph::new(status_line)
.style(Style::default().bg(theme.bg));
if show_fps {
line_spans
.push(Span::styled(separator, Style::default().fg(theme.border)));
line_spans.push(Span::styled(
fps_text.as_str(),
Style::default().fg(theme.secondary),
));
}
let padding_needed = available_width.saturating_sub(current_content_width);
if padding_needed > 0 {
line_spans.push(Span::styled(
" ".repeat(padding_needed),
Style::default().bg(theme.bg),
));
}
let paragraph =
Paragraph::new(Line::from(line_spans)).style(Style::default().bg(theme.bg));
f.render_widget(paragraph, area);
}

View File

@@ -0,0 +1,331 @@
// src/components/common/text_editor.rs
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
use ratatui::style::{Color, Style, Modifier};
use tui_textarea::{Input, Key, TextArea, CursorMove};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VimMode {
Normal,
Insert,
Visual,
Operator(char),
}
impl VimMode {
pub fn cursor_style(&self) -> Style {
let color = match self {
Self::Normal => Color::Reset,
Self::Insert => Color::LightBlue,
Self::Visual => Color::LightYellow,
Self::Operator(_) => Color::LightGreen,
};
Style::default().fg(color).add_modifier(Modifier::REVERSED)
}
}
impl fmt::Display for VimMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
match self {
Self::Normal => write!(f, "NORMAL"),
Self::Insert => write!(f, "INSERT"),
Self::Visual => write!(f, "VISUAL"),
Self::Operator(c) => write!(f, "OPERATOR({})", c),
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum Transition {
Nop,
Mode(VimMode),
Pending(Input),
}
#[derive(Debug, Clone)]
pub struct VimState {
pub mode: VimMode,
pub pending: Input,
}
impl Default for VimState {
fn default() -> Self {
Self {
mode: VimMode::Normal,
pending: Input::default(),
}
}
}
impl VimState {
pub fn new(mode: VimMode) -> Self {
Self {
mode,
pending: Input::default(),
}
}
fn with_pending(self, pending: Input) -> Self {
Self {
mode: self.mode,
pending,
}
}
fn transition(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition {
if input.key == Key::Null {
return Transition::Nop;
}
match self.mode {
VimMode::Normal | VimMode::Visual | VimMode::Operator(_) => {
match input {
Input { key: Key::Char('h'), .. } => textarea.move_cursor(CursorMove::Back),
Input { key: Key::Char('j'), .. } => textarea.move_cursor(CursorMove::Down),
Input { key: Key::Char('k'), .. } => textarea.move_cursor(CursorMove::Up),
Input { key: Key::Char('l'), .. } => textarea.move_cursor(CursorMove::Forward),
Input { key: Key::Char('w'), .. } => textarea.move_cursor(CursorMove::WordForward),
Input { key: Key::Char('e'), ctrl: false, .. } => {
textarea.move_cursor(CursorMove::WordEnd);
if matches!(self.mode, VimMode::Operator(_)) {
textarea.move_cursor(CursorMove::Forward);
}
}
Input { key: Key::Char('b'), ctrl: false, .. } => textarea.move_cursor(CursorMove::WordBack),
Input { key: Key::Char('^'), .. } => textarea.move_cursor(CursorMove::Head),
Input { key: Key::Char('$'), .. } => textarea.move_cursor(CursorMove::End),
Input { key: Key::Char('0'), .. } => textarea.move_cursor(CursorMove::Head),
Input { key: Key::Char('D'), .. } => {
textarea.delete_line_by_end();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('C'), .. } => {
textarea.delete_line_by_end();
textarea.cancel_selection();
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('p'), .. } => {
textarea.paste();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('u'), ctrl: false, .. } => {
textarea.undo();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('r'), ctrl: true, .. } => {
textarea.redo();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('x'), .. } => {
textarea.delete_next_char();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('i'), .. } => {
textarea.cancel_selection();
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('a'), .. } => {
textarea.cancel_selection();
textarea.move_cursor(CursorMove::Forward);
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('A'), .. } => {
textarea.cancel_selection();
textarea.move_cursor(CursorMove::End);
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('o'), .. } => {
textarea.move_cursor(CursorMove::End);
textarea.insert_newline();
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('O'), .. } => {
textarea.move_cursor(CursorMove::Head);
textarea.insert_newline();
textarea.move_cursor(CursorMove::Up);
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('I'), .. } => {
textarea.cancel_selection();
textarea.move_cursor(CursorMove::Head);
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Normal => {
textarea.start_selection();
return Transition::Mode(VimMode::Visual);
}
Input { key: Key::Char('V'), ctrl: false, .. } if self.mode == VimMode::Normal => {
textarea.move_cursor(CursorMove::Head);
textarea.start_selection();
textarea.move_cursor(CursorMove::End);
return Transition::Mode(VimMode::Visual);
}
Input { key: Key::Esc, .. } | Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Visual => {
textarea.cancel_selection();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('g'), ctrl: false, .. } if matches!(
self.pending,
Input { key: Key::Char('g'), ctrl: false, .. }
) => {
textarea.move_cursor(CursorMove::Top)
}
Input { key: Key::Char('G'), ctrl: false, .. } => textarea.move_cursor(CursorMove::Bottom),
Input { key: Key::Char(c), ctrl: false, .. } if self.mode == VimMode::Operator(c) => {
textarea.move_cursor(CursorMove::Head);
textarea.start_selection();
let cursor = textarea.cursor();
textarea.move_cursor(CursorMove::Down);
if cursor == textarea.cursor() {
textarea.move_cursor(CursorMove::End);
}
}
Input { key: Key::Char(op @ ('y' | 'd' | 'c')), ctrl: false, .. } if self.mode == VimMode::Normal => {
textarea.start_selection();
return Transition::Mode(VimMode::Operator(op));
}
Input { key: Key::Char('y'), ctrl: false, .. } if self.mode == VimMode::Visual => {
textarea.move_cursor(CursorMove::Forward);
textarea.copy();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('d'), ctrl: false, .. } if self.mode == VimMode::Visual => {
textarea.move_cursor(CursorMove::Forward);
textarea.cut();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('c'), ctrl: false, .. } if self.mode == VimMode::Visual => {
textarea.move_cursor(CursorMove::Forward);
textarea.cut();
return Transition::Mode(VimMode::Insert);
}
// Arrow keys work in normal mode
Input { key: Key::Up, .. } => textarea.move_cursor(CursorMove::Up),
Input { key: Key::Down, .. } => textarea.move_cursor(CursorMove::Down),
Input { key: Key::Left, .. } => textarea.move_cursor(CursorMove::Back),
Input { key: Key::Right, .. } => textarea.move_cursor(CursorMove::Forward),
input => return Transition::Pending(input),
}
// Handle the pending operator
match self.mode {
VimMode::Operator('y') => {
textarea.copy();
Transition::Mode(VimMode::Normal)
}
VimMode::Operator('d') => {
textarea.cut();
Transition::Mode(VimMode::Normal)
}
VimMode::Operator('c') => {
textarea.cut();
Transition::Mode(VimMode::Insert)
}
_ => Transition::Nop,
}
}
VimMode::Insert => match input {
Input { key: Key::Esc, .. } | Input { key: Key::Char('c'), ctrl: true, .. } => {
Transition::Mode(VimMode::Normal)
}
input => {
textarea.input(input);
Transition::Mode(VimMode::Insert)
}
},
}
}
}
pub struct TextEditor;
impl TextEditor {
pub fn new_textarea(editor_config: &EditorConfig) -> TextArea<'static> {
let mut textarea = TextArea::default();
if editor_config.show_line_numbers {
textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
}
textarea.set_tab_length(editor_config.tab_width);
textarea
}
pub fn handle_input(
textarea: &mut TextArea<'static>,
key_event: KeyEvent,
keybinding_mode: &EditorKeybindingMode,
vim_state: &mut VimState,
) -> bool {
match keybinding_mode {
EditorKeybindingMode::Vim => {
Self::handle_vim_input(textarea, key_event, vim_state)
}
_ => {
let tui_input: Input = key_event.into();
textarea.input(tui_input)
}
}
}
fn handle_vim_input(
textarea: &mut TextArea<'static>,
key_event: KeyEvent,
vim_state: &mut VimState,
) -> bool {
let input = Self::convert_key_event_to_input(key_event);
*vim_state = match vim_state.transition(input, textarea) {
Transition::Mode(mode) if vim_state.mode != mode => {
// Update cursor style based on mode
textarea.set_cursor_style(mode.cursor_style());
VimState::new(mode)
}
Transition::Nop | Transition::Mode(_) => vim_state.clone(),
Transition::Pending(input) => vim_state.clone().with_pending(input),
};
true // Always consider input as handled in vim mode
}
fn convert_key_event_to_input(key_event: KeyEvent) -> Input {
let key = match key_event.code {
KeyCode::Char(c) => Key::Char(c),
KeyCode::Enter => Key::Enter,
KeyCode::Left => Key::Left,
KeyCode::Right => Key::Right,
KeyCode::Up => Key::Up,
KeyCode::Down => Key::Down,
KeyCode::Backspace => Key::Backspace,
KeyCode::Delete => Key::Delete,
KeyCode::Home => Key::Home,
KeyCode::End => Key::End,
KeyCode::PageUp => Key::PageUp,
KeyCode::PageDown => Key::PageDown,
KeyCode::Tab => Key::Tab,
KeyCode::Esc => Key::Esc,
_ => Key::Null,
};
Input {
key,
ctrl: key_event.modifiers.contains(KeyModifiers::CONTROL),
alt: key_event.modifiers.contains(KeyModifiers::ALT),
shift: key_event.modifiers.contains(KeyModifiers::SHIFT),
}
}
pub fn get_vim_mode_status(vim_state: &VimState) -> String {
vim_state.mode.to_string()
}
pub fn is_vim_insert_mode(vim_state: &VimState) -> bool {
matches!(vim_state.mode, VimMode::Insert)
}
pub fn is_vim_normal_mode(vim_state: &VimState) -> bool {
matches!(vim_state.mode, VimMode::Normal)
}
}

View File

@@ -1,66 +1,98 @@
// src/components/form/form.rs
use crate::components::common::autocomplete;
use crate::config::colors::themes::Theme;
use canvas::canvas::{CanvasState, render_canvas, HighlightState};
use crate::state::pages::form::FormState;
use ratatui::{
widgets::{Paragraph, Block, Borders},
layout::{Layout, Constraint, Direction, Rect, Margin, Alignment},
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::Style,
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::config::colors::themes::Theme;
use crate::state::canvas_state::CanvasState;
use crate::components::handlers::canvas::render_canvas;
pub fn render_form(
f: &mut Frame,
area: Rect,
form_state: &impl CanvasState,
form_state: &FormState,
fields: &[&str],
current_field: &usize,
current_field_idx: &usize,
inputs: &[&String],
table_name: &str,
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
total_count: u64,
current_position: u64,
) {
// Create Adresar card
let card_title = format!(" {} ", table_name);
let adresar_card = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.title(" Adresar ")
.title(card_title)
.style(Style::default().bg(theme.bg).fg(theme.fg));
f.render_widget(adresar_card, area);
// Define inner area
let inner_area = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
// Create main layout
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(1),
])
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner_area);
// Render count/position
let count_position_text = format!("Total: {} | Position: {}", total_count, current_position);
let count_position_text = if total_count == 0 && current_position == 1 {
"Total: 0 | New Entry".to_string()
} else if current_position > total_count && total_count > 0 {
format!("Total: {} | New Entry ({})", total_count, current_position)
} else if total_count == 0 && current_position > 1 {
format!("Total: 0 | New Entry ({})", current_position)
} else {
format!(
"Total: {} | Position: {}/{}",
total_count, current_position, total_count
)
};
let count_para = Paragraph::new(count_position_text)
.style(Style::default().fg(theme.fg))
.alignment(Alignment::Left);
f.render_widget(count_para, main_layout[0]);
// Delegate input handling to canvas
render_canvas(
// Use the canvas library's render_canvas function
let active_field_rect = render_canvas(
f,
main_layout[1],
form_state,
fields,
current_field,
inputs,
theme,
is_edit_mode,
highlight_state,
);
// --- RENDER RICH AUTOCOMPLETE ONLY ---
if form_state.autocomplete_active {
if let Some(active_rect) = active_field_rect {
// Get selected index directly from form_state
let selected_index = form_state.selected_suggestion_index;
// Only render rich suggestions (your Hit objects)
if let Some(rich_suggestions) = form_state.get_rich_suggestions() {
if !rich_suggestions.is_empty() {
autocomplete::render_hit_autocomplete_dropdown(
f,
active_rect,
f.area(),
theme,
rich_suggestions,
selected_index,
form_state,
);
}
}
// Removed simple suggestions - we only use rich ones now!
}
}
}

View File

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

View File

@@ -0,0 +1,80 @@
// src/components/handlers/buffer_list.rs
use crate::config::colors::themes::Theme;
use crate::state::app::buffer::BufferState;
use crate::state::app::state::AppState; // Add this import
use ratatui::{
layout::{Alignment, Rect},
style::Style,
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use unicode_width::UnicodeWidthStr;
use crate::functions::common::buffer::get_view_layer;
pub fn render_buffer_list(
f: &mut Frame,
area: Rect,
theme: &Theme,
buffer_state: &BufferState,
app_state: &AppState,
) {
// --- Style Definitions ---
let active_style = Style::default()
.fg(theme.bg)
.bg(theme.highlight);
let inactive_style = Style::default()
.fg(theme.fg)
.bg(theme.bg);
// --- Determine Active Layer ---
let active_layer = match buffer_state.history.get(buffer_state.active_index) {
Some(view) => get_view_layer(view),
None => 1,
};
// --- Create Spans ---
let mut spans = Vec::new();
let mut current_width = 0;
let current_table_name = app_state.current_view_table_name.as_deref();
for (original_index, view) in buffer_state.history.iter().enumerate() {
// Filter: Only process views matching the active layer
if get_view_layer(view) != active_layer {
continue;
}
let is_active = original_index == buffer_state.active_index;
let buffer_name = view.display_name_with_context(current_table_name);
let buffer_text = format!(" {} ", buffer_name);
let text_width = UnicodeWidthStr::width(buffer_text.as_str());
// Calculate width needed for this buffer (separator + text)
let needed_width = text_width;
if current_width + needed_width > area.width as usize {
break;
}
// Add the buffer text itself
let text_style = if is_active { active_style } else { inactive_style };
spans.push(Span::styled(buffer_text, text_style));
current_width += text_width;
}
// --- Filler Span ---
let remaining_width = area.width.saturating_sub(current_width as u16);
if !spans.is_empty() || remaining_width > 0 {
spans.push(Span::styled(
" ".repeat(remaining_width as usize),
inactive_style,
));
}
// --- Render ---
let buffer_line = Line::from(spans);
let paragraph = Paragraph::new(buffer_line).alignment(Alignment::Left);
f.render_widget(paragraph, area);
}

View File

@@ -1,89 +0,0 @@
// src/components/handlers/canvas.rs
use ratatui::{
widgets::{Paragraph, Block, Borders},
layout::{Layout, Constraint, Direction, Rect},
style::Style,
text::{Line, Span},
Frame,
prelude::Alignment,
};
use crate::config::colors::themes::Theme;
use crate::state::canvas_state::CanvasState;
pub fn render_canvas(
f: &mut Frame,
area: Rect,
form_state: &impl CanvasState,
fields: &[&str],
current_field: &usize,
inputs: &[&String],
theme: &Theme,
is_edit_mode: bool,
) {
// Split area into columns
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(area);
// Input container styling
let input_container = Block::default()
.borders(Borders::ALL)
.border_style(if is_edit_mode {
form_state.has_unsaved_changes().then(|| theme.warning).unwrap_or(theme.accent)
} else {
theme.secondary
})
.style(Style::default().bg(theme.bg));
// Input block dimensions
let input_block = Rect {
x: columns[1].x,
y: columns[1].y,
width: columns[1].width,
height: fields.len() as u16 + 2,
};
f.render_widget(&input_container, input_block);
// Input rows layout
let input_area = input_container.inner(input_block);
let input_rows = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(1); fields.len()])
.split(input_area);
// Render labels
for (i, field) in fields.iter().enumerate() {
let label = Paragraph::new(Line::from(Span::styled(
format!("{}:", field),
Style::default().fg(theme.fg)),
));
f.render_widget(label, Rect {
x: columns[0].x,
y: input_block.y + 1 + i as u16,
width: columns[0].width,
height: 1,
});
}
// Render inputs and cursor
for (i, input) in inputs.iter().enumerate() {
let is_active = i == *current_field;
let input_display = Paragraph::new(input.as_str())
.alignment(Alignment::Left)
.style(if is_active {
Style::default().fg(theme.highlight)
} else {
Style::default().fg(theme.fg)
});
f.render_widget(input_display, input_rows[i]);
if is_active {
let cursor_x = input_rows[i].x + form_state.current_cursor_pos() as u16;
let cursor_y = input_rows[i].y;
f.set_cursor_position((cursor_x, cursor_y));
}
}
}

View File

@@ -6,12 +6,17 @@ use ratatui::{
Frame,
};
use crate::config::colors::themes::Theme;
use common::proto::multieko2::table_definition::{ProfileTreeResponse};
use common::proto::komp_ac::table_definition::{ProfileTreeResponse};
use ratatui::text::{Span, Line};
use crate::components::utils::text::truncate_string;
// Reduced sidebar width
const SIDEBAR_WIDTH: u16 = 12;
const SIDEBAR_WIDTH: u16 = 20;
// --- Icons ---
const ICON_PROFILE: &str = "📁";
const ICON_TABLE: &str = "📄";
pub fn calculate_sidebar_layout(show_sidebar: bool, main_content_area: Rect) -> (Option<Rect>, Rect) {
if show_sidebar {
let chunks = Layout::default()
@@ -36,18 +41,54 @@ pub fn render_sidebar(
) {
let sidebar_block = Block::default().style(Style::default().bg(theme.bg));
let mut items = Vec::new();
let profile_name_available_width = (SIDEBAR_WIDTH as usize).saturating_sub(3);
let table_name_available_width = (SIDEBAR_WIDTH as usize).saturating_sub(5);
if let Some(profile_name) = selected_profile {
// Existing code for when a profile is selected...
// Find the selected profile in the tree
if let Some(profile) = profile_tree
.profiles
.iter()
.find(|p| &p.name == profile_name)
{
// Add profile name as header
items.push(ListItem::new(Line::from(vec![
Span::styled(format!("{} ", ICON_PROFILE), Style::default().fg(theme.accent)),
Span::styled(
truncate_string(&profile.name, profile_name_available_width),
Style::default().fg(theme.highlight)
),
])));
// List tables for the selected profile
for table in &profile.tables {
// Get table name without year prefix to save space
let display_name = if table.name.starts_with("2025_") {
&table.name[5..] // Skip "2025_" prefix
} else {
&table.name
};
items.push(ListItem::new(Line::from(vec![
Span::raw(" "), // Indentation
Span::styled(format!("{} ", ICON_TABLE), Style::default().fg(theme.secondary)),
Span::styled(
truncate_string(display_name, table_name_available_width),
theme.fg
),
])));
}
}
} else {
// Show full profile tree when no profile is selected (compact version)
for (profile_idx, profile) in profile_tree.profiles.iter().enumerate() {
// Profile header - more compact
items.push(ListItem::new(Line::from(vec![
Span::styled("", Style::default().fg(theme.accent)),
Span::styled(&profile.name, Style::default().fg(theme.highlight)),
Span::styled(format!("{} ", ICON_PROFILE), Style::default().fg(theme.accent)),
Span::styled(
&profile.name,
Style::default().fg(theme.highlight)
),
])));
// Tables with compact prefixes
for (table_idx, table) in profile.tables.iter().enumerate() {
let is_last_table = table_idx == profile.tables.len() - 1;
@@ -68,18 +109,18 @@ pub fn render_sidebar(
&table.name
};
let mut line = vec![
Span::styled(prefix, Style::default().fg(theme.fg)),
Span::styled(display_name, Style::default().fg(theme.fg)),
];
// Adjust available width if dependency arrow is shown
let current_table_available_width = if !table.depends_on.is_empty() {
table_name_available_width.saturating_sub(1)
} else {
table_name_available_width
};
// Show a simple indicator for dependencies instead of listing them
if !table.depends_on.is_empty() {
line.push(Span::styled(
"",
Style::default().fg(theme.secondary)
));
}
let line = vec![
Span::styled(prefix, Style::default().fg(theme.fg)),
Span::styled(format!("{} ", ICON_TABLE), Style::default().fg(theme.secondary)),
Span::styled(truncate_string(display_name, current_table_available_width), Style::default().fg(theme.fg)),
];
items.push(ListItem::new(Line::from(line)));
}

View File

@@ -8,114 +8,80 @@ use ratatui::{
Frame,
};
use crate::config::colors::themes::Theme;
use crate::state::pages::intro::IntroState;
pub struct IntroState {
pub selected_option: usize,
pub fn render_intro(f: &mut Frame, intro_state: &IntroState, area: Rect, theme: &Theme) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent))
.style(Style::default().bg(theme.bg));
let inner_area = block.inner(area);
f.render_widget(block, area);
// Center layout
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40),
Constraint::Length(5),
Constraint::Percentage(40),
])
.split(inner_area);
// Title
let title = Line::from(vec![
Span::styled("komp_ac", Style::default().fg(theme.highlight)),
Span::styled(" v", Style::default().fg(theme.fg)),
Span::styled(env!("CARGO_PKG_VERSION"), Style::default().fg(theme.secondary)),
]);
let title_para = Paragraph::new(title)
.alignment(Alignment::Center);
f.render_widget(title_para, chunks[1]);
// Buttons - now with 4 options
let button_area = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
])
.split(chunks[1].inner(Margin {
horizontal: 1,
vertical: 1
}));
let buttons = ["Continue", "Admin", "Login", "Register"];
for (i, &text) in buttons.iter().enumerate() {
render_button(f, button_area[i], text, intro_state.selected_option == i, theme);
}
}
impl IntroState {
pub fn new() -> Self {
Self { selected_option: 0 }
}
pub fn render(&self, f: &mut Frame, area: Rect, theme: &Theme) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent))
.style(Style::default().bg(theme.bg));
let inner_area = block.inner(area);
f.render_widget(block, area);
// Center layout
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40),
Constraint::Length(5), // Increased to accommodate 3 buttons
Constraint::Percentage(40),
])
.split(inner_area);
// Title
let title = Line::from(vec![
Span::styled("multieko2", Style::default().fg(theme.highlight)),
Span::styled(" v", Style::default().fg(theme.fg)),
Span::styled(env!("CARGO_PKG_VERSION"), Style::default().fg(theme.secondary)),
]);
let title_para = Paragraph::new(title)
.alignment(Alignment::Center);
f.render_widget(title_para, chunks[1]);
// Buttons - now with 3 options
let button_area = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(33),
])
.split(chunks[1].inner(Margin {
horizontal: 1,
vertical: 1
}));
self.render_button(
f,
button_area[0],
"Continue",
self.selected_option == 0,
theme,
);
self.render_button(
f,
button_area[1],
"Admin",
self.selected_option == 1,
theme,
);
self.render_button(
f,
button_area[2],
"Login",
self.selected_option == 2,
theme,
);
}
fn render_button(&self, f: &mut Frame, area: Rect, text: &str, selected: bool, theme: &Theme) {
let button_style = if selected {
Style::default()
.fg(theme.highlight)
.bg(theme.bg)
.add_modifier(ratatui::style::Modifier::BOLD)
fn render_button(f: &mut Frame, area: Rect, text: &str, selected: bool, theme: &Theme) {
let button_style = Style::default()
.fg(if selected { theme.highlight } else { theme.fg })
.bg(theme.bg)
.add_modifier(if selected {
ratatui::style::Modifier::BOLD
} else {
Style::default().fg(theme.fg).bg(theme.bg)
};
ratatui::style::Modifier::empty()
});
let button = Paragraph::new(text)
.style(button_style)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(if selected {
Style::default().fg(theme.accent)
} else {
Style::default().fg(theme.border)
}),
);
let border_style = Style::default()
.fg(if selected { theme.accent } else { theme.border });
f.render_widget(button, area);
}
let button = Paragraph::new(text)
.style(button_style)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(border_style),
);
pub fn next_option(&mut self) {
self.selected_option = (self.selected_option + 1) % 3;
}
pub fn previous_option(&mut self) {
self.selected_option = if self.selected_option == 0 { 2 } else { self.selected_option - 1 };
}
f.render_widget(button, area);
}

View File

@@ -5,6 +5,7 @@ pub mod admin;
pub mod common;
pub mod form;
pub mod auth;
pub mod utils;
pub use handlers::*;
pub use intro::*;
@@ -12,3 +13,4 @@ pub use admin::*;
pub use common::*;
pub use form::*;
pub use auth::*;
pub use utils::*;

View File

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

View File

@@ -0,0 +1,29 @@
// src/components/utils/text.rs
use unicode_width::UnicodeWidthStr;
use unicode_segmentation::UnicodeSegmentation;
/// Truncates a string to a maximum width, adding an ellipsis if truncated.
/// Considers unicode character widths.
pub fn truncate_string(s: &str, max_width: usize) -> String {
if UnicodeWidthStr::width(s) <= max_width {
s.to_string()
} else {
let ellipsis = "";
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
let mut truncated_width = 0;
let mut end_byte_index = 0;
// Iterate over graphemes to handle multi-byte characters correctly
for (i, g) in s.grapheme_indices(true) {
let char_width = UnicodeWidthStr::width(g);
if truncated_width + char_width + ellipsis_width > max_width {
break;
}
truncated_width += char_width;
end_byte_index = i + g.len();
}
format!("{}{}", &s[..end_byte_index], ellipsis)
}
}

View File

@@ -1,10 +1,57 @@
// src/config/binds/config.rs
use serde::Deserialize;
use serde::{Deserialize, Serialize}; // Added Serialize for EditorKeybindingMode if needed elsewhere
use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use crossterm::event::{KeyCode, KeyModifiers};
// NEW: Editor Keybinding Mode Enum
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum EditorKeybindingMode {
#[serde(rename = "default")]
Default,
#[serde(rename = "vim")]
Vim,
#[serde(rename = "emacs")]
Emacs,
}
impl Default for EditorKeybindingMode {
fn default() -> Self {
EditorKeybindingMode::Default
}
}
// NEW: Editor Configuration Struct
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditorConfig {
#[serde(default)]
pub keybinding_mode: EditorKeybindingMode,
#[serde(default = "default_show_line_numbers")]
pub show_line_numbers: bool,
#[serde(default = "default_tab_width")]
pub tab_width: u8,
}
fn default_show_line_numbers() -> bool {
true
}
fn default_tab_width() -> u8 {
4
}
impl Default for EditorConfig {
fn default() -> Self {
EditorConfig {
keybinding_mode: EditorKeybindingMode::default(),
show_line_numbers: default_show_line_numbers(),
tab_width: default_tab_width(),
}
}
}
#[derive(Debug, Deserialize, Default)]
pub struct ColorsConfig {
#[serde(default = "default_theme")]
@@ -21,9 +68,14 @@ pub struct Config {
pub keybindings: ModeKeybindings,
#[serde(default)]
pub colors: ColorsConfig,
// NEW: Add editor configuration
#[serde(default)]
pub editor: EditorConfig,
}
#[derive(Debug, Deserialize)]
// ... (rest of your Config struct and impl Config remains the same)
// Make sure ModeKeybindings is also deserializable if it's not already
#[derive(Debug, Deserialize, Default)] // Added Default here if not present
pub struct ModeKeybindings {
#[serde(default)]
pub general: HashMap<String, Vec<String>>,
@@ -32,6 +84,8 @@ pub struct ModeKeybindings {
#[serde(default)]
pub edit: HashMap<String, Vec<String>>,
#[serde(default)]
pub highlight: HashMap<String, Vec<String>>,
#[serde(default)]
pub command: HashMap<String, Vec<String>>,
#[serde(default)]
pub common: HashMap<String, Vec<String>>,
@@ -41,16 +95,16 @@ pub struct ModeKeybindings {
impl Config {
/// Loads the configuration from "config.toml" in the client crate directory.
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
pub fn load() -> Result<Self> {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let config_path = Path::new(manifest_dir).join("config.toml");
let config_str = std::fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config file at {:?}: {}", config_path, e))?;
let config: Config = toml::from_str(&config_str)?;
.with_context(|| format!("Failed to read config file at {:?}", config_path))?;
let config: Config = toml::from_str(&config_str)
.with_context(|| format!("Failed to parse config file: {}. Check for syntax errors or missing fields like an empty [editor] section if you added it.", config_str))?; // Enhanced error message
Ok(config)
}
pub fn get_general_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_for_key_in_mode(&self.keybindings.general, key, modifiers)
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
@@ -75,6 +129,14 @@ impl Config {
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
}
/// Gets an action for a key in Highlight mode, also checking common/global keybindings.
pub fn get_highlight_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_for_key_in_mode(&self.keybindings.highlight, key, modifiers)
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.common, key, modifiers))
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.read_only, key, modifiers))
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
}
/// Gets an action for a key in Command mode, also checking common keybindings.
pub fn get_command_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_for_key_in_mode(&self.keybindings.command, key, modifiers)
@@ -189,47 +251,206 @@ impl Config {
key: KeyCode,
modifiers: KeyModifiers,
) -> bool {
// For multi-character bindings without modifiers, handle them in matches_key_sequence.
// Special handling for shift+character combinations
if binding.to_lowercase().starts_with("shift+") {
let parts: Vec<&str> = binding.split('+').collect();
if parts.len() == 2 && parts[1].len() == 1 {
let expected_lowercase = parts[1].chars().next().unwrap().to_lowercase().next().unwrap();
let expected_uppercase = expected_lowercase.to_uppercase().next().unwrap();
if let KeyCode::Char(actual_char) = key {
if actual_char == expected_uppercase && modifiers.contains(KeyModifiers::SHIFT) {
return true;
}
}
}
}
// Handle Shift+Tab -> BackTab
if binding.to_lowercase() == "shift+tab" && key == KeyCode::BackTab && modifiers.is_empty() {
return true;
}
// Handle multi-character bindings (all standard keys without modifiers)
if binding.len() > 1 && !binding.contains('+') {
return match binding.to_lowercase().as_str() {
// Navigation keys
"left" => key == KeyCode::Left,
"right" => key == KeyCode::Right,
"up" => key == KeyCode::Up,
"down" => key == KeyCode::Down,
"esc" => key == KeyCode::Esc,
"enter" => key == KeyCode::Enter,
"delete" => key == KeyCode::Delete,
"home" => key == KeyCode::Home,
"end" => key == KeyCode::End,
"pageup" | "pgup" => key == KeyCode::PageUp,
"pagedown" | "pgdn" => key == KeyCode::PageDown,
// Editing keys
"insert" | "ins" => key == KeyCode::Insert,
"delete" | "del" => key == KeyCode::Delete,
"backspace" => key == KeyCode::Backspace,
// Tab keys
"tab" => key == KeyCode::Tab,
"backtab" => key == KeyCode::BackTab,
_ => false,
// Special keys
"enter" | "return" => key == KeyCode::Enter,
"escape" | "esc" => key == KeyCode::Esc,
"space" => key == KeyCode::Char(' '),
// Function keys F1-F24
"f1" => key == KeyCode::F(1),
"f2" => key == KeyCode::F(2),
"f3" => key == KeyCode::F(3),
"f4" => key == KeyCode::F(4),
"f5" => key == KeyCode::F(5),
"f6" => key == KeyCode::F(6),
"f7" => key == KeyCode::F(7),
"f8" => key == KeyCode::F(8),
"f9" => key == KeyCode::F(9),
"f10" => key == KeyCode::F(10),
"f11" => key == KeyCode::F(11),
"f12" => key == KeyCode::F(12),
"f13" => key == KeyCode::F(13),
"f14" => key == KeyCode::F(14),
"f15" => key == KeyCode::F(15),
"f16" => key == KeyCode::F(16),
"f17" => key == KeyCode::F(17),
"f18" => key == KeyCode::F(18),
"f19" => key == KeyCode::F(19),
"f20" => key == KeyCode::F(20),
"f21" => key == KeyCode::F(21),
"f22" => key == KeyCode::F(22),
"f23" => key == KeyCode::F(23),
"f24" => key == KeyCode::F(24),
// Lock keys
"capslock" => key == KeyCode::CapsLock,
"scrolllock" => key == KeyCode::ScrollLock,
"numlock" => key == KeyCode::NumLock,
// System keys
"printscreen" => key == KeyCode::PrintScreen,
"pause" => key == KeyCode::Pause,
"menu" => key == KeyCode::Menu,
"keypadbegin" => key == KeyCode::KeypadBegin,
// Media keys
"mediaplay" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Play),
"mediapause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Pause),
"mediaplaypause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::PlayPause),
"mediareverse" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Reverse),
"mediastop" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Stop),
"mediafastforward" => key == KeyCode::Media(crossterm::event::MediaKeyCode::FastForward),
"mediarewind" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Rewind),
"mediatracknext" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackNext),
"mediatrackprevious" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackPrevious),
"mediarecord" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Record),
"medialowervolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::LowerVolume),
"mediaraisevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::RaiseVolume),
"mediamutevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::MuteVolume),
// Multi-key sequences need special handling
"gg" => false, // This needs sequence handling
_ => {
// Handle single characters and punctuation
if binding.len() == 1 {
if let Some(c) = binding.chars().next() {
key == KeyCode::Char(c)
} else {
false
}
} else {
false
}
}
};
}
// Handle modifier combinations (like "Ctrl+F5", "Alt+Shift+A")
let parts: Vec<&str> = binding.split('+').collect();
let mut expected_modifiers = KeyModifiers::empty();
let mut expected_key = None;
for part in parts {
match part.to_lowercase().as_str() {
"ctrl" => expected_modifiers |= KeyModifiers::CONTROL,
// Modifiers
"ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
"shift" => expected_modifiers |= KeyModifiers::SHIFT,
"alt" => expected_modifiers |= KeyModifiers::ALT,
"super" | "windows" | "cmd" => expected_modifiers |= KeyModifiers::SUPER,
"hyper" => expected_modifiers |= KeyModifiers::HYPER,
"meta" => expected_modifiers |= KeyModifiers::META,
// Navigation keys
"left" => expected_key = Some(KeyCode::Left),
"right" => expected_key = Some(KeyCode::Right),
"up" => expected_key = Some(KeyCode::Up),
"down" => expected_key = Some(KeyCode::Down),
"esc" => expected_key = Some(KeyCode::Esc),
"enter" => expected_key = Some(KeyCode::Enter),
"delete" => expected_key = Some(KeyCode::Delete),
"home" => expected_key = Some(KeyCode::Home),
"end" => expected_key = Some(KeyCode::End),
"pageup" | "pgup" => expected_key = Some(KeyCode::PageUp),
"pagedown" | "pgdn" => expected_key = Some(KeyCode::PageDown),
// Editing keys
"insert" | "ins" => expected_key = Some(KeyCode::Insert),
"delete" | "del" => expected_key = Some(KeyCode::Delete),
"backspace" => expected_key = Some(KeyCode::Backspace),
// Tab keys
"tab" => expected_key = Some(KeyCode::Tab),
"backtab" => expected_key = Some(KeyCode::BackTab),
// Special keys
"enter" | "return" => expected_key = Some(KeyCode::Enter),
"escape" | "esc" => expected_key = Some(KeyCode::Esc),
"space" => expected_key = Some(KeyCode::Char(' ')),
// Function keys
"f1" => expected_key = Some(KeyCode::F(1)),
"f2" => expected_key = Some(KeyCode::F(2)),
"f3" => expected_key = Some(KeyCode::F(3)),
"f4" => expected_key = Some(KeyCode::F(4)),
"f5" => expected_key = Some(KeyCode::F(5)),
"f6" => expected_key = Some(KeyCode::F(6)),
"f7" => expected_key = Some(KeyCode::F(7)),
"f8" => expected_key = Some(KeyCode::F(8)),
"f9" => expected_key = Some(KeyCode::F(9)),
"f10" => expected_key = Some(KeyCode::F(10)),
"f11" => expected_key = Some(KeyCode::F(11)),
"f12" => expected_key = Some(KeyCode::F(12)),
"f13" => expected_key = Some(KeyCode::F(13)),
"f14" => expected_key = Some(KeyCode::F(14)),
"f15" => expected_key = Some(KeyCode::F(15)),
"f16" => expected_key = Some(KeyCode::F(16)),
"f17" => expected_key = Some(KeyCode::F(17)),
"f18" => expected_key = Some(KeyCode::F(18)),
"f19" => expected_key = Some(KeyCode::F(19)),
"f20" => expected_key = Some(KeyCode::F(20)),
"f21" => expected_key = Some(KeyCode::F(21)),
"f22" => expected_key = Some(KeyCode::F(22)),
"f23" => expected_key = Some(KeyCode::F(23)),
"f24" => expected_key = Some(KeyCode::F(24)),
// Lock keys
"capslock" => expected_key = Some(KeyCode::CapsLock),
"scrolllock" => expected_key = Some(KeyCode::ScrollLock),
"numlock" => expected_key = Some(KeyCode::NumLock),
// System keys
"printscreen" => expected_key = Some(KeyCode::PrintScreen),
"pause" => expected_key = Some(KeyCode::Pause),
"menu" => expected_key = Some(KeyCode::Menu),
"keypadbegin" => expected_key = Some(KeyCode::KeypadBegin),
// Special characters and colon (legacy support)
":" => expected_key = Some(KeyCode::Char(':')),
// Single character (letters, numbers, punctuation)
part => {
if part.len() == 1 {
let c = part.chars().next().unwrap();
expected_key = Some(KeyCode::Char(c));
if let Some(c) = part.chars().next() {
expected_key = Some(KeyCode::Char(c));
}
}
}
}

View File

@@ -149,14 +149,17 @@ fn parse_key_part(part: &str) -> Option<ParsedKey> {
let mut code = None;
if part.contains('+') {
// This handles modifiers like "ctrl+s"
// This handles modifiers like "ctrl+s", "super+shift+f5"
let components: Vec<&str> = part.split('+').collect();
for component in components {
match component.to_lowercase().as_str() {
"ctrl" => modifiers |= KeyModifiers::CONTROL,
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
"shift" => modifiers |= KeyModifiers::SHIFT,
"alt" => modifiers |= KeyModifiers::ALT,
"super" | "windows" | "cmd" => modifiers |= KeyModifiers::SUPER,
"hyper" => modifiers |= KeyModifiers::HYPER,
"meta" => modifiers |= KeyModifiers::META,
_ => {
// Last component is the key
code = string_to_keycode(component);

View File

@@ -1,5 +1,6 @@
// src/client/themes/colors.rs
// src/config/colors/themes.rs
use ratatui::style::Color;
use canvas::canvas::CanvasTheme;
#[derive(Debug, Clone)]
pub struct Theme {
@@ -10,6 +11,8 @@ pub struct Theme {
pub highlight: Color,
pub warning: Color,
pub border: Color,
pub highlight_bg: Color,
pub inactive_highlight_bg: Color,// admin panel no idea what it really is
}
impl Theme {
@@ -31,6 +34,8 @@ impl Theme {
highlight: Color::Rgb(152, 251, 152), // Pastel green
warning: Color::Rgb(255, 182, 193), // Pastel pink
border: Color::Rgb(220, 220, 220), // Light gray border
highlight_bg: Color::Rgb(70, 70, 70), // Darker grey for highlight background
inactive_highlight_bg: Color::Rgb(50, 50, 50),
}
}
@@ -44,6 +49,8 @@ impl Theme {
highlight: Color::Rgb(50, 205, 50), // Bright green
warning: Color::Rgb(255, 99, 71), // Bright red
border: Color::Rgb(100, 100, 100), // Medium gray border
highlight_bg: Color::Rgb(180, 180, 180), // Lighter grey for highlight background
inactive_highlight_bg: Color::Rgb(50, 50, 50),
}
}
@@ -57,6 +64,8 @@ impl Theme {
highlight: Color::Rgb(0, 128, 0), // Green
warning: Color::Rgb(255, 0, 0), // Red
border: Color::Rgb(0, 0, 0), // Black border
highlight_bg: Color::Rgb(180, 180, 180), // Lighter grey for highlight background
inactive_highlight_bg: Color::Rgb(50, 50, 50),
}
}
}
@@ -66,3 +75,37 @@ impl Default for Theme {
Self::light() // Default to light theme
}
}
impl CanvasTheme for Theme {
fn bg(&self) -> Color {
self.bg
}
fn fg(&self) -> Color {
self.fg
}
fn border(&self) -> Color {
self.border
}
fn accent(&self) -> Color {
self.accent
}
fn secondary(&self) -> Color {
self.secondary
}
fn highlight(&self) -> Color {
self.highlight
}
fn highlight_bg(&self) -> Color {
self.highlight_bg
}
fn warning(&self) -> Color {
self.warning
}
}

View File

@@ -2,3 +2,4 @@
pub mod binds;
pub mod colors;
pub mod storage;

View File

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

View File

@@ -0,0 +1,101 @@
// src/config/storage/storage.rs
use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::Write;
use std::path::PathBuf;
use anyhow::{Context, Result};
use tracing::{error, info};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
pub const APP_NAME: &str = "komp_ac_client";
pub const TOKEN_FILE_NAME: &str = "auth.token";
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct StoredAuthData {
pub access_token: String,
pub user_id: String,
pub role: String,
pub username: String,
}
pub fn get_token_storage_path() -> Result<PathBuf> {
let state_dir = dirs::state_dir()
.or_else(|| dirs::home_dir().map(|home| home.join(".local").join("state")))
.ok_or_else(|| anyhow::anyhow!("Could not determine state directory"))?;
let app_state_dir = state_dir.join(APP_NAME);
fs::create_dir_all(&app_state_dir)
.with_context(|| format!("Failed to create app state directory at {:?}", app_state_dir))?;
Ok(app_state_dir.join(TOKEN_FILE_NAME))
}
pub fn save_auth_data(data: &StoredAuthData) -> Result<()> {
let path = get_token_storage_path()?;
let json_data = serde_json::to_string(data)
.context("Failed to serialize auth data")?;
let mut file = File::create(&path)
.with_context(|| format!("Failed to create token file at {:?}", path))?;
file.write_all(json_data.as_bytes())
.context("Failed to write token data to file")?;
// Set file permissions to 600 (owner read/write only) on Unix
#[cfg(unix)]
{
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.context("Failed to set token file permissions")?;
}
info!("Auth data saved to {:?}", path);
Ok(())
}
pub fn load_auth_data() -> Result<Option<StoredAuthData>> {
let path = get_token_storage_path()?;
if !path.exists() {
info!("Token file not found at {:?}", path);
return Ok(None);
}
let json_data = fs::read_to_string(&path)
.with_context(|| format!("Failed to read token file at {:?}", path))?;
if json_data.trim().is_empty() {
info!("Token file is empty at {:?}", path);
return Ok(None);
}
match serde_json::from_str::<StoredAuthData>(&json_data) {
Ok(data) => {
info!("Auth data loaded from {:?}", path);
Ok(Some(data))
}
Err(e) => {
error!("Failed to deserialize token data from {:?}: {}. Deleting corrupt file.", path, e);
if let Err(del_e) = fs::remove_file(&path) {
error!("Failed to delete corrupt token file: {}", del_e);
}
Ok(None)
}
}
}
pub fn delete_auth_data() -> Result<()> {
let path = get_token_storage_path()?;
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("Failed to delete token file at {:?}", path))?;
info!("Token file deleted from {:?}", path);
} else {
info!("Token file not found for deletion at {:?}", path);
}
Ok(())
}

View File

@@ -0,0 +1,5 @@
// src/functions/common.rs
pub mod buffer;
pub use buffer::*;

View File

@@ -0,0 +1,35 @@
// src/functions/common/buffer.rs
use crate::state::app::buffer::BufferState;
use crate::state::app::buffer::AppView;
pub fn get_view_layer(view: &AppView) -> u8 {
match view {
AppView::Intro => 1,
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
AppView::Form | AppView::Scratch => 3,
}
}
/// Switches the active buffer index.
pub fn switch_buffer(buffer_state: &mut BufferState, next: bool) -> bool {
if buffer_state.history.len() <= 1 {
return false;
}
let len = buffer_state.history.len();
let current_index = buffer_state.active_index;
let new_index = if next {
(current_index + 1) % len
} else {
(current_index + len - 1) % len
};
if new_index != current_index {
buffer_state.active_index = new_index;
true
} else {
false
}
}

View File

@@ -0,0 +1,6 @@
// src/functions/mod.rs
pub mod common;
pub mod modes;
pub use modes::*;

View File

@@ -0,0 +1,5 @@
// src/functions/modes.rs
pub mod navigation;
pub use navigation::*;

View File

@@ -0,0 +1,5 @@
// src/functions/modes/navigation.rs
pub mod admin_nav;
pub mod add_table_nav;
pub mod add_logic_nav;

View File

@@ -0,0 +1,440 @@
// src/functions/modes/navigation/add_logic_nav.rs
use crate::config::binds::config::{Config, EditorKeybindingMode};
use crate::state::{
app::state::AppState,
pages::add_logic::{AddLogicFocus, AddLogicState},
app::buffer::AppView,
app::buffer::BufferState,
};
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
use crate::services::GrpcClient;
use tokio::sync::mpsc;
use anyhow::Result;
use crate::components::common::text_editor::TextEditor;
use crate::services::ui_service::UiService;
use tui_textarea::CursorMove; // Ensure this import is present
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
pub fn handle_add_logic_navigation(
key_event: KeyEvent,
config: &Config,
app_state: &mut AppState,
add_logic_state: &mut AddLogicState,
is_edit_mode: &mut bool,
buffer_state: &mut BufferState,
grpc_client: GrpcClient,
_save_logic_sender: SaveLogicResultSender, // Marked as unused
command_message: &mut String,
) -> bool {
// === FULLSCREEN SCRIPT EDITING - COMPLETE ISOLATION ===
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
// === AUTOCOMPLETE HANDLING ===
if add_logic_state.script_editor_autocomplete_active {
match key_event.code {
// ... (Char, Backspace, Tab, Down, Up cases remain the same) ...
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
add_logic_state.script_editor_filter_text.push(c);
add_logic_state.update_script_editor_suggestions();
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
*command_message = format!("Filtering: @{}", add_logic_state.script_editor_filter_text);
return true;
}
KeyCode::Backspace => {
if !add_logic_state.script_editor_filter_text.is_empty() {
add_logic_state.script_editor_filter_text.pop();
add_logic_state.update_script_editor_suggestions();
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
*command_message = if add_logic_state.script_editor_filter_text.is_empty() {
"Autocomplete: @".to_string()
} else {
format!("Filtering: @{}", add_logic_state.script_editor_filter_text)
};
} else {
let should_deactivate = if let Some((trigger_line, trigger_col)) = add_logic_state.script_editor_trigger_position {
let current_cursor = {
let editor_borrow = add_logic_state.script_content_editor.borrow();
editor_borrow.cursor()
};
current_cursor.0 == trigger_line && current_cursor.1 == trigger_col + 1
} else {
false
};
if should_deactivate {
add_logic_state.deactivate_script_editor_autocomplete();
*command_message = "Autocomplete cancelled".to_string();
}
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
}
return true;
}
KeyCode::Tab | KeyCode::Down => {
if !add_logic_state.script_editor_suggestions.is_empty() {
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
let next = (current + 1) % add_logic_state.script_editor_suggestions.len();
add_logic_state.script_editor_selected_suggestion_index = Some(next);
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[next]);
}
return true;
}
KeyCode::Up => {
if !add_logic_state.script_editor_suggestions.is_empty() {
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
let prev = if current == 0 {
add_logic_state.script_editor_suggestions.len() - 1
} else {
current - 1
};
add_logic_state.script_editor_selected_suggestion_index = Some(prev);
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[prev]);
}
return true;
}
KeyCode::Enter => {
if let Some(selected_idx) = add_logic_state.script_editor_selected_suggestion_index {
if let Some(suggestion) = add_logic_state.script_editor_suggestions.get(selected_idx).cloned() {
let trigger_pos = add_logic_state.script_editor_trigger_position;
let filter_len = add_logic_state.script_editor_filter_text.len();
add_logic_state.deactivate_script_editor_autocomplete();
add_logic_state.has_unsaved_changes = true;
if let Some(pos) = trigger_pos {
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
if suggestion == "sql" {
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, "sql");
editor_borrow.insert_str("('')");
// Move cursor back twice to be between the single quotes
editor_borrow.move_cursor(CursorMove::Back); // Before ')'
editor_borrow.move_cursor(CursorMove::Back); // Before ''' (inside '')
*command_message = "Inserted: @sql('')".to_string();
} else {
let is_table_selection = add_logic_state.is_table_name_suggestion(&suggestion);
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, &suggestion);
if is_table_selection {
editor_borrow.insert_str(".");
let new_cursor = editor_borrow.cursor();
drop(editor_borrow); // Release borrow before calling add_logic_state methods
add_logic_state.script_editor_trigger_position = Some(new_cursor);
add_logic_state.script_editor_autocomplete_active = true;
add_logic_state.script_editor_filter_text.clear();
add_logic_state.trigger_column_autocomplete_for_table(suggestion.clone());
let profile_name = add_logic_state.profile_name.clone();
let table_name_for_fetch = suggestion.clone();
let mut client_clone = grpc_client.clone();
tokio::spawn(async move {
match UiService::fetch_columns_for_table(&mut client_clone, &profile_name, &table_name_for_fetch).await {
Ok(_columns) => {
// Result handled by main UI loop
}
Err(e) => {
tracing::error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name_for_fetch, e);
}
}
});
*command_message = format!("Selected table '{}', fetching columns...", suggestion);
} else {
*command_message = format!("Inserted: {}", suggestion);
}
}
}
return true;
}
}
add_logic_state.deactivate_script_editor_autocomplete();
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
return true;
}
KeyCode::Esc => {
add_logic_state.deactivate_script_editor_autocomplete();
*command_message = "Autocomplete cancelled".to_string();
}
_ => {
add_logic_state.deactivate_script_editor_autocomplete();
*command_message = "Autocomplete cancelled".to_string();
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
return true;
}
}
}
if key_event.code == KeyCode::Char('@') && key_event.modifiers == KeyModifiers::NONE {
let should_trigger = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => *is_edit_mode,
_ => true,
};
if should_trigger {
let cursor_before = {
let editor_borrow = add_logic_state.script_content_editor.borrow();
editor_borrow.cursor()
};
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
add_logic_state.script_editor_trigger_position = Some(cursor_before);
add_logic_state.script_editor_autocomplete_active = true;
add_logic_state.script_editor_filter_text.clear();
add_logic_state.update_script_editor_suggestions();
add_logic_state.has_unsaved_changes = true;
*command_message = "Autocomplete: @ (Tab/↑↓ to navigate, Enter to select, Esc to cancel)".to_string();
return true;
}
}
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
if *is_edit_mode {
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
if TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) {
*is_edit_mode = false;
*command_message = "VIM: Normal Mode. Esc again to exit script.".to_string();
}
} else {
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
app_state.ui.focus_outside_canvas = true;
*is_edit_mode = false;
*command_message = "Exited script editing.".to_string();
}
}
_ => {
if *is_edit_mode {
*is_edit_mode = false;
*command_message = "Exited script edit. Esc again to exit script.".to_string();
} else {
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
app_state.ui.focus_outside_canvas = true;
*is_edit_mode = false;
*command_message = "Exited script editing.".to_string();
}
}
}
return true;
}
let changed = {
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
)
};
if changed {
add_logic_state.has_unsaved_changes = true;
}
if add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim {
*is_edit_mode = !TextEditor::is_vim_normal_mode(&add_logic_state.vim_state);
}
return true;
}
let action = config.get_general_action(key_event.code, key_event.modifiers);
let current_focus = add_logic_state.current_focus;
let mut handled = true;
let mut new_focus = current_focus;
match action.as_deref() {
Some("exit_table_scroll") => {
handled = false;
}
Some("move_up") => {
match current_focus {
AddLogicFocus::InputLogicName => {}
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputLogicName,
AddLogicFocus::InputDescription => new_focus = AddLogicFocus::InputTargetColumn,
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
_ => handled = false,
}
}
Some("move_down") => {
match current_focus {
AddLogicFocus::InputLogicName => new_focus = AddLogicFocus::InputTargetColumn,
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputDescription,
AddLogicFocus::InputDescription => {
add_logic_state.last_canvas_field = 2;
new_focus = AddLogicFocus::ScriptContentPreview;
},
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => {}
_ => handled = false,
}
}
Some("next_option") => {
match current_focus {
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
{ new_focus = AddLogicFocus::ScriptContentPreview; }
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => { }
_ => handled = false,
}
}
Some("previous_option") => {
match current_focus {
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
{ }
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
_ => handled = false,
}
}
Some("next_field") => {
new_focus = match current_focus {
AddLogicFocus::InputLogicName => AddLogicFocus::InputTargetColumn,
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputDescription,
AddLogicFocus::InputDescription => AddLogicFocus::ScriptContentPreview,
AddLogicFocus::ScriptContentPreview => AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => AddLogicFocus::InputLogicName,
_ => current_focus,
};
}
Some("prev_field") => {
new_focus = match current_focus {
AddLogicFocus::InputLogicName => AddLogicFocus::CancelButton,
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputLogicName,
AddLogicFocus::InputDescription => AddLogicFocus::InputTargetColumn,
AddLogicFocus::ScriptContentPreview => AddLogicFocus::InputDescription,
AddLogicFocus::SaveButton => AddLogicFocus::ScriptContentPreview,
AddLogicFocus::CancelButton => AddLogicFocus::SaveButton,
_ => current_focus,
};
}
Some("select") => {
match current_focus {
AddLogicFocus::ScriptContentPreview => {
new_focus = AddLogicFocus::InsideScriptContent;
*is_edit_mode = false;
app_state.ui.focus_outside_canvas = false;
let mode_hint = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => "VIM mode - 'i'/'a'/'o' to edit",
_ => "Enter/Ctrl+E to edit",
};
*command_message = format!("Fullscreen script editing. {} or Esc to exit.", mode_hint);
}
AddLogicFocus::SaveButton => {
*command_message = "Save logic action".to_string();
}
AddLogicFocus::CancelButton => {
buffer_state.update_history(AppView::Admin);
app_state.ui.show_add_logic = false;
*command_message = "Cancelled Add Logic".to_string();
*is_edit_mode = false;
}
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
*is_edit_mode = !*is_edit_mode;
*command_message = format!("Field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
}
_ => handled = false,
}
}
Some("toggle_edit_mode") => {
match current_focus {
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
*is_edit_mode = !*is_edit_mode;
*command_message = format!("Canvas field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
}
_ => {
*command_message = "Cannot toggle edit mode here.".to_string();
}
}
}
_ => handled = false,
}
if handled && current_focus != new_focus {
add_logic_state.current_focus = new_focus;
let new_is_canvas_input_focus = matches!(new_focus,
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription
);
if new_is_canvas_input_focus {
*is_edit_mode = false;
app_state.ui.focus_outside_canvas = false;
} else {
app_state.ui.focus_outside_canvas = true;
if matches!(new_focus, AddLogicFocus::ScriptContentPreview) {
*is_edit_mode = false;
}
}
}
handled
}
fn replace_autocomplete_text(
editor: &mut tui_textarea::TextArea,
trigger_pos: (usize, usize),
filter_len: usize,
replacement: &str,
) {
// use tui_textarea::CursorMove; // Already imported at the top of the module
let filter_start_pos = (trigger_pos.0, trigger_pos.1 + 1);
editor.move_cursor(CursorMove::Jump(filter_start_pos.0 as u16, filter_start_pos.1 as u16));
for _ in 0..filter_len {
editor.delete_next_char();
}
editor.insert_str(replacement);
}

View File

@@ -0,0 +1,205 @@
// src/functions/modes/navigation/add_table_nav.rs
use crate::config::binds::config::Config;
use crate::state::{
app::state::AppState,
pages::add_table::{AddTableFocus, AddTableState},
};
use crossterm::event::{KeyEvent};
use ratatui::widgets::TableState;
use crate::tui::functions::common::add_table::{handle_add_column_action, handle_save_table_action};
use crate::ui::handlers::context::DialogPurpose;
use crate::services::GrpcClient;
use tokio::sync::mpsc;
use anyhow::Result;
pub type SaveTableResultSender = mpsc::Sender<Result<String>>;
fn navigate_table_up(table_state: &mut TableState, item_count: usize) -> bool {
if item_count == 0 { return false; }
let current_selection = table_state.selected();
match current_selection {
Some(index) => {
if index > 0 { table_state.select(Some(index - 1)); true }
else { false }
}
None => { table_state.select(Some(0)); true }
}
}
fn navigate_table_down(table_state: &mut TableState, item_count: usize) -> bool {
if item_count == 0 { return false; }
let current_selection = table_state.selected();
match current_selection {
Some(index) => {
if index < item_count - 1 { table_state.select(Some(index + 1)); true }
else { false }
}
None => { table_state.select(Some(0)); true }
}
}
pub fn handle_add_table_navigation(
key: KeyEvent,
config: &Config,
app_state: &mut AppState,
add_table_state: &mut AddTableState,
grpc_client: GrpcClient,
save_result_sender: SaveTableResultSender,
command_message: &mut String,
) -> bool {
let action = config.get_general_action(key.code, key.modifiers);
let current_focus = add_table_state.current_focus;
let mut handled = true;
let mut new_focus = current_focus;
if matches!(current_focus, AddTableFocus::InsideColumnsTable | AddTableFocus::InsideIndexesTable | AddTableFocus::InsideLinksTable) {
if matches!(action.as_deref(), Some("next_option") | Some("previous_option")) {
*command_message = "Press Esc to exit table item navigation first.".to_string();
return true;
}
}
match action.as_deref() {
Some("exit_table_scroll") => {
match current_focus {
AddTableFocus::InsideColumnsTable => {
add_table_state.column_table_state.select(None);
new_focus = AddTableFocus::ColumnsTable;
// *command_message = "Exited Columns Table".to_string(); // Minimal change: remove message
}
AddTableFocus::InsideIndexesTable => {
add_table_state.index_table_state.select(None);
new_focus = AddTableFocus::IndexesTable;
// *command_message = "Exited Indexes Table".to_string();
}
AddTableFocus::InsideLinksTable => {
add_table_state.link_table_state.select(None);
new_focus = AddTableFocus::LinksTable;
// *command_message = "Exited Links Table".to_string();
}
_ => handled = false,
}
}
Some("move_up") => {
match current_focus {
AddTableFocus::InputTableName => {
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
// *command_message = "At top of form.".to_string(); // Remove message
}
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputTableName,
AddTableFocus::InputColumnType => new_focus = AddTableFocus::InputColumnName,
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
AddTableFocus::InsideColumnsTable => { navigate_table_up(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
AddTableFocus::InsideIndexesTable => { navigate_table_up(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
AddTableFocus::InsideLinksTable => { navigate_table_up(&mut add_table_state.link_table_state, add_table_state.links.len()); }
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
}
}
Some("move_down") => {
match current_focus {
AddTableFocus::InputTableName => new_focus = AddTableFocus::InputColumnName,
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputColumnType,
AddTableFocus::InputColumnType => {
add_table_state.last_canvas_field = 2;
new_focus = AddTableFocus::AddColumnButton;
},
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
AddTableFocus::InsideColumnsTable => { navigate_table_down(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
AddTableFocus::InsideIndexesTable => { navigate_table_down(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
AddTableFocus::InsideLinksTable => { navigate_table_down(&mut add_table_state.link_table_state, add_table_state.links.len()); }
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
AddTableFocus::CancelButton => {
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
// *command_message = "At bottom of form.".to_string(); // Remove message
}
}
}
Some("next_option") => { // This logic should already be non-wrapping
match current_focus {
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
{ new_focus = AddTableFocus::AddColumnButton; }
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
AddTableFocus::CancelButton => { /* *command_message = "At last focusable area.".to_string(); */ } // No change in focus
_ => handled = false,
}
}
Some("previous_option") => { // This logic should already be non-wrapping
match current_focus {
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
{ /* *command_message = "At first focusable area.".to_string(); */ } // No change in focus
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
_ => handled = false,
}
}
Some("next_field") => {
new_focus = match current_focus {
AddTableFocus::InputTableName => AddTableFocus::InputColumnName, AddTableFocus::InputColumnName => AddTableFocus::InputColumnType, AddTableFocus::InputColumnType => AddTableFocus::AddColumnButton, AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable,
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::IndexesTable, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::LinksTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::SaveButton,
AddTableFocus::SaveButton => AddTableFocus::DeleteSelectedButton, AddTableFocus::DeleteSelectedButton => AddTableFocus::CancelButton, AddTableFocus::CancelButton => AddTableFocus::InputTableName,
};
}
Some("prev_field") => {
new_focus = match current_focus {
AddTableFocus::InputTableName => AddTableFocus::CancelButton, AddTableFocus::InputColumnName => AddTableFocus::InputTableName, AddTableFocus::InputColumnType => AddTableFocus::InputColumnName, AddTableFocus::AddColumnButton => AddTableFocus::InputColumnType,
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::AddColumnButton, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::ColumnsTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::IndexesTable,
AddTableFocus::SaveButton => AddTableFocus::LinksTable, AddTableFocus::DeleteSelectedButton => AddTableFocus::SaveButton, AddTableFocus::CancelButton => AddTableFocus::DeleteSelectedButton,
};
}
Some("select") => {
match current_focus {
AddTableFocus::ColumnsTable => { new_focus = AddTableFocus::InsideColumnsTable; if add_table_state.column_table_state.selected().is_none() && !add_table_state.columns.is_empty() { add_table_state.column_table_state.select(Some(0)); } /* Message removed */ }
AddTableFocus::IndexesTable => { new_focus = AddTableFocus::InsideIndexesTable; if add_table_state.index_table_state.selected().is_none() && !add_table_state.indexes.is_empty() { add_table_state.index_table_state.select(Some(0)); } /* Message removed */ }
AddTableFocus::LinksTable => { new_focus = AddTableFocus::InsideLinksTable; if add_table_state.link_table_state.selected().is_none() && !add_table_state.links.is_empty() { add_table_state.link_table_state.select(Some(0)); } /* Message removed */ }
AddTableFocus::InsideColumnsTable => { if let Some(index) = add_table_state.column_table_state.selected() { if let Some(col) = add_table_state.columns.get_mut(index) { col.selected = !col.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
AddTableFocus::InsideIndexesTable => { if let Some(index) = add_table_state.index_table_state.selected() { if let Some(idx_def) = add_table_state.indexes.get_mut(index) { idx_def.selected = !idx_def.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
AddTableFocus::InsideLinksTable => { if let Some(index) = add_table_state.link_table_state.selected() { if let Some(link) = add_table_state.links.get_mut(index) { link.selected = !link.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
AddTableFocus::AddColumnButton => { if let Some(focus_after_add) = handle_add_column_action(add_table_state, command_message) { new_focus = focus_after_add; } else { /* Message already set by handle_add_column_action */ }}
AddTableFocus::SaveButton => { if add_table_state.table_name.is_empty() { *command_message = "Cannot save: Table name is empty.".to_string(); } else if add_table_state.columns.is_empty() { *command_message = "Cannot save: No columns defined.".to_string(); } else { *command_message = "Saving table...".to_string(); app_state.show_loading_dialog("Saving", "Please wait..."); let mut client_clone = grpc_client.clone(); let state_clone = add_table_state.clone(); let sender_clone = save_result_sender.clone(); tokio::spawn(async move { let result = handle_save_table_action(&mut client_clone, &state_clone).await; let _ = sender_clone.send(result).await; }); }}
AddTableFocus::DeleteSelectedButton => { let columns_to_delete: Vec<(usize, String, String)> = add_table_state.columns.iter().enumerate().filter(|(_, col)| col.selected).map(|(index, col)| (index, col.name.clone(), col.data_type.clone())).collect(); if columns_to_delete.is_empty() { *command_message = "No columns selected for deletion.".to_string(); } else { let column_details: String = columns_to_delete.iter().map(|(index, name, dtype)| format!("{}. {} ({})", index + 1, name, dtype)).collect::<Vec<String>>().join("\n"); let message = format!("Delete the following columns?\n\n{}", column_details); app_state.show_dialog("Confirm Deletion", &message, vec!["Confirm".to_string(), "Cancel".to_string()], DialogPurpose::ConfirmDeleteColumns); }}
AddTableFocus::CancelButton => { *command_message = "Action: Cancel Add Table (Not Implemented)".to_string(); }
_ => { handled = false; }
}
}
_ => handled = false,
}
if handled && current_focus != new_focus {
add_table_state.current_focus = new_focus;
// Minimal change: Command message update logic can be simplified or removed if not desired
// For now, let's keep it minimal and only update if it was truly a focus change,
// and not a boundary message.
if !command_message.starts_with("At ") && current_focus != new_focus { // Avoid overwriting boundary messages
// *command_message = format!("Focus: {:?}", add_table_state.current_focus); // Optional: restore if needed
}
let new_is_canvas_input_focus = matches!(new_focus,
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType
);
app_state.ui.focus_outside_canvas = !new_is_canvas_input_focus;
}
// If not handled, command_message remains as it was (e.g., from a deeper function call or previous event)
// or can be cleared if that's the desired default. For minimal change, we leave it.
handled
}

View File

@@ -0,0 +1,351 @@
// src/functions/modes/navigation/admin_nav.rs
use crate::state::pages::admin::{AdminFocus, AdminState};
use crate::state::app::state::AppState;
use crate::config::binds::config::Config;
use crate::state::app::buffer::{BufferState, AppView};
use crate::state::pages::add_table::{AddTableState, LinkDefinition};
use ratatui::widgets::ListState;
use crate::state::pages::add_logic::{AddLogicState, AddLogicFocus}; // Added AddLogicFocus import
// Helper functions list_select_next and list_select_previous remain the same
fn list_select_next(list_state: &mut ListState, item_count: usize) {
if item_count == 0 {
list_state.select(None);
return;
}
let i = match list_state.selected() {
Some(i) => if i >= item_count - 1 { 0 } else { i + 1 },
None => 0,
};
list_state.select(Some(i));
}
fn list_select_previous(list_state: &mut ListState, item_count: usize) {
if item_count == 0 {
list_state.select(None);
return;
}
let i = match list_state.selected() {
Some(i) => if i == 0 { item_count - 1 } else { i - 1 },
None => if item_count > 0 { item_count - 1 } else { 0 },
};
list_state.select(Some(i));
}
pub fn handle_admin_navigation(
key: crossterm::event::KeyEvent,
config: &Config,
app_state: &mut AppState,
admin_state: &mut AdminState,
buffer_state: &mut BufferState,
command_message: &mut String,
) -> bool {
let action = config.get_general_action(key.code, key.modifiers).map(String::from);
let current_focus = admin_state.current_focus;
let profile_count = app_state.profile_tree.profiles.len();
let mut handled = false;
match current_focus {
AdminFocus::ProfilesPane => {
match action.as_deref() {
Some("select") => {
admin_state.current_focus = AdminFocus::InsideProfilesList;
if !app_state.profile_tree.profiles.is_empty() {
if admin_state.profile_list_state.selected().is_none() {
admin_state.profile_list_state.select(Some(0));
}
}
*command_message = "Navigating profiles. Use Up/Down. Esc to exit.".to_string();
handled = true;
}
Some("next_option") | Some("move_down") => {
admin_state.current_focus = AdminFocus::Tables;
*command_message = "Focus: Tables Pane".to_string();
handled = true;
}
Some("previous_option") | Some("move_up") => {
// No wrap-around: Stay on ProfilesPane if trying to go "before" it
*command_message = "At first focusable pane.".to_string();
handled = true;
}
_ => handled = false,
}
}
AdminFocus::InsideProfilesList => {
match action.as_deref() {
Some("move_up") => {
if profile_count > 0 {
list_select_previous(&mut admin_state.profile_list_state, profile_count);
*command_message = "".to_string();
handled = true;
}
}
Some("move_down") => {
if profile_count > 0 {
list_select_next(&mut admin_state.profile_list_state, profile_count);
*command_message = "".to_string();
handled = true;
}
}
Some("select") => {
admin_state.selected_profile_index = admin_state.profile_list_state.selected();
admin_state.selected_table_index = None; // Deselect table when profile changes
if let Some(profile_idx) = admin_state.selected_profile_index {
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
if !profile.tables.is_empty() {
admin_state.table_list_state.select(Some(0)); // Auto-select first table for nav
} else {
admin_state.table_list_state.select(None);
}
}
} else {
admin_state.table_list_state.select(None);
}
*command_message = format!(
"Profile '{}' set as active.",
admin_state.get_selected_profile_name().unwrap_or(&"N/A".to_string())
);
handled = true;
}
Some("exit_table_scroll") => {
admin_state.current_focus = AdminFocus::ProfilesPane;
*command_message = "Focus: Profiles Pane".to_string();
handled = true;
}
_ => handled = false,
}
}
AdminFocus::Tables => {
match action.as_deref() {
Some("select") => {
admin_state.current_focus = AdminFocus::InsideTablesList;
let current_profile_idx = admin_state.selected_profile_index
.or_else(|| admin_state.profile_list_state.selected());
if let Some(profile_idx) = current_profile_idx {
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
if !profile.tables.is_empty() {
if admin_state.table_list_state.selected().is_none() {
admin_state.table_list_state.select(Some(0));
}
} else {
admin_state.table_list_state.select(None);
}
} else {
admin_state.table_list_state.select(None);
}
} else {
admin_state.table_list_state.select(None);
*command_message = "Select a profile first to view its tables.".to_string();
}
if admin_state.current_focus == AdminFocus::InsideTablesList && !admin_state.table_list_state.selected().is_none() {
*command_message = "Navigating tables. Use Up/Down. Esc to exit.".to_string();
} else if admin_state.table_list_state.selected().is_none() {
if current_profile_idx.is_none() {
*command_message = "No profile selected to view tables.".to_string();
} else {
*command_message = "No tables in selected profile.".to_string();
}
admin_state.current_focus = AdminFocus::Tables; // Stay in Tables pane if no tables to enter
}
handled = true;
}
Some("previous_option") | Some("move_up") => {
admin_state.current_focus = AdminFocus::ProfilesPane;
*command_message = "Focus: Profiles Pane".to_string();
handled = true;
}
Some("next_option") | Some("move_down") => {
admin_state.current_focus = AdminFocus::Button1;
*command_message = "Focus: Add Logic Button".to_string();
handled = true;
}
_ => handled = false,
}
}
AdminFocus::InsideTablesList => {
match action.as_deref() {
Some("move_up") => {
let current_profile_idx = admin_state.selected_profile_index
.or_else(|| admin_state.profile_list_state.selected());
if let Some(p_idx) = current_profile_idx {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
if !profile.tables.is_empty() {
list_select_previous(&mut admin_state.table_list_state, profile.tables.len());
*command_message = "".to_string();
handled = true;
} else {
*command_message = "No tables to navigate.".to_string();
handled = true;
}
}
} else {
*command_message = "No active profile for tables.".to_string();
handled = true;
}
}
Some("move_down") => {
let current_profile_idx = admin_state.selected_profile_index
.or_else(|| admin_state.profile_list_state.selected());
if let Some(p_idx) = current_profile_idx {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
if !profile.tables.is_empty() {
list_select_next(&mut admin_state.table_list_state, profile.tables.len());
*command_message = "".to_string();
handled = true;
} else {
*command_message = "No tables to navigate.".to_string();
handled = true;
}
}
} else {
*command_message = "No active profile for tables.".to_string();
handled = true;
}
}
Some("select") => { // This is for persistently selecting a table with [*]
admin_state.selected_table_index = admin_state.table_list_state.selected();
let table_name = admin_state.selected_profile_index
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx)))
.map_or("N/A", |t| t.name.as_str());
*command_message = format!("Table '{}' set as active.", table_name);
handled = true;
}
Some("exit_table_scroll") => {
admin_state.current_focus = AdminFocus::Tables;
*command_message = "Focus: Tables Pane".to_string();
handled = true;
}
_ => handled = false,
}
}
AdminFocus::Button1 => { // Add Logic Button
match action.as_deref() {
Some("select") => { // Typically "Enter" key
if let Some(p_idx) = admin_state.selected_profile_index {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
if let Some(t_idx) = admin_state.selected_table_index {
if let Some(table) = profile.tables.get(t_idx) {
// Both profile and table are selected, proceed
admin_state.add_logic_state = AddLogicState {
profile_name: profile.name.clone(),
selected_table_name: Some(table.name.clone()),
selected_table_id: Some(table.id), // If you have table IDs
editor_keybinding_mode: config.editor.keybinding_mode.clone(),
current_focus: AddLogicFocus::default(),
..AddLogicState::default()
};
// Store table info for later fetching
app_state.pending_table_structure_fetch = Some((
profile.name.clone(),
table.name.clone()
));
buffer_state.update_history(AppView::AddLogic);
app_state.ui.focus_outside_canvas = false;
*command_message = format!(
"Opening Add Logic for table '{}' in profile '{}'...",
table.name, profile.name
);
} else {
*command_message = "Error: Selected table data not found.".to_string();
}
} else {
*command_message = "Select a table first!".to_string();
}
} else {
*command_message = "Error: Selected profile data not found.".to_string();
}
} else {
*command_message = "Select a profile first!".to_string();
}
handled = true;
}
Some("previous_option") | Some("move_up") => {
admin_state.current_focus = AdminFocus::Tables;
*command_message = "Focus: Tables Pane".to_string();
handled = true;
}
Some("next_option") | Some("move_down") => {
admin_state.current_focus = AdminFocus::Button2;
*command_message = "Focus: Add Table Button".to_string();
handled = true;
}
_ => handled = false,
}
}
AdminFocus::Button2 => { // Add Table Button
match action.as_deref() {
Some("select") => {
if let Some(p_idx) = admin_state.selected_profile_index {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
let selected_profile_name = profile.name.clone();
// Prepare links from the selected profile's existing tables
let available_links: Vec<LinkDefinition> = profile.tables.iter()
.map(|table| LinkDefinition {
linked_table_name: table.name.clone(),
is_required: false, // Default, can be changed in AddTable screen
selected: false,
}).collect();
admin_state.add_table_state = AddTableState {
profile_name: selected_profile_name,
links: available_links,
..AddTableState::default() // Reset other fields
};
buffer_state.update_history(AppView::AddTable);
app_state.ui.focus_outside_canvas = false;
*command_message = format!("Opening Add Table for profile '{}'...", admin_state.add_table_state.profile_name);
handled = true;
} else {
*command_message = "Error: Selected profile index out of bounds.".to_string();
handled = true;
}
} else {
*command_message = "Please select a profile ([*]) first to add a table.".to_string();
handled = true;
}
}
Some("previous_option") | Some("move_up") => {
admin_state.current_focus = AdminFocus::Button1;
*command_message = "Focus: Add Logic Button".to_string();
handled = true;
}
Some("next_option") | Some("move_down") => {
admin_state.current_focus = AdminFocus::Button3;
*command_message = "Focus: Change Table Button".to_string();
handled = true;
}
_ => handled = false,
}
}
AdminFocus::Button3 => { // Change Table Button
match action.as_deref() {
Some("select") => {
// Future: Logic to load selected table into AddTableState for editing
*command_message = "Action: Change Table (Not Implemented)".to_string();
handled = true;
}
Some("previous_option") | Some("move_up") => {
admin_state.current_focus = AdminFocus::Button2;
*command_message = "Focus: Add Table Button".to_string();
handled = true;
}
Some("next_option") | Some("move_down") => {
// No wrap-around: Stay on Button3 if trying to go "after" it
*command_message = "At last focusable button.".to_string();
handled = true;
}
_ => handled = false,
}
}
}
handled
}

View File

@@ -5,7 +5,9 @@ pub mod config;
pub mod state;
pub mod components;
pub mod modes;
pub mod functions;
pub mod services;
pub mod utils;
pub use ui::run_ui;

View File

@@ -1,10 +1,32 @@
// client/src/main.rs
use client::run_ui;
#[cfg(feature = "ui-debug")]
use client::utils::debug_logger::UiDebugWriter;
use dotenvy::dotenv;
use std::error::Error;
use anyhow::Result;
use tracing_subscriber;
use std::env;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
async fn main() -> Result<()> {
#[cfg(feature = "ui-debug")]
{
// If ui-debug is on, set up our custom writer.
let writer = UiDebugWriter::new();
tracing_subscriber::fmt()
.with_level(false) // Don't show INFO, ERROR, etc.
.with_target(false) // Don't show the module path.
.without_time() // This is the correct and simpler method.
.with_writer(move || writer.clone())
.init();
}
#[cfg(not(feature = "ui-debug"))]
{
if env::var("ENABLE_TRACING").is_ok() {
tracing_subscriber::fmt::init();
}
}
dotenv().ok();
run_ui().await
}

View File

@@ -1,4 +1,4 @@
// src/client/modes/canvas.rs
pub mod edit;
pub mod common;
pub mod common_mode;
pub mod read_only;

View File

@@ -1,75 +0,0 @@
// src/modes/canvas/common.rs
use crate::tui::terminal::core::TerminalCore;
use crate::state::pages::{form::FormState, auth::AuthState};
use crate::state::state::AppState;
use crate::services::grpc_client::GrpcClient;
use crate::services::auth::AuthClient;
use crate::tui::functions::common::{
form::{save as form_save, revert as form_revert},
login::{save as login_save, revert as login_revert}
};
pub async fn handle_core_action(
action: &str,
form_state: &mut FormState,
auth_state: &mut AuthState,
grpc_client: &mut GrpcClient,
auth_client: &mut AuthClient,
terminal: &mut TerminalCore,
app_state: &mut AppState,
current_position: &mut u64,
total_count: u64,
) -> Result<(bool, String), Box<dyn std::error::Error>> {
match action {
"save" => {
if app_state.ui.show_login {
let message = login_save(auth_state, auth_client, app_state).await?;
Ok((false, message))
} else {
let message = form_save(
form_state,
grpc_client,
&mut app_state.ui.is_saved,
current_position,
total_count,
).await?;
Ok((false, message))
}
},
"force_quit" => {
terminal.cleanup()?;
Ok((true, "Force exiting without saving.".to_string()))
},
"save_and_quit" => {
let message = if app_state.ui.show_login {
login_save(auth_state, auth_client, app_state).await?
} else {
form_save(
form_state,
grpc_client,
&mut app_state.ui.is_saved,
current_position,
total_count,
).await?
};
terminal.cleanup()?;
Ok((true, format!("{}. Exiting application.", message)))
},
"revert" => {
if app_state.ui.show_login {
let message = login_revert(auth_state, app_state).await;
Ok((false, message))
} else {
let message = form_revert(
form_state,
grpc_client,
current_position,
total_count,
).await?;
Ok((false, message))
}
},
_ => Ok((false, format!("Core action not handled: {}", action))),
}
}

View File

@@ -0,0 +1,86 @@
// src/modes/canvas/common_mode.rs
use crate::tui::terminal::core::TerminalCore;
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState, auth::AuthState};
use crate::state::app::state::AppState;
use crate::services::grpc_client::GrpcClient;
use crate::services::auth::AuthClient;
use crate::modes::handlers::event::EventOutcome;
use crate::tui::functions::common::form::SaveOutcome;
use anyhow::{Context, Result};
use crate::tui::functions::common::{
form::{save as form_save, revert as form_revert},
login::{save as login_save, revert as login_revert},
register::{revert as register_revert},
};
pub async fn handle_core_action(
action: &str,
form_state: &mut FormState,
auth_state: &mut AuthState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
grpc_client: &mut GrpcClient,
auth_client: &mut AuthClient,
terminal: &mut TerminalCore,
app_state: &mut AppState,
) -> Result<EventOutcome> {
match action {
"save" => {
if app_state.ui.show_login {
let message = login_save(auth_state, login_state, auth_client, app_state).await.context("Login save action failed")?;
Ok(EventOutcome::Ok(message))
} else {
let save_outcome = form_save(
app_state,
form_state,
grpc_client,
).await.context("Register save action failed")?;
let message = match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(),
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
};
Ok(EventOutcome::DataSaved(save_outcome, message))
}
},
"force_quit" => {
terminal.cleanup()?;
Ok(EventOutcome::Exit("Force exiting without saving.".to_string()))
},
"save_and_quit" => {
let message = if app_state.ui.show_login {
login_save(auth_state, login_state, auth_client, app_state).await.context("Login save n quit action failed")?
} else {
let save_outcome = form_save(
app_state,
form_state,
grpc_client,
).await?;
match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(),
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
}
};
terminal.cleanup()?;
Ok(EventOutcome::Exit(format!("{}. Exiting application.", message)))
},
"revert" => {
if app_state.ui.show_login {
let message = login_revert(login_state, app_state).await;
Ok(EventOutcome::Ok(message))
} else if app_state.ui.show_register {
let message = register_revert(register_state, app_state).await;
Ok(EventOutcome::Ok(message))
} else {
let message = form_revert(
form_state,
grpc_client,
).await.context("Form revert x action failed")?;
Ok(EventOutcome::Ok(message))
}
},
_ => Ok(EventOutcome::Ok(format!("Core action not handled: {}", action))),
}
}

View File

@@ -1,511 +1,496 @@
// src/modes/canvas/edit.rs
// TODO THIS is freaking bloated with functions it never uses REFACTOR 200 LOC can be gone
use std::any::Any;
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
use crate::config::binds::config::Config;
use crate::state::canvas_state::CanvasState;
use crate::state::pages::form::FormState;
use crate::modes::handlers::event::EventHandler;
use crate::services::grpc_client::GrpcClient;
use crate::tui::functions::common::form::{save, revert};
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::state::pages::{
auth::{LoginState, RegisterState},
form::FormState,
};
use canvas::canvas::CanvasState;
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
use anyhow::Result;
use common::proto::komp_ac::search::search_response::Hit;
use crossterm::event::{KeyCode, KeyEvent};
use tokio::sync::mpsc;
use tracing::info;
pub async fn handle_edit_event_internal<S: CanvasState + Any>(
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditEventOutcome {
Message(String),
ExitEditMode,
}
/// Helper function to spawn a non-blocking search task for autocomplete.
async fn trigger_form_autocomplete_search(
form_state: &mut FormState,
grpc_client: &mut GrpcClient,
sender: mpsc::UnboundedSender<Vec<Hit>>,
) {
if let Some(field_def) = form_state.fields.get(form_state.current_field) {
if field_def.is_link {
if let Some(target_table) = &field_def.link_target_table {
// 1. Update state for immediate UI feedback
form_state.autocomplete_loading = true;
form_state.autocomplete_active = true;
form_state.autocomplete_suggestions.clear();
form_state.selected_suggestion_index = None;
// 2. Clone everything needed for the background task
let query = form_state.get_current_input().to_string();
let table_to_search = target_table.clone();
let mut grpc_client_clone = grpc_client.clone();
info!(
"[Autocomplete] Spawning search in '{}' for query: '{}'",
table_to_search, query
);
// 3. Spawn the non-blocking task
tokio::spawn(async move {
match grpc_client_clone
.search_table(table_to_search, query)
.await
{
Ok(response) => {
// Send results back through the channel
let _ = sender.send(response.hits);
}
Err(e) => {
tracing::error!(
"[Autocomplete] Search failed: {:?}",
e
);
// Send an empty vec on error so the UI can stop loading
let _ = sender.send(vec![]);
}
}
});
}
}
}
}
pub async fn handle_form_edit_with_canvas(
key_event: KeyEvent,
config: &Config,
form_state: &mut FormState,
ideal_cursor_column: &mut usize,
) -> Result<String> {
// Try canvas action from key first
let canvas_config = canvas::config::CanvasConfig::load();
if let Some(action_name) = canvas_config.get_edit_action(key_event.code, key_event.modifiers) {
let canvas_action = CanvasAction::from_string(action_name);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(_) => {
// Fall through to try config mapping
}
}
}
// Try config-mapped action
if let Some(action_str) = config.get_edit_action_for_key(key_event.code, key_event.modifiers) {
let canvas_action = CanvasAction::from_string(&action_str);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(e) => {
return Ok(format!("Action failed: {}", e));
}
}
}
Ok(String::new())
}
/// Helper function to execute a specific action using canvas library
async fn execute_canvas_action(
action: &str,
key: KeyEvent,
form_state: &mut FormState,
ideal_cursor_column: &mut usize,
) -> Result<String> {
let canvas_action = CanvasAction::from_string(action);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => Ok(msg.unwrap_or_default()),
Ok(ActionResult::HandledByFeature(msg)) => Ok(msg),
Ok(ActionResult::Error(msg)) => Ok(format!("Error: {}", msg)),
Ok(ActionResult::RequiresContext(msg)) => Ok(format!("Context needed: {}", msg)),
Err(e) => Ok(format!("Action failed: {}", e)),
}
}
/// NEW: Unified canvas action handler for any CanvasState (LoginState, RegisterState, etc.)
/// This replaces the old auth_e::execute_edit_action calls with the new canvas library
/// NEW: Unified canvas action handler for any CanvasState with character fallback
/// Complete canvas action handler with fallbacks for common keys
/// Debug version to see what's happening
async fn handle_canvas_state_edit<S: CanvasState>(
key: KeyEvent,
config: &Config,
state: &mut S,
ideal_cursor_column: &mut usize,
command_message: &mut String,
is_saved: &mut bool,
current_position: &mut u64,
total_count: u64,
grpc_client: &mut GrpcClient,
) -> Result<String, Box<dyn std::error::Error>> {
if let Some("enter_command_mode") = config.get_action_for_key_in_mode(&config.keybindings.global, key.code, key.modifiers) {
handle_edit_specific_input(key, state, ideal_cursor_column);
return Ok(command_message.clone());
}
) -> Result<String> {
println!("DEBUG: Key pressed: {:?}", key); // DEBUG
// Try direct key mapping first (same pattern as FormState)
let canvas_config = canvas::config::CanvasConfig::load();
if let Some(action_name) = canvas_config.get_edit_action(key.code, key.modifiers) {
println!("DEBUG: Canvas config mapped to: {}", action_name); // DEBUG
let canvas_action = CanvasAction::from_string(action_name);
if let Some(action) = config.get_action_for_key_in_mode(&config.keybindings.common, key.code, key.modifiers) {
return execute_common_action(
action,
state,
grpc_client,
is_saved,
current_position,
total_count,
).await;
}
if let Some(action) = config.get_edit_action_for_key(key.code, key.modifiers) {
return execute_edit_action(
action,
state,
ideal_cursor_column,
grpc_client,
is_saved,
current_position,
total_count,
).await;
}
handle_edit_specific_input(key, state, ideal_cursor_column);
Ok(command_message.clone())
}
async fn execute_common_action<S: CanvasState + Any>(
action: &str,
state: &mut S,
grpc_client: &mut GrpcClient,
is_saved: &mut bool,
current_position: &mut u64,
total_count: u64,
) -> Result<String, Box<dyn std::error::Error>> {
match action {
"save" | "revert" if state.has_unsaved_changes() => {
if let Some(form_state) = (state as &mut dyn Any).downcast_mut::<FormState>() {
return match action {
"save" => save(form_state, grpc_client, is_saved, current_position, total_count).await,
"revert" => revert(form_state, grpc_client, current_position, total_count).await,
_ => unreachable!(),
};
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(_) => {
println!("DEBUG: Canvas action failed, trying client config"); // DEBUG
}
Ok("Action not available in this context".to_string())
}
"move_up" | "move_down" => {
execute_edit_action(
action,
state,
&mut 0,
grpc_client,
is_saved,
current_position,
total_count,
).await
}
_ => Ok(format!("Common action not handled: {}", action)),
} else {
println!("DEBUG: No canvas config mapping found"); // DEBUG
}
// Try config-mapped action (same pattern as FormState)
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
println!("DEBUG: Client config mapped to: {}", action_str); // DEBUG
let canvas_action = CanvasAction::from_string(&action_str);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(e) => {
return Ok(format!("Action failed: {}", e));
}
}
} else {
println!("DEBUG: No client config mapping found"); // DEBUG
}
// Character insertion fallback
if let KeyCode::Char(c) = key.code {
println!("DEBUG: Using character fallback for: {}", c); // DEBUG
let canvas_action = CanvasAction::InsertChar(c);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(e) => {
return Ok(format!("Character insertion failed: {}", e));
}
}
}
println!("DEBUG: No action taken for key: {:?}", key); // DEBUG
Ok(String::new())
}
fn handle_edit_specific_input<S: CanvasState>( // No Any needed here
#[allow(clippy::too_many_arguments)]
pub async fn handle_edit_event(
key: KeyEvent,
state: &mut S,
ideal_cursor_column: &mut usize,
) {
match key.code {
KeyCode::Char(c) => {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() {
chars.insert(cursor_pos, c);
*field_value = chars.into_iter().collect();
// Use trait setters
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = state.current_cursor_pos(); // Update ideal column
}
}
KeyCode::Backspace => {
if state.current_cursor_pos() > 0 {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() && cursor_pos > 0 {
chars.remove(cursor_pos - 1);
*field_value = chars.into_iter().collect();
// Use trait setters
state.set_current_cursor_pos(cursor_pos - 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = state.current_cursor_pos(); // Update ideal column
config: &Config,
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
admin_state: &mut AdminState,
current_position: &mut u64,
total_count: u64,
event_handler: &mut EventHandler,
app_state: &AppState,
) -> Result<EditEventOutcome> {
// --- AUTOCOMPLETE-SPECIFIC KEY HANDLING ---
if app_state.ui.show_form && form_state.autocomplete_active {
if let Some(action) =
config.get_edit_action_for_key(key.code, key.modifiers)
{
match action {
"suggestion_down" => {
if !form_state.autocomplete_suggestions.is_empty() {
let current =
form_state.selected_suggestion_index.unwrap_or(0);
let next = (current + 1)
% form_state.autocomplete_suggestions.len();
form_state.selected_suggestion_index = Some(next);
}
return Ok(EditEventOutcome::Message(String::new()));
}
"suggestion_up" => {
if !form_state.autocomplete_suggestions.is_empty() {
let current =
form_state.selected_suggestion_index.unwrap_or(0);
let prev = if current == 0 {
form_state.autocomplete_suggestions.len() - 1
} else {
current - 1
};
form_state.selected_suggestion_index = Some(prev);
}
return Ok(EditEventOutcome::Message(String::new()));
}
"exit" => {
form_state.deactivate_autocomplete();
return Ok(EditEventOutcome::Message(
"Autocomplete cancelled".to_string(),
));
}
"enter_decider" => {
if let Some(selected_idx) =
form_state.selected_suggestion_index
{
if let Some(selection) = form_state
.autocomplete_suggestions
.get(selected_idx)
.cloned()
{
// --- THIS IS THE CORE LOGIC CHANGE ---
// 1. Get the friendly display name for the UI
let display_name =
form_state.get_display_name_for_hit(&selection);
// 2. Store the REAL ID in the form's values
let current_input =
form_state.get_current_input_mut();
*current_input = selection.id.to_string();
// 3. Set the persistent display override in the map
form_state.link_display_map.insert(
form_state.current_field,
display_name,
);
// 4. Finalize state
form_state.deactivate_autocomplete();
form_state.set_has_unsaved_changes(true);
return Ok(EditEventOutcome::Message(
"Selection made".to_string(),
));
}
}
form_state.deactivate_autocomplete();
// Fall through to default 'enter' behavior
}
_ => {} // Let other keys fall through to the live search logic
}
}
KeyCode::Delete => {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let chars: Vec<char> = field_value.chars().collect();
if cursor_pos < chars.len() {
let mut new_chars = chars.clone();
new_chars.remove(cursor_pos);
*field_value = new_chars.into_iter().collect();
// Use trait setter
state.set_has_unsaved_changes(true);
// Cursor position doesn't change, but ideal might if text changed
*ideal_cursor_column = state.current_cursor_pos();
}
// --- LIVE AUTOCOMPLETE TRIGGER LOGIC ---
let mut trigger_search = false;
if app_state.ui.show_form {
// Manual trigger
if let Some("trigger_autocomplete") =
config.get_edit_action_for_key(key.code, key.modifiers)
{
if !form_state.autocomplete_active {
trigger_search = true;
}
}
KeyCode::Tab => {
let num_fields = state.fields().len();
if num_fields > 0 { // Avoid panic on empty fields
let current_field = state.current_field();
let new_field = if key.modifiers.contains(KeyModifiers::SHIFT) {
if current_field == 0 { num_fields - 1 } else { current_field - 1 }
// Live search trigger while typing
else if form_state.autocomplete_active {
if let KeyCode::Char(_) | KeyCode::Backspace = key.code {
let action = if let KeyCode::Backspace = key.code {
"delete_char_backward"
} else {
(current_field + 1) % num_fields
"insert_char"
};
// Use trait setter
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = current_input.len();
// Use trait setter
state.set_current_cursor_pos((*ideal_cursor_column).min(max_cursor_pos));
// FIXED: Use canvas library instead of form_e::execute_edit_action
execute_canvas_action(
action,
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?;
trigger_search = true;
}
}
KeyCode::Enter => {
let num_fields = state.fields().len();
if num_fields > 0 { // Avoid panic on empty fields
let new_field = (state.current_field() + 1) % num_fields;
// Use trait setter
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = current_input.len();
// Use trait setter
state.set_current_cursor_pos((*ideal_cursor_column).min(max_cursor_pos));
}
}
_ => {}
}
}
async fn execute_edit_action<S: CanvasState>( // No Any needed here
action: &str,
state: &mut S,
ideal_cursor_column: &mut usize,
// These parameters are unused if save/revert aren't handled here,
// but keep them for now if execute_common_action calls this
_grpc_client: &mut GrpcClient,
_is_saved: &mut bool,
_current_position: &mut u64,
_total_count: u64,
) -> Result<String, Box<dyn std::error::Error>> {
match action {
"move_left" => {
let new_pos = state.current_cursor_pos().saturating_sub(1);
// Use trait setter
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
// Allow moving cursor to position *after* last character
if current_pos < current_input.len() {
let new_pos = current_pos + 1;
// Use trait setter
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_up" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = if current_field == 0 { num_fields - 1 } else { current_field - 1 };
// Use trait setter
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
// Use trait setter
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_down" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = (state.current_field() + 1) % num_fields;
// Use trait setter
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
// Use trait setter
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_line_start" => {
// Use trait setter
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
let new_pos = current_input.len();
// Use trait setter
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_first_line" => {
let num_fields = state.fields().len();
if num_fields > 0 {
// Use trait setter
state.set_current_field(0);
let current_input = state.get_current_input();
let max_pos = current_input.len();
// Use trait setter
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("Moved to first line".to_string())
}
"move_last_line" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = num_fields - 1;
// Use trait setter
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
// Use trait setter
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("Moved to last line".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = new_pos.min(current_input.len());
// Use trait setter
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_word_end(current_input, state.current_cursor_pos());
let final_pos = new_pos.min(current_input.len());
// Use trait setter
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
// Use trait setter
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
// Use trait setter
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("Moved to previous word end".to_string())
}
"delete_char_forward" => {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect(); // Use mut for modification
if cursor_pos < chars.len() {
chars.remove(cursor_pos);
*field_value = chars.into_iter().collect();
// Use trait setter
state.set_has_unsaved_changes(true);
// Cursor position doesn't change
*ideal_cursor_column = cursor_pos;
}
Ok("".to_string())
}
"delete_char_backward" => {
if state.current_cursor_pos() > 0 {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect(); // Use mut
if cursor_pos <= chars.len() && cursor_pos > 0 {
chars.remove(cursor_pos - 1);
*field_value = chars.into_iter().collect();
let new_pos = cursor_pos - 1;
// Use trait setters
state.set_current_cursor_pos(new_pos);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = new_pos;
}
}
Ok("".to_string())
}
"insert_char" => {
// This action doesn't make sense here as char insertion is handled
// directly in handle_edit_specific_input
Ok("Character insertion handled directly".to_string())
}
"next_field" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = (state.current_field() + 1) % num_fields;
// Use trait setter
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
// Use trait setter
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"prev_field" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = if current_field == 0 { num_fields - 1 } else { current_field - 1 };
// Use trait setter
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
// Use trait setter
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
_ => Ok(format!("Unknown edit action: {}", action)),
}
}
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos >= chars.len() {
return current_pos;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
if current_pos >= chars.len() - 1 {
return chars.len() - 1;
}
let mut pos = current_pos;
if get_char_type(chars[pos]) == CharType::Whitespace {
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
} else {
let current_type = get_char_type(chars[pos]);
if pos + 1 < chars.len() && get_char_type(chars[pos + 1]) != current_type {
pos += 1;
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
}
}
if pos >= chars.len() {
return chars.len() - 1;
}
let word_type = get_char_type(chars[pos]);
while pos + 1 < chars.len() && get_char_type(chars[pos + 1]) == word_type {
pos += 1;
}
pos
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if get_char_type(chars[pos]) != CharType::Whitespace {
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
}
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos <= 1 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 && get_char_type(chars[pos]) != CharType::Whitespace {
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos -= 1;
let prev_word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == prev_word_type {
pos -= 1;
}
while pos < chars.len() - 1 &&
get_char_type(chars[pos + 1]) == prev_word_type {
pos += 1;
}
}
}
pos
if trigger_search {
trigger_form_autocomplete_search(
form_state,
&mut event_handler.grpc_client,
event_handler.autocomplete_result_sender.clone(),
)
.await;
return Ok(EditEventOutcome::Message("Searching...".to_string()));
}
// --- GENERAL EDIT MODE EVENT HANDLING (IF NOT AUTOCOMPLETE) ---
if let Some(action_str) =
config.get_edit_action_for_key(key.code, key.modifiers)
{
// Handle Enter key (next field)
if action_str == "enter_decider" {
// FIXED: Use canvas library instead of form_e::execute_edit_action
let msg = execute_canvas_action(
"next_field",
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?;
return Ok(EditEventOutcome::Message(msg));
}
// Handle exiting edit mode
if action_str == "exit" {
return Ok(EditEventOutcome::ExitEditMode);
}
// Handle all other edit actions - NOW USING CANVAS LIBRARY
let msg = if app_state.ui.show_login {
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
login_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table {
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_table_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic {
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
register_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else {
// FIXED: Use canvas library instead of form_e::execute_edit_action
execute_canvas_action(
action_str,
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?
};
return Ok(EditEventOutcome::Message(msg));
}
// --- FALLBACK FOR CHARACTER INSERTION (IF NO OTHER BINDING MATCHED) ---
if let KeyCode::Char(_) = key.code {
let msg = if app_state.ui.show_login {
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
login_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table {
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_table_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic {
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
register_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else {
// FIXED: Use canvas library instead of form_e::execute_edit_action
execute_canvas_action(
"insert_char",
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?
};
return Ok(EditEventOutcome::Message(msg));
}
Ok(EditEventOutcome::Message(String::new())) // No action taken
}

View File

@@ -3,239 +3,298 @@
use crate::config::binds::config::Config;
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::services::grpc_client::GrpcClient;
use crate::state::canvas_state::CanvasState; // Import the trait
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
use crate::state::pages::form::FormState;
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::add_table::AddTableState;
use crate::state::app::state::AppState;
use canvas::{canvas::{CanvasAction, CanvasState, ActionResult}, dispatcher::ActionDispatcher};
use crossterm::event::KeyEvent;
use anyhow::Result;
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
/// Helper function to dispatch canvas action for any CanvasState
async fn dispatch_canvas_action<S: CanvasState>(
action: &str,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> String {
let canvas_action = CanvasAction::from_string(action);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => msg.unwrap_or_default(),
Ok(ActionResult::HandledByFeature(msg)) => msg,
Ok(ActionResult::Error(msg)) => format!("Error: {}", msg),
Ok(ActionResult::RequiresContext(msg)) => format!("Context needed: {}", msg),
Err(e) => format!("Action failed: {}", e),
}
}
/// Helper function to dispatch canvas action to the appropriate state based on UI
async fn dispatch_to_active_state(
action: &str,
app_state: &AppState,
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
add_table_state: &mut AddTableState,
add_logic_state: &mut AddLogicState,
ideal_cursor_column: &mut usize,
) -> String {
if app_state.ui.show_add_table {
dispatch_canvas_action(action, add_table_state, ideal_cursor_column).await
} else if app_state.ui.show_add_logic {
dispatch_canvas_action(action, add_logic_state, ideal_cursor_column).await
} else if app_state.ui.show_register {
dispatch_canvas_action(action, register_state, ideal_cursor_column).await
} else if app_state.ui.show_login {
dispatch_canvas_action(action, login_state, ideal_cursor_column).await
} else {
dispatch_canvas_action(action, form_state, ideal_cursor_column).await
}
}
/// Helper function to handle context-specific actions that need special treatment
async fn handle_context_action(
action: &str,
app_state: &AppState,
form_state: &mut FormState,
grpc_client: &mut GrpcClient,
ideal_cursor_column: &mut usize,
) -> Result<Option<String>> {
const CONTEXT_ACTIONS_FORM: &[&str] = &[
"previous_entry",
"next_entry",
];
const CONTEXT_ACTIONS_LOGIN: &[&str] = &[
"previous_entry",
"next_entry",
];
if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
Ok(Some(crate::tui::functions::form::handle_action(
action,
form_state,
grpc_client,
ideal_cursor_column,
).await?))
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
Ok(Some(crate::tui::functions::login::handle_action(action).await?))
} else {
Ok(None) // Not a context action, use regular canvas dispatch
}
}
pub async fn handle_form_readonly_with_canvas(
key_event: KeyEvent,
config: &Config,
form_state: &mut FormState,
ideal_cursor_column: &mut usize,
) -> Result<String> {
// Try canvas action from key first
let canvas_config = canvas::config::CanvasConfig::load();
if let Some(action_name) = canvas_config.get_read_only_action(key_event.code, key_event.modifiers) {
let canvas_action = CanvasAction::from_string(action_name);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(_) => {
// Fall through to try config mapping
}
}
}
// Try config-mapped action
if let Some(action_str) = config.get_read_only_action_for_key(key_event.code, key_event.modifiers) {
let canvas_action = CanvasAction::from_string(&action_str);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(e) => {
return Ok(format!("Action failed: {}", e));
}
}
}
Ok(String::new())
}
pub async fn handle_read_only_event(
app_state: &crate::state::state::AppState,
app_state: &mut AppState,
key: KeyEvent,
config: &Config,
form_state: &mut FormState, // Keep specific types here for routing
auth_state: &mut AuthState, // Keep specific types here for routing
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
add_table_state: &mut AddTableState,
add_logic_state: &mut AddLogicState,
key_sequence_tracker: &mut KeySequenceTracker,
current_position: &mut u64, // Needed for form actions
total_count: u64, // Needed for form actions
grpc_client: &mut GrpcClient, // Needed for form actions
grpc_client: &mut GrpcClient,
command_message: &mut String,
edit_mode_cooldown: &mut bool,
ideal_cursor_column: &mut usize,
) -> Result<(bool, String), Box<dyn std::error::Error>> {
// Check for entering Edit mode from Read-Only mode
) -> Result<(bool, String)> {
if config.is_enter_edit_mode_before(key.code, key.modifiers) {
*edit_mode_cooldown = true;
*command_message = "Entering Edit mode".to_string();
// The actual mode switch happens in event.rs based on this return
return Ok((false, command_message.clone()));
}
if config.is_enter_edit_mode_after(key.code, key.modifiers) {
// Use the correct state based on context
let (current_input, current_pos) = if app_state.ui.show_login {
(
auth_state.get_current_input(),
auth_state.current_cursor_pos(),
)
// Determine target state to adjust cursor - all states now use CanvasState trait
if app_state.ui.show_login {
let current_input = login_state.get_current_input();
let current_pos = login_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
login_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = login_state.current_cursor_pos();
}
} else if app_state.ui.show_add_logic {
let current_input = add_logic_state.get_current_input();
let current_pos = add_logic_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
add_logic_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = add_logic_state.current_cursor_pos();
}
} else if app_state.ui.show_register {
let current_input = register_state.get_current_input();
let current_pos = register_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
register_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = register_state.current_cursor_pos();
}
} else if app_state.ui.show_add_table {
let current_input = add_table_state.get_current_input();
let current_pos = add_table_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
add_table_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = add_table_state.current_cursor_pos();
}
} else {
(
form_state.get_current_input(),
form_state.current_cursor_pos(),
)
};
if !current_input.is_empty() && current_pos < current_input.len() {
// Update the correct state
if app_state.ui.show_login {
auth_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = auth_state.current_cursor_pos();
} else {
// Handle FormState
let current_input = form_state.get_current_input();
let current_pos = form_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
form_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = form_state.current_cursor_pos();
}
}
*edit_mode_cooldown = true;
*command_message = "Entering Edit mode (after cursor)".to_string();
// The actual mode switch happens in event.rs based on this return
return Ok((false, command_message.clone()));
}
// Handle Read-Only mode keybindings
if key.modifiers.is_empty() {
key_sequence_tracker.add_key(key.code);
let sequence = key_sequence_tracker.get_sequence();
// Try to match the current sequence against Read-Only mode bindings
if let Some(action) = config.matches_key_sequence_generalized(&sequence)
{
// Handle context-specific actions first
let result = if app_state.ui.show_form
&& (action == "previous_entry" || action == "next_entry")
{
// Form-specific navigation
crate::tui::functions::form::handle_action(
action,
form_state,
grpc_client,
current_position,
total_count,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_login
&& (action == "move_up" || action == "move_down")
{
// Login-specific field navigation
crate::tui::functions::login::handle_action(
action,
auth_state,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_form
&& (action == "move_up" || action == "move_down")
{
// Form-specific field navigation (can reuse login handler logic if identical)
crate::tui::functions::form::handle_action(
action,
form_state,
grpc_client, // Might not be needed for simple up/down
current_position,
total_count,
ideal_cursor_column,
)
.await?
if let Some(action) = config.matches_key_sequence_generalized(&sequence).as_deref() {
// Try context-specific actions first, otherwise use canvas dispatch
let result = if let Some(context_result) = handle_context_action(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
).await? {
context_result
} else {
// Handle common navigation actions generically
if app_state.ui.show_login {
execute_action(
action,
auth_state, // Pass AuthState
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
} else {
execute_action(
action,
form_state, // Pass FormState
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
}
dispatch_to_active_state(
action,
app_state,
form_state,
login_state,
register_state,
add_table_state,
add_logic_state,
ideal_cursor_column,
).await
};
key_sequence_tracker.reset();
return Ok((false, result));
}
// Check if this might be a prefix of a longer sequence
if config.is_key_sequence_prefix(&sequence) {
return Ok((false, command_message.clone()));
}
// Since it's not part of a multi-key sequence, check for a direct action
if sequence.len() == 1 && !config.is_key_sequence_prefix(&sequence) {
if let Some(action) =
config.get_read_only_action_for_key(key.code, key.modifiers)
{
// Handle context-specific actions first
let result = if app_state.ui.show_form
&& action == "previous_entry"
{
// Form-specific navigation
crate::tui::functions::form::handle_action(
action,
form_state,
grpc_client,
current_position,
total_count,
ideal_cursor_column,
)
.await?
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
// Try context-specific actions first, otherwise use canvas dispatch
let result = if let Some(context_result) = handle_context_action(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
).await? {
context_result
} else {
// Handle common navigation actions generically
if app_state.ui.show_login {
execute_action(
action,
auth_state, // Pass AuthState
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
} else {
execute_action(
action,
form_state, // Pass FormState
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
}
dispatch_to_active_state(
action,
app_state,
form_state,
login_state,
register_state,
add_table_state,
add_logic_state,
ideal_cursor_column,
).await
};
key_sequence_tracker.reset();
return Ok((false, result));
}
}
key_sequence_tracker.reset();
} else {
// If modifiers are pressed, check for direct key bindings
key_sequence_tracker.reset();
if let Some(action) =
config.get_read_only_action_for_key(key.code, key.modifiers)
{
// Handle context-specific actions first
let result = if app_state.ui.show_form
&& action == "previous_entry"
{
// Form-specific navigation
crate::tui::functions::form::handle_action(
action,
form_state,
grpc_client,
current_position,
total_count,
ideal_cursor_column,
)
.await?
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
// Try context-specific actions first, otherwise use canvas dispatch
let result = if let Some(context_result) = handle_context_action(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
).await? {
context_result
} else {
// Handle common navigation actions generically
if app_state.ui.show_login {
execute_action(
action,
auth_state, // Pass AuthState
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
} else {
execute_action(
action,
form_state, // Pass FormState
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
}
dispatch_to_active_state(
action,
app_state,
form_state,
login_state,
register_state,
add_table_state,
add_logic_state,
ideal_cursor_column,
).await
};
return Ok((false, result));
}
}
// Show a helpful message when no binding was found
if !*edit_mode_cooldown {
let default_key = "i".to_string();
let edit_key = config
@@ -243,296 +302,12 @@ pub async fn handle_read_only_event(
.read_only
.get("enter_edit_mode_before")
.and_then(|keys| keys.first())
.unwrap_or(&default_key);
.map(|k| k.to_string())
.unwrap_or(default_key);
*command_message = format!("Read-only mode - press {} to edit", edit_key);
}
*edit_mode_cooldown = false;
Ok((false, command_message.clone()))
}
// Make this function generic over CanvasState
async fn execute_action<S: CanvasState>(
action: &str,
state: &mut S, // Use generic state
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker, // Keep for resetting
command_message: &mut String, // Keep for clearing
) -> Result<String, Box<dyn std::error::Error>> {
match action {
// These actions are handled outside now based on context
"previous_entry" | "next_entry" => {
key_sequence_tracker.reset();
Ok(format!(
"Action '{}' should be handled by context-specific logic",
action
))
}
// These actions are handled outside now based on context
"move_up" | "move_down" => {
key_sequence_tracker.reset();
Ok(format!(
"Action '{}' should be handled by context-specific logic",
action
))
}
"exit_edit_mode" => {
// This action is primarily for Edit mode, but might be bound here too
key_sequence_tracker.reset();
command_message.clear();
Ok("".to_string())
}
"move_left" => {
let current_pos = state.current_cursor_pos();
let new_pos = current_pos.saturating_sub(1);
state.set_current_cursor_pos(new_pos); // Use trait setter
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
// In read-only, cursor stops AT the last character, not after
if !current_input.is_empty()
&& current_pos < current_input.len().saturating_sub(1)
{
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos); // Use trait setter
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos =
find_next_word_start(current_input, state.current_cursor_pos());
// Clamp to last valid character index in read-only
let final_pos = new_pos.min(current_input.len().saturating_sub(1));
state.set_current_cursor_pos(final_pos); // Use trait setter
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos =
find_word_end(current_input, state.current_cursor_pos());
// Clamp to last valid character index in read-only
let final_pos = new_pos.min(current_input.len().saturating_sub(1));
state.set_current_cursor_pos(final_pos); // Use trait setter
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos); // Use trait setter
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos); // Use trait setter
*ideal_cursor_column = new_pos;
}
Ok("Moved to previous word end".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0); // Use trait setter
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = current_input.len().saturating_sub(1);
state.set_current_cursor_pos(new_pos); // Use trait setter
*ideal_cursor_column = new_pos;
} else {
state.set_current_cursor_pos(0); // Handle empty input case
*ideal_cursor_column = 0;
}
Ok("".to_string())
}
"move_first_line" => {
// Field change is handled outside based on context
// Just set cursor position based on ideal column
let current_input = state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len().saturating_sub(1)
} else {
0
};
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_cursor_pos),
);
Ok("Moved to first line".to_string()) // Message might be inaccurate now
}
"move_last_line" => {
// Field change is handled outside based on context
// Just set cursor position based on ideal column
let current_input = state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len().saturating_sub(1)
} else {
0
};
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_cursor_pos),
);
Ok("Moved to last line".to_string()) // Message might be inaccurate now
}
_ => Ok(format!("Unknown read-only action: {}", action)),
}
}
// Helper functions remain unchanged as they operate on &str
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos >= chars.len() {
return current_pos;
}
let mut pos = current_pos;
// Handle edge case where current_pos might be out of bounds after edits
if pos >= chars.len() {
return chars.len();
}
let initial_type = get_char_type(chars[pos]);
// Move past characters of the same type
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
// Move past whitespace
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
// Handle edge case where current_pos might be out of bounds
let mut pos = current_pos.min(chars.len().saturating_sub(1));
// If starting on whitespace, move to the next non-whitespace
if get_char_type(chars[pos]) == CharType::Whitespace {
while pos + 1 < chars.len() && get_char_type(chars[pos + 1]) == CharType::Whitespace {
pos += 1;
}
// If we are still on whitespace (meaning end of string or only whitespace left), return current pos
if pos + 1 >= chars.len() || get_char_type(chars[pos + 1]) == CharType::Whitespace {
return pos;
}
// Move to the start of the next word
pos += 1;
}
// Now we are on a non-whitespace character
let word_type = get_char_type(chars[pos]);
while pos + 1 < chars.len() && get_char_type(chars[pos + 1]) == word_type {
pos += 1;
}
pos
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
// Move past whitespace
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
// Now on a non-whitespace or at the beginning
if get_char_type(chars[pos]) != CharType::Whitespace {
let word_type = get_char_type(chars[pos]);
// Move to the beginning of the word
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
}
// If pos is 0 and it's whitespace, keep it at 0. Otherwise, it's the start of the word.
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
0
} else {
pos
}
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos <= 1 { // Need at least 2 chars to find a *previous* word end
return 0;
}
let mut pos = current_pos.saturating_sub(1); // Start looking one char back
// Skip trailing whitespace from the current position
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
// Now we are at the end of the word the cursor was in/after, or at the start
if pos == 0 {
return 0; // Reached the beginning
}
// Skip the word itself
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
// Skip whitespace before that word
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
// Now pos is at the beginning of the word *or* the end of the *previous* word.
// If we moved back, pos-1 is the index we want.
if pos > 0 {
pos - 1
} else {
0 // We were at the first word
}
}

View File

@@ -1,3 +1,6 @@
// src/client/modes/common.rs
pub mod command_mode;
pub mod highlight;
pub mod commands;
pub use commands::*;

View File

@@ -1,16 +1,23 @@
// src/modes/handlers/command_mode.rs
// src/modes/common/command_mode.rs
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
use crate::config::binds::config::Config;
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::form::FormState;
use crate::tui::functions::common::commands::CommandHandler;
use crate::state::{app::state::AppState, pages::auth::LoginState, pages::auth::RegisterState};
use crate::modes::common::commands::CommandHandler;
use crate::tui::terminal::core::TerminalCore;
use crate::tui::functions::common::form::{save, revert};
use crate::modes::handlers::event::EventOutcome;
use crate::tui::functions::common::form::SaveOutcome;
use anyhow::Result;
pub async fn handle_command_event(
key: KeyEvent,
config: &Config,
app_state: &mut AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &mut FormState,
command_input: &mut String,
command_message: &mut String,
@@ -19,15 +26,12 @@ pub async fn handle_command_event(
terminal: &mut TerminalCore,
current_position: &mut u64,
total_count: u64,
) -> Result<(bool, String, bool), Box<dyn std::error::Error>> {
// Return value: (should_exit, message, should_exit_command_mode)
) -> Result<EventOutcome> {
// Exit command mode (via configurable keybinding)
if config.is_exit_command_mode(key.code, key.modifiers) {
command_input.clear();
*command_message = "".to_string();
return Ok((false, "".to_string(), true));
return Ok(EventOutcome::Ok("Exited command mode".to_string()));
}
// Execute command (via configurable keybinding, defaults to Enter)
@@ -35,6 +39,9 @@ pub async fn handle_command_event(
return process_command(
config,
form_state,
app_state,
login_state,
register_state,
command_input,
command_message,
grpc_client,
@@ -48,7 +55,7 @@ pub async fn handle_command_event(
// Backspace (via configurable keybinding, defaults to Backspace)
if config.is_command_backspace(key.code, key.modifiers) {
command_input.pop();
return Ok((false, "".to_string(), false));
return Ok(EventOutcome::Ok("".to_string()));
}
// Regular character input - accept any character in command mode
@@ -56,17 +63,20 @@ pub async fn handle_command_event(
// Accept regular or shifted characters (e.g., 'a' or 'A')
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
command_input.push(c);
return Ok((false, "".to_string(), false));
return Ok(EventOutcome::Ok("".to_string()));
}
}
// Ignore all other keys
Ok((false, "".to_string(), false))
Ok(EventOutcome::Ok("".to_string()))
}
async fn process_command(
config: &Config,
form_state: &mut FormState,
app_state: &mut AppState,
login_state: &LoginState,
register_state: &RegisterState,
command_input: &mut String,
command_message: &mut String,
grpc_client: &mut GrpcClient,
@@ -74,12 +84,12 @@ async fn process_command(
terminal: &mut TerminalCore,
current_position: &mut u64,
total_count: u64,
) -> Result<(bool, String, bool), Box<dyn std::error::Error>> {
) -> Result<EventOutcome> {
// Clone the trimmed command to avoid borrow issues
let command = command_input.trim().to_string();
if command.is_empty() {
*command_message = "Empty command".to_string();
return Ok((false, command_message.clone(), false));
return Ok(EventOutcome::Ok(command_message.clone()));
}
// Get the action for the command (now checks global and common bindings too)
@@ -89,41 +99,48 @@ async fn process_command(
match action {
"force_quit" | "save_and_quit" | "quit" => {
let (should_exit, message) = command_handler
.handle_command(action, terminal)
.handle_command(
action,
terminal,
app_state,
form_state,
login_state,
register_state,
)
.await?;
command_input.clear();
Ok((should_exit, message, true))
if should_exit {
Ok(EventOutcome::Exit(message))
} else {
Ok(EventOutcome::Ok(message))
}
},
"save" => {
let message = save(
let outcome = save(
app_state,
form_state,
grpc_client,
&mut command_handler.is_saved,
current_position,
total_count,
).await?;
let message = match outcome {
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
SaveOutcome::UpdatedExisting => "Entry updated".to_string(),
SaveOutcome::NoChange => "No changes to save".to_string(),
};
command_input.clear();
return Ok((false, message, true));
Ok(EventOutcome::DataSaved(outcome, message))
},
"revert" => {
let message = revert(
form_state,
grpc_client,
current_position,
total_count,
).await?;
command_input.clear();
return Ok((false, message, true));
},
"unknown" => {
let message = format!("Unknown command: {}", command);
command_input.clear();
return Ok((false, message, true));
Ok(EventOutcome::Ok(message))
},
_ => {
let message = format!("Unhandled action: {}", action);
command_input.clear();
return Ok((false, message, true));
Ok(EventOutcome::Ok(message))
}
}
}

View File

@@ -0,0 +1,72 @@
// src/modes/common/commands.rs
use crate::tui::terminal::core::TerminalCore;
use crate::state::app::state::AppState;
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState};
use canvas::canvas::CanvasState;
use anyhow::Result;
pub struct CommandHandler;
impl CommandHandler {
pub fn new() -> Self {
Self
}
pub async fn handle_command(
&mut self,
action: &str,
terminal: &mut TerminalCore,
app_state: &AppState,
form_state: &FormState,
login_state: &LoginState,
register_state: &RegisterState,
) -> Result<(bool, String)> {
match action {
"quit" => self.handle_quit(terminal, app_state, form_state, login_state, register_state).await,
"force_quit" => self.handle_force_quit(terminal).await,
"save_and_quit" => self.handle_save_quit(terminal).await,
_ => Ok((false, format!("Unknown command: {}", action))),
}
}
async fn handle_quit(
&self,
terminal: &mut TerminalCore,
app_state: &AppState,
form_state: &FormState,
login_state: &LoginState,
register_state: &RegisterState,
) -> Result<(bool, String)> {
// Use actual unsaved changes state instead of is_saved flag
let has_unsaved = if app_state.ui.show_login {
login_state.has_unsaved_changes()
} else if app_state.ui.show_register {
register_state.has_unsaved_changes()
} else {
form_state.has_unsaved_changes
};
if !has_unsaved {
terminal.cleanup()?;
Ok((true, "Exiting.".into()))
} else {
Ok((false, "No changes saved. Use :q! to force quit.".into()))
}
}
async fn handle_force_quit(
&self,
terminal: &mut TerminalCore,
) -> Result<(bool, String)> {
terminal.cleanup()?;
Ok((true, "Force exiting without saving.".into()))
}
async fn handle_save_quit(
&mut self,
terminal: &mut TerminalCore,
) -> Result<(bool, String)> {
terminal.cleanup()?;
Ok((true, "State saved. Exiting.".into()))
}
}

View File

@@ -1,2 +1,4 @@
// src/client/modes/general.rs
pub mod navigation;
pub mod dialog;
pub mod command_navigation;

View File

@@ -0,0 +1,396 @@
// src/modes/general/command_navigation.rs
use crate::config::binds::config::Config;
use crate::modes::handlers::event::EventOutcome;
use anyhow::Result;
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
use crossterm::event::{KeyCode, KeyEvent};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq)]
pub enum NavigationType {
FindFile,
TableTree,
}
#[derive(Debug, Clone)]
pub struct TableDependencyGraph {
all_tables: HashSet<String>,
dependents_map: HashMap<String, Vec<String>>,
root_tables: Vec<String>,
}
impl TableDependencyGraph {
pub fn from_profile_tree(profile_tree: &ProfileTreeResponse) -> Self {
let mut dependents_map: HashMap<String, Vec<String>> = HashMap::new();
let mut all_tables_set: HashSet<String> = HashSet::new();
let mut table_dependencies: HashMap<String, Vec<String>> = HashMap::new();
for profile in &profile_tree.profiles {
for table in &profile.tables {
all_tables_set.insert(table.name.clone());
table_dependencies.insert(table.name.clone(), table.depends_on.clone());
for dependency_name in &table.depends_on {
dependents_map
.entry(dependency_name.clone())
.or_default()
.push(table.name.clone());
}
}
}
let root_tables: Vec<String> = all_tables_set
.iter()
.filter(|name| {
table_dependencies
.get(*name)
.map_or(true, |deps| deps.is_empty())
})
.cloned()
.collect();
let mut sorted_root_tables = root_tables;
sorted_root_tables.sort();
for dependents_list in dependents_map.values_mut() {
dependents_list.sort();
}
Self {
all_tables: all_tables_set,
dependents_map,
root_tables: sorted_root_tables,
}
}
pub fn get_dependent_children(&self, path: &str) -> Vec<String> {
if path.is_empty() {
return self.root_tables.clone();
}
let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if let Some(last_segment_name) = path_segments.last() {
if self.all_tables.contains(*last_segment_name) {
return self
.dependents_map
.get(*last_segment_name)
.cloned()
.unwrap_or_default();
}
}
Vec::new()
}
}
// ... (NavigationState struct and its new(), activate_*, deactivate(), add_char(), remove_char(), move_*, autocomplete_selected(), get_display_input() methods are unchanged) ...
pub struct NavigationState {
pub active: bool,
pub input: String,
pub selected_index: Option<usize>,
pub filtered_options: Vec<(usize, String)>,
pub navigation_type: NavigationType,
pub current_path: String,
pub graph: Option<TableDependencyGraph>,
pub all_options: Vec<String>,
}
impl NavigationState {
pub fn new() -> Self {
Self {
active: false,
input: String::new(),
selected_index: None,
filtered_options: Vec::new(),
navigation_type: NavigationType::FindFile,
current_path: String::new(),
graph: None,
all_options: Vec::new(),
}
}
pub fn activate_find_file(&mut self, options: Vec<String>) {
self.active = true;
self.navigation_type = NavigationType::FindFile;
self.all_options = options;
self.input.clear();
self.current_path.clear();
self.graph = None;
self.update_filtered_options();
}
pub fn activate_table_tree(&mut self, graph: TableDependencyGraph) {
self.active = true;
self.navigation_type = NavigationType::TableTree;
self.graph = Some(graph);
self.input.clear();
self.current_path.clear();
self.update_options_for_path();
}
pub fn deactivate(&mut self) {
self.active = false;
self.input.clear();
self.all_options.clear();
self.filtered_options.clear();
self.selected_index = None;
self.current_path.clear();
self.graph = None;
}
pub fn add_char(&mut self, c: char) {
match self.navigation_type {
NavigationType::FindFile => {
self.input.push(c);
self.update_filtered_options();
}
NavigationType::TableTree => {
if c == '/' {
if !self.input.is_empty() {
if self.current_path.is_empty() {
self.current_path = self.input.clone();
} else {
self.current_path.push('/');
self.current_path.push_str(&self.input);
}
self.input.clear();
self.update_options_for_path();
}
} else {
self.input.push(c);
self.update_filtered_options();
}
}
}
}
pub fn remove_char(&mut self) {
match self.navigation_type {
NavigationType::FindFile => {
self.input.pop();
self.update_filtered_options();
}
NavigationType::TableTree => {
if self.input.is_empty() {
if !self.current_path.is_empty() {
if let Some(last_slash_idx) = self.current_path.rfind('/') {
self.input = self.current_path[last_slash_idx + 1..].to_string();
self.current_path = self.current_path[..last_slash_idx].to_string();
} else {
self.input = self.current_path.clone();
self.current_path.clear();
}
self.update_options_for_path();
self.update_filtered_options();
}
} else {
self.input.pop();
self.update_filtered_options();
}
}
}
}
pub fn move_up(&mut self) {
if self.filtered_options.is_empty() {
self.selected_index = None;
return;
}
self.selected_index = match self.selected_index {
Some(0) => Some(self.filtered_options.len() - 1),
Some(current) => Some(current - 1),
None => Some(self.filtered_options.len() - 1),
};
}
pub fn move_down(&mut self) {
if self.filtered_options.is_empty() {
self.selected_index = None;
return;
}
self.selected_index = match self.selected_index {
Some(current) if current >= self.filtered_options.len() - 1 => Some(0),
Some(current) => Some(current + 1),
None => Some(0),
};
}
pub fn get_selected_option_str(&self) -> Option<&str> {
self.selected_index
.and_then(|idx| self.filtered_options.get(idx))
.map(|(_, option_str)| option_str.as_str())
}
pub fn autocomplete_selected(&mut self) {
if let Some(selected_option_str) = self.get_selected_option_str() {
self.input = selected_option_str.to_string();
self.update_filtered_options();
}
}
pub fn get_display_input(&self) -> String {
match self.navigation_type {
NavigationType::FindFile => self.input.clone(),
NavigationType::TableTree => {
if self.current_path.is_empty() {
self.input.clone()
} else {
format!("{}/{}", self.current_path, self.input)
}
}
}
}
// --- START FIX ---
pub fn get_selected_value(&self) -> Option<String> {
match self.navigation_type {
NavigationType::FindFile => {
// Return the highlighted option, not the raw input buffer.
self.get_selected_option_str().map(|s| s.to_string())
}
NavigationType::TableTree => {
self.get_selected_option_str().map(|selected_name| {
if self.current_path.is_empty() {
selected_name.to_string()
} else {
format!("{}/{}", self.current_path, selected_name)
}
})
}
}
}
// --- END FIX ---
fn update_options_for_path(&mut self) {
if let NavigationType::TableTree = self.navigation_type {
if let Some(graph) = &self.graph {
self.all_options = graph.get_dependent_children(&self.current_path);
} else {
self.all_options.clear();
}
}
self.update_filtered_options();
}
fn update_filtered_options(&mut self) {
let filter_text = match self.navigation_type {
NavigationType::FindFile => &self.input,
NavigationType::TableTree => &self.input,
}
.to_lowercase();
if filter_text.is_empty() {
self.filtered_options = self
.all_options
.iter()
.enumerate()
.map(|(i, opt)| (i, opt.clone()))
.collect();
} else {
self.filtered_options = self
.all_options
.iter()
.enumerate()
.filter(|(_, opt)| opt.to_lowercase().contains(&filter_text))
.map(|(i, opt)| (i, opt.clone()))
.collect();
}
if self.filtered_options.is_empty() {
self.selected_index = None;
} else {
self.selected_index = Some(0);
}
}
}
pub async fn handle_command_navigation_event(
navigation_state: &mut NavigationState,
key: KeyEvent,
config: &Config,
) -> Result<EventOutcome> {
if !navigation_state.active {
return Ok(EventOutcome::Ok(String::new()));
}
match key.code {
KeyCode::Esc => {
navigation_state.deactivate();
Ok(EventOutcome::Ok("Navigation cancelled".to_string()))
}
KeyCode::Tab => {
if let Some(selected_opt_str) = navigation_state.get_selected_option_str() {
if navigation_state.input == selected_opt_str {
if navigation_state.navigation_type == NavigationType::TableTree {
let path_before_nav = navigation_state.current_path.clone();
let input_before_nav = navigation_state.input.clone();
navigation_state.add_char('/');
if !(navigation_state.input.is_empty() &&
(navigation_state.current_path != path_before_nav || !navigation_state.all_options.is_empty())) {
if !navigation_state.input.is_empty() && navigation_state.input != input_before_nav {
navigation_state.input = input_before_nav;
if navigation_state.current_path != path_before_nav {
navigation_state.current_path = path_before_nav;
}
navigation_state.update_options_for_path();
}
}
}
} else {
navigation_state.autocomplete_selected();
}
}
Ok(EventOutcome::Ok(String::new()))
}
KeyCode::Backspace => {
navigation_state.remove_char();
Ok(EventOutcome::Ok(String::new()))
}
KeyCode::Char(c) => {
navigation_state.add_char(c);
Ok(EventOutcome::Ok(String::new()))
}
_ => {
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
match action {
"move_up" => {
navigation_state.move_up();
Ok(EventOutcome::Ok(String::new()))
}
"move_down" => {
navigation_state.move_down();
Ok(EventOutcome::Ok(String::new()))
}
"select" => {
if let Some(selected_value) = navigation_state.get_selected_value() {
let outcome = match navigation_state.navigation_type {
// --- START FIX ---
NavigationType::FindFile => {
// The purpose of this palette is to select a table.
// Emit a TableSelected event instead of a generic Ok message.
EventOutcome::TableSelected {
path: selected_value,
}
}
// --- END FIX ---
NavigationType::TableTree => {
EventOutcome::TableSelected {
path: selected_value,
}
}
};
navigation_state.deactivate();
Ok(outcome)
} else {
Ok(EventOutcome::Ok("No selection".to_string()))
}
}
_ => Ok(EventOutcome::Ok(String::new())),
}
} else {
Ok(EventOutcome::Ok(String::new()))
}
}
}
}

View File

@@ -0,0 +1,163 @@
// src/modes/general/dialog.rs
use crossterm::event::{Event, KeyCode};
use crate::config::binds::config::Config;
use crate::ui::handlers::context::DialogPurpose;
use crate::state::app::{state::AppState, buffer::AppView};
use crate::state::app::buffer::BufferState;
use crate::state::pages::auth::{LoginState, RegisterState};
use crate::state::pages::admin::AdminState;
use crate::modes::handlers::event::EventOutcome;
use crate::tui::functions::common::{login, register};
use crate::tui::functions::common::add_table::handle_delete_selected_columns;
use anyhow::Result;
/// Handles key events specifically when a dialog is active.
/// Returns Some(Result<EventOutcome, Error>) if the event was handled (consumed),
/// otherwise returns None.
pub async fn handle_dialog_event(
event: &Event,
config: &Config,
app_state: &mut AppState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
buffer_state: &mut BufferState,
admin_state: &mut AdminState,
) -> Option<Result<EventOutcome>> {
if let Event::Key(key) = event {
// Always allow Esc to dismiss
if key.code == KeyCode::Esc {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Dialog dismissed".to_string())));
}
// Check general bindings for dialog actions
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
match action {
"move_down" | "next_option" => {
let current_index = app_state.ui.dialog.dialog_active_button_index;
let num_buttons = app_state.ui.dialog.dialog_buttons.len();
if num_buttons > 0 && current_index < num_buttons - 1 {
app_state.ui.dialog.dialog_active_button_index += 1;
}
return Some(Ok(EventOutcome::Ok(String::new())));
}
"move_up" | "previous_option" => {
let current_index = app_state.ui.dialog.dialog_active_button_index;
if current_index > 0 {
app_state.ui.dialog.dialog_active_button_index -= 1;
}
return Some(Ok(EventOutcome::Ok(String::new())));
}
"select" => {
let selected_index = app_state.ui.dialog.dialog_active_button_index;
let purpose = match app_state.ui.dialog.purpose {
Some(p) => p,
None => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Internal Error: Dialog context lost".to_string())));
}
};
// Handle Dialog Actions Directly Here
match purpose {
DialogPurpose::LoginSuccess => {
match selected_index {
0 => { // "Menu" button selected
app_state.hide_dialog();
let message = login::back_to_main(login_state, app_state, buffer_state).await;
return Some(Ok(EventOutcome::Ok(message)));
}
1 => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Exiting dialog".to_string())));
}
_ => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
}
}
}
DialogPurpose::LoginFailed => {
match selected_index {
0 => { // "OK" button selected
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Login failed dialog dismissed".to_string())));
}
_ => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
}
}
}
DialogPurpose::RegisterSuccess => { // Add this arm
match selected_index {
0 => { // "OK" button for RegisterSuccess
app_state.hide_dialog();
let message = register::back_to_login(register_state, app_state, buffer_state).await;
return Some(Ok(EventOutcome::Ok(message)));
}
_ => { // Default for RegisterSuccess
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
}
}
}
DialogPurpose::RegisterFailed => { // Add this arm
match selected_index {
0 => { // "OK" button for RegisterFailed
app_state.hide_dialog(); // Just dismiss
return Some(Ok(EventOutcome::Ok("Register failed dialog dismissed".to_string())));
}
_ => { // Default for RegisterFailed
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
}
}
}
DialogPurpose::ConfirmDeleteColumns => {
match selected_index {
0 => { // "Confirm" button selected
let outcome_message = handle_delete_selected_columns(&mut admin_state.add_table_state);
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok(outcome_message)));
}
1 => { // "Cancel" button selected
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Deletion cancelled.".to_string())));
}
_ => { /* Handle unexpected index */ }
}
}
DialogPurpose::SaveTableSuccess => {
match selected_index {
0 => { // "OK" button selected
app_state.hide_dialog();
buffer_state.update_history(AppView::Admin); // Navigate back
return Some(Ok(EventOutcome::Ok("Save success dialog dismissed.".to_string())));
}
_ => { /* Handle unexpected index */ }
}
}
DialogPurpose::SaveLogicSuccess => {
match selected_index {
0 => { // "OK" button selected
app_state.hide_dialog();
buffer_state.update_history(AppView::Admin);
return Some(Ok(EventOutcome::Ok("Save success dialog dismissed.".to_string())));
}
_ => { /* Handle unexpected index */ }
}
}
}
}
_ => {} // Ignore other general actions when dialog is shown
}
}
// If it was a key event but not handled above, consume it
Some(Ok(EventOutcome::Ok(String::new())))
} else {
// If it wasn't a key event, consume it too while dialog is active
Some(Ok(EventOutcome::Ok(String::new())))
}
}

View File

@@ -2,133 +2,146 @@
use crossterm::event::KeyEvent;
use crate::config::binds::config::Config;
use crate::state::state::AppState;
use crate::state::app::state::AppState;
use crate::state::pages::form::FormState;
use crate::tui::functions::{intro, admin};
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
use crate::state::pages::intro::IntroState;
use crate::state::pages::admin::AdminState;
use crate::ui::handlers::context::UiContext;
use crate::modes::handlers::event::EventOutcome;
use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState};
use canvas::canvas::CanvasState;
use anyhow::Result;
pub async fn handle_navigation_event(
key: KeyEvent,
config: &Config,
form_state: &mut FormState,
app_state: &mut AppState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
intro_state: &mut IntroState,
admin_state: &mut AdminState,
command_mode: &mut bool,
command_input: &mut String,
command_message: &mut String,
) -> Result<(bool, String), Box<dyn std::error::Error>> {
navigation_state: &mut NavigationState,
) -> Result<EventOutcome> {
// Handle command navigation first if active
if navigation_state.active {
return handle_command_navigation_event(navigation_state, key, config).await;
}
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
match action {
"move_up" => {
move_up(app_state);
return Ok((false, String::new()));
move_up(app_state, login_state, register_state, intro_state, admin_state);
return Ok(EventOutcome::Ok(String::new()));
}
"move_down" => {
move_down(app_state);
return Ok((false, String::new()));
move_down(app_state, intro_state, admin_state);
return Ok(EventOutcome::Ok(String::new()));
}
"next_option" => {
next_option(app_state); // Intro has 2 options
return Ok((false, String::new()));
next_option(app_state, intro_state);
return Ok(EventOutcome::Ok(String::new()));
}
"previous_option" => {
previous_option(app_state);
return Ok((false, String::new()));
}
"select" => {
select(app_state);
return Ok((false, "Selected".to_string()));
}
"toggle_sidebar" => {
toggle_sidebar(app_state);
return Ok((false, format!("Sidebar {}",
if app_state.ui.show_sidebar { "shown" } else { "hidden" }
)));
previous_option(app_state, intro_state);
return Ok(EventOutcome::Ok(String::new()));
}
"next_field" => {
next_field(form_state);
return Ok((false, String::new()));
return Ok(EventOutcome::Ok(String::new()));
}
"prev_field" => {
prev_field(form_state);
return Ok((false, String::new()));
return Ok(EventOutcome::Ok(String::new()));
}
"enter_command_mode" => {
handle_enter_command_mode(command_mode, command_input, command_message);
return Ok((false, String::new()));
return Ok(EventOutcome::Ok(String::new()));
}
"select" => {
let (context, index) = if app_state.ui.show_intro {
(UiContext::Intro, intro_state.selected_option)
} else if app_state.ui.show_login && app_state.ui.focus_outside_canvas {
(UiContext::Login, app_state.focused_button_index)
} else if app_state.ui.show_register && app_state.ui.focus_outside_canvas {
(UiContext::Register, app_state.focused_button_index)
} else if app_state.ui.show_admin {
(UiContext::Admin, admin_state.get_selected_index().unwrap_or(0))
} else if app_state.ui.dialog.dialog_show {
(UiContext::Dialog, app_state.ui.dialog.dialog_active_button_index)
} else {
return Ok(EventOutcome::Ok("Select (No Action)".to_string()));
};
return Ok(EventOutcome::ButtonSelected { context, index });
}
_ => {}
}
}
Ok((false, String::new()))
Ok(EventOutcome::Ok(String::new()))
}
pub fn move_up(app_state: &mut AppState) {
if app_state.ui.show_intro {
app_state.ui.intro_state.previous_option();
} else if app_state.ui.show_admin {
// Assuming profile_tree.profiles is the list we're navigating
let profile_count = app_state.profile_tree.profiles.len();
if profile_count == 0 {
return;
}
// Use general state for tracking selection in admin panel
if app_state.general.selected_item == 0 {
app_state.general.selected_item = profile_count - 1;
pub fn move_up(app_state: &mut AppState, login_state: &mut LoginState, register_state: &mut RegisterState, intro_state: &mut IntroState, admin_state: &mut AdminState) {
if app_state.ui.focus_outside_canvas && app_state.ui.show_login || app_state.ui.show_register{
if app_state.focused_button_index == 0 {
app_state.ui.focus_outside_canvas = false;
if app_state.ui.show_login {
let last_field_index = login_state.fields().len().saturating_sub(1);
login_state.set_current_field(last_field_index);
} else {
let last_field_index = register_state.fields().len().saturating_sub(1);
register_state.set_current_field(last_field_index);
}
} else {
app_state.general.selected_item = app_state.general.selected_item.saturating_sub(1);
app_state.focused_button_index = app_state.focused_button_index.saturating_sub(1);
}
}
}
pub fn move_down(app_state: &mut AppState) {
if app_state.ui.show_intro {
app_state.ui.intro_state.next_option();
} else if app_state.ui.show_intro {
intro_state.previous_option();
} else if app_state.ui.show_admin {
// Assuming profile_tree.profiles is the list we're navigating
let profile_count = app_state.profile_tree.profiles.len();
if profile_count == 0 {
return;
}
app_state.general.selected_item = (app_state.general.selected_item + 1) % profile_count;
admin_state.previous();
}
}
pub fn next_option(app_state: &mut AppState) { // Remove option_count parameter
pub fn move_down(app_state: &mut AppState, intro_state: &mut IntroState, admin_state: &mut AdminState) {
if app_state.ui.focus_outside_canvas && app_state.ui.show_login || app_state.ui.show_register {
let num_general_elements = 2;
if app_state.focused_button_index < num_general_elements - 1 {
app_state.focused_button_index += 1;
}
} else if app_state.ui.show_intro {
intro_state.next_option();
} else if app_state.ui.show_admin {
admin_state.next();
}
}
pub fn next_option(app_state: &mut AppState, intro_state: &mut IntroState) {
if app_state.ui.show_intro {
app_state.ui.intro_state.next_option();
intro_state.next_option();
} else {
// Get option count from state instead of parameter
let option_count = app_state.profile_tree.profiles.len();
app_state.general.current_option = (app_state.general.current_option + 1) % option_count;
app_state.focused_button_index = (app_state.focused_button_index + 1) % option_count;
}
}
pub fn previous_option(app_state: &mut AppState) {
pub fn previous_option(app_state: &mut AppState, intro_state: &mut IntroState) {
if app_state.ui.show_intro {
app_state.ui.intro_state.previous_option();
intro_state.previous_option();
} else {
let option_count = app_state.profile_tree.profiles.len();
app_state.general.current_option = if app_state.general.current_option == 0 {
option_count.saturating_sub(1) // Wrap to last option
app_state.focused_button_index = if app_state.focused_button_index == 0 {
option_count.saturating_sub(1)
} else {
app_state.general.current_option - 1
app_state.focused_button_index - 1
};
}
}
pub fn select(app_state: &mut AppState) {
if app_state.ui.show_intro {
intro::handle_intro_selection(app_state);
} else if app_state.ui.show_admin {
admin::handle_admin_selection(app_state);
}
}
pub fn toggle_sidebar(app_state: &mut AppState) {
app_state.ui.show_sidebar = !app_state.ui.show_sidebar;
}
pub fn next_field(form_state: &mut FormState) {
if !form_state.fields.is_empty() {
form_state.current_field = (form_state.current_field + 1) % form_state.fields.len();

View File

@@ -1,3 +1,3 @@
// src/client/modes/handlers.rs
// src/modes/handlers.rs
pub mod event;
pub mod mode_manager;

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,16 @@
// src/modes/handlers/mode_manager.rs
use crate::state::state::AppState;
use crate::state::app::state::AppState;
use crate::modes::handlers::event::EventHandler;
use crate::state::pages::add_logic::AddLogicFocus;
use crate::state::app::highlight::HighlightState;
use crate::state::pages::admin::AdminState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
General, // For intro and admin screens
ReadOnly, // Canvas read-only mode
Edit, // Canvas edit mode
Highlight, // Cnavas highlight/visual mode
Command, // Command mode overlay
}
@@ -14,22 +18,63 @@ pub struct ModeManager;
impl ModeManager {
// Determine current mode based on app state
pub fn derive_mode(app_state: &AppState, event_handler: &EventHandler) -> AppMode {
pub fn derive_mode(
app_state: &AppState,
event_handler: &EventHandler,
admin_state: &AdminState,
) -> AppMode {
if event_handler.navigation_state.active {
return AppMode::General;
}
if event_handler.command_mode {
return AppMode::Command;
}
if app_state.ui.show_login { // NEW: Check auth visibility
if event_handler.is_edit_mode {
AppMode::Edit
} else {
AppMode::ReadOnly
if !matches!(event_handler.highlight_state, HighlightState::Off) {
return AppMode::Highlight;
}
let is_canvas_view = app_state.ui.show_login
|| app_state.ui.show_register
|| app_state.ui.show_form
|| app_state.ui.show_add_table
|| app_state.ui.show_add_logic;
if app_state.ui.show_add_logic {
// Specific logic for AddLogic view
match admin_state.add_logic_state.current_focus {
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription => {
// These are canvas inputs
if event_handler.is_edit_mode {
AppMode::Edit
} else {
AppMode::ReadOnly
}
}
_ => AppMode::General,
}
} else if app_state.ui.show_form {
if event_handler.is_edit_mode {
AppMode::Edit
} else if app_state.ui.show_add_table {
if app_state.ui.focus_outside_canvas {
AppMode::General
} else {
AppMode::ReadOnly
if event_handler.is_edit_mode {
AppMode::Edit
} else {
AppMode::ReadOnly
}
}
} else if is_canvas_view {
if app_state.ui.focus_outside_canvas {
AppMode::General
} else {
if event_handler.is_edit_mode {
AppMode::Edit
} else {
AppMode::ReadOnly
}
}
} else {
AppMode::General
@@ -37,15 +82,19 @@ impl ModeManager {
}
// Mode transition rules
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
!matches!(current_mode, AppMode::Edit) // Can't enter from Edit mode
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
!matches!(current_mode, AppMode::Edit)
}
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::ReadOnly) // Only from ReadOnly
matches!(current_mode, AppMode::ReadOnly)
}
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::Edit | AppMode::Command)
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
}
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::ReadOnly)
}
}

View File

@@ -0,0 +1,2 @@
// src/client/modes/highlight.rs
pub mod highlight;

View File

@@ -0,0 +1,65 @@
// src/modes/highlight/highlight.rs
// (This file is intentionally simple for now, reusing ReadOnly logic)
use crate::config::binds::config::Config;
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
use crate::state::pages::auth::{LoginState, RegisterState};
use crate::state::pages::admin::AdminState;
use crate::state::pages::form::FormState;
use crate::modes::handlers::event::EventOutcome;
use crate::modes::read_only;
use crossterm::event::KeyEvent;
use anyhow::Result;
/// Handles events when in Highlight mode.
/// Currently, it mostly delegates to the read_only handler for movement.
/// Exiting highlight mode is handled directly in the main event handler.
pub async fn handle_highlight_event(
app_state: &mut AppState,
key: KeyEvent,
config: &Config,
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
admin_state: &mut AdminState,
key_sequence_tracker: &mut KeySequenceTracker,
current_position: &mut u64,
total_count: u64,
grpc_client: &mut GrpcClient,
command_message: &mut String,
edit_mode_cooldown: &mut bool,
ideal_cursor_column: &mut usize,
) -> Result<EventOutcome> {
// Delegate movement and other actions to the read_only handler
// The rendering logic will use the highlight_anchor to draw the selection
let (should_exit, message) = read_only::handle_read_only_event(
app_state,
key,
config,
form_state,
login_state,
register_state,
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
key_sequence_tracker,
grpc_client,
command_message, // Pass the message buffer
edit_mode_cooldown,
ideal_cursor_column,
)
.await?;
// ReadOnly handler doesn't return EventOutcome directly, adapt if needed
// For now, assume Ok outcome unless ReadOnly signals an exit (which we ignore here)
if should_exit {
// This exit is likely for the whole app, let the main loop handle it
// We just return the message from read_only
Ok(EventOutcome::Ok(message))
} else {
Ok(EventOutcome::Ok(message))
}
}

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