Compare commits

...

256 Commits

Author SHA1 Message Date
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
356 changed files with 49528 additions and 8348 deletions

1
.envrc Normal file
View File

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

4
.gitignore vendored
View File

@@ -1,2 +1,6 @@
/target
.env
/tantivy_indexes
server/tantivy_indexes
steel_decimal/tests/property_tests.proptest-regressions
.direnv/

1857
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.3.13"
# 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

@@ -14,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!

27
canvas/Cargo.toml Normal file
View File

@@ -0,0 +1,27 @@
[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
[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

56
canvas/canvas_config.toml Normal file
View File

@@ -0,0 +1,56 @@
# 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"]
# Suggestion/autocomplete keybindings
[keybindings.suggestions]
suggestion_up = ["Up", "Ctrl+p"]
suggestion_down = ["Down", "Ctrl+n"]
select_suggestion = ["Enter", "Tab"]
exit_suggestions = ["Esc"]
# Global keybindings (work in both modes)
[keybindings.global]
move_up = ["Up"]
move_down = ["Down"]

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,93 @@
// src/autocomplete/actions.rs
use crate::canvas::state::{CanvasState, ActionContext};
use crate::autocomplete::state::AutocompleteCanvasState;
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::edit::handle_generic_canvas_action; // Import the core function
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,
) -> 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) = state.handle_feature_action(&action, &context) {
return Ok(ActionResult::HandledByFeature(result));
}
// 2. Handle rich autocomplete actions
if let Some(result) = handle_rich_autocomplete_action(&action, state)? {
return Ok(result);
}
// 3. Handle generic canvas actions
handle_generic_canvas_action(action, state, ideal_cursor_column).await
}
/// Handle rich autocomplete actions for AutocompleteCanvasState
fn handle_rich_autocomplete_action<S: CanvasState + AutocompleteCanvasState>(
action: &CanvasAction,
state: &mut S,
) -> Result<Option<ActionResult>> {
match action {
CanvasAction::TriggerAutocomplete => {
if state.supports_autocomplete(state.current_field()) {
state.activate_autocomplete();
Ok(Some(ActionResult::success_with_message("Autocomplete activated - fetching suggestions...")))
} else {
Ok(Some(ActionResult::error("Autocomplete not supported for this field")))
}
}
CanvasAction::SuggestionDown => {
if state.is_autocomplete_ready() {
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
autocomplete_state.select_next();
return Ok(Some(ActionResult::success()));
}
}
Ok(None)
}
CanvasAction::SuggestionUp => {
if state.is_autocomplete_ready() {
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
autocomplete_state.select_previous();
return Ok(Some(ActionResult::success()));
}
}
Ok(None)
}
CanvasAction::SelectSuggestion => {
if state.is_autocomplete_ready() {
if let Some(message) = state.apply_autocomplete_selection() {
return Ok(Some(ActionResult::success_with_message(message)));
} else {
return Ok(Some(ActionResult::error("No suggestion selected")));
}
}
Ok(None)
}
CanvasAction::ExitSuggestions => {
if state.is_autocomplete_active() {
state.deactivate_autocomplete();
Ok(Some(ActionResult::success_with_message("Autocomplete cancelled")))
} else {
Ok(None)
}
}
_ => Ok(None),
}
}

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,378 @@
// canvas/src/actions/edit.rs
use crate::canvas::state::{CanvasState, ActionContext};
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use anyhow::Result;
/// Execute a typed canvas action on any CanvasState implementation
pub async fn execute_canvas_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
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));
}
handle_generic_canvas_action(action, state, ideal_cursor_column).await
}
/// Handle core canvas actions with full type safety
pub async fn handle_generic_canvas_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<ActionResult> {
match action {
CanvasAction::InsertChar(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();
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = state.current_cursor_pos();
Ok(ActionResult::success())
} else {
Ok(ActionResult::error("Invalid cursor position for character insertion"))
}
}
CanvasAction::DeleteBackward => {
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() {
chars.remove(cursor_pos - 1);
*field_value = chars.into_iter().collect();
let new_pos = cursor_pos - 1;
state.set_current_cursor_pos(new_pos);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = new_pos;
}
}
Ok(ActionResult::success())
}
CanvasAction::DeleteForward => {
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.remove(cursor_pos);
*field_value = chars.into_iter().collect();
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos;
}
Ok(ActionResult::success())
}
CanvasAction::NextField => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = (current_field + 1) % num_fields;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok(ActionResult::success())
}
CanvasAction::PrevField => {
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
};
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok(ActionResult::success())
}
CanvasAction::MoveLeft => {
let new_pos = state.current_cursor_pos().saturating_sub(1);
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();
if current_pos < current_input.len() {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok(ActionResult::success())
}
CanvasAction::MoveUp => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok(ActionResult::success())
}
CanvasAction::MoveDown => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = (state.current_field() + 1).min(num_fields - 1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok(ActionResult::success())
}
CanvasAction::MoveLineStart => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok(ActionResult::success())
}
CanvasAction::MoveLineEnd => {
let current_input = state.get_current_input();
let new_pos = current_input.len();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveFirstLine => {
let num_fields = state.fields().len();
if num_fields > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok(ActionResult::success_with_message("Moved to first field"))
}
CanvasAction::MoveLastLine => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = num_fields - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok(ActionResult::success_with_message("Moved to last field"))
}
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 = new_pos.min(current_input.len());
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 = if new_pos == current_pos {
find_word_end(current_input, new_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_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_with_message("Moved to previous word end"))
}
CanvasAction::Custom(action_str) => {
Ok(ActionResult::error(format!("Unknown or unhandled custom action: {}", action_str)))
}
// Autocomplete actions are handled by the autocomplete module
CanvasAction::TriggerAutocomplete | CanvasAction::SuggestionUp | CanvasAction::SuggestionDown |
CanvasAction::SelectSuggestion | CanvasAction::ExitSuggestions => {
Ok(ActionResult::error("Autocomplete actions should be handled by autocomplete module"))
}
}
}
// Word movement helper functions
#[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();
let len = chars.len();
if len == 0 || current_pos >= len {
return len;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < 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();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
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))
}
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 pos == 0 && get_char_type(chars[pos]) == 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;
}
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || 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 pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[pos]) != 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;
}
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,7 @@
// canvas/src/canvas/actions/mod.rs
pub mod types;
pub mod edit;
// Re-export the main types for convenience
pub use types::{CanvasAction, ActionResult};
pub use edit::execute_canvas_action; // Remove execute_edit_action

View File

@@ -0,0 +1,237 @@
// canvas/src/actions/types.rs
use crossterm::event::KeyCode;
/// All possible canvas actions, type-safe and exhaustive
#[derive(Debug, Clone, PartialEq, Eq)]
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 (NEW)
/// Manually trigger autocomplete for current field
TriggerAutocomplete,
/// Move to next suggestion
SuggestionUp,
/// Move to previous suggestion
SuggestionDown,
/// Select the currently highlighted suggestion
SelectSuggestion,
/// Cancel/exit autocomplete mode
ExitSuggestions,
// Custom actions (escape hatch for feature-specific behavior)
Custom(String),
}
impl CanvasAction {
/// Convert a string action to typed action (for backwards compatibility during migration)
pub fn from_string(action: &str) -> Self {
match action {
"insert_char" => {
// This is a bit tricky - we need the char from context
// For now, we'll use Custom until we refactor the call sites
Self::Custom(action.to_string())
}
"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,
// Autocomplete actions
"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()),
}
}
/// Get string representation (for logging, debugging)
pub fn as_str(&self) -> &str {
match self {
Self::InsertChar(_) => "insert_char",
Self::DeleteBackward => "delete_char_backward",
Self::DeleteForward => "delete_char_forward",
Self::MoveLeft => "move_left",
Self::MoveRight => "move_right",
Self::MoveUp => "move_up",
Self::MoveDown => "move_down",
Self::MoveLineStart => "move_line_start",
Self::MoveLineEnd => "move_line_end",
Self::MoveFirstLine => "move_first_line",
Self::MoveLastLine => "move_last_line",
Self::MoveWordNext => "move_word_next",
Self::MoveWordEnd => "move_word_end",
Self::MoveWordPrev => "move_word_prev",
Self::MoveWordEndPrev => "move_word_end_prev",
Self::NextField => "next_field",
Self::PrevField => "prev_field",
// Autocomplete actions
Self::TriggerAutocomplete => "trigger_autocomplete",
Self::SuggestionUp => "suggestion_up",
Self::SuggestionDown => "suggestion_down",
Self::SelectSuggestion => "select_suggestion",
Self::ExitSuggestions => "exit_suggestions",
Self::Custom(s) => s,
}
}
/// Create action from KeyCode for common cases
pub fn from_key(key: KeyCode) -> Option<Self> {
match key {
KeyCode::Char(c) => Some(Self::InsertChar(c)),
KeyCode::Backspace => Some(Self::DeleteBackward),
KeyCode::Delete => Some(Self::DeleteForward),
KeyCode::Left => Some(Self::MoveLeft),
KeyCode::Right => Some(Self::MoveRight),
KeyCode::Up => Some(Self::MoveUp),
KeyCode::Down => Some(Self::MoveDown),
KeyCode::Home => Some(Self::MoveLineStart),
KeyCode::End => Some(Self::MoveLineEnd),
KeyCode::Tab => Some(Self::NextField),
KeyCode::BackTab => Some(Self::PrevField),
_ => None,
}
}
/// Check if this action modifies content
pub fn is_modifying(&self) -> bool {
matches!(self,
Self::InsertChar(_) |
Self::DeleteBackward |
Self::DeleteForward |
Self::SelectSuggestion
)
}
/// Check if this action moves the cursor
pub fn is_movement(&self) -> bool {
matches!(self,
Self::MoveLeft | Self::MoveRight | Self::MoveUp | Self::MoveDown |
Self::MoveLineStart | Self::MoveLineEnd | Self::MoveFirstLine | Self::MoveLastLine |
Self::MoveWordNext | Self::MoveWordEnd | Self::MoveWordPrev | Self::MoveWordEndPrev |
Self::NextField | Self::PrevField
)
}
/// Check if this is a suggestion-related action
pub fn is_suggestion(&self) -> bool {
matches!(self,
Self::TriggerAutocomplete | Self::SuggestionUp | Self::SuggestionDown |
Self::SelectSuggestion | Self::ExitSuggestions
)
}
}
/// Result of executing a canvas action
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ActionResult {
/// Action completed successfully, optional message for user feedback
Success(Option<String>),
/// Action was handled by custom feature logic
HandledByFeature(String),
/// Action requires additional context or cannot be performed
RequiresContext(String),
/// Action failed with error message
Error(String),
}
impl ActionResult {
pub fn success() -> Self {
Self::Success(None)
}
pub fn success_with_message(msg: impl Into<String>) -> Self {
Self::Success(Some(msg.into()))
}
pub fn error(msg: impl Into<String>) -> 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),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_action_from_string() {
assert_eq!(CanvasAction::from_string("move_left"), CanvasAction::MoveLeft);
assert_eq!(CanvasAction::from_string("delete_char_backward"), CanvasAction::DeleteBackward);
assert_eq!(CanvasAction::from_string("trigger_autocomplete"), CanvasAction::TriggerAutocomplete);
assert_eq!(CanvasAction::from_string("unknown"), CanvasAction::Custom("unknown".to_string()));
}
#[test]
fn test_action_from_key() {
assert_eq!(CanvasAction::from_key(KeyCode::Char('a')), Some(CanvasAction::InsertChar('a')));
assert_eq!(CanvasAction::from_key(KeyCode::Left), Some(CanvasAction::MoveLeft));
assert_eq!(CanvasAction::from_key(KeyCode::Backspace), Some(CanvasAction::DeleteBackward));
assert_eq!(CanvasAction::from_key(KeyCode::F(1)), None);
}
#[test]
fn test_action_properties() {
assert!(CanvasAction::InsertChar('a').is_modifying());
assert!(!CanvasAction::MoveLeft.is_modifying());
assert!(CanvasAction::MoveLeft.is_movement());
assert!(!CanvasAction::InsertChar('a').is_movement());
assert!(CanvasAction::SuggestionUp.is_suggestion());
assert!(CanvasAction::TriggerAutocomplete.is_suggestion());
assert!(!CanvasAction::MoveLeft.is_suggestion());
}
}

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));
}

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

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

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,52 @@
// canvas/src/state.rs
use crate::canvas::actions::CanvasAction;
/// 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);
// --- 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 (NEW: 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;
}

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

@@ -0,0 +1,480 @@
// canvas/src/config.rs
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crossterm::event::{KeyCode, KeyModifiers};
use anyhow::{Context, Result};
#[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 {
/// Load from canvas_config.toml or fallback to vim defaults
pub fn load() -> Self {
// Try to load canvas_config.toml from current directory
if let Ok(config) = Self::from_file(std::path::Path::new("canvas_config.toml")) {
return config;
}
// Fallback to vim defaults
Self::default()
}
/// 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)
}
/// 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)
}
}
fn get_action_in_mode<'a>(&self, mode_bindings: &'a HashMap<String, Vec<String>>, key: KeyCode, modifiers: KeyModifiers) -> Option<&'a str> {
for (action, bindings) in mode_bindings {
for binding in bindings {
if self.matches_keybinding(binding, key, modifiers) {
return Some(action);
}
}
}
None
}
fn matches_keybinding(&self, binding: &str, key: KeyCode, modifiers: KeyModifiers) -> bool {
// Special handling for shift+character combinations
if binding.to_lowercase().starts_with("shift+") {
let parts: Vec<&str> = binding.split('+').collect();
if parts.len() == 2 && parts[1].len() == 1 {
let expected_lowercase = parts[1].chars().next().unwrap().to_lowercase().next().unwrap();
let expected_uppercase = expected_lowercase.to_uppercase().next().unwrap();
if let KeyCode::Char(actual_char) = key {
if actual_char == expected_uppercase && modifiers.contains(KeyModifiers::SHIFT) {
return true;
}
}
}
}
// Handle Shift+Tab -> BackTab
if binding.to_lowercase() == "shift+tab" && key == KeyCode::BackTab && modifiers.is_empty() {
return true;
}
// Handle multi-character bindings (all standard keys without modifiers)
if binding.len() > 1 && !binding.contains('+') {
return match binding.to_lowercase().as_str() {
// Navigation keys
"left" => key == KeyCode::Left,
"right" => key == KeyCode::Right,
"up" => key == KeyCode::Up,
"down" => key == KeyCode::Down,
"home" => key == KeyCode::Home,
"end" => key == KeyCode::End,
"pageup" | "pgup" => key == KeyCode::PageUp,
"pagedown" | "pgdn" => key == KeyCode::PageDown,
// Editing keys
"insert" | "ins" => key == KeyCode::Insert,
"delete" | "del" => key == KeyCode::Delete,
"backspace" => key == KeyCode::Backspace,
// Tab keys
"tab" => key == KeyCode::Tab,
"backtab" => key == KeyCode::BackTab,
// Special keys
"enter" | "return" => key == KeyCode::Enter,
"escape" | "esc" => key == KeyCode::Esc,
"space" => key == KeyCode::Char(' '),
// Function keys F1-F24
"f1" => key == KeyCode::F(1),
"f2" => key == KeyCode::F(2),
"f3" => key == KeyCode::F(3),
"f4" => key == KeyCode::F(4),
"f5" => key == KeyCode::F(5),
"f6" => key == KeyCode::F(6),
"f7" => key == KeyCode::F(7),
"f8" => key == KeyCode::F(8),
"f9" => key == KeyCode::F(9),
"f10" => key == KeyCode::F(10),
"f11" => key == KeyCode::F(11),
"f12" => key == KeyCode::F(12),
"f13" => key == KeyCode::F(13),
"f14" => key == KeyCode::F(14),
"f15" => key == KeyCode::F(15),
"f16" => key == KeyCode::F(16),
"f17" => key == KeyCode::F(17),
"f18" => key == KeyCode::F(18),
"f19" => key == KeyCode::F(19),
"f20" => key == KeyCode::F(20),
"f21" => key == KeyCode::F(21),
"f22" => key == KeyCode::F(22),
"f23" => key == KeyCode::F(23),
"f24" => key == KeyCode::F(24),
// Lock keys (may not work reliably in all terminals)
"capslock" => key == KeyCode::CapsLock,
"scrolllock" => key == KeyCode::ScrollLock,
"numlock" => key == KeyCode::NumLock,
// System keys
"printscreen" => key == KeyCode::PrintScreen,
"pause" => key == KeyCode::Pause,
"menu" => key == KeyCode::Menu,
"keypadbegin" => key == KeyCode::KeypadBegin,
// Media keys (rarely supported but included for completeness)
"mediaplay" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Play),
"mediapause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Pause),
"mediaplaypause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::PlayPause),
"mediareverse" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Reverse),
"mediastop" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Stop),
"mediafastforward" => key == KeyCode::Media(crossterm::event::MediaKeyCode::FastForward),
"mediarewind" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Rewind),
"mediatracknext" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackNext),
"mediatrackprevious" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackPrevious),
"mediarecord" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Record),
"medialowervolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::LowerVolume),
"mediaraisevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::RaiseVolume),
"mediamutevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::MuteVolume),
// Modifier keys (these work better as part of combinations)
"leftshift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftShift),
"leftcontrol" | "leftctrl" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftControl),
"leftalt" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftAlt),
"leftsuper" | "leftwindows" | "leftcmd" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftSuper),
"lefthyper" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftHyper),
"leftmeta" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftMeta),
"rightshift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightShift),
"rightcontrol" | "rightctrl" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightControl),
"rightalt" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightAlt),
"rightsuper" | "rightwindows" | "rightcmd" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightSuper),
"righthyper" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightHyper),
"rightmeta" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightMeta),
"isolevel3shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel3Shift),
"isolevel5shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel5Shift),
// Multi-key sequences need special handling
"gg" => false, // This needs sequence handling
_ => {
// Handle single characters and punctuation
if binding.len() == 1 {
if let Some(c) = binding.chars().next() {
key == KeyCode::Char(c)
} else {
false
}
} else {
false
}
}
};
}
// Handle modifier combinations (like "Ctrl+F5", "Alt+Shift+A")
let parts: Vec<&str> = binding.split('+').collect();
let mut expected_modifiers = KeyModifiers::empty();
let mut expected_key = None;
for part in parts {
match part.to_lowercase().as_str() {
// Modifiers
"ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
"shift" => expected_modifiers |= KeyModifiers::SHIFT,
"alt" => expected_modifiers |= KeyModifiers::ALT,
"super" | "windows" | "cmd" => expected_modifiers |= KeyModifiers::SUPER,
"hyper" => expected_modifiers |= KeyModifiers::HYPER,
"meta" => expected_modifiers |= KeyModifiers::META,
// Navigation keys
"left" => expected_key = Some(KeyCode::Left),
"right" => expected_key = Some(KeyCode::Right),
"up" => expected_key = Some(KeyCode::Up),
"down" => expected_key = Some(KeyCode::Down),
"home" => expected_key = Some(KeyCode::Home),
"end" => expected_key = Some(KeyCode::End),
"pageup" | "pgup" => expected_key = Some(KeyCode::PageUp),
"pagedown" | "pgdn" => expected_key = Some(KeyCode::PageDown),
// Editing keys
"insert" | "ins" => expected_key = Some(KeyCode::Insert),
"delete" | "del" => expected_key = Some(KeyCode::Delete),
"backspace" => expected_key = Some(KeyCode::Backspace),
// Tab keys
"tab" => expected_key = Some(KeyCode::Tab),
"backtab" => expected_key = Some(KeyCode::BackTab),
// Special keys
"enter" | "return" => expected_key = Some(KeyCode::Enter),
"escape" | "esc" => expected_key = Some(KeyCode::Esc),
"space" => expected_key = Some(KeyCode::Char(' ')),
// Function keys
"f1" => expected_key = Some(KeyCode::F(1)),
"f2" => expected_key = Some(KeyCode::F(2)),
"f3" => expected_key = Some(KeyCode::F(3)),
"f4" => expected_key = Some(KeyCode::F(4)),
"f5" => expected_key = Some(KeyCode::F(5)),
"f6" => expected_key = Some(KeyCode::F(6)),
"f7" => expected_key = Some(KeyCode::F(7)),
"f8" => expected_key = Some(KeyCode::F(8)),
"f9" => expected_key = Some(KeyCode::F(9)),
"f10" => expected_key = Some(KeyCode::F(10)),
"f11" => expected_key = Some(KeyCode::F(11)),
"f12" => expected_key = Some(KeyCode::F(12)),
"f13" => expected_key = Some(KeyCode::F(13)),
"f14" => expected_key = Some(KeyCode::F(14)),
"f15" => expected_key = Some(KeyCode::F(15)),
"f16" => expected_key = Some(KeyCode::F(16)),
"f17" => expected_key = Some(KeyCode::F(17)),
"f18" => expected_key = Some(KeyCode::F(18)),
"f19" => expected_key = Some(KeyCode::F(19)),
"f20" => expected_key = Some(KeyCode::F(20)),
"f21" => expected_key = Some(KeyCode::F(21)),
"f22" => expected_key = Some(KeyCode::F(22)),
"f23" => expected_key = Some(KeyCode::F(23)),
"f24" => expected_key = Some(KeyCode::F(24)),
// Lock keys
"capslock" => expected_key = Some(KeyCode::CapsLock),
"scrolllock" => expected_key = Some(KeyCode::ScrollLock),
"numlock" => expected_key = Some(KeyCode::NumLock),
// System keys
"printscreen" => expected_key = Some(KeyCode::PrintScreen),
"pause" => expected_key = Some(KeyCode::Pause),
"menu" => expected_key = Some(KeyCode::Menu),
"keypadbegin" => expected_key = Some(KeyCode::KeypadBegin),
// Single character (letters, numbers, punctuation)
part => {
if part.len() == 1 {
if let Some(c) = part.chars().next() {
expected_key = Some(KeyCode::Char(c));
}
}
}
}
}
modifiers == expected_modifiers && Some(key) == expected_key
}
/// 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());
}
}
// Re-export for convenience
pub use crate::canvas::actions::CanvasAction;
pub use crate::dispatcher::ActionDispatcher;

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

@@ -0,0 +1,180 @@
// canvas/src/dispatcher.rs
use crate::canvas::state::CanvasState;
use crate::canvas::actions::{CanvasAction, ActionResult, execute_canvas_action};
/// High-level action dispatcher that coordinates between different action types
pub struct ActionDispatcher;
impl ActionDispatcher {
/// Dispatch any action to the appropriate handler
pub async fn dispatch<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> anyhow::Result<ActionResult> {
execute_canvas_action(action, state, ideal_cursor_column).await
}
/// Quick action dispatch from KeyCode
pub async fn dispatch_key<S: CanvasState>(
key: crossterm::event::KeyCode,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> anyhow::Result<Option<ActionResult>> {
if let Some(action) = CanvasAction::from_key(key) {
let result = Self::dispatch(action, state, ideal_cursor_column).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(); // Check success before moving
results.push(result);
// Stop on first error
if !is_success {
break;
}
}
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actions::CanvasAction;
// Simple test implementation
struct TestFormState {
current_field: usize,
cursor_pos: usize,
inputs: Vec<String>,
field_names: Vec<String>,
has_changes: bool,
}
impl TestFormState {
fn new() -> Self {
Self {
current_field: 0,
cursor_pos: 0,
inputs: vec!["".to_string(), "".to_string()],
field_names: vec!["username".to_string(), "password".to_string()],
has_changes: false,
}
}
}
impl CanvasState for TestFormState {
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.inputs[self.current_field] }
fn get_current_input_mut(&mut self) -> &mut String { &mut self.inputs[self.current_field] }
fn inputs(&self) -> Vec<&String> { self.inputs.iter().collect() }
fn fields(&self) -> Vec<&str> { self.field_names.iter().map(|s| s.as_str()).collect() }
fn has_unsaved_changes(&self) -> bool { self.has_changes }
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
// Custom action handling for testing
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &crate::state::ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(s) if s == "test_custom" => {
Some("Custom action handled".to_string())
}
_ => None,
}
}
}
#[tokio::test]
async fn test_typed_action_dispatch() {
let mut state = TestFormState::new();
let mut ideal_cursor = 0;
// Test character insertion
let result = ActionDispatcher::dispatch(
CanvasAction::InsertChar('a'),
&mut state,
&mut ideal_cursor,
).await.unwrap();
assert!(result.is_success());
assert_eq!(state.get_current_input(), "a");
assert_eq!(state.cursor_pos, 1);
assert!(state.has_changes);
}
#[tokio::test]
async fn test_key_dispatch() {
let mut state = TestFormState::new();
let mut ideal_cursor = 0;
let result = ActionDispatcher::dispatch_key(
crossterm::event::KeyCode::Char('b'),
&mut state,
&mut ideal_cursor,
).await.unwrap();
assert!(result.is_some());
assert!(result.unwrap().is_success());
assert_eq!(state.get_current_input(), "b");
}
#[tokio::test]
async fn test_custom_action() {
let mut state = TestFormState::new();
let mut ideal_cursor = 0;
let result = ActionDispatcher::dispatch(
CanvasAction::Custom("test_custom".to_string()),
&mut state,
&mut ideal_cursor,
).await.unwrap();
match result {
ActionResult::HandledByFeature(msg) => {
assert_eq!(msg, "Custom action handled");
}
_ => panic!("Expected HandledByFeature result"),
}
}
#[tokio::test]
async fn test_batch_dispatch() {
let mut state = TestFormState::new();
let mut ideal_cursor = 0;
let actions = vec![
CanvasAction::InsertChar('h'),
CanvasAction::InsertChar('i'),
CanvasAction::MoveLeft,
CanvasAction::InsertChar('e'),
];
let results = ActionDispatcher::dispatch_batch(
actions,
&mut state,
&mut ideal_cursor,
).await.unwrap();
assert_eq!(results.len(), 4);
assert!(results.iter().all(|r| r.is_success()));
assert_eq!(state.get_current_input(), "hei");
}
}

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

@@ -0,0 +1,5 @@
// src/lib.rs
pub mod canvas;
pub mod autocomplete;
pub mod config;
pub mod dispatcher;

View File

@@ -5,20 +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"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
time = "0.3.41"
tokio = { version = "1.43.0", features = ["full", "macros"] }
toml = "0.8.20"
tonic = "0.12.3"
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 = "0.2.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"

57
client/canvas_config.toml Normal file
View File

@@ -0,0 +1,57 @@
# 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"]
# 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,8 +2,9 @@
[keybindings]
enter_command_mode = [":", "ctrl+;"]
next_buffer = ["ctrl+l"]
previous_buffer = ["ctrl+h"]
next_buffer = ["space+b+n"]
previous_buffer = ["space+b+p"]
close_buffer = ["space+b+d"]
[keybindings.general]
move_up = ["k", "Up"]
@@ -16,12 +17,11 @@ 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"]
@@ -29,6 +29,7 @@ move_up = ["Up"]
move_down = ["Down"]
toggle_sidebar = ["ctrl+t"]
toggle_buffer_list = ["ctrl+b"]
revert = ["space+b+r"]
# MODE SPECIFIC
# READ ONLY MODE
@@ -82,6 +83,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

@@ -2,7 +2,9 @@
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,313 @@
// 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 crate::state::pages::canvas_state::CanvasState;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use crate::components::handlers::canvas::render_canvas;
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
use crate::config::binds::config::EditorKeybindingMode;
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
let focus_on_canvas_inputs = matches!(
add_logic_state.current_focus,
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription
);
// Call render_canvas and get the active_field_rect
let active_field_rect = render_canvas(
f,
canvas_area,
add_logic_state, // Pass the whole state as it impl CanvasState
&add_logic_state.fields(),
&add_logic_state.current_field(),
&add_logic_state.inputs(),
theme,
is_edit_mode && focus_on_canvas_inputs, // is_edit_mode for canvas fields
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 let Some(suggestions) = add_logic_state.get_suggestions() { // Uses CanvasState impl
let selected = add_logic_state.get_selected_suggestion_index();
if !suggestions.is_empty() { // Only render if there are suggestions to show
if let Some(input_rect) = active_field_rect {
autocomplete::render_autocomplete_dropdown(
f,
input_rect,
f.area(), // Full frame area for clamping
theme,
suggestions,
selected,
);
}
}
}
}
// 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

@@ -89,6 +89,77 @@ pub fn render_add_table(
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;
@@ -249,7 +320,6 @@ pub fn render_add_table(
.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_highlight_symbol = if add_table_state.current_focus == AddTableFocus::InsideColumnsTable { " > " } else { " " };
let columns_table = Table::new(
column_rows,
[ // Define constraints for 3 columns: Sel, Name, Type
@@ -344,18 +414,20 @@ pub fn render_add_table(
let index_rows: Vec<Row<'_>> = add_table_state
.indexes
.iter()
.map(|index_name| {
Row::new(vec![Cell::from(index_name.clone())])
.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 = ["Column Name"]
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_highlight_symbol = if add_table_state.current_focus == AddTableFocus::InsideIndexesTable { " > " } else { " " };
let indexes_table =
Table::new(index_rows, [Constraint::Percentage(100)])
Table::new(index_rows, [Constraint::Length(5), Constraint::Percentage(95)])
.header(index_header)
.block(
Block::default()
@@ -389,19 +461,18 @@ pub fn render_add_table(
.iter()
.map(|link_def| {
Row::new(vec![
Cell::from(if link_def.selected { "[*]" } else { "[ ]" }),
Cell::from(link_def.linked_table_name.clone()),
Cell::from(if link_def.is_required { "[X]" } else { "[ ]" }),
])
.style(Style::default().fg(theme.fg))
})
.collect();
let link_header_cells = ["Linked Table", "Selected"]
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_highlight_symbol = if add_table_state.current_focus == AddTableFocus::InsideLinksTable { " > " } else { " " };
let links_table =
Table::new(link_rows, [Constraint::Percentage(80), Constraint::Min(5)])
Table::new(link_rows, [Constraint::Length(5), Constraint::Percentage(95)])
.header(link_header)
.block(
Block::default()

View File

@@ -4,9 +4,9 @@ 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::multieko2::table_definition::ProfileTreeResponse;
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap},

View File

@@ -6,12 +6,11 @@ use crate::state::app::state::AppState;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span, Text}, // Added Text
widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph},
text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph},
Frame,
};
/// Renders the view specific to admin users with a three-pane layout.
pub fn render_admin_panel_admin(
f: &mut Frame,
area: Rect,
@@ -19,15 +18,13 @@ pub fn render_admin_panel_admin(
admin_state: &mut AdminState,
theme: &Theme,
) {
// Split vertically: Top for panes, Bottom for buttons
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)].as_ref()) // 1 line for buttons
.constraints([Constraint::Min(0), Constraint::Length(1)].as_ref())
.split(area);
let panes_area = main_chunks[0];
let buttons_area = main_chunks[1];
// Split the top area into three panes: Profiles | Tables | Dependencies
let pane_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
@@ -35,208 +32,148 @@ pub fn render_admin_panel_admin(
Constraint::Percentage(40), // Tables
Constraint::Percentage(35), // Dependencies
].as_ref())
.split(panes_area); // Use the whole area directly
.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_focus = admin_state.current_focus == AdminFocus::Profiles;
let profile_border_style = if profile_focus {
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)
};
// Block for the profiles pane
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); // Get inner area for list
f.render_widget(profiles_block, profiles_pane); // Render the block itself
// Create profile list items
let profile_list_items: Vec<ListItem> = app_state
.profile_tree
.profiles
.iter()
.enumerate()
.map(|(idx, profile)| {
// Check persistent selection for prefix, navigation state for style/highlight
let is_selected = admin_state.selected_profile_index == Some(idx); // Use persistent state for [*]
let is_navigated = admin_state.profile_list_state.selected() == Some(idx); // Use nav state for highlight/>
let prefix = if is_selected { "[*] " } else { "[ ] " };
let style = if is_selected { // Style based on selection too
Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
ListItem::new(Line::from(vec![
Span::styled(prefix, style),
Span::styled(&profile.name, style)
]))
})
.collect();
// Build and render profile list inside the block's inner area
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 depends on focus AND navigation state
.highlight_style(if profile_focus { // Use focus state
Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
} else {
Style::default()
})
.highlight_symbol(if profile_focus { "> " } else { " " });
.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_focus = admin_state.current_focus == AdminFocus::Tables;
let table_border_style = if table_focus {
Style::default().fg(theme.highlight)
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 {
Style::default().fg(theme.border)
};
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);
// Get selected profile information
let navigated_profile_idx = admin_state.profile_list_state.selected(); // Use nav state for display
let selected_profile_name = app_state
.profile_tree
.profiles
.get(navigated_profile_idx.unwrap_or(usize::MAX)) // Use nav state for title
.map_or("None", |p| &p.name);
// Block for the tables pane
let tables_block = Block::default()
.title(format!(" Tables (Profile: {}) ", selected_profile_name))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(table_border_style);
let tables_inner_area = tables_block.inner(tables_pane); // Get inner area for list
f.render_widget(tables_block, tables_pane); // Render the block itself
// Create table list items and get dependencies for the selected table
let (table_list_items, selected_table_deps): (Vec<ListItem>, Vec<String>) = if let Some(
profile, // Get profile based on NAVIGATED profile index
) = navigated_profile_idx.and_then(|idx| app_state.profile_tree.profiles.get(idx)) {
let items: Vec<ListItem> = profile
.tables
.iter()
.enumerate()
.map(|(idx, table)| { // Renamed i to idx for clarity
// Check persistent selection for prefix, navigation state for style/highlight
let is_selected = admin_state.selected_table_index == Some(idx); // Use persistent state for [*]
let is_navigated = admin_state.table_list_state.selected() == Some(idx); // Use nav state for highlight/>
let prefix = if is_selected { "[*] " } else { "[ ] " };
let style = if is_navigated { // Style based on navigation highlight
Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
ListItem::new(Line::from(vec![
Span::styled(prefix, style),
Span::styled(&table.name, style),
]))
})
.collect();
// Get dependencies only for the PERSISTENTLY selected table in the PERSISTENTLY selected profile
let chosen_profile_idx = admin_state.selected_profile_index; // Use persistent profile selection
let deps = chosen_profile_idx // Start with the chosen profile index
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx)) // Get the chosen profile
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx))) // Get the chosen table using its index
.map_or(Vec::new(), |t| t.depends_on.clone()); // If found, clone its depends_on, otherwise return empty Vec
(items, deps)
} else {
// Default when no profile is selected
(vec![ListItem::new("Select a profile to see tables")], vec![])
};
// Build and render table list inside the block's inner area
let table_list = List::new(table_list_items)
// Highlight style depends on focus AND navigation state
.highlight_style(if table_focus { // Use focus state
Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
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 {
Style::default()
})
.highlight_symbol(if table_focus { "> " } else { " " }); // Focus indicator
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) ---
// Get name based on PERSISTENT selections
let chosen_profile_idx = admin_state.selected_profile_index; // Use persistent profile selection
let selected_table_name = chosen_profile_idx
.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))) // Use persistent table selection
.map_or("N/A", |t| &t.name); // Get name of the selected table
// Block for the dependencies pane
// --- 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: {}) ", selected_table_name))
.title(format!(" Dependencies (Table: {}) ", deps_pane_title_table_name))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border)); // No focus highlight for deps pane
let deps_inner_area = deps_block.inner(deps_pane); // Get inner area for content
f.render_widget(deps_block, deps_pane); // Render the block itself
.border_style(Style::default().fg(theme.border));
let deps_inner_area = deps_block.inner(deps_pane);
f.render_widget(deps_block, deps_pane);
// Prepare content for the dependencies paragraph
let mut deps_content = Text::default();
deps_content.lines.push(Line::from(Span::styled(
"Depends On:",
Style::default().fg(theme.accent), // Use accent color for the label
Style::default().fg(theme.accent),
)));
if !selected_table_deps.is_empty() {
for dep in selected_table_deps {
// List each dependency
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 {
// Indicate if there are no dependencies
deps_content.lines.push(Line::from(Span::styled(" None", theme.secondary)));
}
// Build and render dependencies paragraph inside the block's inner area
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 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);
// Define the helper closure to get style based on focus
let get_btn_style = |button_focus: AdminFocus| {
if admin_state.current_focus == button_focus {
// Apply highlight style if this button is focused
btn_base_style.add_modifier(ratatui::style::Modifier::REVERSED)
} else {
btn_base_style // Use base style otherwise
}
};
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);
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

@@ -13,6 +13,16 @@ use ratatui::{
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,
@@ -48,17 +58,15 @@ pub fn render_login(
])
.split(inner_area);
// --- FORM RENDERING ---
crate::components::handlers::canvas::render_canvas(
// --- FORM RENDERING (Using canvas library directly) ---
let canvas_highlight_state = convert_highlight_state(highlight_state);
render_canvas(
f,
chunks[0],
login_state,
&["Username/Email", "Password"],
&login_state.current_field,
&[&login_state.username, &login_state.password],
theme,
login_state, // LoginState implements CanvasState
theme, // Theme implements CanvasTheme
is_edit_mode,
highlight_state,
&canvas_highlight_state,
);
// --- ERROR MESSAGE ---
@@ -71,7 +79,7 @@ pub fn render_login(
);
}
// --- BUTTONS ---
// --- BUTTONS (unchanged) ---
let button_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
@@ -83,7 +91,7 @@ pub fn render_login(
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 {
@@ -105,12 +113,12 @@ pub fn render_login(
);
// Return Button
let return_button_index = 1; // Assuming Return is the second general element
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 // Not active if focus is in canvas or other modes
};
false
};
let mut return_style = Style::default().fg(theme.fg);
let mut return_border = Style::default().fg(theme.border);
if return_active {
@@ -132,17 +140,15 @@ pub fn render_login(
);
// --- DIALOG ---
// Check the correct field name for showing the dialog
if app_state.ui.dialog.dialog_show {
// Pass all 7 arguments correctly
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, // Pass buttons slice
app_state.ui.dialog.dialog_active_button_index,
&app_state.ui.dialog.dialog_buttons,
app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
);
}

View File

@@ -2,10 +2,9 @@
use crate::{
config::colors::themes::Theme,
state::pages::auth::RegisterState, // Use RegisterState
components::common::{dialog, autocomplete},
state::pages::auth::RegisterState,
components::common::dialog,
state::app::state::AppState,
state::pages::canvas_state::CanvasState,
modes::handlers::mode_manager::AppMode,
};
use ratatui::{
@@ -15,12 +14,24 @@ use ratatui::{
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, // Use RegisterState
state: &RegisterState,
app_state: &AppState,
is_edit_mode: bool,
highlight_state: &HighlightState,
@@ -29,7 +40,7 @@ pub fn render_register(
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(theme.border))
.title(" Register ") // Update title
.title(" Register ")
.style(Style::default().bg(theme.bg));
f.render_widget(block, area);
@@ -39,7 +50,6 @@ pub fn render_register(
vertical: 1,
});
// Adjust constraints for 4 fields + error + buttons
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
@@ -50,23 +60,15 @@ pub fn render_register(
])
.split(inner_area);
// --- FORM RENDERING (Using render_canvas) ---
let active_field_rect = crate::components::handlers::canvas::render_canvas(
// --- FORM RENDERING (Using canvas library directly) ---
let canvas_highlight_state = convert_highlight_state(highlight_state);
let input_rect = render_canvas(
f,
chunks[0], // Area for the canvas
state, // The state object (RegisterState)
&[ // Field labels
"Username",
"Email*",
"Password*",
"Confirm Password",
"Role* (Tab)",
],
&state.current_field(), // Pass current field index
&state.inputs().iter().map(|s| *s).collect::<Vec<&String>>(), // Pass inputs directly
theme,
chunks[0],
state, // RegisterState implements CanvasState
theme, // Theme implements CanvasTheme
is_edit_mode,
highlight_state,
&canvas_highlight_state,
);
// --- HELP TEXT ---
@@ -75,7 +77,6 @@ pub fn render_register(
.alignment(Alignment::Center);
f.render_widget(help_text, chunks[1]);
// --- ERROR MESSAGE ---
if let Some(err) = &state.error_message {
f.render_widget(
@@ -107,7 +108,7 @@ pub fn render_register(
}
f.render_widget(
Paragraph::new("Register") // Update button text
Paragraph::new("Register")
.style(register_style)
.alignment(Alignment::Center)
.block(
@@ -119,7 +120,7 @@ pub fn render_register(
button_chunks[0],
);
// Return Button (logic remains similar)
// 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
@@ -146,19 +147,22 @@ pub fn render_register(
button_chunks[1],
);
// --- Render Autocomplete Dropdown (Draw AFTER buttons) ---
// --- AUTOCOMPLETE DROPDOWN (Using canvas library directly) ---
if app_state.current_mode == AppMode::Edit {
if let Some(suggestions) = state.get_suggestions() {
let selected = state.get_selected_suggestion_index();
if !suggestions.is_empty() {
if let Some(input_rect) = active_field_rect {
autocomplete::render_autocomplete_dropdown(f, input_rect, f.size(), theme, suggestions, selected);
}
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 --- (Keep dialog logic)
// --- DIALOG ---
if app_state.ui.dialog.dialog_show {
dialog::render_dialog(
f,

View File

@@ -1,12 +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

@@ -1,6 +1,8 @@
// 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},
@@ -9,7 +11,8 @@ use ratatui::{
};
use unicode_width::UnicodeWidthStr;
/// Renders an opaque dropdown list for autocomplete suggestions.
/// 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,
@@ -21,39 +24,32 @@ pub fn render_autocomplete_dropdown(
if suggestions.is_empty() {
return;
}
// --- Calculate Dropdown Size & Position ---
let max_suggestion_width = suggestions.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
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, // Align horizontally with input
y: input_rect.y + 1, // Position directly below input
x: input_rect.x,
y: input_rect.y + 1,
width: dropdown_width,
height: dropdown_height,
};
// --- Clamping Logic (prevent rendering off-screen) ---
// Clamp vertically (if it goes below the frame)
if dropdown_area.bottom() > frame_area.height {
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height); // Try rendering above
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
}
// Clamp horizontally (if it goes past the right edge)
if dropdown_area.right() > frame_area.width {
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
}
// Ensure x is not negative (if clamping pushes it left)
dropdown_area.x = dropdown_area.x.max(0);
// Ensure y is not negative (if clamping pushes it up)
dropdown_area.y = dropdown_area.y.max(0);
// --- End Clamping ---
// Render a solid background block first to ensure opacity
let background_block = Block::default().style(Style::default().bg(Color::DarkGray));
let background_block =
Block::default().style(Style::default().bg(Color::DarkGray));
f.render_widget(background_block, dropdown_area);
// Create list items, ensuring each has a defined background
let items: Vec<ListItem> = suggestions
.iter()
.enumerate()
@@ -61,30 +57,97 @@ pub fn render_autocomplete_dropdown(
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));
let padded_s =
format!("{}{}", s, " ".repeat(padding_needed as usize));
ListItem::new(padded_s).style(if is_selected {
Style::default()
.fg(theme.bg) // Text color on highlight
.bg(theme.highlight) // Highlight background
.fg(theme.bg)
.bg(theme.highlight)
.add_modifier(Modifier::BOLD)
} else {
// Style for non-selected items (matching background block)
Style::default()
.fg(theme.fg) // Text color on gray
.bg(Color::DarkGray) // Explicit gray background
Style::default().fg(theme.fg).bg(Color::DarkGray)
})
})
.collect();
// Create the list widget (without its own block)
let list = List::new(items);
let mut list_state = ListState::default();
list_state.select(selected_index);
// State for managing selection highlight (still needed for logic)
let mut profile_list_state = ListState::default();
profile_list_state.select(selected_index);
// Render the list statefully *over* the background block
f.render_stateful_widget(list, dropdown_area, &mut profile_list_state);
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

@@ -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 @@
use ratatui::{
style::Style,
layout::Rect,
Frame,
text::{Line, Span},
widgets::Paragraph,
};
use unicode_width::UnicodeWidthStr;
// 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,
@@ -16,11 +19,41 @@ pub fn render_status_line(
theme: &Theme,
is_edit_mode: bool,
current_fps: f64,
app_state: &AppState,
) {
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());
// 2. Create a Paragraph from the Text and TELL IT TO WRAP.
Paragraph::new(error_text)
.wrap(Wrap { trim: true }) // This line makes the text break into new rows.
.style(Style::default().bg(theme.highlight).fg(theme.bg))
} else {
// --- This is for normal, single-line info messages ---
Paragraph::new(debug_state.displayed_message.as_str())
.style(Style::default().fg(theme.accent).bg(theme.bg))
};
f.render_widget(paragraph, area);
} else {
// Fallback for when debug state is None
let paragraph = Paragraph::new("").style(Style::default().bg(theme.bg));
f.render_widget(paragraph, area);
}
return; // Stop here and don't render the normal status line.
}
// --- The normal status line rendering logic (unchanged) ---
let program_info = format!("komp_ac v{}", env!("CARGO_PKG_VERSION"));
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
let home_dir = dirs::home_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
let 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 {
@@ -35,16 +68,30 @@ pub fn render_status_line(
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 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 }
mode_width
+ separator_width
+ separator_width
+ program_info_width
+ (if show_fps {
separator_width + fps_width
} else {
0
}),
);
let dir_display_text = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir {
let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str())
<= remaining_width_for_dir
{
display_dir
} else {
let dir_name = Path::new(current_dir)
@@ -54,25 +101,55 @@ pub fn render_status_line(
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
dir_name.to_string()
} else {
dir_name.chars().take(remaining_width_for_dir).collect()
dir_name
.chars()
.take(remaining_width_for_dir)
.collect::<String>()
}
};
let mut spans = vec![
let mut current_content_width = mode_width
+ separator_width
+ UnicodeWidthStr::width(dir_display_text_str.as_str())
+ separator_width
+ program_info_width;
if show_fps {
current_content_width += separator_width + fps_width;
}
let mut line_spans = vec![
Span::styled(mode_text, Style::default().fg(theme.accent)),
Span::styled(" | ", Style::default().fg(theme.border)),
Span::styled(dir_display_text, Style::default().fg(theme.fg)),
Span::styled(" | ", Style::default().fg(theme.border)),
Span::styled(program_info, Style::default().fg(theme.secondary)),
Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(
dir_display_text_str.as_str(),
Style::default().fg(theme.fg),
),
Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(
program_info.as_str(),
Style::default().fg(theme.secondary),
),
];
if show_fps {
spans.push(Span::styled(" | ", Style::default().fg(theme.border)));
spans.push(Span::styled(fps_text, Style::default().fg(theme.secondary)));
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 paragraph = Paragraph::new(Line::from(spans))
.style(Style::default().bg(theme.bg));
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,69 +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::pages::canvas_state::CanvasState;
use crate::state::app::highlight::HighlightState;
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

@@ -2,9 +2,10 @@
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, Stylize},
style::Style,
text::{Line, Span},
widgets::Paragraph,
Frame,
@@ -17,6 +18,7 @@ pub fn render_buffer_list(
area: Rect,
theme: &Theme,
buffer_state: &BufferState,
app_state: &AppState,
) {
// --- Style Definitions ---
let active_style = Style::default()
@@ -37,6 +39,8 @@ pub fn render_buffer_list(
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 {
@@ -44,7 +48,7 @@ pub fn render_buffer_list(
}
let is_active = original_index == buffer_state.active_index;
let buffer_name = view.display_name();
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());

View File

@@ -1,35 +1,99 @@
// src/components/handlers/canvas.rs
use ratatui::{
widgets::{Paragraph, Block, Borders},
layout::{Layout, Constraint, Direction, Rect},
style::{Style, Modifier},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
prelude::Alignment,
};
use crate::config::colors::themes::Theme;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::highlight::HighlightState; // Ensure correct import path
use std::cmp::{min, max};
use crate::state::app::highlight::HighlightState;
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
use canvas::canvas::CanvasState as LibraryCanvasState;
use std::cmp::{max, min};
/// Render canvas for legacy CanvasState (AddTableState, LoginState, RegisterState, AddLogicState)
pub fn render_canvas(
f: &mut Frame,
area: Rect,
form_state: &impl CanvasState,
form_state: &impl LegacyCanvasState,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState, // Using the enum state
highlight_state: &HighlightState,
) -> Option<Rect> {
// ... (setup code remains the same) ...
render_canvas_impl(
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),
)
}
/// Render canvas for library CanvasState (FormState)
pub fn render_canvas_library(
f: &mut Frame,
area: Rect,
form_state: &impl LibraryCanvasState,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
) -> Option<Rect> {
render_canvas_impl(
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),
)
}
/// Internal implementation shared by both render functions
fn render_canvas_impl<F1, F2>(
f: &mut Frame,
area: Rect,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
theme: &Theme,
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,
{
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(area);
let border_style = if form_state.has_unsaved_changes() {
let border_style = if has_unsaved_changes {
Style::default().fg(theme.warning)
} else if is_edit_mode {
Style::default().fg(theme.accent)
@@ -58,46 +122,46 @@ pub fn render_canvas(
let mut active_field_input_rect = None;
// 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,
});
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() {
for (i, _input) in inputs.iter().enumerate() {
let is_active = i == *current_field_idx;
let current_cursor_pos = form_state.current_cursor_pos();
let text = input.as_str();
let text_len = text.chars().count();
// Use the provided closure to get display value
let text = get_display_value(i);
let text_len = text.chars().count();
let line: Line;
// --- Use match on the highlight_state enum ---
match highlight_state {
HighlightState::Off => {
// Not in highlight mode, render normally
line = Line::from(Span::styled(
text,
if is_active { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.fg) }
&text,
if is_active {
Style::default().fg(theme.highlight)
} else {
Style::default().fg(theme.fg)
},
));
}
HighlightState::Characterwise { anchor } => {
// --- Character-wise Highlight Logic ---
let (anchor_field, anchor_char) = *anchor;
let start_field = min(anchor_field, *current_field_idx);
let end_field = max(anchor_field, *current_field_idx);
// Use start_char and end_char consistently
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 {
@@ -111,24 +175,20 @@ pub fn render_canvas(
let normal_style_outside = Style::default().fg(theme.fg);
if i >= start_field && i <= end_field {
// This line is within the character-wise highlight range
if start_field == end_field { // Case 1: Single Line Highlight
// Use start_char and end_char here
if start_field == end_field {
let clamped_start = start_char.min(text_len);
let clamped_end = end_char.min(text_len); // Use text_len for slicing logic
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();
// Define 'after' here
let after: String = text.chars().skip(clamped_end + 1).collect();
line = Line::from(vec![
Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight), // Use defined 'after'
Span::styled(after, normal_style_in_highlight),
]);
} else if i == start_field { // Case 2: Multi-Line Highlight - Start Line
// Use start_char here
} else if i == start_field {
let safe_start = start_char.min(text_len);
let before: String = text.chars().take(safe_start).collect();
let highlighted: String = text.chars().skip(safe_start).collect();
@@ -136,8 +196,7 @@ pub fn render_canvas(
Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style),
]);
} else if i == end_field { // Case 3: Multi-Line Highlight - End Line (Corrected index)
// Use end_char here
} else if i == end_field {
let safe_end_inclusive = if text_len > 0 { end_char.min(text_len - 1) } else { 0 };
let highlighted: String = text.chars().take(safe_end_inclusive + 1).collect();
let after: String = text.chars().skip(safe_end_inclusive + 1).collect();
@@ -145,19 +204,17 @@ pub fn render_canvas(
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight),
]);
} else { // Case 4: Multi-Line Highlight - Middle Line (Corrected index)
line = Line::from(Span::styled(text, highlight_style)); // Highlight whole line
} else {
line = Line::from(Span::styled(&text, highlight_style));
}
} else { // Case 5: Line Outside Character-wise Highlight Range
} else {
line = Line::from(Span::styled(
text,
// Use normal styling (active or inactive)
&text,
if is_active { normal_style_in_highlight } else { normal_style_outside }
));
}
}
HighlightState::Linewise { anchor_line } => {
// --- Linewise Highlight Logic ---
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);
@@ -165,25 +222,30 @@ pub fn render_canvas(
let normal_style_outside = Style::default().fg(theme.fg);
if i >= start_field && i <= end_field {
// Highlight the entire line
line = Line::from(Span::styled(text, highlight_style));
line = Line::from(Span::styled(&text, highlight_style));
} else {
// Line outside linewise highlight range
line = Line::from(Span::styled(
text,
// Use normal styling (active or inactive)
&text,
if is_active { normal_style_in_highlight } else { normal_style_outside }
));
}
}
} // End match highlight_state
}
let input_display = Paragraph::new(line).alignment(Alignment::Left);
f.render_widget(input_display, input_rows[i]);
if is_active {
active_field_input_rect = Some(input_rows[i]);
let cursor_x = input_rows[i].x + form_state.current_cursor_pos() as u16;
// Use the provided closure to check for display override
let cursor_x = if has_display_override(i) {
// If an override exists, place the cursor at the end.
input_rows[i].x + text.chars().count() as u16
} else {
// Otherwise, use the real cursor position.
input_rows[i].x + current_cursor_pos as u16
};
let cursor_y = input_rows[i].y;
f.set_cursor_position((cursor_x, cursor_y));
}
@@ -191,4 +253,3 @@ pub fn render_canvas(
active_field_input_rect
}

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

@@ -32,7 +32,7 @@ pub fn render_intro(f: &mut Frame, intro_state: &IntroState, area: Rect, theme:
// Title
let title = Line::from(vec![
Span::styled("multieko2", Style::default().fg(theme.highlight)),
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)),
]);

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>>,
@@ -43,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))
@@ -199,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 {
@@ -74,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

@@ -6,8 +6,8 @@ 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 => 2,
AppView::Form(_) | AppView::Scratch => 3,
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
AppView::Form | AppView::Scratch => 3,
}
}

View File

@@ -3,3 +3,4 @@
pub mod form_e;
pub mod auth_e;
pub mod add_table_e;
pub mod add_logic_e;

View File

@@ -0,0 +1,135 @@
// src/functions/modes/edit/add_logic_e.rs
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::canvas_state::CanvasState;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
pub async fn execute_edit_action(
action: &str,
key: KeyEvent, // Keep key for insert_char
state: &mut AddLogicState,
ideal_cursor_column: &mut usize,
) -> Result<String> {
let mut message = String::new();
match action {
"next_field" => {
let current_field = state.current_field();
let next_field = (current_field + 1) % AddLogicState::INPUT_FIELD_COUNT;
state.set_current_field(next_field);
*ideal_cursor_column = state.current_cursor_pos();
message = format!("Focus on field {}", state.fields()[next_field]);
}
"prev_field" => {
let current_field = state.current_field();
let prev_field = if current_field == 0 {
AddLogicState::INPUT_FIELD_COUNT - 1
} else {
current_field - 1
};
state.set_current_field(prev_field);
*ideal_cursor_column = state.current_cursor_pos();
message = format!("Focus on field {}", state.fields()[prev_field]);
}
"delete_char_forward" => {
let current_pos = state.current_cursor_pos();
let current_input_mut = state.get_current_input_mut();
if current_pos < current_input_mut.len() {
current_input_mut.remove(current_pos);
state.set_has_unsaved_changes(true);
if state.current_field() == 1 { state.update_target_column_suggestions(); }
}
}
"delete_char_backward" => {
let current_pos = state.current_cursor_pos();
if current_pos > 0 {
let new_pos = current_pos - 1;
state.get_current_input_mut().remove(new_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
state.set_has_unsaved_changes(true);
if state.current_field() == 1 { state.update_target_column_suggestions(); }
}
}
"move_left" => {
let current_pos = state.current_cursor_pos();
if current_pos > 0 {
let new_pos = current_pos - 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
}
"move_right" => {
let current_pos = state.current_cursor_pos();
let input_len = state.get_current_input().len();
if current_pos < input_len {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
}
"insert_char" => {
if let KeyCode::Char(c) = key.code {
let current_pos = state.current_cursor_pos();
state.get_current_input_mut().insert(current_pos, c);
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
state.set_has_unsaved_changes(true);
if state.current_field() == 1 {
state.update_target_column_suggestions();
}
}
}
"suggestion_down" => {
if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
let next_selection = (current_selection + 1) % state.target_column_suggestions.len();
state.selected_target_column_suggestion_index = Some(next_selection);
}
}
"suggestion_up" => {
if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
let prev_selection = if current_selection == 0 {
state.target_column_suggestions.len() - 1
} else {
current_selection - 1
};
state.selected_target_column_suggestion_index = Some(prev_selection);
}
}
"select_suggestion" => {
if state.in_target_column_suggestion_mode {
let mut selected_suggestion_text: Option<String> = None;
if let Some(selected_idx) = state.selected_target_column_suggestion_index {
if let Some(suggestion) = state.target_column_suggestions.get(selected_idx) {
selected_suggestion_text = Some(suggestion.clone());
}
}
if let Some(suggestion_text) = selected_suggestion_text {
state.target_column_input = suggestion_text.clone();
state.target_column_cursor_pos = state.target_column_input.len();
*ideal_cursor_column = state.target_column_cursor_pos;
state.set_has_unsaved_changes(true);
message = format!("Selected column: '{}'", suggestion_text);
}
state.in_target_column_suggestion_mode = false;
state.show_target_column_suggestions = false;
state.selected_target_column_suggestion_index = None;
state.update_target_column_suggestions();
} else {
let current_field = state.current_field();
let next_field = (current_field + 1) % AddLogicState::INPUT_FIELD_COUNT;
state.set_current_field(next_field);
*ideal_cursor_column = state.current_cursor_pos();
message = format!("Focus on field {}", state.fields()[next_field]);
}
}
_ => {}
}
Ok(message)
}

View File

@@ -2,7 +2,7 @@
use crate::state::pages::add_table::AddTableState;
use crate::state::pages::canvas_state::CanvasState; // Use trait
use crossterm::event::{KeyCode, KeyEvent};
use std::error::Error;
use anyhow::Result;
#[derive(PartialEq)]
enum CharType {
@@ -134,7 +134,7 @@ pub async fn execute_edit_action(
state: &mut AddTableState,
ideal_cursor_column: &mut usize,
// Add other params like grpc_client if needed for future actions (e.g., validation)
) -> Result<String, Box<dyn Error>> {
) -> Result<String> {
// Use the CanvasState trait methods implemented for AddTableState
match action {
"insert_char" => {

View File

@@ -1,20 +1,24 @@
// src/functions/modes/edit/auth_e.rs
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState;
use crate::state::pages::auth::RegisterState;
use crate::state::app::state::AppState;
use crate::tui::functions::common::form::{revert, save};
use crossterm::event::{KeyCode, KeyEvent};
use canvas::autocomplete::AutocompleteCanvasState;
use canvas::canvas::CanvasState;
use std::any::Any;
use anyhow::Result;
pub async fn execute_common_action<S: CanvasState + Any>(
action: &str,
state: &mut S,
grpc_client: &mut GrpcClient,
app_state: &AppState,
current_position: &mut u64,
total_count: u64,
) -> Result<String, Box<dyn std::error::Error>> {
) -> Result<String> {
match action {
"save" | "revert" => {
if !state.has_unsaved_changes() {
@@ -26,10 +30,9 @@ pub async fn execute_common_action<S: CanvasState + Any>(
match action {
"save" => {
let outcome = save(
app_state,
form_state,
grpc_client,
current_position,
total_count,
)
.await?;
let message = format!("Save successful: {:?}", outcome); // Simple message for now
@@ -39,8 +42,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
revert(
form_state,
grpc_client,
current_position,
total_count,
)
.await
}
@@ -62,10 +63,7 @@ pub async fn execute_edit_action<S: CanvasState + Any + Send>(
key: KeyEvent,
state: &mut S,
ideal_cursor_column: &mut usize,
grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
) -> Result<String, Box<dyn std::error::Error>> {
) -> Result<String> {
match action {
"insert_char" => {
if let KeyCode::Char(c) = key.code {
@@ -120,7 +118,7 @@ pub async fn execute_edit_action<S: CanvasState + Any + Send>(
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = (current_field + 1) % num_fields;
let new_field = (current_field + 1).min(num_fields - 1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
@@ -135,11 +133,7 @@ pub async fn execute_edit_action<S: CanvasState + Any + Send>(
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
};
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
@@ -302,53 +296,42 @@ pub async fn execute_edit_action<S: CanvasState + Any + Send>(
"suggestion_down" | "suggestion_up" | "select_suggestion" | "exit_suggestion_mode" => {
// Attempt to downcast to RegisterState to handle suggestion logic here
if let Some(register_state) = (state as &mut dyn Any).downcast_mut::<RegisterState>() {
// Only handle if it's the role field (index 4)
if register_state.current_field() == 4 {
// Only handle if it's the role field (index 4) and autocomplete is active
if register_state.current_field() == 4 && register_state.is_autocomplete_active() {
match action {
"suggestion_down" if register_state.in_suggestion_mode => {
let max_index = register_state.role_suggestions.len().saturating_sub(1);
let current_index = register_state.selected_suggestion_index.unwrap_or(0);
register_state.selected_suggestion_index = Some(if current_index >= max_index { 0 } else { current_index + 1 });
Ok("Suggestion changed down".to_string())
"suggestion_down" => {
if let Some(autocomplete_state) = register_state.autocomplete_state_mut() {
autocomplete_state.select_next();
Ok("Suggestion changed down".to_string())
} else {
Ok("No autocomplete state".to_string())
}
}
"suggestion_up" if register_state.in_suggestion_mode => {
let max_index = register_state.role_suggestions.len().saturating_sub(1);
let current_index = register_state.selected_suggestion_index.unwrap_or(0);
register_state.selected_suggestion_index = Some(if current_index == 0 { max_index } else { current_index.saturating_sub(1) });
Ok("Suggestion changed up".to_string())
"suggestion_up" => {
if let Some(autocomplete_state) = register_state.autocomplete_state_mut() {
autocomplete_state.select_previous();
Ok("Suggestion changed up".to_string())
} else {
Ok("No autocomplete state".to_string())
}
}
"select_suggestion" if register_state.in_suggestion_mode => {
if let Some(index) = register_state.selected_suggestion_index {
if let Some(selected_role) = register_state.role_suggestions.get(index).cloned() {
register_state.role = selected_role.clone(); // Update the role field
register_state.in_suggestion_mode = false; // Exit suggestion mode
register_state.show_role_suggestions = false; // Hide suggestions
register_state.selected_suggestion_index = None; // Clear selection
Ok(format!("Selected role: {}", selected_role)) // Return success message
} else {
Ok("Selected suggestion index out of bounds.".to_string()) // Error case
}
"select_suggestion" => {
if let Some(message) = register_state.apply_autocomplete_selection() {
Ok(message)
} else {
Ok("No suggestion selected".to_string())
}
}
"exit_suggestion_mode" => { // Handle Esc or other conditions
register_state.show_role_suggestions = false;
register_state.selected_suggestion_index = None;
register_state.in_suggestion_mode = false;
"exit_suggestion_mode" => {
register_state.deactivate_autocomplete();
Ok("Suggestions hidden".to_string())
}
_ => {
// Action is suggestion-related but state doesn't match (e.g., not in suggestion mode)
Ok("Suggestion action ignored: State mismatch.".to_string())
}
_ => Ok("Suggestion action ignored: State mismatch.".to_string())
}
} else {
// It's RegisterState, but not the role field
Ok("Suggestion action ignored: Not on role field.".to_string())
}
Ok("Suggestion action ignored: Not on role field or autocomplete not active.".to_string())
}
} else {
// Downcast failed - this action is only for RegisterState
Ok(format!("Action '{}' not applicable for this state type.", action))
}
}

View File

@@ -1,21 +1,22 @@
// src/functions/modes/edit/form_e.rs
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState;
use crate::state::app::state::AppState;
use crate::tui::functions::common::form::{revert, save};
use crate::tui::functions::common::form::SaveOutcome;
use crate::modes::handlers::event::EventOutcome;
use crossterm::event::{KeyCode, KeyEvent};
use canvas::canvas::CanvasState;
use std::any::Any;
use anyhow::Result;
pub async fn execute_common_action<S: CanvasState + Any>(
action: &str,
state: &mut S,
grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
app_state: &AppState,
) -> Result<EventOutcome> {
match action {
"save" | "revert" => {
if !state.has_unsaved_changes() {
@@ -27,12 +28,11 @@ pub async fn execute_common_action<S: CanvasState + Any>(
match action {
"save" => {
let save_result = save(
app_state,
form_state,
grpc_client,
current_position,
total_count,
).await;
match save_result {
Ok(save_outcome) => {
let message = match save_outcome {
@@ -49,10 +49,8 @@ pub async fn execute_common_action<S: CanvasState + Any>(
let revert_result = revert(
form_state,
grpc_client,
current_position,
total_count,
).await;
match revert_result {
Ok(message) => Ok(EventOutcome::Ok(message)),
Err(e) => Err(e),
@@ -76,10 +74,7 @@ pub async fn execute_edit_action<S: CanvasState>(
key: KeyEvent,
state: &mut S,
ideal_cursor_column: &mut usize,
grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
) -> Result<String, Box<dyn std::error::Error>> {
) -> Result<String> {
match action {
"insert_char" => {
if let KeyCode::Char(c) = key.code {

View File

@@ -2,3 +2,4 @@
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

@@ -6,89 +6,95 @@ use crate::state::{
};
use crossterm::event::{KeyEvent};
use ratatui::widgets::TableState;
use crate::tui::functions::common::add_table::handle_add_column_action;
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 }
}
}
/// Handles navigation events specifically for the Add Table view.
/// Returns true if the event was handled, false otherwise.
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; // Assume handled unless logic determines otherwise
let mut new_focus = current_focus; // Initialize new_focus
let mut handled = true;
let mut new_focus = current_focus;
// Define focus groups for horizontal navigation
let is_left_pane_block_focus = matches!(current_focus, // Focus on the table blocks
AddTableFocus::ColumnsTable | AddTableFocus::IndexesTable | AddTableFocus::LinksTable
);
let is_inside_table_focus = matches!(current_focus, // Focus inside for scrolling
AddTableFocus::InsideColumnsTable | AddTableFocus::InsideIndexesTable | AddTableFocus::InsideLinksTable
);
let is_right_pane_general_focus = matches!(current_focus, // Non-canvas elements in right pane
AddTableFocus::AddColumnButton | AddTableFocus::SaveButton | AddTableFocus::DeleteSelectedButton | AddTableFocus::CancelButton
);
let is_canvas_input_focus = matches!(current_focus,
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType
);
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() {
// --- Handle Exiting Table Scroll Mode ---
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();
// *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();
// *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();
}
_ => {
// Action triggered but not applicable in this focus state
handled = false;
// *command_message = "Exited Links Table".to_string();
}
_ => handled = false,
}
// If handled (i.e., focus changed), handled remains true.
// If not handled, handled becomes false.
}
// --- Vertical Navigation (Up/Down) ---
Some("move_up") => {
match current_focus {
AddTableFocus::InputTableName => new_focus = AddTableFocus::CancelButton,
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,
// Navigate between blocks when focus is on the table block itself
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton, // Move up to right pane
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
// Scroll inside the table when focus is internal
AddTableFocus::InsideColumnsTable => {
navigate_table_up(&mut add_table_state.column_table_state, add_table_state.columns.len());
// Stay inside the table, don't change new_focus
}
AddTableFocus::InsideIndexesTable => {
navigate_table_up(&mut add_table_state.index_table_state, add_table_state.indexes.len());
// Stay inside the table
}
AddTableFocus::InsideLinksTable => {
navigate_table_up(&mut add_table_state.link_table_state, add_table_state.links.len());
// Stay inside the table
}
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,
@@ -98,280 +104,102 @@ pub fn handle_add_table_navigation(
match current_focus {
AddTableFocus::InputTableName => new_focus = AddTableFocus::InputColumnName,
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputColumnType,
AddTableFocus::InputColumnType => new_focus = AddTableFocus::AddColumnButton,
AddTableFocus::InputColumnType => {
add_table_state.last_canvas_field = 2;
new_focus = AddTableFocus::AddColumnButton;
},
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
// Navigate between blocks when focus is on the table block itself
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton, // Move down to right pane
// Scroll inside the table when focus is internal
AddTableFocus::InsideColumnsTable => {
navigate_table_down(&mut add_table_state.column_table_state, add_table_state.columns.len());
// Stay inside the table
}
AddTableFocus::InsideIndexesTable => {
navigate_table_down(&mut add_table_state.index_table_state, add_table_state.indexes.len());
// Stay inside the table
}
AddTableFocus::InsideLinksTable => {
navigate_table_down(&mut add_table_state.link_table_state, add_table_state.links.len());
// Stay inside the table
}
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 => new_focus = AddTableFocus::InputTableName,
AddTableFocus::CancelButton => {
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
// *command_message = "At bottom of form.".to_string(); // Remove message
}
}
}
// --- Horizontal Navigation (Left/Right) ---
Some("next_option") => { // 'l' or Right: Move from Left Pane to Right Pane
// Horizontal nav within bottom buttons
if current_focus == AddTableFocus::SaveButton {
new_focus = AddTableFocus::DeleteSelectedButton;
} else if current_focus == AddTableFocus::DeleteSelectedButton {
new_focus = AddTableFocus::CancelButton;
}
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") => { // 'h' or Left: Move from Right Pane to Left Pane
// Horizontal nav within bottom buttons
if current_focus == AddTableFocus::CancelButton {
new_focus = AddTableFocus::DeleteSelectedButton;
} else if current_focus == AddTableFocus::DeleteSelectedButton {
new_focus = AddTableFocus::SaveButton;
}
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,
}
}
// --- Tab / Shift+Tab Navigation (Keep as vertical cycle) ---
Some("next_field") => { // Tab
Some("next_field") => {
new_focus = match current_focus {
AddTableFocus::InputTableName => AddTableFocus::InputColumnName,
AddTableFocus::InputColumnName => AddTableFocus::InputColumnType,
AddTableFocus::InputColumnType => AddTableFocus::AddColumnButton,
AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable,
// Treat Inside* same as block focus for tabbing out
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, // Wrap
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") => { // Shift+Tab
Some("prev_field") => {
new_focus = match current_focus {
AddTableFocus::InputTableName => AddTableFocus::CancelButton, // Wrap
AddTableFocus::InputColumnName => AddTableFocus::InputTableName,
AddTableFocus::InputColumnType => AddTableFocus::InputColumnName,
AddTableFocus::AddColumnButton => AddTableFocus::InputColumnType,
// Treat Inside* same as block focus for tabbing out
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,
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,
};
}
// --- Selection ---
Some("select") => {
match current_focus {
// --- Enter/Exit Table Focus ---
AddTableFocus::ColumnsTable => {
new_focus = AddTableFocus::InsideColumnsTable;
// Select first item if none selected when entering
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));
}
*command_message = "Entered Columns Table (Scroll with Up/Down, Select to exit)".to_string();
}
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));
}
*command_message = "Entered Indexes Table (Scroll with Up/Down, Select to exit)".to_string();
}
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));
}
*command_message = "Entered Links Table (Scroll with Up/Down, Select to toggle/exit)".to_string();
}
AddTableFocus::InsideColumnsTable => {
// Toggle selection when pressing select *inside* the columns table
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;
*command_message = format!(
"Toggled selection for column: {} to {}",
col.name, col.selected
);
}
} else {
*command_message = "No column highlighted to toggle selection".to_string();
}
}
AddTableFocus::InsideIndexesTable => {
// Select does nothing here anymore, only Esc exits.
if let Some(index) = add_table_state.index_table_state.selected() {
*command_message = format!("Selected index index {} (Press Esc to exit scroll mode)", index);
} else {
*command_message = "No index selected (Press Esc to exit scroll mode)".to_string();
}
}
AddTableFocus::InsideLinksTable => {
// Toggle selection when pressing select *inside* the links table
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; // Toggle the selected state
add_table_state.has_unsaved_changes = true; // Mark changes
*command_message = format!(
"Toggled selection for link: {} to {}",
link.linked_table_name, link.selected
);
} else {
*command_message = "Error: Selected link index out of bounds".to_string();
}
} else {
*command_message = "No link selected to toggle".to_string();
}
// Stay inside the links table after toggling
new_focus = AddTableFocus::InsideLinksTable;
// Alternative: Exit after toggle:
// new_focus = AddTableFocus::LinksTable;
// *command_message = format!("{} - Exited Links Table", command_message);
}
// --- Other Select Actions ---
AddTableFocus::AddColumnButton => {
if let Some(focus_after_add) = handle_add_column_action(add_table_state, command_message) {
new_focus = focus_after_add;
}
}
AddTableFocus::SaveButton => {
*command_message = "Action: Save Table (Not Implemented)".to_string();
// TODO: Implement logic
}
AddTableFocus::DeleteSelectedButton => {
// --- Show Confirmation Dialog ---
// Collect tuples of (index, name, type) for selected columns
let columns_to_delete: Vec<(usize, String, String)> = add_table_state
.columns
.iter()
.enumerate() // Get index along with the column
.filter(|(_index, col)| col.selected) // Filter based on selection
.map(|(index, col)| (index, col.name.clone(), col.data_type.clone())) // Map to (index, name, type)
.collect();
if columns_to_delete.is_empty() {
*command_message = "No columns selected for deletion.".to_string();
} else {
// Format the message to include index, name, and type
let column_details: String = columns_to_delete
.iter()
// Add 1 to index for 1-based numbering for user display
.map(|(index, name, dtype)| format!("{}. {} ({})", index + 1, name, dtype))
.collect::<Vec<String>>()
.join("\n");
// Use the formatted column_details string in the message
let message = format!(
"Delete the following columns?\n\n{}",
column_details
);
let buttons = vec!["Confirm".to_string(), "Cancel".to_string()];
app_state.show_dialog(
"Confirm Deletion",
&message,
buttons,
DialogPurpose::ConfirmDeleteColumns,
);
}
}
AddTableFocus::CancelButton => {
*command_message = "Action: Cancel Add Table".to_string();
// TODO: Implement logic
}
_ => { // Input fields
*command_message = format!("Select on {:?}", current_focus);
handled = false; // Let main loop handle edit mode toggle maybe
}
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; }
}
// Keep handled = true for select actions unless specifically set to false
}
// --- Other General Keys ---
Some("toggle_sidebar") | Some("toggle_buffer_list") => {
handled = false;
}
// --- No matching action ---
_ => handled = false,
}
// Update focus state if it changed and was handled
if handled && current_focus != new_focus {
add_table_state.current_focus = new_focus;
// Avoid overwriting specific messages set during 'select' handling
if command_message.is_empty() || command_message.starts_with("Focus set to") {
*command_message = format!("Focus set to {:?}", add_table_state.current_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
}
// Check if the *new* focus target is one of the canvas input fields
let new_is_canvas_input_focus = matches!(new_focus,
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType
);
// Focus is outside canvas if it's not an input field
app_state.ui.focus_outside_canvas = !new_is_canvas_input_focus;
} else if !handled {
// command_message.clear(); // Optional: Clear message if not handled here
}
// 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
}
// Helper function for navigating up within a table state
// Returns true if navigation happened within the table, false if it reached the top
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 // Navigation happened
} else {
false // Was at the top
}
}
None => { // No item selected, select the last one
table_state.select(Some(item_count - 1));
true // Navigation happened (selection set)
}
}
}
// Helper function for navigating down within a table state
// Returns true if navigation happened within the table, false if it reached the bottom
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 // Navigation happened
} else {
false // Was at the bottom
}
}
None => { // No item selected, select the first one
table_state.select(Some(0));
true // Navigation happened (selection set)
}
}
}

View File

@@ -1,213 +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::state::AppState,
pages::admin::{AdminFocus, AdminState},
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::state::app::buffer::AppView;
use crate::state::app::buffer::BufferState;
use crate::state::pages::add_table::AddTableState;
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));
}
/// Handles navigation events specifically for the Admin Panel view.
/// Returns true if the event was handled, false otherwise.
pub fn handle_admin_navigation(
key: KeyEvent,
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);
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 action {
// --- Vertical Navigation (Up/Down) ---
Some("move_up") => {
match current_focus {
AdminFocus::Profiles => {
if profile_count > 0 {
// Updates navigation state, resets table state
admin_state.previous_profile(profile_count);
*command_message = "Navigated profiles".to_string();
}
}
AdminFocus::Tables => {
// Updates table navigation state
if let Some(nav_profile_idx) = admin_state.profile_list_state.selected() {
if let Some(profile) = app_state.profile_tree.profiles.get(nav_profile_idx) {
let table_count = profile.tables.len();
if table_count > 0 {
admin_state.previous_table(table_count);
*command_message = "Navigated tables".to_string();
}
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;
}
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3 => {}
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,
}
true // Event handled
}
Some("move_down") => {
match current_focus {
AdminFocus::Profiles => {
AdminFocus::InsideProfilesList => {
match action.as_deref() {
Some("move_up") => {
if profile_count > 0 {
// Updates navigation state, resets table state
admin_state.next_profile(profile_count);
*command_message = "Navigated profiles".to_string();
list_select_previous(&mut admin_state.profile_list_state, profile_count);
*command_message = "".to_string();
handled = true;
}
}
AdminFocus::Tables => {
if let Some(nav_profile_idx) = admin_state.profile_list_state.selected() {
if let Some(profile) = app_state.profile_tree.profiles.get(nav_profile_idx) {
let table_count = profile.tables.len();
if table_count > 0 {
admin_state.next_table(table_count);
*command_message = "Navigated tables".to_string();
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);
}
}
}
}
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3 => {}
}
true // Event handled
}
// --- Horizontal Navigation (Focus Change) ---
Some("next_option") | Some("previous_option") => {
let old_focus = admin_state.current_focus;
let is_next = action == Some("next_option"); // Check if 'l' or 'h'
admin_state.current_focus = match old_focus {
AdminFocus::Profiles => if is_next { AdminFocus::Tables } else { AdminFocus::Button3 }, // P -> T (l) or P -> B3 (h)
AdminFocus::Tables => if is_next { AdminFocus::Button1 } else { AdminFocus::Profiles }, // T -> B1 (l) or T -> P (h)
AdminFocus::Button1 => if is_next { AdminFocus::Button2 } else { AdminFocus::Tables }, // B1 -> B2 (l) or B1 -> T (h)
AdminFocus::Button2 => if is_next { AdminFocus::Button3 } else { AdminFocus::Button1 }, // B2 -> B3 (l) or B2 -> B1 (h)
AdminFocus::Button3 => if is_next { AdminFocus::Profiles } else { AdminFocus::Button2 }, // B3 -> P (l) or B3 -> B2 (h)
};
let new_focus = admin_state.current_focus;
*command_message = format!("Focus set to {:?}", new_focus);
// Auto-select first item only when moving from Profiles to Tables via 'l'
if old_focus == AdminFocus::Profiles && new_focus == AdminFocus::Tables && is_next {
if let Some(profile_idx) = admin_state.profile_list_state.selected() {
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));
} else {
admin_state.table_list_state.select(None);
}
} else {
admin_state.table_list_state.select(None);
}
} else {
admin_state.table_list_state.select(None);
}
}
// Clear table nav selection if moving away from Tables
if old_focus == AdminFocus::Tables && new_focus != AdminFocus::Tables {
admin_state.table_list_state.select(None);
}
// Clear profile nav selection if moving away from Profiles
if old_focus == AdminFocus::Profiles && new_focus != AdminFocus::Profiles {
// Maybe keep profile nav highlight? Let's try clearing it.
// admin_state.profile_list_state.select(None); // Optional: clear profile nav highlight
}
true // Event handled
}
// --- Selection ---
Some("select") => {
match current_focus {
AdminFocus::Profiles => {
// Set the persistent selection to the currently navigated item
if let Some(nav_idx) = admin_state.profile_list_state.selected() {
admin_state.selected_profile_index = Some(nav_idx); // Set persistent selection
// Move focus to Tables (like pressing 'l')
admin_state.current_focus = AdminFocus::Tables;
// Select the first table for navigation highlight
admin_state.table_list_state.select(None); // Clear table nav first
admin_state.selected_table_index = None; // Clear persistent table selection
if let Some(profile) = app_state.profile_tree.profiles.get(nav_idx) {
if !profile.tables.is_empty() {
// Set table nav highlight
admin_state.table_list_state.select(Some(0));
}
}
*command_message = format!("Selected profile idx {}, focus on Tables", nav_idx);
} else {
*command_message = "No profile selected".to_string();
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;
}
}
AdminFocus::Tables => {
// Set the persistent selection to the currently navigated item
if let Some(nav_idx) = admin_state.table_list_state.selected() {
admin_state.selected_table_index = Some(nav_idx); // Set persistent selection
*command_message = format!("Selected table index {}", nav_idx);
} else {
*command_message = "No table highlighted".to_string();
}
// We don't change focus here for now.
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;
}
}
AdminFocus::Button1 => {
*command_message = "Action: Add Logic (Not Implemented)".to_string();
// TODO: Trigger action for Button 1
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;
}
AdminFocus::Button2 => {
// --- Prepare AddTableState based on persistent selections ---
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();
// Create and populate the new AddTableState
let new_add_table_state = AddTableState {
profile_name: selected_profile_name,
// Reset other fields to defaults for a fresh start
..AddTableState::default()
};
// Assign the prepared state
admin_state.add_table_state = new_add_table_state;
// Switch view
buffer_state.update_history(AppView::AddTable);
app_state.ui.focus_outside_canvas = false;
*command_message = format!(
"Navigating to Add Table for profile '{}'...",
admin_state.add_table_state.profile_name
);
} else {
*command_message = "Error: Selected profile index out of bounds.".to_string();
}
} else {
*command_message = "Please select a profile ([*]) first.".to_string();
}
// --- End preparation ---
}
AdminFocus::Button3 => {
*command_message = "Action: Change Table (Not Implemented)".to_string();
// TODO: Trigger action for Button 3
Some("exit_table_scroll") => {
admin_state.current_focus = AdminFocus::Tables;
*command_message = "Focus: Tables Pane".to_string();
handled = true;
}
_ => handled = false,
}
true // Event handled
}
// --- Other General Keys (Ignore for admin nav) ---
Some("toggle_sidebar") | Some("toggle_buffer_list") | Some("next_field") | Some("prev_field") => {
// These are handled globally or not applicable here.
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,
}
}
// --- No matching action ---
_ => false, // Event not handled by admin navigation
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

@@ -3,3 +3,4 @@
pub mod auth_ro;
pub mod form_ro;
pub mod add_table_ro;
pub mod add_logic_ro;

View File

@@ -0,0 +1,235 @@
// src/functions/modes/read_only/add_logic_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::add_logic::AddLogicState; // Changed
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::state::AppState;
use anyhow::Result;
// Word navigation helpers (get_char_type, find_next_word_start, etc.)
// can be kept as they are generic.
#[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();
let len = chars.len();
if len == 0 || current_pos >= len { return len; }
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type { pos += 1; }
while pos < 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();
let len = chars.len();
if len == 0 { return 0; }
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
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))
}
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 pos == 0 && get_char_type(chars[pos]) == 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; }
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let prev_start = find_prev_word_start(text, current_pos);
if prev_start == 0 { return 0; }
find_word_end(text, prev_start.saturating_sub(1))
}
/// Executes read-only actions for the AddLogic view canvas.
pub async fn execute_action(
action: &str,
app_state: &mut AppState,
state: &mut AddLogicState,
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String,
) -> Result<String> {
match action {
"move_up" => {
key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); }
let current_field = state.current_field();
if current_field > 0 {
let new_field = current_field - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
} else {
*command_message = "At top of form.".to_string();
}
Ok(command_message.clone())
}
"move_down" => {
key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); }
let current_field = state.current_field();
let last_field_index = num_fields - 1;
if current_field < last_field_index {
let new_field = current_field + 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
} else {
// Move focus outside canvas when moving down from the last field
// FIX: Go to ScriptContentPreview instead of SaveButton
app_state.ui.focus_outside_canvas = true;
state.last_canvas_field = 2;
state.current_focus = crate::state::pages::add_logic::AddLogicFocus::ScriptContentPreview; // FIXED!
*command_message = "Focus moved to script preview".to_string();
}
Ok(command_message.clone())
}
// ... (rest of the actions remain the same) ...
"move_first_line" => {
key_sequence_tracker.reset();
if AddLogicState::INPUT_FIELD_COUNT > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_last_line" => {
key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let last_field_index = num_fields - 1;
state.set_current_field(last_field_index);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
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);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
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);
*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());
let final_pos = new_pos.min(current_input.len().saturating_sub(1));
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 current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos == current_pos && current_pos < current_input.len().saturating_sub(1) {
find_word_end(current_input, current_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_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);
*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);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*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);
*ideal_cursor_column = new_pos;
} else {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
}
Ok("".to_string())
}
"enter_edit_mode_before" | "enter_edit_mode_after" | "enter_command_mode" | "exit_highlight_mode" => {
key_sequence_tracker.reset();
Ok("Mode change handled by main loop".to_string())
}
_ => {
key_sequence_tracker.reset();
command_message.clear();
Ok(format!("Unknown read-only action: {}", action))
},
}
}

View File

@@ -1,9 +1,9 @@
// src/functions/modes/read_only/add_table_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::add_table::AddTableState;
use crate::state::pages::canvas_state::CanvasState; // Use trait for common actions
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::state::AppState;
use std::error::Error;
use anyhow::Result;
// Re-use word navigation helpers if they are public or move them to a common module
// For now, duplicating them here for simplicity. Consider refactoring later.
@@ -74,47 +74,42 @@ pub async fn execute_action(
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String, // Keep for potential messages
) -> Result<String, Box<dyn Error>> {
) -> Result<String> {
// Use the CanvasState trait methods implemented for AddTableState
match action {
"move_up" => {
key_sequence_tracker.reset();
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); }
if num_fields == 0 {
*command_message = "No fields.".to_string();
return Ok(command_message.clone());
}
let current_field = state.current_field(); // Gets the index (0, 1, or 2)
if current_field > 0 {
// This handles moving from field 2 -> 1, or 1 -> 0
let new_field = current_field - 1;
state.set_current_field(new_field);
// ... (rest of the logic to set cursor position) ...
let current_input = state.get_current_input();
let max_cursor_pos = current_input.len(); // Allow cursor at end
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column as cursor moved
*command_message = "".to_string(); // Clear message for successful internal navigation
} else {
// --- THIS IS WHERE THE FIX GOES ---
// current_field is 0 (InputTableName), and user pressed Up.
// We need to move focus *outside* the canvas.
// Set the flag to indicate focus is leaving the canvas
app_state.ui.focus_outside_canvas = true;
// Decide which element gets focus. Based on your layout and the
// downward navigation (CancelButton wraps to InputTableName),
// moving up from InputTableName should likely go to CancelButton.
state.current_focus = crate::state::pages::add_table::AddTableFocus::CancelButton;
// Reset the sequence tracker as the action is complete
key_sequence_tracker.reset();
// Return a message indicating the focus change
return Ok("Focus moved above canvas".to_string());
// --- END FIX ---
// Forbid moving up. Do not change focus or cursor.
*command_message = "At top of form.".to_string();
}
// If we moved within the canvas (e.g., 1 -> 0), return empty string
Ok("".to_string())
Ok(command_message.clone())
}
"move_down" => {
key_sequence_tracker.reset();
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); }
if num_fields == 0 {
*command_message = "No fields.".to_string();
return Ok(command_message.clone());
}
let current_field = state.current_field();
let last_field_index = num_fields - 1;
@@ -125,16 +120,19 @@ pub async fn execute_action(
let max_cursor_pos = current_input.len(); // Allow cursor at end
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column
*command_message = "".to_string();
} else {
// Move focus outside canvas when moving down from the last field
app_state.ui.focus_outside_canvas = true;
// Set focus to the first element outside canvas (AddColumnButton)
state.current_focus = crate::state::pages::add_table::AddTableFocus::AddColumnButton;
key_sequence_tracker.reset();
return Ok("Focus moved below canvas".to_string());
state.current_focus =
crate::state::pages::add_table::AddTableFocus::AddColumnButton;
*command_message = "Focus moved below canvas".to_string();
}
Ok("".to_string())
Ok(command_message.clone())
}
// ... (other actions like "move_first_line", "move_left", etc. remain the same) ...
"move_first_line" => {
key_sequence_tracker.reset();
if AddTableState::INPUT_FIELD_COUNT > 0 {
@@ -145,7 +143,8 @@ pub async fn execute_action(
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column
}
Ok("".to_string())
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_last_line" => {
key_sequence_tracker.reset();
@@ -159,14 +158,16 @@ pub async fn execute_action(
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column
}
Ok("".to_string())
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_left" => {
let current_pos = state.current_cursor_pos();
let new_pos = current_pos.saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_right" => {
let current_input = state.get_current_input();
@@ -177,68 +178,90 @@ pub async fn execute_action(
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_word_next" => {
let current_input = state.get_current_input();
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
let new_pos = find_next_word_start(
current_input,
state.current_cursor_pos(),
);
let final_pos = new_pos.min(current_input.len()); // Allow cursor at end
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
Ok("".to_string())
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_word_end" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
// If find_word_end returns current_pos, try starting search from next char
let final_pos = if new_pos == current_pos && current_pos < current_input.len() {
find_word_end(current_input, current_pos + 1)
} else {
new_pos
};
let final_pos =
if new_pos == current_pos && current_pos < current_input.len() {
find_word_end(current_input, current_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len(); // Allow cursor at end
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
Ok("".to_string())
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_word_prev" => {
let current_input = state.get_current_input();
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
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("".to_string())
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
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("".to_string())
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_line_end" => {
let current_input = state.get_current_input();
let new_pos = current_input.len(); // Allow cursor at end
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
*command_message = "".to_string();
Ok(command_message.clone())
}
// Actions handled by main event loop (mode changes)
"enter_edit_mode_before" | "enter_edit_mode_after" | "enter_command_mode" | "exit_highlight_mode" => {
key_sequence_tracker.reset();
Ok("Mode change handled by main loop".to_string())
"enter_edit_mode_before" | "enter_edit_mode_after"
| "enter_command_mode" | "exit_highlight_mode" => {
key_sequence_tracker.reset();
// These actions are primarily mode changes handled by the main event loop.
// The message here might be overridden by the main loop's message for mode change.
*command_message = "Mode change initiated".to_string();
Ok(command_message.clone())
}
_ => {
key_sequence_tracker.reset();
command_message.clear(); // Clear message for unhandled actions
Ok(format!("Unknown read-only action: {}", action))
},
key_sequence_tracker.reset();
*command_message =
format!("Unknown read-only action: {}", action);
Ok(command_message.clone())
}
}
}

View File

@@ -1,9 +1,9 @@
// src/functions/modes/read_only/auth_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::state::AppState;
use std::error::Error;
use canvas::canvas::CanvasState;
use anyhow::Result;
#[derive(PartialEq)]
enum CharType {
@@ -19,7 +19,7 @@ pub async fn execute_action<S: CanvasState>(
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String,
) -> Result<String, Box<dyn Error>> {
) -> Result<String> {
match action {
"previous_entry" | "next_entry" => {
key_sequence_tracker.reset();
@@ -252,28 +252,6 @@ fn find_next_word_start(text: &str, current_pos: usize) -> usize {
pos
}
fn find_next_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let next_start = find_next_word_start(text, current_pos);
if next_start >= chars.len() {
return chars.len().saturating_sub(1);
}
let mut pos = next_start;
let word_type = get_char_type(chars[pos]);
while pos < chars.len() && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(chars.len().saturating_sub(1))
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
@@ -282,8 +260,6 @@ fn find_word_end(text: &str, current_pos: usize) -> usize {
}
let mut pos = current_pos.min(len - 1);
let original_pos = pos;
let current_type = get_char_type(chars[pos]);
if current_type != CharType::Whitespace {
while pos < len && get_char_type(chars[pos]) == current_type {

View File

@@ -1,8 +1,8 @@
// src/functions/modes/read_only/form_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::canvas_state::CanvasState;
use std::error::Error;
use canvas::canvas::CanvasState;
use anyhow::Result;
#[derive(PartialEq)]
enum CharType {
@@ -17,7 +17,7 @@ pub async fn execute_action<S: CanvasState>(
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String,
) -> Result<String, Box<dyn Error>> {
) -> Result<String> {
match action {
"previous_entry" | "next_entry" => {
key_sequence_tracker.reset();
@@ -238,28 +238,6 @@ fn find_next_word_start(text: &str, current_pos: usize) -> usize {
pos
}
fn find_next_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let next_start = find_next_word_start(text, current_pos);
if next_start >= chars.len() {
return chars.len().saturating_sub(1);
}
let mut pos = next_start;
let word_type = get_char_type(chars[pos]);
while pos < chars.len() && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(chars.len().saturating_sub(1))
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
@@ -268,8 +246,6 @@ fn find_word_end(text: &str, current_pos: usize) -> usize {
}
let mut pos = current_pos.min(len - 1);
let original_pos = pos;
let current_type = get_char_type(chars[pos]);
if current_type != CharType::Whitespace {
while pos < len && get_char_type(chars[pos]) == current_type {

View File

@@ -7,6 +7,7 @@ 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

@@ -7,10 +7,11 @@ 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::{save as register_save, revert as register_revert},
register::{revert as register_revert},
};
pub async fn handle_core_action(
@@ -23,24 +24,18 @@ pub async fn handle_core_action(
auth_client: &mut AuthClient,
terminal: &mut TerminalCore,
app_state: &mut AppState,
current_position: &mut u64,
total_count: u64,
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
) -> Result<EventOutcome> {
match action {
"save" => {
if app_state.ui.show_login {
let message = login_save(auth_state, login_state, auth_client, app_state).await?;
Ok(EventOutcome::Ok(message))
} else if app_state.ui.show_register {
let message = register_save(register_state, auth_client, app_state).await?;
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,
current_position,
total_count,
).await?;
).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(),
@@ -55,15 +50,12 @@ pub async fn handle_core_action(
},
"save_and_quit" => {
let message = if app_state.ui.show_login {
login_save(auth_state, login_state, auth_client, app_state).await?
} else if app_state.ui.show_register {
register_save(register_state, auth_client, app_state).await?
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,
current_position,
total_count,
).await?;
match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(),
@@ -85,9 +77,7 @@ pub async fn handle_core_action(
let message = form_revert(
form_state,
grpc_client,
current_position,
total_count,
).await?;
).await.context("Form revert x action failed")?;
Ok(EventOutcome::Ok(message))
}
},

View File

@@ -1,381 +1,442 @@
// src/modes/canvas/edit.rs
use crate::config::binds::config::Config;
use crate::functions::modes::edit::{
add_logic_e, add_table_e, form_e,
};
use crate::modes::handlers::event::EventHandler;
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::state::pages::{
auth::{LoginState, RegisterState},
canvas_state::CanvasState,
form::FormState,
};
use crate::state::pages::form::FormState;
use crate::state::pages::add_table::AddTableState;
use crate::modes::handlers::event::EventOutcome;
use crate::functions::modes::edit::{auth_e, form_e};
use crate::functions::modes::edit::add_table_e;
use crate::state::app::state::AppState;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
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;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditEventOutcome {
Message(String), // Return a message, stay in Edit mode
ExitEditMode, // Signal to exit Edit mode
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
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
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())
}
/// 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
async fn handle_canvas_state_edit<S: CanvasState>(
key: KeyEvent,
config: &Config,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<String> {
// Try direct key mapping first (same pattern as FormState)
if let Some(canvas_action) = CanvasAction::from_key(key.code) {
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(_) => {
// Fall through to try config mapping
}
}
}
// Try config-mapped action (same pattern as FormState)
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
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));
}
}
}
Ok(String::new())
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_edit_event(
key: KeyEvent,
config: &Config,
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
add_table_state: &mut AddTableState,
ideal_cursor_column: &mut usize,
admin_state: &mut AdminState,
current_position: &mut u64,
total_count: u64,
grpc_client: &mut GrpcClient,
event_handler: &mut EventHandler,
app_state: &AppState,
) -> Result<EditEventOutcome, Box<dyn std::error::Error>> {
// Global command mode check (should ideally be handled before calling this function)
if let Some("enter_command_mode") = config.get_action_for_key_in_mode(
&config.keybindings.global,
key.code,
key.modifiers,
) {
return Ok(EditEventOutcome::Message(
"Command mode entry handled globally.".to_string(),
));
}
if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.common,
key.code,
key.modifiers,
).as_deref() {
if matches!(action, "save" | "revert") {
let message_string: String = if app_state.ui.show_login {
auth_e::execute_common_action(
action,
login_state,
grpc_client,
current_position,
total_count,
)
.await?
} else if app_state.ui.show_register {
auth_e::execute_common_action(
action,
register_state,
grpc_client,
current_position,
total_count,
)
.await?
} else if app_state.ui.show_add_table {
format!(
"Action '{}' not fully implemented for Add Table view here.",
action
)
} else {
let outcome = form_e::execute_common_action(
action,
form_state,
grpc_client,
current_position,
total_count,
)
.await?;
match outcome {
EventOutcome::Ok(msg) => msg,
EventOutcome::DataSaved(_, msg) => msg,
_ => format!(
"Unexpected outcome from common action: {:?}",
outcome
),
) -> 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()));
}
};
return Ok(EditEventOutcome::Message(message_string));
"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
}
}
}
// Edit-specific actions
if let Some(action) =
config.get_edit_action_for_key(key.code, key.modifiers)
.as_deref() {
// Handle enter_decider first
if action == "enter_decider" {
let effective_action = if app_state.ui.show_register
&& register_state.in_suggestion_mode
&& register_state.current_field() == 4 {
"select_suggestion"
} else {
"next_field"
};
let msg = if app_state.ui.show_login {
auth_e::execute_edit_action(
effective_action,
key,
login_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
)
.await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action(
effective_action,
key,
add_table_state,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
auth_e::execute_edit_action(
effective_action,
key,
register_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
)
.await?
} else {
// --- 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;
}
}
// Live search trigger while typing
else if form_state.autocomplete_active {
if let KeyCode::Char(_) | KeyCode::Backspace = key.code {
let action = if let KeyCode::Backspace = key.code {
"delete_char_backward"
} else {
"insert_char"
};
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
effective_action,
action,
key,
form_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
&mut event_handler.ideal_cursor_column,
)
.await?
};
.await?;
trigger_search = true;
}
}
}
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" {
// FIX: Pass &mut event_handler.ideal_cursor_column
let msg = form_e::execute_edit_action(
"next_field",
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?;
return Ok(EditEventOutcome::Message(msg));
}
if action == "exit" {
if app_state.ui.show_register && register_state.in_suggestion_mode {
let msg = auth_e::execute_edit_action(
"exit_suggestion_mode",
key,
register_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
)
.await?;
return Ok(EditEventOutcome::Message(msg));
} else {
return Ok(EditEventOutcome::ExitEditMode);
}
// Handle exiting edit mode
if action_str == "exit" {
return Ok(EditEventOutcome::ExitEditMode);
}
// Special handling for role field suggestions (Register view only)
if app_state.ui.show_register && register_state.current_field() == 4 {
if !register_state.in_suggestion_mode
&& key.code == KeyCode::Tab
&& key.modifiers == KeyModifiers::NONE
{
register_state.update_role_suggestions();
if !register_state.role_suggestions.is_empty() {
register_state.in_suggestion_mode = true;
register_state.selected_suggestion_index = Some(0);
return Ok(EditEventOutcome::Message(
"Suggestions shown".to_string(),
));
} else {
return Ok(EditEventOutcome::Message(
"No suggestions available".to_string(),
));
}
}
if register_state.in_suggestion_mode
&& matches!(
action,
"suggestion_down" | "suggestion_up"
)
{
let msg = auth_e::execute_edit_action(
action,
key,
register_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
)
.await?;
return Ok(EditEventOutcome::Message(msg));
}
}
// Execute other edit actions based on the current view
// Handle all other edit actions - NOW USING CANVAS LIBRARY
let msg = if app_state.ui.show_login {
auth_e::execute_edit_action(
action,
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
login_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_table_e::execute_edit_action(
action,
action_str,
key,
add_table_state,
ideal_cursor_column,
&mut admin_state.add_table_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_logic_e::execute_edit_action(
action_str,
key,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
auth_e::execute_edit_action(
action,
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
register_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
&mut event_handler.ideal_cursor_column,
)
.await?
} else {
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
action,
action_str,
key,
form_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
&mut event_handler.ideal_cursor_column,
)
.await?
};
return Ok(EditEventOutcome::Message(msg));
}
// --- Character insertion ---
if let KeyCode::Char(c) = key.code {
if app_state.ui.show_register && register_state.in_suggestion_mode {
register_state.in_suggestion_mode = false;
register_state.show_role_suggestions = false;
register_state.selected_suggestion_index = None;
}
// --- FALLBACK FOR CHARACTER INSERTION (IF NO OTHER BINDING MATCHED) ---
if let KeyCode::Char(_) = key.code {
let msg = if app_state.ui.show_login {
auth_e::execute_edit_action(
"insert_char",
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
login_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_table_e::execute_edit_action(
"insert_char",
key,
add_table_state,
ideal_cursor_column,
&mut admin_state.add_table_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_logic_e::execute_edit_action(
"insert_char",
key,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
auth_e::execute_edit_action(
"insert_char",
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
register_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
&mut event_handler.ideal_cursor_column,
)
.await?
} else {
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
"insert_char",
key,
form_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
&mut event_handler.ideal_cursor_column,
)
.await?
};
if app_state.ui.show_register && register_state.current_field() == 4 {
register_state.update_role_suggestions();
}
return Ok(EditEventOutcome::Message(msg));
}
// --- Handle Backspace/Delete ---
if matches!(key.code, KeyCode::Backspace | KeyCode::Delete) {
if app_state.ui.show_register && register_state.in_suggestion_mode {
register_state.in_suggestion_mode = false;
register_state.show_role_suggestions = false;
register_state.selected_suggestion_index = None;
}
let action_str = if key.code == KeyCode::Backspace {
"delete_char_backward"
} else {
"delete_char_forward"
};
let result_msg: String = if app_state.ui.show_login {
auth_e::execute_edit_action(
action_str,
key,
login_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
)
.await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action(
action_str,
key,
add_table_state,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
auth_e::execute_edit_action(
action_str,
key,
register_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
)
.await?
} else {
form_e::execute_edit_action(
action_str,
key,
form_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count
).await?
};
if app_state.ui.show_register && register_state.current_field() == 4 {
register_state.update_role_suggestions();
}
return Ok(EditEventOutcome::Message(result_msg));
}
Ok(EditEventOutcome::Message("".to_string()))
Ok(EditEventOutcome::Message(String::new())) // No action taken
}

View File

@@ -3,13 +3,69 @@
use crate::config::binds::config::Config;
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::{canvas_state::CanvasState, auth::RegisterState};
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
use crate::state::pages::canvas_state::CanvasState as LocalCanvasState;
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 crate::functions::modes::read_only::{auth_ro, form_ro, add_table_ro};
use crate::functions::modes::read_only::{add_logic_ro, auth_ro, form_ro, add_table_ro};
use canvas::{canvas::{CanvasAction, CanvasState, ActionResult}, dispatcher::ActionDispatcher};
use crossterm::event::KeyEvent;
use anyhow::Result;
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
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(_) => {
// Fall through to try config mapping
}
}
}
// Try config-mapped action
if let Some(action_str) = config.get_read_only_action_for_key(key_event.code, key_event.modifiers) {
let canvas_action = CanvasAction::from_string(&action_str);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(e) => {
return Ok(format!("Action failed: {}", e));
}
}
}
Ok(String::new())
}
pub async fn handle_read_only_event(
app_state: &mut AppState,
@@ -19,14 +75,13 @@ pub async fn handle_read_only_event(
login_state: &mut LoginState,
register_state: &mut RegisterState,
add_table_state: &mut AddTableState,
add_logic_state: &mut AddLogicState,
key_sequence_tracker: &mut KeySequenceTracker,
current_position: &mut u64,
total_count: u64,
grpc_client: &mut GrpcClient,
command_message: &mut String,
edit_mode_cooldown: &mut bool,
ideal_cursor_column: &mut usize,
) -> Result<(bool, String), Box<dyn std::error::Error>> {
) -> 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();
@@ -35,17 +90,46 @@ pub async fn handle_read_only_event(
if config.is_enter_edit_mode_after(key.code, key.modifiers) {
// Determine target state to adjust cursor
let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state }
else if app_state.ui.show_register { register_state }
else if app_state.ui.show_add_table { add_table_state }
else { form_state };
let current_input = target_state.get_current_input();
let current_pos = target_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
target_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = target_state.current_cursor_pos();
if app_state.ui.show_login {
let current_input = login_state.get_current_input();
let current_pos = login_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
login_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = login_state.current_cursor_pos();
}
} else if app_state.ui.show_add_logic {
let current_input = add_logic_state.get_current_input();
let current_pos = add_logic_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
add_logic_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = add_logic_state.current_cursor_pos();
}
} else if app_state.ui.show_register {
let current_input = register_state.get_current_input();
let current_pos = register_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
register_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = register_state.current_cursor_pos();
}
} else if app_state.ui.show_add_table {
let current_input = add_table_state.get_current_input();
let current_pos = add_table_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
add_table_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = add_table_state.current_cursor_pos();
}
} else {
// Handle FormState (uses library CanvasState)
use canvas::canvas::CanvasState as LibraryCanvasState; // Import at the top of the function
let current_input = form_state.get_current_input();
let current_pos = form_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
form_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = form_state.current_cursor_pos();
}
}
*edit_mode_cooldown = true;
*command_message = "Entering Edit mode (after cursor)".to_string();
return Ok((false, command_message.clone()));
@@ -59,10 +143,6 @@ pub async fn handle_read_only_event(
"previous_entry",
"next_entry",
];
// Add context actions specific to register if needed, otherwise reuse login/form ones
const CONTEXT_ACTIONS_REGISTER: &[&str] = &[
// Add actions like "next_field", "prev_field" if handled differently than general read-only
];
if key.modifiers.is_empty() {
key_sequence_tracker.add_key(key.code);
@@ -74,12 +154,10 @@ pub async fn handle_read_only_event(
action,
form_state,
grpc_client,
current_position,
total_count,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table {
add_table_ro::execute_action(
@@ -90,6 +168,15 @@ pub async fn handle_read_only_event(
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register{
auth_ro::execute_action(
action,
@@ -134,12 +221,10 @@ pub async fn handle_read_only_event(
action,
form_state,
grpc_client,
current_position,
total_count,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table {
add_table_ro::execute_action(
@@ -150,7 +235,16 @@ pub async fn handle_read_only_event(
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register /* && CONTEXT_ACTIONS_REGISTER.contains(&action) */ { // Handle register general actions
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register {
auth_ro::execute_action(
action,
app_state,
@@ -159,7 +253,7 @@ pub async fn handle_read_only_event(
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_login { // Handle login general actions
} else if app_state.ui.show_login {
auth_ro::execute_action(
action,
app_state,
@@ -193,8 +287,6 @@ pub async fn handle_read_only_event(
action,
form_state,
grpc_client,
current_position,
total_count,
ideal_cursor_column,
)
.await?
@@ -209,7 +301,16 @@ pub async fn handle_read_only_event(
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register /* && CONTEXT_ACTIONS_REGISTER.contains(&action) */ { // Handle register general actions
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register {
auth_ro::execute_action(
action,
app_state,
@@ -218,7 +319,7 @@ pub async fn handle_read_only_event(
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_login { // Handle login general actions
} else if app_state.ui.show_login {
auth_ro::execute_action(
action,
app_state,

View File

@@ -10,12 +10,12 @@ 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 std::error::Error;
use anyhow::Result;
pub async fn handle_command_event(
key: KeyEvent,
config: &Config,
app_state: &AppState,
app_state: &mut AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &mut FormState,
@@ -26,7 +26,7 @@ pub async fn handle_command_event(
terminal: &mut TerminalCore,
current_position: &mut u64,
total_count: u64,
) -> Result<EventOutcome, Box<dyn Error>> {
) -> Result<EventOutcome> {
// Exit command mode (via configurable keybinding)
if config.is_exit_command_mode(key.code, key.modifiers) {
command_input.clear();
@@ -74,7 +74,7 @@ pub async fn handle_command_event(
async fn process_command(
config: &Config,
form_state: &mut FormState,
app_state: &AppState,
app_state: &mut AppState,
login_state: &LoginState,
register_state: &RegisterState,
command_input: &mut String,
@@ -84,7 +84,7 @@ async fn process_command(
terminal: &mut TerminalCore,
current_position: &mut u64,
total_count: u64,
) -> Result<EventOutcome, Box<dyn Error>> {
) -> Result<EventOutcome> {
// Clone the trimmed command to avoid borrow issues
let command = command_input.trim().to_string();
if command.is_empty() {
@@ -117,10 +117,9 @@ async fn process_command(
},
"save" => {
let outcome = save(
app_state,
form_state,
grpc_client,
current_position,
total_count,
).await?;
let message = match outcome {
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
@@ -134,8 +133,6 @@ async fn process_command(
let message = revert(
form_state,
grpc_client,
current_position,
total_count,
).await?;
command_input.clear();
Ok(EventOutcome::Ok(message))

View File

@@ -2,7 +2,8 @@
use crate::tui::terminal::core::TerminalCore;
use crate::state::app::state::AppState;
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState};
use crate::state::pages::canvas_state::CanvasState;
use canvas::canvas::CanvasState;
use anyhow::Result;
pub struct CommandHandler;
@@ -19,7 +20,7 @@ impl CommandHandler {
form_state: &FormState,
login_state: &LoginState,
register_state: &RegisterState,
) -> Result<(bool, String), Box<dyn std::error::Error>> {
) -> 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,
@@ -35,7 +36,7 @@ impl CommandHandler {
form_state: &FormState,
login_state: &LoginState,
register_state: &RegisterState,
) -> Result<(bool, String), Box<dyn std::error::Error>> {
) -> 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()
@@ -56,7 +57,7 @@ impl CommandHandler {
async fn handle_force_quit(
&self,
terminal: &mut TerminalCore,
) -> Result<(bool, String), Box<dyn std::error::Error>> {
) -> Result<(bool, String)> {
terminal.cleanup()?;
Ok((true, "Force exiting without saving.".into()))
}
@@ -64,7 +65,7 @@ impl CommandHandler {
async fn handle_save_quit(
&mut self,
terminal: &mut TerminalCore,
) -> Result<(bool, String), Box<dyn std::error::Error>> {
) -> Result<(bool, String)> {
terminal.cleanup()?;
Ok((true, "State saved. Exiting.".into()))
}

View File

@@ -1,3 +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

@@ -3,14 +3,14 @@
use crossterm::event::{Event, KeyCode};
use crate::config::binds::config::Config;
use crate::ui::handlers::context::DialogPurpose;
use crate::state::app::state::AppState;
use crate::state::app::{state::AppState, buffer::AppView};
use crate::state::app::buffer::BufferState;
use crate::state::pages::auth::AuthState;
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),
@@ -19,12 +19,11 @@ pub async fn handle_dialog_event(
event: &Event,
config: &Config,
app_state: &mut AppState,
auth_state: &mut AuthState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
buffer_state: &mut BufferState,
admin_state: &mut AdminState,
) -> Option<Result<EventOutcome, Box<dyn std::error::Error>>> {
) -> Option<Result<EventOutcome>> {
if let Event::Key(key) = event {
// Always allow Esc to dismiss
if key.code == KeyCode::Esc {
@@ -130,6 +129,26 @@ pub async fn handle_dialog_event(
_ => { /* 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

View File

@@ -8,9 +8,11 @@ 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::state::pages::canvas_state::CanvasState;
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,
@@ -24,7 +26,13 @@ pub async fn handle_navigation_event(
command_mode: &mut bool,
command_input: &mut String,
command_message: &mut String,
) -> Result<EventOutcome, 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" => {

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
// src/modes/handlers/event_helper.rs
//! Helper functions to handle the differences between legacy and library CanvasState traits
use crate::state::app::state::AppState;
use crate::state::pages::{
form::FormState,
auth::{LoginState, RegisterState},
};
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
use canvas::canvas::CanvasState as LibraryCanvasState;
/// Get the current field index from the appropriate state based on which UI is active
pub fn get_current_field_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login {
login_state.current_field() // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.current_field() // Uses LegacyCanvasState
} else {
form_state.current_field() // Uses LibraryCanvasState
}
}
/// Get the current cursor position from the appropriate state based on which UI is active
pub fn get_current_cursor_pos_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login {
login_state.current_cursor_pos() // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.current_cursor_pos() // Uses LegacyCanvasState
} else {
form_state.current_cursor_pos() // Uses LibraryCanvasState
}
}
/// Check if the appropriate state has unsaved changes based on which UI is active
pub fn get_has_unsaved_changes_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> bool {
if app_state.ui.show_login {
login_state.has_unsaved_changes() // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.has_unsaved_changes() // Uses LegacyCanvasState
} else {
form_state.has_unsaved_changes() // Uses LibraryCanvasState
}
}
/// Get the current input from the appropriate state based on which UI is active
pub fn get_current_input_for_state<'a>(
app_state: &AppState,
login_state: &'a LoginState,
register_state: &'a RegisterState,
form_state: &'a FormState,
) -> &'a str {
if app_state.ui.show_login {
login_state.get_current_input() // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.get_current_input() // Uses LegacyCanvasState
} else {
form_state.get_current_input() // Uses LibraryCanvasState
}
}
/// Set the cursor position for the appropriate state based on which UI is active
pub fn set_current_cursor_pos_for_state(
app_state: &AppState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
form_state: &mut FormState,
pos: usize,
) {
if app_state.ui.show_login {
login_state.set_current_cursor_pos(pos); // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.set_current_cursor_pos(pos); // Uses LegacyCanvasState
} else {
form_state.set_current_cursor_pos(pos); // Uses LibraryCanvasState
}
}
/// Get cursor position for mixed login/register vs form logic
pub fn get_cursor_pos_for_mixed_state(
app_state: &AppState,
login_state: &LoginState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login || app_state.ui.show_register {
login_state.current_cursor_pos() // Uses LegacyCanvasState
} else {
form_state.current_cursor_pos() // Uses LibraryCanvasState
}
}

View File

@@ -1,8 +1,8 @@
// src/modes/handlers/mode_manager.rs
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::add_table::AddTableFocus;
use crate::state::pages::admin::AdminState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -18,7 +18,15 @@ 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;
}
@@ -27,16 +35,28 @@ impl ModeManager {
return AppMode::Highlight;
}
if app_state.ui.focus_outside_canvas {
return AppMode::General;
}
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_table
|| app_state.ui.show_add_logic;
if is_canvas_view {
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_add_table {
if app_state.ui.focus_outside_canvas {
AppMode::General
} else {
@@ -46,20 +66,30 @@ impl ModeManager {
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
}
}
// 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 | AppMode::Highlight)
}

View File

@@ -6,11 +6,12 @@ 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::add_table::AddTableState;
use crate::state::pages::admin::AdminState;
use crate::state::pages::form::FormState;
use crate::modes::handlers::event::EventOutcome;
use crate::modes::read_only; // Import the ReadOnly handler
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.
@@ -22,7 +23,7 @@ pub async fn handle_highlight_event(
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
add_table_state: &mut AddTableState,
admin_state: &mut AdminState,
key_sequence_tracker: &mut KeySequenceTracker,
current_position: &mut u64,
total_count: u64,
@@ -30,7 +31,7 @@ pub async fn handle_highlight_event(
command_message: &mut String,
edit_mode_cooldown: &mut bool,
ideal_cursor_column: &mut usize,
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
) -> 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(
@@ -40,10 +41,9 @@ pub async fn handle_highlight_event(
form_state,
login_state,
register_state,
add_table_state,
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
key_sequence_tracker,
current_position,
total_count,
grpc_client,
command_message, // Pass the message buffer
edit_mode_cooldown,

View File

@@ -9,4 +9,3 @@ pub use handlers::*;
pub use canvas::*;
pub use general::*;
pub use common::*;
pub use highlight::*;

View File

@@ -1,23 +1,27 @@
// src/services/auth.rs
use tonic::transport::Channel;
use common::proto::multieko2::auth::{
use common::proto::komp_ac::auth::{
auth_service_client::AuthServiceClient,
LoginRequest, LoginResponse,
RegisterRequest, AuthResponse,
};
use anyhow::{Context, Result};
#[derive(Clone)]
pub struct AuthClient {
client: AuthServiceClient<Channel>,
}
impl AuthClient {
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
let client = AuthServiceClient::connect("http://[::1]:50051").await?;
pub async fn new() -> Result<Self> {
let client = AuthServiceClient::connect("http://[::1]:50051")
.await
.context("Failed to connect to auth service")?;
Ok(Self { client })
}
/// Login user via gRPC.
pub async fn login(&mut self, identifier: String, password: String) -> Result<LoginResponse, Box<dyn std::error::Error>> {
pub async fn login(&mut self, identifier: String, password: String) -> Result<LoginResponse> {
let request = tonic::Request::new(LoginRequest { identifier, password });
let response = self.client.login(request).await?.into_inner();
Ok(response)
@@ -31,7 +35,7 @@ impl AuthClient {
password: Option<String>,
password_confirmation: Option<String>,
role: Option<String>,
) -> Result<AuthResponse, Box<dyn std::error::Error>> {
) -> Result<AuthResponse> {
let request = tonic::Request::new(RegisterRequest {
username,
email,

View File

@@ -1,69 +1,257 @@
// src/services/grpc_client.rs
use tonic::transport::Channel;
use common::proto::multieko2::adresar::adresar_client::AdresarClient;
use common::proto::multieko2::adresar::{AdresarResponse, PostAdresarRequest, PutAdresarRequest};
use common::proto::multieko2::common::{CountResponse, PositionRequest, Empty};
use common::proto::multieko2::table_structure::table_structure_service_client::TableStructureServiceClient;
use common::proto::multieko2::table_structure::TableStructureResponse;
use common::proto::multieko2::table_definition::{
use common::proto::komp_ac::common::Empty;
use common::proto::komp_ac::table_structure::table_structure_service_client::TableStructureServiceClient;
use common::proto::komp_ac::table_structure::{GetTableStructureRequest, TableStructureResponse};
use common::proto::komp_ac::table_definition::{
table_definition_client::TableDefinitionClient,
ProfileTreeResponse
PostTableDefinitionRequest, ProfileTreeResponse, TableDefinitionResponse,
};
use common::proto::komp_ac::table_script::{
table_script_client::TableScriptClient,
PostTableScriptRequest, TableScriptResponse,
};
use common::proto::komp_ac::tables_data::{
tables_data_client::TablesDataClient,
GetTableDataByPositionRequest,
GetTableDataRequest, // ADD THIS
GetTableDataResponse,
DeleteTableDataRequest, // ADD THIS
DeleteTableDataResponse, // ADD THIS
GetTableDataCountRequest,
PostTableDataRequest, PostTableDataResponse, PutTableDataRequest,
PutTableDataResponse,
};
use common::proto::komp_ac::search::{
searcher_client::SearcherClient, SearchRequest, SearchResponse,
};
use anyhow::{Context, Result};
use std::collections::HashMap;
use tonic::transport::Channel;
use prost_types::Value;
#[derive(Clone)]
pub struct GrpcClient {
adresar_client: AdresarClient<Channel>,
table_structure_client: TableStructureServiceClient<Channel>,
table_definition_client: TableDefinitionClient<Channel>,
table_script_client: TableScriptClient<Channel>,
tables_data_client: TablesDataClient<Channel>,
search_client: SearcherClient<Channel>,
}
impl GrpcClient {
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
let adresar_client = AdresarClient::connect("http://[::1]:50051").await?;
let table_structure_client = TableStructureServiceClient::connect("http://[::1]:50051").await?;
let table_definition_client = TableDefinitionClient::connect("http://[::1]:50051").await?;
pub async fn new() -> Result<Self> {
let channel = Channel::from_static("http://[::1]:50051")
.connect()
.await
.context("Failed to create gRPC channel")?;
let table_structure_client =
TableStructureServiceClient::new(channel.clone());
let table_definition_client =
TableDefinitionClient::new(channel.clone());
let table_script_client = TableScriptClient::new(channel.clone());
let tables_data_client = TablesDataClient::new(channel.clone());
let search_client = SearcherClient::new(channel.clone());
Ok(Self {
adresar_client,
table_structure_client,
table_definition_client,
table_script_client,
tables_data_client,
search_client,
})
}
pub async fn get_adresar_count(&mut self) -> Result<u64, Box<dyn std::error::Error>> {
let request = tonic::Request::new(Empty::default());
let response: CountResponse = self.adresar_client.get_adresar_count(request).await?.into_inner();
Ok(response.count as u64)
}
pub async fn get_adresar_by_position(&mut self, position: u64) -> Result<AdresarResponse, Box<dyn std::error::Error>> {
let request = tonic::Request::new(PositionRequest { position: position as i64 });
let response: AdresarResponse = self.adresar_client.get_adresar_by_position(request).await?.into_inner();
Ok(response)
}
pub async fn post_adresar(&mut self, request: PostAdresarRequest) -> Result<tonic::Response<AdresarResponse>, Box<dyn std::error::Error>> {
let request = tonic::Request::new(request);
let response = self.adresar_client.post_adresar(request).await?;
Ok(response)
}
pub async fn put_adresar(&mut self, request: PutAdresarRequest) -> Result<tonic::Response<AdresarResponse>, Box<dyn std::error::Error>> {
let request = tonic::Request::new(request);
let response = self.adresar_client.put_adresar(request).await?;
Ok(response)
}
pub async fn get_table_structure(&mut self) -> Result<TableStructureResponse, Box<dyn std::error::Error>> {
let request = tonic::Request::new(Empty::default());
let response = self.table_structure_client.get_adresar_table_structure(request).await?;
pub async fn get_table_structure(
&mut self,
profile_name: String,
table_name: String,
) -> Result<TableStructureResponse> {
let grpc_request = GetTableStructureRequest {
profile_name,
table_name,
};
let request = tonic::Request::new(grpc_request);
let response = self
.table_structure_client
.get_table_structure(request)
.await
.context("gRPC GetTableStructure call failed")?;
Ok(response.into_inner())
}
pub async fn get_profile_tree(&mut self) -> Result<ProfileTreeResponse, Box<dyn std::error::Error>> {
pub async fn get_profile_tree(
&mut self,
) -> Result<ProfileTreeResponse> {
let request = tonic::Request::new(Empty::default());
let response = self.table_definition_client.get_profile_tree(request).await?;
let response = self
.table_definition_client
.get_profile_tree(request)
.await
.context("gRPC GetProfileTree call failed")?;
Ok(response.into_inner())
}
pub async fn post_table_definition(
&mut self,
request: PostTableDefinitionRequest,
) -> Result<TableDefinitionResponse> {
let tonic_request = tonic::Request::new(request);
let response = self
.table_definition_client
.post_table_definition(tonic_request)
.await
.context("gRPC PostTableDefinition call failed")?;
Ok(response.into_inner())
}
pub async fn post_table_script(
&mut self,
request: PostTableScriptRequest,
) -> Result<TableScriptResponse> {
let tonic_request = tonic::Request::new(request);
let response = self
.table_script_client
.post_table_script(tonic_request)
.await
.context("gRPC PostTableScript call failed")?;
Ok(response.into_inner())
}
// Existing TablesData methods
pub async fn get_table_data_count(
&mut self,
profile_name: String,
table_name: String,
) -> Result<u64> {
let grpc_request = GetTableDataCountRequest {
profile_name,
table_name,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.get_table_data_count(request)
.await
.context("gRPC GetTableDataCount call failed")?;
Ok(response.into_inner().count as u64)
}
pub async fn get_table_data_by_position(
&mut self,
profile_name: String,
table_name: String,
position: i32,
) -> Result<GetTableDataResponse> {
let grpc_request = GetTableDataByPositionRequest {
profile_name,
table_name,
position,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.get_table_data_by_position(request)
.await
.context("gRPC GetTableDataByPosition call failed")?;
Ok(response.into_inner())
}
// ADD THIS: Missing get_table_data method
pub async fn get_table_data(
&mut self,
profile_name: String,
table_name: String,
id: i64,
) -> Result<GetTableDataResponse> {
let grpc_request = GetTableDataRequest {
profile_name,
table_name,
id,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.get_table_data(request)
.await
.context("gRPC GetTableData call failed")?;
Ok(response.into_inner())
}
// ADD THIS: Missing delete_table_data method
pub async fn delete_table_data(
&mut self,
profile_name: String,
table_name: String,
record_id: i64,
) -> Result<DeleteTableDataResponse> {
let grpc_request = DeleteTableDataRequest {
profile_name,
table_name,
record_id,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.delete_table_data(request)
.await
.context("gRPC DeleteTableData call failed")?;
Ok(response.into_inner())
}
pub async fn post_table_data(
&mut self,
profile_name: String,
table_name: String,
data: HashMap<String, Value>,
) -> Result<PostTableDataResponse> {
let grpc_request = PostTableDataRequest {
profile_name,
table_name,
data,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.post_table_data(request)
.await
.context("gRPC PostTableData call failed")?;
Ok(response.into_inner())
}
pub async fn put_table_data(
&mut self,
profile_name: String,
table_name: String,
id: i64,
data: HashMap<String, Value>,
) -> Result<PutTableDataResponse> {
let grpc_request = PutTableDataRequest {
profile_name,
table_name,
id,
data,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.put_table_data(request)
.await
.context("gRPC PutTableData call failed")?;
Ok(response.into_inner())
}
pub async fn search_table(
&mut self,
table_name: String,
query: String,
) -> Result<SearchResponse> {
let request = tonic::Request::new(SearchRequest { table_name, query });
let response = self
.search_client
.search_table(request)
.await?;
Ok(response.into_inner())
}
}

View File

@@ -1,112 +1,317 @@
// src/services/ui_service.rs
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::form::FormState;
use crate::tui::functions::common::form::SaveOutcome;
use crate::state::app::state::AppState;
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::form::{FieldDefinition, FormState};
use crate::tui::functions::common::form::SaveOutcome;
use crate::utils::columns::filter_user_columns;
use anyhow::{anyhow, Context, Result};
use std::sync::Arc;
pub struct UiService;
impl UiService {
pub async fn initialize_app_state(
pub async fn load_table_view(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
// Fetch profile tree
let profile_tree = grpc_client.get_profile_tree().await?;
app_state.profile_tree = profile_tree;
profile_name: &str,
table_name: &str,
) -> Result<FormState> {
// 1. & 2. Fetch and Cache Schema - UNCHANGED
let table_structure = grpc_client
.get_table_structure(profile_name.to_string(), table_name.to_string())
.await
.context(format!(
"Failed to get table structure for {}.{}",
profile_name, table_name
))?;
let cache_key = format!("{}.{}", profile_name, table_name);
app_state
.schema_cache
.insert(cache_key, Arc::new(table_structure.clone()));
tracing::info!("Schema for '{}.{}' cached.", profile_name, table_name);
// Fetch table structure
let table_structure = grpc_client.get_table_structure().await?;
// --- START: FINAL, SIMPLIFIED, CORRECT LOGIC ---
// Extract the column names from the response
let column_names: Vec<String> = table_structure
// 3a. Create definitions for REGULAR fields first.
let mut fields: Vec<FieldDefinition> = table_structure
.columns
.iter()
.map(|col| col.name.clone())
.filter(|col| {
!col.is_primary_key
&& col.name != "deleted"
&& col.name != "created_at"
&& !col.name.ends_with("_id") // Filter out ALL potential links
})
.map(|col| FieldDefinition {
display_name: col.name.clone(),
data_key: col.name.clone(),
is_link: false,
link_target_table: None,
})
.collect();
Ok(column_names)
// 3b. Now, find and APPEND definitions for LINK fields based on the `_id` convention.
let link_fields: Vec<FieldDefinition> = table_structure
.columns
.iter()
.filter(|col| col.name.ends_with("_id")) // Find all foreign key columns
.map(|col| {
// The table we link to is derived from the column name.
// e.g., "test_diacritics_id" -> "test_diacritics"
let target_table_base = col
.name
.strip_suffix("_id")
.unwrap_or(&col.name);
// Find the full table name from the profile tree for display.
// e.g., "test_diacritics" -> "2025_test_diacritics"
let full_target_table_name = app_state
.profile_tree
.profiles
.iter()
.find(|p| p.name == profile_name)
.and_then(|p| p.tables.iter().find(|t| t.name.ends_with(target_table_base)))
.map_or(target_table_base.to_string(), |t| t.name.clone());
FieldDefinition {
display_name: full_target_table_name.clone(),
data_key: col.name.clone(), // The actual FK column name
is_link: true,
link_target_table: Some(full_target_table_name),
}
})
.collect();
fields.extend(link_fields); // Append the link fields to the end
// --- END: FINAL, SIMPLIFIED, CORRECT LOGIC ---
Ok(FormState::new(
profile_name.to_string(),
table_name.to_string(),
fields,
))
}
pub async fn initialize_adresar_count(
pub async fn initialize_add_logic_table_data(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
) -> Result<(), Box<dyn std::error::Error>> {
let total_count = grpc_client.get_adresar_count().await?;
app_state.update_total_count(total_count);
app_state.update_current_position(total_count.saturating_add(1)); // Start in new entry mode
Ok(())
add_logic_state: &mut AddLogicState,
profile_tree: &common::proto::komp_ac::table_definition::ProfileTreeResponse,
) -> Result<String> {
let profile_name_clone_opt = Some(add_logic_state.profile_name.clone());
let table_name_opt_clone = add_logic_state.selected_table_name.clone();
// Collect table names from SAME profile only
let same_profile_table_names: Vec<String> = profile_tree.profiles
.iter()
.find(|profile| profile.name == add_logic_state.profile_name)
.map(|profile| profile.tables.iter().map(|table| table.name.clone()).collect())
.unwrap_or_default();
// Set same profile table names for autocomplete
add_logic_state.set_same_profile_table_names(same_profile_table_names.clone());
if let (Some(profile_name_clone), Some(table_name_clone)) = (profile_name_clone_opt, table_name_opt_clone) {
match grpc_client.get_table_structure(profile_name_clone.clone(), table_name_clone.clone()).await {
Ok(response) => {
let column_names: Vec<String> = response.columns
.into_iter()
.map(|col| col.name)
.collect();
add_logic_state.set_table_columns(column_names.clone());
Ok(format!(
"Loaded {} columns for table '{}' and {} tables from profile '{}'",
column_names.len(),
table_name_clone,
same_profile_table_names.len(),
add_logic_state.profile_name
))
}
Err(e) => {
tracing::warn!(
"Failed to fetch table structure for {}.{}: {}",
profile_name_clone,
table_name_clone,
e
);
Ok(format!(
"Warning: Could not load table structure for '{}'. Autocomplete will use {} tables from profile '{}'.",
table_name_clone,
same_profile_table_names.len(),
add_logic_state.profile_name
))
}
}
} else {
Ok(format!(
"No table selected for Add Logic. Loaded {} tables from profile '{}' for autocomplete.",
same_profile_table_names.len(),
add_logic_state.profile_name
))
}
}
pub async fn update_adresar_count(
/// Fetches columns for a specific table (used for table.column autocomplete)
pub async fn fetch_columns_for_table(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
) -> Result<(), Box<dyn std::error::Error>> {
let total_count = grpc_client.get_adresar_count().await?;
app_state.update_total_count(total_count);
Ok(())
}
pub async fn load_adresar_by_position(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
form_state: &mut FormState,
position: u64,
) -> Result<String, Box<dyn std::error::Error>> {
match grpc_client.get_adresar_by_position(position).await {
profile_name: &str,
table_name: &str,
) -> Result<Vec<String>> {
match grpc_client.get_table_structure(profile_name.to_string(), table_name.to_string()).await {
Ok(response) => {
// Set the ID properly
form_state.id = response.id;
// Update form values dynamically
form_state.values = vec![
response.firma,
response.kz,
response.drc,
response.ulica,
response.psc,
response.mesto,
response.stat,
response.banka,
response.ucet,
response.skladm,
response.ico,
response.kontakt,
response.telefon,
response.skladu,
response.fax,
];
form_state.has_unsaved_changes = false;
Ok(format!("Loaded entry {}", position))
let column_names: Vec<String> = response.columns
.into_iter()
.map(|col| col.name)
.collect();
Ok(filter_user_columns(column_names))
}
Err(e) => {
Ok(format!("Error loading entry: {}", e))
tracing::warn!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
Err(e.into())
}
}
}
/// Handles the consequences of a save operation, like updating counts.
pub async fn handle_save_outcome(
save_outcome: SaveOutcome,
// TODO REFACTOR (maybe)
pub async fn initialize_app_state_and_form(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
form_state: &mut FormState, // Needed to potentially update position/ID
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(String, String, Vec<String>)> {
let profile_tree = grpc_client
.get_profile_tree()
.await
.context("Failed to get profile tree")?;
app_state.profile_tree = profile_tree;
// Find first profile that contains tables
let (initial_profile_name, initial_table_name) = app_state
.profile_tree
.profiles
.iter()
.find(|profile| !profile.tables.is_empty())
.and_then(|profile| {
profile.tables.first().map(|table| {
(profile.name.clone(), table.name.clone())
})
})
.ok_or_else(|| anyhow!("No profiles with tables found. Create a table first."))?;
app_state.set_current_view_table(
initial_profile_name.clone(),
initial_table_name.clone(),
);
let form_state = Self::load_table_view(
grpc_client,
app_state,
&initial_profile_name,
&initial_table_name,
)
.await?;
let field_names = form_state.fields.iter().map(|f| f.display_name.clone()).collect();
Ok((initial_profile_name, initial_table_name, field_names))
}
pub async fn fetch_and_set_table_count(
grpc_client: &mut GrpcClient,
form_state: &mut FormState,
) -> Result<()> {
let total_count = grpc_client
.get_table_data_count(
form_state.profile_name.clone(),
form_state.table_name.clone(),
)
.await
.context(format!(
"Failed to get count for table {}.{}",
form_state.profile_name, form_state.table_name
))?;
form_state.total_count = total_count;
if total_count > 0 {
form_state.current_position = total_count;
} else {
form_state.current_position = 1;
}
Ok(())
}
pub async fn load_table_data_by_position(
grpc_client: &mut GrpcClient,
form_state: &mut FormState,
) -> Result<String> {
if form_state.current_position == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) {
form_state.reset_to_empty();
return Ok(format!(
"New entry mode for table {}.{}",
form_state.profile_name, form_state.table_name
));
}
if form_state.total_count == 0 && form_state.current_position == 1 {
form_state.reset_to_empty();
return Ok(format!(
"New entry mode for empty table {}.{}",
form_state.profile_name, form_state.table_name
));
}
match grpc_client
.get_table_data_by_position(
form_state.profile_name.clone(),
form_state.table_name.clone(),
form_state.current_position as i32,
)
.await
{
Ok(response) => {
// FIX: Pass the current position as the second argument
form_state.update_from_response(&response.data, form_state.current_position);
Ok(format!(
"Loaded entry {}/{} for table {}.{}",
form_state.current_position,
form_state.total_count,
form_state.profile_name,
form_state.table_name
))
}
Err(e) => {
tracing::error!(
"Error loading entry {} for table {}.{}: {}",
form_state.current_position,
form_state.profile_name,
form_state.table_name,
e
);
Err(anyhow::anyhow!(
"Error loading entry {}: {}",
form_state.current_position,
e
))
}
}
}
pub async fn handle_save_outcome(
save_outcome: SaveOutcome,
_grpc_client: &mut GrpcClient,
_app_state: &mut AppState,
form_state: &mut FormState,
) -> Result<()> {
match save_outcome {
SaveOutcome::CreatedNew(new_id) => {
// A new record was created, update the count!
UiService::update_adresar_count(grpc_client, app_state).await?;
// Navigate to the new record (now that count is updated)
app_state.update_current_position(app_state.total_count);
form_state.id = new_id; // Ensure ID is set (might be redundant if save already did it)
form_state.id = new_id;
}
SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => {
// No count update needed for these outcomes
// No action needed
}
}
Ok(())
}
}

View File

@@ -2,4 +2,5 @@
pub mod state;
pub mod buffer;
pub mod search;
pub mod highlight;

View File

@@ -1,6 +1,5 @@
// src/state/app/buffer.rs
/// Represents the distinct views or "buffers" the user can navigate.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppView {
Intro,
@@ -8,12 +7,14 @@ pub enum AppView {
Register,
Admin,
AddTable,
Form(String),
AddLogic,
Form,
Scratch,
}
impl AppView {
/// Returns the display name for the view.
/// For Form, pass the current table name to get dynamic naming.
pub fn display_name(&self) -> &str {
match self {
AppView::Intro => "Intro",
@@ -21,13 +22,25 @@ impl AppView {
AppView::Register => "Register",
AppView::Admin => "Admin_Panel",
AppView::AddTable => "Add_Table",
AppView::Form(name) => name.as_str(),
AppView::AddLogic => "Add_Logic",
AppView::Form => "Form",
AppView::Scratch => "*scratch*",
}
}
/// Returns the display name with dynamic context (for Form buffers)
pub fn display_name_with_context(&self, current_table_name: Option<&str>) -> String {
match self {
AppView::Form => {
current_table_name
.unwrap_or("Data Form")
.to_string()
}
_ => self.display_name().to_string(),
}
}
}
/// Holds the state related to buffer management (navigation history).
#[derive(Debug, Clone)]
pub struct BufferState {
pub history: Vec<AppView>,
@@ -37,23 +50,17 @@ pub struct BufferState {
impl Default for BufferState {
fn default() -> Self {
Self {
history: vec![AppView::Intro], // Start with Intro view
history: vec![AppView::Intro],
active_index: 0,
}
}
}
impl BufferState {
/// Updates the buffer history and active index.
/// If the view already exists, it sets it as active.
/// Otherwise, it adds the new view and makes it active.
pub fn update_history(&mut self, view: AppView) {
let existing_pos = self.history.iter().position(|v| v == &view);
match existing_pos {
Some(pos) => {
self.active_index = pos;
}
Some(pos) => self.active_index = pos,
None => {
self.history.push(view.clone());
self.active_index = self.history.len() - 1;
@@ -61,34 +68,52 @@ impl BufferState {
}
}
/// Gets the currently active view.
pub fn get_active_view(&self) -> Option<&AppView> {
self.history.get(self.active_index)
}
/// Removes the currently active buffer from the history, unless it's the Intro buffer.
/// Sets the new active buffer to the one preceding the closed one.
/// # Returns
/// * `true` if a non-Intro buffer was closed.
/// * `false` if the active buffer was Intro or only Intro remained.
pub fn close_active_buffer(&mut self) -> bool {
let current_index = self.active_index;
// Rule 1: Cannot close Intro buffer.
if matches!(self.history.get(current_index), Some(AppView::Intro)) {
if self.history.is_empty() {
self.history.push(AppView::Intro);
self.active_index = 0;
return false;
}
// Rule 2: Cannot close if only Intro would remain (or already remains).
// This check implicitly covers the case where len <= 1.
if self.history.len() <= 1 {
return false;
}
let current_index = self.active_index;
self.history.remove(current_index);
self.active_index = current_index.saturating_sub(1).min(self.history.len() - 1);
if self.history.is_empty() {
self.history.push(AppView::Intro);
self.active_index = 0;
} else if self.active_index >= self.history.len() {
self.active_index = self.history.len() - 1;
}
true
}
}
pub fn close_buffer_with_intro_fallback(&mut self, current_table_name: Option<&str>) -> String {
let current_view_cloned = self.get_active_view().cloned();
if let Some(AppView::Intro) = current_view_cloned {
if self.history.len() == 1 {
self.close_active_buffer();
return "Intro buffer reset".to_string();
}
}
let closed_name = current_view_cloned
.as_ref()
.map(|v| v.display_name_with_context(current_table_name))
.unwrap_or_else(|| "Unknown".to_string());
if self.close_active_buffer() {
if self.history.len() == 1 && matches!(self.history.get(0), Some(AppView::Intro)) {
format!("Closed '{}' - returned to Intro", closed_name)
} else {
format!("Closed '{}'", closed_name)
}
} else {
format!("Buffer '{}' could not be closed", closed_name)
}
}
}

View File

@@ -0,0 +1,56 @@
// src/state/app/search.rs
use common::proto::komp_ac::search::search_response::Hit;
/// Holds the complete state for the search palette.
pub struct SearchState {
/// The name of the table being searched.
pub table_name: String,
/// The current text entered by the user.
pub input: String,
/// The position of the cursor within the input text.
pub cursor_position: usize,
/// The search results returned from the server.
pub results: Vec<Hit>,
/// The index of the currently selected search result.
pub selected_index: usize,
/// A flag to indicate if a search is currently in progress.
pub is_loading: bool,
}
impl SearchState {
/// Creates a new SearchState for a given table.
pub fn new(table_name: String) -> Self {
Self {
table_name,
input: String::new(),
cursor_position: 0,
results: Vec::new(),
selected_index: 0,
is_loading: false,
}
}
/// Moves the selection to the next item, wrapping around if at the end.
pub fn next_result(&mut self) {
if !self.results.is_empty() {
let next = self.selected_index + 1;
self.selected_index = if next >= self.results.len() {
0 // Wrap to the start
} else {
next
};
}
}
/// Moves the selection to the previous item, wrapping around if at the beginning.
pub fn previous_result(&mut self) {
if !self.results.is_empty() {
self.selected_index = if self.selected_index == 0 {
self.results.len() - 1 // Wrap to the end
} else {
self.selected_index - 1
};
}
}
}

View File

@@ -1,10 +1,19 @@
// src/state/state.rs
// src/state/app/state.rs
use std::env;
use common::proto::multieko2::table_definition::ProfileTreeResponse;
use anyhow::Result;
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
// NEW: Import the types we need for the cache
use common::proto::komp_ac::table_structure::TableStructureResponse;
use crate::modes::handlers::mode_manager::AppMode;
use crate::state::app::search::SearchState;
use crate::ui::handlers::context::DialogPurpose;
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
#[cfg(feature = "ui-debug")]
use std::time::Instant;
// --- DialogState and UiState are unchanged ---
pub struct DialogState {
pub dialog_show: bool,
pub dialog_title: String,
@@ -21,60 +30,79 @@ pub struct UiState {
pub show_intro: bool,
pub show_admin: bool,
pub show_add_table: bool,
pub show_add_logic: bool,
pub show_form: bool,
pub show_login: bool,
pub show_register: bool,
pub show_search_palette: bool,
pub focus_outside_canvas: bool,
pub dialog: DialogState,
}
#[cfg(feature = "ui-debug")]
#[derive(Debug, Clone)]
pub struct DebugState {
pub displayed_message: String,
pub is_error: bool,
pub display_start_time: Instant,
}
pub struct AppState {
// Core editor state
pub current_dir: String,
pub total_count: u64,
pub current_position: u64,
pub profile_tree: ProfileTreeResponse,
pub selected_profile: Option<String>,
pub current_mode: AppMode,
pub current_view_profile_name: Option<String>,
pub current_view_table_name: Option<String>,
// NEW: The "Rulebook" cache. We use Arc for efficient sharing.
pub schema_cache: HashMap<String, Arc<TableStructureResponse>>,
pub focused_button_index: usize,
pub pending_table_structure_fetch: Option<(String, String)>,
pub search_state: Option<SearchState>,
// UI preferences
pub ui: UiState,
#[cfg(feature = "ui-debug")]
pub debug_state: Option<DebugState>,
}
impl AppState {
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
let current_dir = env::current_dir()?
.to_string_lossy()
.to_string();
pub fn new() -> Result<Self> {
let current_dir = env::current_dir()?.to_string_lossy().to_string();
Ok(AppState {
current_dir,
total_count: 0,
current_position: 0,
profile_tree: ProfileTreeResponse::default(),
selected_profile: None,
current_view_profile_name: None,
current_view_table_name: None,
current_mode: AppMode::General,
schema_cache: HashMap::new(), // NEW: Initialize the cache
focused_button_index: 0,
pending_table_structure_fetch: None,
search_state: None,
ui: UiState::default(),
#[cfg(feature = "ui-debug")]
debug_state: None,
})
}
// Existing methods remain unchanged
pub fn update_total_count(&mut self, total_count: u64) {
self.total_count = total_count;
}
pub fn update_current_position(&mut self, current_position: u64) {
self.current_position = current_position;
}
// --- ALL YOUR EXISTING METHODS ARE UNTOUCHED ---
pub fn update_mode(&mut self, mode: AppMode) {
self.current_mode = mode;
}
// Add dialog helper methods
/// Shows a dialog with the given title, message, and buttons.
/// The first button (index 0) is active by default.
pub fn set_current_view_table(&mut self, profile_name: String, table_name: String) {
self.current_view_profile_name = Some(profile_name);
self.current_view_table_name = Some(table_name);
}
pub fn show_dialog(
&mut self,
title: &str,
@@ -92,19 +120,17 @@ impl AppState {
self.ui.focus_outside_canvas = true;
}
/// Shows a dialog specifically for loading states.
pub fn show_loading_dialog(&mut self, title: &str, message: &str) {
self.ui.dialog.dialog_title = title.to_string();
self.ui.dialog.dialog_message = message.to_string();
self.ui.dialog.dialog_buttons.clear(); // No buttons during loading
self.ui.dialog.dialog_buttons.clear();
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = None; // Purpose is set when loading finishes
self.ui.dialog.purpose = None;
self.ui.dialog.is_loading = true;
self.ui.dialog.dialog_show = true;
self.ui.focus_outside_canvas = true; // Keep focus management consistent
self.ui.focus_outside_canvas = true;
}
/// Updates the content of an existing dialog, typically after loading.
pub fn update_dialog_content(
&mut self,
message: &str,
@@ -114,15 +140,12 @@ impl AppState {
if self.ui.dialog.dialog_show {
self.ui.dialog.dialog_message = message.to_string();
self.ui.dialog.dialog_buttons = buttons;
self.ui.dialog.dialog_active_button_index = 0; // Reset focus
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = Some(purpose);
self.ui.dialog.is_loading = false; // Loading finished
// Keep dialog_show = true
// Keep focus_outside_canvas = true
self.ui.dialog.is_loading = false;
}
}
/// Hides the dialog and clears its content.
pub fn hide_dialog(&mut self) {
self.ui.dialog.dialog_show = false;
self.ui.dialog.dialog_title.clear();
@@ -131,32 +154,30 @@ impl AppState {
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = None;
self.ui.focus_outside_canvas = false;
self.ui.dialog.is_loading = false;
}
/// Sets the active button index, wrapping around if necessary.
pub fn next_dialog_button(&mut self) {
if !self.ui.dialog.dialog_buttons.is_empty() {
let next_index = (self.ui.dialog.dialog_active_button_index + 1)
% self.ui.dialog.dialog_buttons.len();
self.ui.dialog.dialog_active_button_index = next_index; // Use new name
self.ui.dialog.dialog_active_button_index = next_index;
}
}
/// Sets the active button index, wrapping around if necessary.
pub fn previous_dialog_button(&mut self) {
if !self.ui.dialog.dialog_buttons.is_empty() {
let len = self.ui.dialog.dialog_buttons.len();
let prev_index =
(self.ui.dialog.dialog_active_button_index + len - 1) % len;
self.ui.dialog.dialog_active_button_index = prev_index; // Use new name
self.ui.dialog.dialog_active_button_index = prev_index;
}
}
/// Gets the label of the currently active button, if any.
pub fn get_active_dialog_button_label(&self) -> Option<&str> {
self.ui.dialog
.dialog_buttons // Use new name
.get(self.ui.dialog.dialog_active_button_index) // Use new name
.dialog_buttons
.get(self.ui.dialog.dialog_active_button_index)
.map(|s| s.as_str())
}
}
@@ -168,17 +189,18 @@ impl Default for UiState {
show_intro: true,
show_admin: false,
show_add_table: false,
show_add_logic: false,
show_form: false,
show_login: false,
show_register: false,
show_buffer_list: true,
show_search_palette: false, // ADDED
focus_outside_canvas: false,
dialog: DialogState::default(),
}
}
}
// Update the Default implementation for DialogState itself
impl Default for DialogState {
fn default() -> Self {
Self {

View File

@@ -5,4 +5,5 @@ pub mod auth;
pub mod admin;
pub mod intro;
pub mod add_table;
pub mod add_logic;
pub mod canvas_state;

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