Compare commits

..

124 Commits

Author SHA1 Message Date
filipriec
afd9228efa json in the otput of the tantivy 2025-06-11 14:07:22 +02:00
filipriec
495d77fda5 4 ngram tokenizer, not doing anything elsekeeping this as is 2025-06-10 23:56:31 +02:00
filipriec
679bb3b6ab search in common module, now fixing layer mixing issue 2025-06-10 13:47:18 +02:00
filipriec
350c522d19 better search but still has some flaws. It at least works, even tho its not perfect. Needs more testing, but im pretty happy with it rn, keeping it this way 2025-06-10 00:22:31 +02:00
filipriec
4760f42589 slovak language tokenized search 2025-06-09 16:36:18 +02:00
filipriec
50d15e321f automatic indexing is working perfectly well 2025-06-08 23:26:13 +02:00
filipriec
a3e7fd8f0a forgotten changes to the lib that are needed for a single port of two crates working separately 2025-06-08 22:40:46 +02:00
filipriec
645172747a we are now running search server at the same port as the whole backend service 2025-06-08 21:53:48 +02:00
filipriec
7c4ac1eebc search via tantivy on different grpc port works perfectly well now 2025-06-08 21:28:10 +02:00
filipriec
4b4301ad49 fixed now it all compiled successfuly 2025-06-08 20:14:44 +02:00
filipriec
b60e03eb70 search crate compiled, lets get to fixing all the other errors 2025-06-08 20:10:57 +02:00
filipriec
2c7bda3ff1 search crate created 2025-06-08 16:25:56 +02:00
filipriec
eeaaa3635b crucial dialog reloading bug fixed for good(hardest bug had a single line of code fix) 2025-06-08 10:53:46 +02:00
filipriec
e61cbb3956 features ui debug is now working perfectly well, it debugs the rerender flags 2025-06-08 09:26:56 +02:00
filipriec
f9841f2ef3 centralizing logic in the formstate 2025-06-08 00:00:37 +02:00
filipriec
dc232b2523 form is now working as expected 2025-06-07 15:25:35 +02:00
filipriec
b086b3e236 hardcoded firma is being removed part2 2025-06-07 15:12:00 +02:00
filipriec
387e1a0fe0 displaying data properly, fixing hardcoded backend to firma part one 2025-06-07 14:05:35 +02:00
filipriec
08e01d41f2 now properly not displaying in the frontend form fields that should be hidden from the user 2025-06-07 09:37:12 +02:00
filipriec
f5edf52571 working find palette now properly well 2025-06-07 09:16:43 +02:00
filipriec
02c62213c3 making select from the find file to work, not yet working, needs more redesign in how select is working 2025-06-06 23:44:29 +02:00
filipriec
d0722fbbbe working well now, creation of the columns 2025-06-06 20:18:51 +02:00
filipriec
4ec569342d hidden from the user now in the form 2025-06-03 18:47:14 +02:00
filipriec
9540d9ccb9 table definitions are now forbidden for user to allocated rust autoallocated table columns 2025-06-03 18:46:57 +02:00
filipriec
6b5cbe854b now working with the gen schema in the database 2025-06-02 12:39:23 +02:00
filipriec
59ed52814e compiled, needs other fixes 2025-06-02 12:08:16 +02:00
filipriec
3488ab4f6b hardcoded adresar to general form 2025-06-02 10:32:39 +02:00
filipriec
6e2fc5349b code cleanup 2025-05-31 23:02:09 +02:00
filipriec
ea88c2686d tabbing now adds / if there is nothing to tab to 2025-05-30 23:43:49 +02:00
filipriec
3df4baec92 tabbing now works perfectly well 2025-05-30 23:36:53 +02:00
filipriec
ff74e1aaa1 it works amazingly well now, we can select the table name via command line 2025-05-30 22:46:32 +02:00
filipriec
b0c865ab76 workig suggestion menu 2025-05-29 19:46:58 +02:00
filipriec
3dbc086f10 overriding overflows by using empty spaces as letters 2025-05-29 19:32:48 +02:00
filipriec
e9b4b34fb4 fixed height of the find file 2025-05-29 19:02:02 +02:00
filipriec
668eeee197 navigation in the menu but needs refactoring 2025-05-29 16:11:41 +02:00
filipriec
799d8471c9 open menu in command mode now implemented 2025-05-28 19:09:55 +02:00
filipriec
f77c16dec9 temp fix, before implementing C-x C-f 2025-05-28 15:53:33 +02:00
filipriec
45026cac6a table schema is gen now 2025-05-28 15:40:17 +02:00
filipriec
edf6ab5bca gen schema being created 2025-05-28 13:10:08 +02:00
filipriec
462b1f14e2 generated tables are now in gen schema, breaking change, needs crucial fixes NOW 2025-05-27 22:21:40 +02:00
filipriec
7a8f18b116 cargo fix 2025-05-26 22:28:58 +02:00
filipriec
d255e4abb6 proper postiion of the cursor when using sql 2025-05-26 20:53:05 +02:00
filipriec
b770240f0d better autocomplete 2025-05-26 20:43:58 +02:00
filipriec
43b064673b autocomplete is now powerful 2025-05-26 20:22:47 +02:00
filipriec
bf2726c151 tablenames added properly well 2025-05-26 19:51:48 +02:00
filipriec
f3cd921c76 we are suggesting properly table column names now 2025-05-26 19:42:23 +02:00
filipriec
913f6b6b64 broken autocomplete in the add_logic, but its usable, we are keeping it as is, there is nothing more we can do 2025-05-26 16:37:01 +02:00
filipriec
3463a52960 working autocomplete, need more fixes soon 2025-05-26 11:54:28 +02:00
filipriec
116db3566f intro buffer can be killed now also 2025-05-25 22:37:27 +02:00
filipriec
32210a5f7c killing of the buffer now works amazingly well 2025-05-25 22:24:26 +02:00
filipriec
d8f9372bbd killing buffers 2025-05-25 22:02:18 +02:00
filipriec
6e1997fd9d storage in the system is now storing log in details properly well 2025-05-25 21:33:24 +02:00
filipriec
4e7213d1aa automcomplete running and working now 2025-05-25 19:26:30 +02:00
filipriec
5afb427bb4 neccessary hardcode changes to fix the last changes introducing bug. general solution soon 2025-05-25 19:16:42 +02:00
filipriec
685361a11a server table structure response is now generalized 2025-05-25 18:57:13 +02:00
filipriec
bd7c97ca91 required table to access logic 2025-05-25 17:53:06 +02:00
filipriec
81235c67dc add script now has a proper way of doing things 2025-05-25 15:46:06 +02:00
filipriec
65e8e03224 better and better add script 2025-05-25 15:27:41 +02:00
filipriec
85eb3adec7 logic is being implemented properly well 2025-05-25 15:09:38 +02:00
filipriec
5d0f958a68 working cursor tracking in the add_table 2025-05-25 14:08:50 +02:00
filipriec
b82f50b76b movement in the add_table now fixed 2025-05-25 12:40:19 +02:00
filipriec
0ab11a9bf9 add table movement adjustements 2025-05-25 12:27:30 +02:00
filipriec
d28c310704 forbid jump from the last to the first and vice versa 2025-05-25 11:39:25 +02:00
filipriec
2e1d7fdf2b admin panel fixed completely 2025-05-25 11:26:19 +02:00
filipriec
82e96f7b86 vim or default mode workin properly now 2025-05-24 19:25:35 +02:00
filipriec
7229e2abbd weird highlight is gone 2025-05-24 18:53:06 +02:00
filipriec
4e35043da0 vim keybinings are now working properly well 2025-05-24 18:41:41 +02:00
filipriec
56fe1c2ccc text area working now perfectly well 2025-05-24 16:34:59 +02:00
filipriec
a874edf2a1 text area on focus is now big 2025-05-24 14:24:19 +02:00
filipriec
9d55ec3e43 h and l movements are now working 2025-05-23 16:59:12 +02:00
filipriec
05580ac978 better and better 2025-05-23 15:40:16 +02:00
filipriec
667eb4809d compiled but the readonly and edit mode is not working 2025-05-23 15:22:21 +02:00
filipriec
58fdaa8298 add logic, not working tho 2025-05-23 13:34:49 +02:00
filipriec
5478a2ac27 server changes for the ID in the tree 2025-05-23 13:34:39 +02:00
filipriec
ad37990da9 server is being licensed as AGPL instead of GPL 2025-05-21 10:29:22 +02:00
filipriec
66824030f2 refresh of the admin panel after adding the table 2025-04-23 12:31:47 +02:00
filipriec
90ca8cf97c working dialog is now at the correct place 2025-04-23 12:13:56 +02:00
filipriec
3c8ea28da1 dialog on add table save working 2025-04-23 12:04:54 +02:00
filipriec
5c352eb863 tracing running only when enabled 2025-04-23 11:29:00 +02:00
filipriec
8c312bc163 tracing on add_table 2025-04-23 11:02:17 +02:00
filipriec
6fa8b06063 grpc post request to the table definition from add table, not working, major bug, needs debugging to make it work 2025-04-22 23:22:59 +02:00
filipriec
2992f122bc PROTOBUF CHANGED I HOPE IT WAS ONLY A MISTAKE OTHERWISE IM SCREWING SOMETHING UP AND I HAVE NO CLUE WHAT 2025-04-22 22:38:52 +02:00
filipriec
e507993065 ui imporvements 2025-04-22 21:21:49 +02:00
filipriec
6f22aad6f4 working perfectly well admin panel for admin 2025-04-22 18:17:41 +02:00
filipriec
097264040f admin_panel for admins have now some adjustements 2025-04-22 18:03:22 +02:00
filipriec
2c03ee6af0 add_table ready to be used as a post request, only small changes are needed 2025-04-22 17:36:00 +02:00
filipriec
ec596b2ada indexing now works amazingly well 2025-04-22 16:03:16 +02:00
filipriec
b01ba0b2d9 cargo fix on the server 2025-04-19 19:00:14 +02:00
filipriec
f74a6ef093 upgraded to tonic 0.13 2025-04-19 18:55:26 +02:00
filipriec
ee687fafbe upgrading and updating my repo 2025-04-19 18:36:26 +02:00
filipriec
60ba17cfea TCP connection creation overhead fixed by cloning once created TCP connection. Huge performance gain on login and register. Utilizing gRPC 2025-04-19 15:54:58 +02:00
filipriec
8b3aa5891e sidebar redesigned correctly and properly 2025-04-19 00:53:57 +02:00
filipriec
3ff9399b81 cargo fix 2025-04-18 23:27:14 +02:00
filipriec
d18f7862ab login refactored 2025-04-18 23:26:48 +02:00
filipriec
dc6c1ce43c refactor happend and its perfectly fine 2025-04-18 23:16:22 +02:00
filipriec
8d1adccec6 async registration working 2025-04-18 22:36:30 +02:00
filipriec
420ce71fb2 asnyc gRPC call that needs nonblocking waiting operation with redraws docs describing setup on what to do 2025-04-18 22:15:08 +02:00
filipriec
8e5a269ff0 finally working amazingly well 2025-04-18 22:09:07 +02:00
filipriec
f357d6f0ee working blocked constant redraws 2025-04-18 21:43:54 +02:00
filipriec
a0467d17a8 cleanup 2025-04-18 21:11:49 +02:00
filipriec
ef3ecfc73f we compiled 2025-04-18 21:04:36 +02:00
filipriec
d3fcb23e22 fixing, nothing works lmao 2025-04-18 20:48:39 +02:00
filipriec
5a029283a1 anyhow used 2025-04-18 19:04:05 +02:00
filipriec
09ccad2bd4 time for changing it all 2025-04-18 18:11:12 +02:00
filipriec
bdcc10bd40 auth verification of emptiness 2025-04-18 17:08:38 +02:00
filipriec
2a1fafc3f9 warnings fixed 2025-04-18 16:17:02 +02:00
filipriec
6010b9a0af fixing warnings 2025-04-18 15:50:47 +02:00
filipriec
11e8f87fe6 cargo fix 2025-04-18 14:59:34 +02:00
filipriec
14b81cba19 fixed, removed log library 2025-04-18 14:58:05 +02:00
filipriec
2b37de3b4d login waiting dialog works, THIS COMMIT NEEDS TO BE REFACTORED 2025-04-18 14:44:04 +02:00
filipriec
73d9a6367c canvas edit mode movement fixed 2025-04-18 13:10:39 +02:00
filipriec
c90233b56f dialog in add_table is now working properly well 2025-04-18 12:29:29 +02:00
filipriec
39dcf38462 add_table dialog is now working properly well 2025-04-18 12:20:08 +02:00
filipriec
efa27cd2dd highlight restored 2025-04-18 11:35:15 +02:00
filipriec
ca231964f2 fullscreen on enter for add_table 2025-04-18 11:31:50 +02:00
filipriec
2bb83cb990 gui changes 2025-04-18 11:29:11 +02:00
filipriec
305bcfcf62 deletion of the selected works 2025-04-18 11:15:15 +02:00
filipriec
92a9011f27 buttons are only border and text colors now in add_table 2025-04-18 11:04:13 +02:00
filipriec
e64cebdfc2 deselect have no highlight now 2025-04-18 10:52:18 +02:00
filipriec
0db426d278 h l movement in add_table fixed for now 2025-04-18 10:48:52 +02:00
filipriec
6bfef1c7a0 exit in the general mode is on esc and select is not escaping in the add_table anymore 2025-04-18 10:37:52 +02:00
filipriec
f50fe788cb scrolling in the add table doesnt highlight first item anymore 2025-04-18 09:27:28 +02:00
filipriec
4db78ecf1b working properly well to distinguish enter in the edit mode now 2025-04-18 00:15:34 +02:00
filipriec
f22dd7749f proper scroll behaviour on the page now 2025-04-17 23:37:58 +02:00
131 changed files with 9164 additions and 3228 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/target /target
.env .env
/tantivy_indexes

1034
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["client", "server", "common"] members = ["client", "server", "common", "search"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
@@ -16,4 +16,27 @@ categories = ["command-line-interface"]
# [workspace.metadata] # [workspace.metadata]
# TODO: # 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"
# 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"
common = { path = "./common" }

View File

@@ -14,3 +14,12 @@ Client:
cargo watch -x 'run --package client -- 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
```

View File

@@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
anyhow = "1.0.98"
async-trait = "0.1.88" async-trait = "0.1.88"
common = { path = "../common" } common = { path = "../common" }
@@ -13,12 +14,19 @@ dirs = "6.0.0"
dotenvy = "0.15.7" dotenvy = "0.15.7"
lazy_static = "1.5.0" lazy_static = "1.5.0"
prost = "0.13.5" prost = "0.13.5"
ratatui = "0.29.0" ratatui = { version = "0.29.0", features = ["crossterm"] }
serde = { version = "1.0.218", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
time = "0.3.41" time = "0.3.41"
tokio = { version = "1.43.0", features = ["full", "macros"] } tokio = { version = "1.44.2", features = ["full", "macros"] }
toml = "0.8.20" toml = "0.8.20"
tonic = "0.12.3" tonic = "0.13.0"
tracing = "0.1.41" 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-segmentation = "1.12.0"
unicode-width = "0.2.0" unicode-width = "0.2.0"
[features]
default = []
ui-debug = []

View File

@@ -4,6 +4,7 @@
enter_command_mode = [":", "ctrl+;"] enter_command_mode = [":", "ctrl+;"]
next_buffer = ["ctrl+l"] next_buffer = ["ctrl+l"]
previous_buffer = ["ctrl+h"] previous_buffer = ["ctrl+h"]
close_buffer = ["ctrl+k"]
[keybindings.general] [keybindings.general]
move_up = ["k", "Up"] move_up = ["k", "Up"]
@@ -15,6 +16,7 @@ toggle_sidebar = ["ctrl+t"]
toggle_buffer_list = ["ctrl+b"] toggle_buffer_list = ["ctrl+b"]
next_field = ["Tab"] next_field = ["Tab"]
prev_field = ["Shift+Tab"] prev_field = ["Shift+Tab"]
exit_table_scroll = ["esc"]
[keybindings.common] [keybindings.common]
save = ["ctrl+s"] save = ["ctrl+s"]
@@ -60,16 +62,17 @@ enter_highlight_mode_linewise = ["ctrl+v"]
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE # BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
# exit_edit_mode = ["esc","ctrl+e"] # exit_edit_mode = ["esc","ctrl+e"]
# exit_suggestion_mode = ["esc"] # exit_suggestion_mode = ["esc"]
# select_suggestion = ["enter"]
# next_field = ["enter"]
enter_decider = ["enter"]
prev_field = ["shift+enter"]
exit = ["esc", "ctrl+e"] exit = ["esc", "ctrl+e"]
delete_char_forward = ["delete"] delete_char_forward = ["delete"]
delete_char_backward = ["backspace"] delete_char_backward = ["backspace"]
next_field = ["enter"]
prev_field = ["shift+enter"]
move_left = ["left"] move_left = ["left"]
move_right = ["right"] move_right = ["right"]
suggestion_down = ["ctrl+n", "tab"] suggestion_down = ["ctrl+n", "tab"]
suggestion_up = ["ctrl+p", "shift+tab"] suggestion_up = ["ctrl+p", "shift+tab"]
select_suggestion = ["enter"]
[keybindings.command] [keybindings.command]
exit_command_mode = ["ctrl+g", "esc"] exit_command_mode = ["ctrl+g", "esc"]
@@ -80,6 +83,10 @@ quit = ["q"]
force_quit = ["q!"] force_quit = ["q!"]
save_and_quit = ["wq"] save_and_quit = ["wq"]
revert = ["r"] revert = ["r"]
find_file_palette_toggle = ["ff"]
[editor]
keybinding_mode = "vim" # Options: "default", "vim", "emacs"
[colors] [colors]
theme = "dark" theme = "dark"

View File

@@ -2,7 +2,9 @@
pub mod admin_panel; pub mod admin_panel;
pub mod admin_panel_admin; pub mod admin_panel_admin;
pub mod add_table; pub mod add_table;
pub mod add_logic;
pub use admin_panel::*; pub use admin_panel::*;
pub use admin_panel_admin::*; pub use admin_panel_admin::*;
pub use add_table::*; 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

@@ -13,6 +13,7 @@ use ratatui::{
Frame, Frame,
}; };
use crate::components::handlers::canvas::render_canvas; use crate::components::handlers::canvas::render_canvas;
use crate::components::common::dialog;
/// Renders the Add New Table page layout, structuring the display of table information, /// Renders the Add New Table page layout, structuring the display of table information,
/// input fields, and action buttons. Adapts layout based on terminal width. /// input fields, and action buttons. Adapts layout based on terminal width.
@@ -20,7 +21,7 @@ pub fn render_add_table(
f: &mut Frame, f: &mut Frame,
area: Rect, area: Rect,
theme: &Theme, theme: &Theme,
_app_state: &AppState, // Currently unused, might be needed later app_state: &AppState, // Currently unused, might be needed later
add_table_state: &mut AddTableState, add_table_state: &mut AddTableState,
is_edit_mode: bool, // Determines if canvas inputs are in edit mode is_edit_mode: bool, // Determines if canvas inputs are in edit mode
highlight_state: &HighlightState, // For text highlighting in canvas highlight_state: &HighlightState, // For text highlighting in canvas
@@ -48,6 +49,117 @@ pub fn render_add_table(
let inner_area = main_block.inner(area); let inner_area = main_block.inner(area);
f.render_widget(main_block, area); f.render_widget(main_block, area);
// --- Fullscreen Columns Table Check (Narrow Screens Only) ---
if area.width < NARROW_LAYOUT_THRESHOLD && add_table_state.current_focus == AddTableFocus::InsideColumnsTable {
// Render ONLY the columns table taking the full inner area
let columns_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
let column_rows: Vec<Row<'_>> = add_table_state
.columns
.iter()
.map(|col_def| {
Row::new(vec![
Cell::from(if col_def.selected { "[*]" } else { "[ ]" }),
Cell::from(col_def.name.clone()),
Cell::from(col_def.data_type.clone()),
])
.style(Style::default().fg(theme.fg))
})
.collect();
let header_cells = ["Sel", "Name", "Type"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
let header = Row::new(header_cells).height(1).bottom_margin(1);
let columns_table = Table::new(column_rows, [Constraint::Length(5), Constraint::Percentage(50), Constraint::Percentage(50)])
.header(header)
.block(
Block::default()
.title(Span::styled(" Columns (Fullscreen) ", theme.fg)) // Indicate fullscreen
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(columns_border_style),
)
.row_highlight_style(
Style::default()
.add_modifier(Modifier::REVERSED)
.fg(theme.highlight),
)
.highlight_symbol(" > "); // Use the inside symbol
f.render_stateful_widget(columns_table, inner_area, &mut add_table_state.column_table_state);
return; // IMPORTANT: Stop rendering here for fullscreen mode
}
// --- Fullscreen Indexes Table Check ---
if add_table_state.current_focus == AddTableFocus::InsideIndexesTable { // Remove width check
// Render ONLY the indexes table taking the full inner area
let indexes_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
let index_rows: Vec<Row<'_>> = add_table_state
.indexes
.iter()
.map(|index_def| {
Row::new(vec![
Cell::from(if index_def.selected { "[*]" } else { "[ ]" }),
Cell::from(index_def.name.clone()),
])
.style(Style::default().fg(theme.fg))
})
.collect();
let index_header_cells = ["Sel", "Column Name"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
let index_header = Row::new(index_header_cells).height(1).bottom_margin(1);
let indexes_table = Table::new(index_rows, [Constraint::Length(5), Constraint::Percentage(95)])
.header(index_header)
.block(
Block::default()
.title(Span::styled(" Indexes (Fullscreen) ", theme.fg)) // Indicate fullscreen
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(indexes_border_style),
)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
.highlight_symbol(" > "); // Use the inside symbol
f.render_stateful_widget(indexes_table, inner_area, &mut add_table_state.index_table_state);
return; // IMPORTANT: Stop rendering here for fullscreen mode
}
// --- Fullscreen Links Table Check ---
if add_table_state.current_focus == AddTableFocus::InsideLinksTable {
// Render ONLY the links table taking the full inner area
let links_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
let link_rows: Vec<Row<'_>> = add_table_state
.links
.iter()
.map(|link_def| {
Row::new(vec![
Cell::from(if link_def.selected { "[*]" } else { "[ ]" }), // Selection first
Cell::from(link_def.linked_table_name.clone()), // Table name second
])
.style(Style::default().fg(theme.fg))
})
.collect();
let link_header_cells = ["Sel", "Available Table"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
let link_header = Row::new(link_header_cells).height(1).bottom_margin(1);
let links_table = Table::new(link_rows, [Constraint::Length(5), Constraint::Percentage(95)])
.header(link_header)
.block(
Block::default()
.title(Span::styled(" Links (Fullscreen) ", theme.fg)) // Indicate fullscreen
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(links_border_style),
)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
.highlight_symbol(" > "); // Use the inside symbol
f.render_stateful_widget(links_table, inner_area, &mut add_table_state.link_table_state);
return; // IMPORTANT: Stop rendering here for fullscreen mode
}
// --- Area Variable Declarations --- // --- Area Variable Declarations ---
let top_info_area: Rect; let top_info_area: Rect;
let columns_area: Rect; let columns_area: Rect;
@@ -77,8 +189,8 @@ pub fn render_add_table(
let middle_chunks = Layout::default() let middle_chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
Constraint::Percentage(60), // Left: Columns Table Constraint::Percentage(50), // Left: Columns Table
Constraint::Percentage(40), // Right: Inputs etc. Constraint::Percentage(50), // Right: Inputs etc.
]) ])
.split(middle_area); .split(middle_area);
@@ -186,8 +298,7 @@ pub fn render_add_table(
// --- Common Widget Rendering (Uses calculated areas) --- // --- Common Widget Rendering (Uses calculated areas) ---
// --- Columns Table Rendering --- // --- Columns Table Rendering ---
let columns_focused = let columns_focused = matches!(add_table_state.current_focus, AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable);
add_table_state.current_focus == AddTableFocus::ColumnsTable;
let columns_border_style = if columns_focused { let columns_border_style = if columns_focused {
Style::default().fg(theme.highlight) Style::default().fg(theme.highlight)
} else { } else {
@@ -198,27 +309,31 @@ pub fn render_add_table(
.iter() .iter()
.map(|col_def| { .map(|col_def| {
Row::new(vec![ Row::new(vec![
Cell::from(if col_def.selected { "[*]" } else { "[ ]" }),
Cell::from(col_def.name.clone()), Cell::from(col_def.name.clone()),
Cell::from(col_def.data_type.clone()), Cell::from(col_def.data_type.clone()),
]) ])
.style(Style::default().fg(theme.fg)) .style(Style::default().fg(theme.fg))
}) })
.collect(); .collect();
// Use different headers/constraints based on layout? For now, keep consistent. let header_cells = ["Sel", "Name", "Type"]
let header_cells = ["Name", "Type"]
.iter() .iter()
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent))); .map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
let header = Row::new(header_cells).height(1).bottom_margin(1); let header = Row::new(header_cells).height(1).bottom_margin(1);
let columns_table = Table::new( let columns_table = Table::new(
column_rows, column_rows,
[Constraint::Percentage(60), Constraint::Percentage(40)], [ // Define constraints for 3 columns: Sel, Name, Type
Constraint::Length(5),
Constraint::Percentage(60),
Constraint::Percentage(35),
],
) )
.header(header) .header(header)
.block( .block(
Block::default() Block::default()
.title(Span::styled(" Columns ", theme.fg)) .title(Span::styled(" Columns ", theme.fg))
.title_alignment(Alignment::Center) .title_alignment(Alignment::Center)
.borders(Borders::ALL) // Use ALL borders for consistency .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(columns_border_style), .border_style(columns_border_style),
) )
@@ -249,22 +364,22 @@ pub fn render_add_table(
// --- Button Style Helpers --- // --- Button Style Helpers ---
let get_button_style = |button_focus: AddTableFocus, current_focus| { let get_button_style = |button_focus: AddTableFocus, current_focus| {
// Only handles text style (FG + Bold) now, no BG
let is_focused = current_focus == button_focus; let is_focused = current_focus == button_focus;
let base_style = Style::default().fg(if is_focused { let base_style = Style::default().fg(if is_focused {
theme.bg // Reversed text color theme.highlight // Highlighted text color
} else { } else {
theme.secondary // Normal text color theme.secondary // Normal text color
}); });
if is_focused { if is_focused {
base_style base_style.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::BOLD)
.bg(theme.highlight) // Reversed background
} else { } else {
base_style base_style
} }
}; };
let get_button_border_style = |button_focus: AddTableFocus, current_focus| { // Updated signature to accept bool and theme
if current_focus == button_focus { let get_button_border_style = |is_focused: bool, theme: &Theme| {
if is_focused {
Style::default().fg(theme.highlight) Style::default().fg(theme.highlight)
} else { } else {
Style::default().fg(theme.secondary) Style::default().fg(theme.secondary)
@@ -272,26 +387,25 @@ pub fn render_add_table(
}; };
// --- Add Button Rendering --- // --- Add Button Rendering ---
// Determine if the add button is focused
let is_add_button_focused = add_table_state.current_focus == AddTableFocus::AddColumnButton;
// Create the Add button Paragraph widget
let add_button = Paragraph::new(" Add ") let add_button = Paragraph::new(" Add ")
.style(get_button_style( .style(get_button_style(AddTableFocus::AddColumnButton, add_table_state.current_focus)) // Use existing closure
AddTableFocus::AddColumnButton,
add_table_state.current_focus,
))
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block( .block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(get_button_border_style( .border_style(get_button_border_style(is_add_button_focused, theme)), // Pass bool and theme
AddTableFocus::AddColumnButton,
add_table_state.current_focus,
)),
); );
f.render_widget(add_button, add_button_area); // Render into the calculated area
// Render the button in its designated area
f.render_widget(add_button, add_button_area);
// --- Indexes Table Rendering --- // --- Indexes Table Rendering ---
let indexes_focused = let indexes_focused = matches!(add_table_state.current_focus, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable);
add_table_state.current_focus == AddTableFocus::IndexesTable;
let indexes_border_style = if indexes_focused { let indexes_border_style = if indexes_focused {
Style::default().fg(theme.highlight) Style::default().fg(theme.highlight)
} else { } else {
@@ -300,17 +414,20 @@ pub fn render_add_table(
let index_rows: Vec<Row<'_>> = add_table_state let index_rows: Vec<Row<'_>> = add_table_state
.indexes .indexes
.iter() .iter()
.map(|index_name| { .map(|index_def| { // Use index_def now
Row::new(vec![Cell::from(index_name.clone())]) Row::new(vec![
Cell::from(if index_def.selected { "[*]" } else { "[ ]" }), // Display selection
Cell::from(index_def.name.clone()),
])
.style(Style::default().fg(theme.fg)) .style(Style::default().fg(theme.fg))
}) })
.collect(); .collect();
let index_header_cells = ["Column Name"] let index_header_cells = ["Sel", "Column Name"]
.iter() .iter()
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent))); .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 index_header = Row::new(index_header_cells).height(1).bottom_margin(1);
let indexes_table = let indexes_table =
Table::new(index_rows, [Constraint::Percentage(100)]) Table::new(index_rows, [Constraint::Length(5), Constraint::Percentage(95)])
.header(index_header) .header(index_header)
.block( .block(
Block::default() Block::default()
@@ -333,7 +450,7 @@ pub fn render_add_table(
); );
// --- Links Table Rendering --- // --- Links Table Rendering ---
let links_focused = add_table_state.current_focus == AddTableFocus::LinksTable; let links_focused = matches!(add_table_state.current_focus, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable);
let links_border_style = if links_focused { let links_border_style = if links_focused {
Style::default().fg(theme.highlight) Style::default().fg(theme.highlight)
} else { } else {
@@ -344,18 +461,18 @@ pub fn render_add_table(
.iter() .iter()
.map(|link_def| { .map(|link_def| {
Row::new(vec![ Row::new(vec![
Cell::from(if link_def.selected { "[*]" } else { "[ ]" }),
Cell::from(link_def.linked_table_name.clone()), Cell::from(link_def.linked_table_name.clone()),
Cell::from(if link_def.is_required { "[X]" } else { "[ ]" }),
]) ])
.style(Style::default().fg(theme.fg)) .style(Style::default().fg(theme.fg))
}) })
.collect(); .collect();
let link_header_cells = ["Linked Table", "Req"] let link_header_cells = ["Sel", "Available Table"]
.iter() .iter()
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent))); .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 link_header = Row::new(link_header_cells).height(1).bottom_margin(1);
let links_table = 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) .header(link_header)
.block( .block(
Block::default() Block::default()
@@ -381,9 +498,9 @@ pub fn render_add_table(
let bottom_button_chunks = Layout::default() let bottom_button_chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
Constraint::Percentage(33), // Save Button Constraint::Percentage(33), // Save Button
Constraint::Percentage(34), // Delete Button Constraint::Percentage(34), // Delete Button
Constraint::Percentage(33), // Cancel Button Constraint::Percentage(33), // Cancel Button
]) ])
.split(bottom_buttons_area); .split(bottom_buttons_area);
@@ -398,28 +515,28 @@ pub fn render_add_table(
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(get_button_border_style( .border_style(get_button_border_style(
AddTableFocus::SaveButton, add_table_state.current_focus == AddTableFocus::SaveButton, // Pass bool
add_table_state.current_focus, theme,
)), )),
); );
f.render_widget(save_button, bottom_button_chunks[0]); f.render_widget(save_button, bottom_button_chunks[0]);
let delete_button = Paragraph::new(" Delete Selected ") let delete_button = Paragraph::new(" Delete Selected ")
.style(get_button_style( .style(get_button_style(
AddTableFocus::DeleteSelectedButton, AddTableFocus::DeleteSelectedButton,
add_table_state.current_focus, add_table_state.current_focus,
)) ))
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block( .block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(get_button_border_style( .border_style(get_button_border_style(
AddTableFocus::DeleteSelectedButton, add_table_state.current_focus == AddTableFocus::DeleteSelectedButton, // Pass bool
add_table_state.current_focus, theme,
)), )),
); );
f.render_widget(delete_button, bottom_button_chunks[1]); f.render_widget(delete_button, bottom_button_chunks[1]);
let cancel_button = Paragraph::new(" Cancel ") let cancel_button = Paragraph::new(" Cancel ")
.style(get_button_style( .style(get_button_style(
@@ -432,9 +549,24 @@ pub fn render_add_table(
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(get_button_border_style( .border_style(get_button_border_style(
AddTableFocus::CancelButton, add_table_state.current_focus == AddTableFocus::CancelButton, // Pass bool
add_table_state.current_focus, theme,
)), )),
); );
f.render_widget(cancel_button, bottom_button_chunks[2]); f.render_widget(cancel_button, bottom_button_chunks[2]);
// --- DIALOG ---
// Render the dialog overlay if it's active
if app_state.ui.dialog.dialog_show { // Use the passed-in app_state
dialog::render_dialog(
f,
f.area(), // Render over the whole frame area
theme,
&app_state.ui.dialog.dialog_title,
&app_state.ui.dialog.dialog_message,
&app_state.ui.dialog.dialog_buttons,
app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
);
}
} }

View File

@@ -6,7 +6,7 @@ use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState; use crate::state::pages::admin::AdminState;
use common::proto::multieko2::table_definition::ProfileTreeResponse; use common::proto::multieko2::table_definition::ProfileTreeResponse;
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::Style, style::Style,
text::{Line, Span, Text}, text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap}, widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap},

View File

@@ -6,12 +6,11 @@ use crate::state::app::state::AppState;
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::Style, style::Style,
text::{Line, Span, Text}, // Added Text text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph}, widgets::{Block, BorderType, Borders, List, ListItem, Paragraph},
Frame, Frame,
}; };
/// Renders the view specific to admin users with a three-pane layout.
pub fn render_admin_panel_admin( pub fn render_admin_panel_admin(
f: &mut Frame, f: &mut Frame,
area: Rect, area: Rect,
@@ -19,15 +18,13 @@ pub fn render_admin_panel_admin(
admin_state: &mut AdminState, admin_state: &mut AdminState,
theme: &Theme, theme: &Theme,
) { ) {
// Split vertically: Top for panes, Bottom for buttons
let main_chunks = Layout::default() let main_chunks = Layout::default()
.direction(Direction::Vertical) .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); .split(area);
let panes_area = main_chunks[0]; let panes_area = main_chunks[0];
let buttons_area = main_chunks[1]; let buttons_area = main_chunks[1];
// Split the top area into three panes: Profiles | Tables | Dependencies
let pane_chunks = Layout::default() let pane_chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
@@ -35,208 +32,148 @@ pub fn render_admin_panel_admin(
Constraint::Percentage(40), // Tables Constraint::Percentage(40), // Tables
Constraint::Percentage(35), // Dependencies Constraint::Percentage(35), // Dependencies
].as_ref()) ].as_ref())
.split(panes_area); // Use the whole area directly .split(panes_area);
let profiles_pane = pane_chunks[0]; let profiles_pane = pane_chunks[0];
let tables_pane = pane_chunks[1]; let tables_pane = pane_chunks[1];
let deps_pane = pane_chunks[2]; let deps_pane = pane_chunks[2];
// --- Profiles Pane (Left) --- // --- Profiles Pane (Left) ---
let profile_focus = admin_state.current_focus == AdminFocus::Profiles; let profile_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::ProfilesPane | AdminFocus::InsideProfilesList);
let profile_border_style = if profile_focus { let profile_border_style = if profile_pane_has_focus {
Style::default().fg(theme.highlight) Style::default().fg(theme.highlight)
} else { } else {
Style::default().fg(theme.border) Style::default().fg(theme.border)
}; };
let profiles_block = Block::default().title(" Profiles ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(profile_border_style);
// Block for the profiles pane let profiles_inner_area = profiles_block.inner(profiles_pane);
let profiles_block = Block::default() f.render_widget(profiles_block, profiles_pane);
.title(" Profiles ") let profile_list_items: Vec<ListItem> = app_state.profile_tree.profiles.iter().enumerate().map(|(idx, profile)| {
.borders(Borders::ALL) let is_persistently_selected = admin_state.selected_profile_index == Some(idx);
.border_type(BorderType::Rounded) let is_nav_highlighted = admin_state.profile_list_state.selected() == Some(idx) && admin_state.current_focus == AdminFocus::InsideProfilesList;
.border_style(profile_border_style); let prefix = if is_persistently_selected { "[*] " } else { "[ ] " };
let profiles_inner_area = profiles_block.inner(profiles_pane); // Get inner area for list let item_style = if is_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
f.render_widget(profiles_block, profiles_pane); // Render the block itself else if is_persistently_selected { Style::default().fg(theme.accent) }
else { Style::default().fg(theme.fg) };
// Create profile list items ListItem::new(Line::from(vec![Span::styled(prefix, item_style), Span::styled(&profile.name, item_style)]))
let profile_list_items: Vec<ListItem> = app_state }).collect();
.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 profile_list = List::new(profile_list_items) let profile_list = List::new(profile_list_items)
// Highlight style depends on focus AND navigation state .highlight_style(if admin_state.current_focus == AdminFocus::InsideProfilesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() })
.highlight_style(if profile_focus { // Use focus state .highlight_symbol(if admin_state.current_focus == AdminFocus::InsideProfilesList { "> " } else { " " });
Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
} else {
Style::default()
})
.highlight_symbol(if profile_focus { "> " } else { " " });
f.render_stateful_widget(profile_list, profiles_inner_area, &mut admin_state.profile_list_state); f.render_stateful_widget(profile_list, profiles_inner_area, &mut admin_state.profile_list_state);
// --- Tables Pane (Middle) --- // --- Tables Pane (Middle) ---
let table_focus = admin_state.current_focus == AdminFocus::Tables; let table_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::Tables | AdminFocus::InsideTablesList);
let table_border_style = if table_focus { let table_border_style = if table_pane_has_focus { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.border) };
Style::default().fg(theme.highlight)
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 { } 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 table_list_items_for_display: Vec<ListItem> =
let navigated_profile_idx = admin_state.profile_list_state.selected(); // Use nav state for display if let Some(profile_data_for_tables) = profile_to_display_tables_for_idx
let selected_profile_name = app_state .and_then(|idx| app_state.profile_tree.profiles.get(idx)) {
.profile_tree profile_data_for_tables.tables.iter().enumerate().map(|(idx, table)| {
.profiles let is_table_persistently_selected = admin_state.selected_table_index == Some(idx) &&
.get(navigated_profile_idx.unwrap_or(usize::MAX)) // Use nav state for title profile_to_display_tables_for_idx == admin_state.selected_profile_index;
.map_or("None", |p| &p.name); let is_table_nav_highlighted = admin_state.table_list_state.selected() == Some(idx) &&
admin_state.current_focus == AdminFocus::InsideTablesList;
// Block for the tables pane let prefix = if is_table_persistently_selected { "[*] " } else { "[ ] " };
let tables_block = Block::default() let style = if is_table_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
.title(format!(" Tables (Profile: {}) ", selected_profile_name)) else if is_table_persistently_selected { Style::default().fg(theme.accent) }
.borders(Borders::ALL) else { Style::default().fg(theme.fg) };
.border_type(BorderType::Rounded) ListItem::new(Line::from(vec![Span::styled(prefix, style), Span::styled(&table.name, style)]))
.border_style(table_border_style); }).collect()
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)
} else { } else {
Style::default() vec![ListItem::new("Select a profile to see tables")]
}) };
.highlight_symbol(if table_focus { "> " } else { " " }); // Focus indicator 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); 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() let deps_block = Block::default()
.title(format!(" Dependencies (Table: {}) ", selected_table_name)) .title(format!(" Dependencies (Table: {}) ", deps_pane_title_table_name))
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border)); // No focus highlight for deps pane .border_style(Style::default().fg(theme.border));
let deps_inner_area = deps_block.inner(deps_pane); // Get inner area for content let deps_inner_area = deps_block.inner(deps_pane);
f.render_widget(deps_block, deps_pane); // Render the block itself f.render_widget(deps_block, deps_pane);
// Prepare content for the dependencies paragraph
let mut deps_content = Text::default(); let mut deps_content = Text::default();
deps_content.lines.push(Line::from(Span::styled( deps_content.lines.push(Line::from(Span::styled(
"Depends On:", "Depends On:",
Style::default().fg(theme.accent), // Use accent color for the label Style::default().fg(theme.accent),
))); )));
if !selected_table_deps.is_empty() { if !dependencies_to_display.is_empty() {
for dep in selected_table_deps { for dep in dependencies_to_display {
// List each dependency
deps_content.lines.push(Line::from(Span::styled(format!("- {}", dep), theme.fg))); deps_content.lines.push(Line::from(Span::styled(format!("- {}", dep), theme.fg)));
} }
} else { } else {
// Indicate if there are no dependencies
deps_content.lines.push(Line::from(Span::styled(" None", theme.secondary))); 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); let deps_paragraph = Paragraph::new(deps_content);
f.render_widget(deps_paragraph, deps_inner_area); f.render_widget(deps_paragraph, deps_inner_area);
// --- Buttons Row --- // --- Buttons Row ---
let button_chunks = Layout::default() let button_chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Percentage(33), Constraint::Percentage(34), Constraint::Percentage(33)].as_ref()).split(buttons_area);
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(34),
Constraint::Percentage(33),
].as_ref())
.split(buttons_area);
let btn_base_style = Style::default().fg(theme.secondary); let btn_base_style = Style::default().fg(theme.secondary);
let get_btn_style = |button_focus: AdminFocus| { if admin_state.current_focus == button_focus { btn_base_style.add_modifier(ratatui::style::Modifier::REVERSED) } else { btn_base_style } };
// Define the helper closure to get style based on focus let btn1 = Paragraph::new("Add Logic").style(get_btn_style(AdminFocus::Button1)).alignment(Alignment::Center);
let get_btn_style = |button_focus: AdminFocus| { let btn2 = Paragraph::new("Add Table").style(get_btn_style(AdminFocus::Button2)).alignment(Alignment::Center);
if admin_state.current_focus == button_focus { let btn3 = Paragraph::new("Change Table").style(get_btn_style(AdminFocus::Button3)).alignment(Alignment::Center);
// 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);
f.render_widget(btn1, button_chunks[0]); f.render_widget(btn1, button_chunks[0]);
f.render_widget(btn2, button_chunks[1]); f.render_widget(btn2, button_chunks[1]);
f.render_widget(btn3, button_chunks[2]); f.render_widget(btn3, button_chunks[2]);

View File

@@ -142,7 +142,8 @@ pub fn render_login(
&app_state.ui.dialog.dialog_title, &app_state.ui.dialog.dialog_title,
&app_state.ui.dialog.dialog_message, &app_state.ui.dialog.dialog_message,
&app_state.ui.dialog.dialog_buttons, // Pass buttons slice &app_state.ui.dialog.dialog_buttons, // Pass buttons slice
app_state.ui.dialog.dialog_active_button_index, // Pass active index app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
); );
} }
} }

View File

@@ -152,7 +152,7 @@ pub fn render_register(
let selected = state.get_selected_suggestion_index(); let selected = state.get_selected_suggestion_index();
if !suggestions.is_empty() { if !suggestions.is_empty() {
if let Some(input_rect) = active_field_rect { if let Some(input_rect) = active_field_rect {
autocomplete::render_autocomplete_dropdown(f, input_rect, f.size(), theme, suggestions, selected); autocomplete::render_autocomplete_dropdown(f, input_rect, f.area(), theme, suggestions, selected);
} }
} }
} }
@@ -168,6 +168,7 @@ pub fn render_register(
&app_state.ui.dialog.dialog_message, &app_state.ui.dialog.dialog_message,
&app_state.ui.dialog.dialog_buttons, &app_state.ui.dialog.dialog_buttons,
app_state.ui.dialog.dialog_active_button_index, app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
); );
} }
} }

View File

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

View File

@@ -1,4 +1,5 @@
// src/client/components/command_line.rs // src/components/common/command_line.rs
use ratatui::{ use ratatui::{
widgets::{Block, Paragraph}, widgets::{Block, Paragraph},
style::Style, style::Style,
@@ -6,30 +7,63 @@ use ratatui::{
Frame, Frame,
}; };
use crate::config::colors::themes::Theme; 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) { pub fn render_command_line(
let prompt = if active { f: &mut Frame,
":" area: Rect,
} else { 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 content_width = UnicodeWidthStr::width(display_text.as_str());
let display_text = if message.is_empty() { let available_width = area.width as usize;
format!("{}{}", prompt, input) let padding_needed = available_width.saturating_sub(content_width);
let display_text_padded = if padding_needed > 0 {
format!("{}{}", display_text, " ".repeat(padding_needed))
} else { } 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) Style::default().fg(theme.accent)
} else { } 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) Style::default().fg(theme.fg)
}; };
let paragraph = Paragraph::new(display_text) let paragraph = Paragraph::new(display_text_padded)
.block(Block::default().style(Style::default().bg(theme.bg))) .block(Block::default().style(Style::default().bg(theme.bg))) // Block ensures bg for whole area
.style(style); .style(text_style); // Style for the text itself
f.render_widget(paragraph, area); f.render_widget(paragraph, area);
} }

View File

@@ -18,6 +18,7 @@ pub fn render_dialog(
dialog_message: &str, dialog_message: &str,
dialog_buttons: &[String], dialog_buttons: &[String],
dialog_active_button_index: usize, dialog_active_button_index: usize,
is_loading: bool,
) { ) {
// Calculate required height based on the actual number of lines in the message // Calculate required height based on the actual number of lines in the message
let message_lines: Vec<_> = dialog_message.lines().collect(); let message_lines: Vec<_> = dialog_message.lines().collect();
@@ -63,27 +64,36 @@ pub fn render_dialog(
vertical: 1, // Top/Bottom padding inside border vertical: 1, // Top/Bottom padding inside border
}); });
// Layout for Message and Buttons based on actual message height if is_loading {
let mut constraints = vec![ // --- Loading State ---
// Allocate space for message, ensuring at least 1 line height let loading_text = Paragraph::new(dialog_message) // Use the message passed for loading
Constraint::Length(message_height.max(1)), // Use actual calculated height .style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC))
]; .alignment(Alignment::Center);
if button_row_height > 0 { // Render loading message centered in the inner area
constraints.push(Constraint::Length(button_row_height)); f.render_widget(loading_text, inner_area);
} } else {
// --- Normal State (Message + Buttons) ---
let chunks = Layout::default() // Layout for Message and Buttons based on actual message height
.direction(Direction::Vertical) let mut constraints = vec![
.constraints(constraints) // Allocate space for message, ensuring at least 1 line height
.split(inner_area); Constraint::Length(message_height.max(1)), // Use actual calculated height
];
if button_row_height > 0 {
constraints.push(Constraint::Length(button_row_height));
}
// Render Message let chunks = Layout::default()
let available_width = inner_area.width as usize; .direction(Direction::Vertical)
let ellipsis = "..."; .constraints(constraints)
let ellipsis_width = UnicodeWidthStr::width(ellipsis); .split(inner_area);
let processed_lines: Vec<Line> = // Render Message
message_lines let available_width = inner_area.width as usize;
let ellipsis = "...";
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
let processed_lines: Vec<Line> = message_lines
.into_iter() .into_iter()
.map(|line| { .map(|line| {
let line_width = UnicodeWidthStr::width(line); let line_width = UnicodeWidthStr::width(line);
@@ -91,81 +101,83 @@ pub fn render_dialog(
// Truncate with ellipsis // Truncate with ellipsis
let mut truncated_len = 0; let mut truncated_len = 0;
let mut current_width = 0; let mut current_width = 0;
// Iterate over graphemes to handle multi-byte characters correctly
for (idx, grapheme) in line.grapheme_indices(true) { for (idx, grapheme) in line.grapheme_indices(true) {
let grapheme_width = UnicodeWidthStr::width(grapheme); let grapheme_width = UnicodeWidthStr::width(grapheme);
if current_width + grapheme_width > available_width.saturating_sub(ellipsis_width) { if current_width + grapheme_width
break; // Stop before exceeding width needed for text + ellipsis > available_width.saturating_sub(ellipsis_width)
{
break;
} }
current_width += grapheme_width; current_width += grapheme_width;
truncated_len = idx + grapheme.len(); // Store the byte index of the end of the last fitting grapheme truncated_len = idx + grapheme.len();
} }
let truncated_line = format!("{}{}", &line[..truncated_len], ellipsis); let truncated_line =
Line::from(Span::styled(truncated_line, Style::default().fg(theme.fg))) format!("{}{}", &line[..truncated_len], ellipsis);
Line::from(Span::styled(
truncated_line,
Style::default().fg(theme.fg),
))
} else { } else {
// Line fits, use it as is
Line::from(Span::styled(line, Style::default().fg(theme.fg))) Line::from(Span::styled(line, Style::default().fg(theme.fg)))
} }
}) })
.collect(); .collect();
let message_paragraph = let message_paragraph =
Paragraph::new(Text::from(processed_lines)).alignment(Alignment::Center); Paragraph::new(Text::from(processed_lines)).alignment(Alignment::Center);
// Render message in the first chunk f.render_widget(message_paragraph, chunks[0]); // Render message in the first chunk
f.render_widget(message_paragraph, chunks[0]);
// Render Buttons if they exist and there's a chunk for them // Render Buttons if they exist and there's a chunk for them
if !dialog_buttons.is_empty() && chunks.len() > 1 { if !dialog_buttons.is_empty() && chunks.len() > 1 {
let button_area = chunks[1]; let button_area = chunks[1];
let button_count = dialog_buttons.len(); let button_count = dialog_buttons.len();
// Use Ratio for potentially more even distribution with few buttons let button_constraints = std::iter::repeat(Constraint::Ratio(
let button_constraints = std::iter::repeat(Constraint::Ratio( 1,
1, button_count as u32,
button_count as u32, ))
)) .take(button_count)
.take(button_count) .collect::<Vec<_>>();
.collect::<Vec<_>>();
let button_chunks = Layout::default() let button_chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints(button_constraints) .constraints(button_constraints)
.horizontal_margin(1) // Add space between buttons .horizontal_margin(1) // Add space between buttons
.split(button_area); .split(button_area);
for (i, button_label) in dialog_buttons.iter().enumerate() { for (i, button_label) in dialog_buttons.iter().enumerate() {
// Ensure we don't try to render into a non-existent chunk if i >= button_chunks.len() {
if i >= button_chunks.len() { break;
break; }
}
let is_active = i == dialog_active_button_index; let is_active = i == dialog_active_button_index;
let (button_style, border_style) = if is_active { let (button_style, border_style) = if is_active {
( (
Style::default() Style::default()
.fg(theme.highlight) .fg(theme.highlight)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
Style::default().fg(theme.accent), // Highlight border Style::default().fg(theme.accent),
) )
} else { } else {
( (
Style::default().fg(theme.fg), Style::default().fg(theme.fg),
Style::default().fg(theme.border), // Normal border Style::default().fg(theme.border),
) )
}; };
let button_block = Block::default() let button_block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Plain) .border_type(BorderType::Plain)
.border_style(border_style); .border_style(border_style);
f.render_widget( f.render_widget(
Paragraph::new(button_label.as_str()) Paragraph::new(button_label.as_str())
.block(button_block) .block(button_block)
.style(button_style) .style(button_style)
.alignment(Alignment::Center), .alignment(Alignment::Center),
button_chunks[i], button_chunks[i],
); );
}
} }
} }
} }

View File

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

View File

@@ -1,13 +1,15 @@
// src/components/common/status_line.rs
use crate::config::colors::themes::Theme;
use crate::state::app::state::AppState;
use ratatui::{ use ratatui::{
style::Style,
layout::Rect, layout::Rect,
Frame, style::Style,
text::{Line, Span}, text::{Line, Span},
widgets::Paragraph, widgets::Paragraph,
Frame,
}; };
use unicode_width::UnicodeWidthStr;
use crate::config::colors::themes::Theme;
use std::path::Path; use std::path::Path;
use unicode_width::UnicodeWidthStr;
pub fn render_status_line( pub fn render_status_line(
f: &mut Frame, f: &mut Frame,
@@ -16,11 +18,24 @@ pub fn render_status_line(
theme: &Theme, theme: &Theme,
is_edit_mode: bool, is_edit_mode: bool,
current_fps: f64, current_fps: f64,
app_state: &AppState,
) { ) {
// --- START FIX ---
// Ensure debug_text is always a &str, which implements UnicodeWidthStr.
#[cfg(feature = "ui-debug")]
let debug_text = app_state.debug_info.as_str();
#[cfg(not(feature = "ui-debug"))]
let debug_text = "";
// --- END FIX ---
let debug_width = UnicodeWidthStr::width(debug_text);
let debug_separator_width = if !debug_text.is_empty() { UnicodeWidthStr::width(" | ") } else { 0 };
let program_info = format!("multieko2 v{}", env!("CARGO_PKG_VERSION")); let program_info = format!("multieko2 v{}", env!("CARGO_PKG_VERSION"));
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" }; 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) { let display_dir = if current_dir.starts_with(&home_dir) {
current_dir.replacen(&home_dir, "~", 1) current_dir.replacen(&home_dir, "~", 1)
} else { } else {
@@ -35,16 +50,19 @@ pub fn render_status_line(
let separator = " | "; let separator = " | ";
let separator_width = UnicodeWidthStr::width(separator); let separator_width = UnicodeWidthStr::width(separator);
let fixed_width_with_fps = mode_width + separator_width + separator_width + let fixed_width_with_fps = mode_width + separator_width + separator_width +
program_info_width + separator_width + fps_width; program_info_width + separator_width + fps_width +
let show_fps = fixed_width_with_fps < available_width; debug_separator_width + debug_width;
let show_fps = fixed_width_with_fps <= available_width;
let remaining_width_for_dir = available_width.saturating_sub( let remaining_width_for_dir = available_width.saturating_sub(
mode_width + separator_width + separator_width + program_info_width + mode_width + separator_width +
if show_fps { separator_width + fps_width } else { 0 } separator_width + program_info_width +
(if show_fps { separator_width + fps_width } else { 0 }) +
debug_separator_width + debug_width,
); );
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 display_dir
} else { } else {
let dir_name = Path::new(current_dir) let dir_name = Path::new(current_dir)
@@ -54,24 +72,46 @@ pub fn render_status_line(
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir { if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
dir_name.to_string() dir_name.to_string()
} else { } 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 +
debug_separator_width + debug_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(mode_text, Style::default().fg(theme.accent)),
Span::styled(" | ", Style::default().fg(theme.border)), Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(dir_display_text, Style::default().fg(theme.fg)), Span::styled(dir_display_text_str.as_str(), Style::default().fg(theme.fg)),
Span::styled(" | ", Style::default().fg(theme.border)), Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(program_info, Style::default().fg(theme.secondary)), Span::styled(program_info.as_str(), Style::default().fg(theme.secondary)),
]; ];
if show_fps { if show_fps {
spans.push(Span::styled(" | ", Style::default().fg(theme.border))); line_spans.push(Span::styled(separator, Style::default().fg(theme.border)));
spans.push(Span::styled(fps_text, Style::default().fg(theme.secondary))); line_spans.push(Span::styled(fps_text.as_str(), Style::default().fg(theme.secondary)));
} }
let paragraph = Paragraph::new(Line::from(spans)) #[cfg(feature = "ui-debug")]
{
line_spans.push(Span::styled(separator, Style::default().fg(theme.border)));
line_spans.push(Span::styled(debug_text, Style::default().fg(theme.accent)));
}
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)); .style(Style::default().bg(theme.bg));
f.render_widget(paragraph, area); 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

@@ -13,32 +13,33 @@ use crate::components::handlers::canvas::render_canvas;
pub fn render_form( pub fn render_form(
f: &mut Frame, f: &mut Frame,
area: Rect, area: Rect,
form_state: &impl CanvasState, form_state_param: &impl CanvasState,
fields: &[&str], fields: &[&str],
current_field: &usize, current_field_idx: &usize,
inputs: &[&String], inputs: &[&String],
table_name: &str, // This parameter receives the correct table name
theme: &Theme, theme: &Theme,
is_edit_mode: bool, is_edit_mode: bool,
highlight_state: &HighlightState, highlight_state: &HighlightState,
total_count: u64, total_count: u64,
current_position: u64, current_position: u64,
) { ) {
// Create Adresar card // Use the dynamic `table_name` parameter for the title instead of a hardcoded string.
let card_title = format!(" {} ", table_name);
let adresar_card = Block::default() let adresar_card = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(theme.border)) .border_style(Style::default().fg(theme.border))
.title(" Adresar ") .title(card_title) // Use the dynamic title
.style(Style::default().bg(theme.bg).fg(theme.fg)); .style(Style::default().bg(theme.bg).fg(theme.fg));
f.render_widget(adresar_card, area); f.render_widget(adresar_card, area);
// Define inner area
let inner_area = area.inner(Margin { let inner_area = area.inner(Margin {
horizontal: 1, horizontal: 1,
vertical: 1, vertical: 1,
}); });
// Create main layout
let main_layout = Layout::default() let main_layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
@@ -47,20 +48,27 @@ pub fn render_form(
]) ])
.split(inner_area); .split(inner_area);
// Render count/position let count_position_text = if total_count == 0 && current_position == 1 {
let count_position_text = format!("Total: {} | Position: {}", total_count, current_position); "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) let count_para = Paragraph::new(count_position_text)
.style(Style::default().fg(theme.fg)) .style(Style::default().fg(theme.fg))
.alignment(Alignment::Left); .alignment(Alignment::Left);
f.render_widget(count_para, main_layout[0]); f.render_widget(count_para, main_layout[0]);
// Delegate input handling to canvas
render_canvas( render_canvas(
f, f,
main_layout[1], main_layout[1],
form_state, form_state_param,
fields, fields,
current_field, current_field_idx,
inputs, inputs,
theme, theme,
is_edit_mode, is_edit_mode,

View File

@@ -2,9 +2,10 @@
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use crate::state::app::buffer::BufferState; use crate::state::app::buffer::BufferState;
use crate::state::app::state::AppState; // Add this import
use ratatui::{ use ratatui::{
layout::{Alignment, Rect}, layout::{Alignment, Rect},
style::{Style, Stylize}, style::Style,
text::{Line, Span}, text::{Line, Span},
widgets::Paragraph, widgets::Paragraph,
Frame, Frame,
@@ -17,6 +18,7 @@ pub fn render_buffer_list(
area: Rect, area: Rect,
theme: &Theme, theme: &Theme,
buffer_state: &BufferState, buffer_state: &BufferState,
app_state: &AppState,
) { ) {
// --- Style Definitions --- // --- Style Definitions ---
let active_style = Style::default() let active_style = Style::default()
@@ -37,6 +39,8 @@ pub fn render_buffer_list(
let mut spans = Vec::new(); let mut spans = Vec::new();
let mut current_width = 0; 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() { for (original_index, view) in buffer_state.history.iter().enumerate() {
// Filter: Only process views matching the active layer // Filter: Only process views matching the active layer
if get_view_layer(view) != 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 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 buffer_text = format!(" {} ", buffer_name);
let text_width = UnicodeWidthStr::width(buffer_text.as_str()); let text_width = UnicodeWidthStr::width(buffer_text.as_str());

View File

@@ -8,10 +8,15 @@ use ratatui::{
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use common::proto::multieko2::table_definition::{ProfileTreeResponse}; use common::proto::multieko2::table_definition::{ProfileTreeResponse};
use ratatui::text::{Span, Line}; use ratatui::text::{Span, Line};
use crate::components::utils::text::truncate_string;
// Reduced sidebar width // 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) { pub fn calculate_sidebar_layout(show_sidebar: bool, main_content_area: Rect) -> (Option<Rect>, Rect) {
if show_sidebar { if show_sidebar {
let chunks = Layout::default() let chunks = Layout::default()
@@ -36,18 +41,54 @@ pub fn render_sidebar(
) { ) {
let sidebar_block = Block::default().style(Style::default().bg(theme.bg)); let sidebar_block = Block::default().style(Style::default().bg(theme.bg));
let mut items = Vec::new(); 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 { 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 { } else {
// Show full profile tree when no profile is selected (compact version) // Show full profile tree when no profile is selected (compact version)
for (profile_idx, profile) in profile_tree.profiles.iter().enumerate() { for (profile_idx, profile) in profile_tree.profiles.iter().enumerate() {
// Profile header - more compact // Profile header - more compact
items.push(ListItem::new(Line::from(vec![ items.push(ListItem::new(Line::from(vec![
Span::styled("", Style::default().fg(theme.accent)), Span::styled(format!("{} ", ICON_PROFILE), Style::default().fg(theme.accent)),
Span::styled(&profile.name, Style::default().fg(theme.highlight)), Span::styled(
&profile.name,
Style::default().fg(theme.highlight)
),
]))); ])));
// Tables with compact prefixes // Tables with compact prefixes
for (table_idx, table) in profile.tables.iter().enumerate() { for (table_idx, table) in profile.tables.iter().enumerate() {
let is_last_table = table_idx == profile.tables.len() - 1; let is_last_table = table_idx == profile.tables.len() - 1;
@@ -68,18 +109,18 @@ pub fn render_sidebar(
&table.name &table.name
}; };
let mut line = vec![ // Adjust available width if dependency arrow is shown
Span::styled(prefix, Style::default().fg(theme.fg)), let current_table_available_width = if !table.depends_on.is_empty() {
Span::styled(display_name, Style::default().fg(theme.fg)), table_name_available_width.saturating_sub(1)
]; } else {
table_name_available_width
};
// Show a simple indicator for dependencies instead of listing them let line = vec![
if !table.depends_on.is_empty() { Span::styled(prefix, Style::default().fg(theme.fg)),
line.push(Span::styled( 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)),
Style::default().fg(theme.secondary) ];
));
}
items.push(ListItem::new(Line::from(line))); items.push(ListItem::new(Line::from(line)));
} }

View File

@@ -5,6 +5,7 @@ pub mod admin;
pub mod common; pub mod common;
pub mod form; pub mod form;
pub mod auth; pub mod auth;
pub mod utils;
pub use handlers::*; pub use handlers::*;
pub use intro::*; pub use intro::*;
@@ -12,3 +13,4 @@ pub use admin::*;
pub use common::*; pub use common::*;
pub use form::*; pub use form::*;
pub use auth::*; 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 // 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::collections::HashMap;
use std::path::Path; use std::path::Path;
use anyhow::{Context, Result};
use crossterm::event::{KeyCode, KeyModifiers}; 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)] #[derive(Debug, Deserialize, Default)]
pub struct ColorsConfig { pub struct ColorsConfig {
#[serde(default = "default_theme")] #[serde(default = "default_theme")]
@@ -21,9 +68,14 @@ pub struct Config {
pub keybindings: ModeKeybindings, pub keybindings: ModeKeybindings,
#[serde(default)] #[serde(default)]
pub colors: ColorsConfig, 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 { pub struct ModeKeybindings {
#[serde(default)] #[serde(default)]
pub general: HashMap<String, Vec<String>>, pub general: HashMap<String, Vec<String>>,
@@ -43,16 +95,16 @@ pub struct ModeKeybindings {
impl Config { impl Config {
/// Loads the configuration from "config.toml" in the client crate directory. /// 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 manifest_dir = env!("CARGO_MANIFEST_DIR");
let config_path = Path::new(manifest_dir).join("config.toml"); let config_path = Path::new(manifest_dir).join("config.toml");
let config_str = std::fs::read_to_string(&config_path) let config_str = std::fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config file at {:?}: {}", config_path, e))?; .with_context(|| format!("Failed to read config file at {:?}", config_path))?;
let config: Config = toml::from_str(&config_str)?; 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) Ok(config)
} }
pub fn get_general_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> { pub fn get_general_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_for_key_in_mode(&self.keybindings.general, key, modifiers) 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)) .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))

View File

@@ -2,3 +2,4 @@
pub mod binds; pub mod binds;
pub mod colors; 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 = "multieko2_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 { pub fn get_view_layer(view: &AppView) -> u8 {
match view { match view {
AppView::Intro => 1, AppView::Intro => 1,
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable => 2, AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
AppView::Form(_) | AppView::Scratch => 3, AppView::Form | AppView::Scratch => 3,
} }
} }

View File

@@ -3,3 +3,4 @@
pub mod form_e; pub mod form_e;
pub mod auth_e; pub mod auth_e;
pub mod add_table_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::add_table::AddTableState;
use crate::state::pages::canvas_state::CanvasState; // Use trait use crate::state::pages::canvas_state::CanvasState; // Use trait
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
use std::error::Error; use anyhow::Result;
#[derive(PartialEq)] #[derive(PartialEq)]
enum CharType { enum CharType {
@@ -134,7 +134,7 @@ pub async fn execute_edit_action(
state: &mut AddTableState, state: &mut AddTableState,
ideal_cursor_column: &mut usize, ideal_cursor_column: &mut usize,
// Add other params like grpc_client if needed for future actions (e.g., validation) // 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 // Use the CanvasState trait methods implemented for AddTableState
match action { match action {
"insert_char" => { "insert_char" => {
@@ -186,8 +186,11 @@ pub async fn execute_edit_action(
let num_fields = AddTableState::INPUT_FIELD_COUNT; let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 { if num_fields > 0 {
let current_field = state.current_field(); let current_field = state.current_field();
let new_field = (current_field + 1) % num_fields; let last_field_index = num_fields - 1;
state.set_current_field(new_field); // Prevent cycling forward
if current_field < last_field_index {
state.set_current_field(current_field + 1);
}
let current_input = state.get_current_input(); let current_input = state.get_current_input();
let max_pos = current_input.len(); let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
@@ -198,12 +201,9 @@ pub async fn execute_edit_action(
let num_fields = AddTableState::INPUT_FIELD_COUNT; let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 { if num_fields > 0 {
let current_field = state.current_field(); let current_field = state.current_field();
let new_field = if current_field == 0 { if current_field > 0 {
num_fields - 1 state.set_current_field(current_field - 1);
} else { }
current_field - 1
};
state.set_current_field(new_field);
let current_input = state.get_current_input(); let current_input = state.get_current_input();
let max_pos = current_input.len(); let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
@@ -227,18 +227,16 @@ pub async fn execute_edit_action(
Ok("".to_string()) Ok("".to_string())
} }
"move_up" => { "move_up" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT; let current_field = state.current_field();
if num_fields > 0 { // Prevent moving up from the first field
let current_field = state.current_field(); if current_field > 0 {
if current_field > 0 { let new_field = current_field - 1;
let new_field = current_field - 1; state.set_current_field(new_field);
state.set_current_field(new_field); let current_input = state.get_current_input();
let current_input = state.get_current_input(); let max_pos = current_input.len();
let max_pos = current_input.len(); state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
} }
Ok("".to_string()) Ok("ahoj".to_string())
} }
"move_down" => { "move_down" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT; let num_fields = AddTableState::INPUT_FIELD_COUNT;

View File

@@ -7,6 +7,7 @@ use crate::state::pages::auth::RegisterState;
use crate::tui::functions::common::form::{revert, save}; use crate::tui::functions::common::form::{revert, save};
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
use std::any::Any; use std::any::Any;
use anyhow::Result;
pub async fn execute_common_action<S: CanvasState + Any>( pub async fn execute_common_action<S: CanvasState + Any>(
action: &str, action: &str,
@@ -14,7 +15,7 @@ pub async fn execute_common_action<S: CanvasState + Any>(
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
current_position: &mut u64, current_position: &mut u64,
total_count: u64, total_count: u64,
) -> Result<String, Box<dyn std::error::Error>> { ) -> Result<String> {
match action { match action {
"save" | "revert" => { "save" | "revert" => {
if !state.has_unsaved_changes() { if !state.has_unsaved_changes() {
@@ -28,8 +29,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
let outcome = save( let outcome = save(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
) )
.await?; .await?;
let message = format!("Save successful: {:?}", outcome); // Simple message for now let message = format!("Save successful: {:?}", outcome); // Simple message for now
@@ -39,8 +38,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
revert( revert(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
) )
.await .await
} }
@@ -62,10 +59,7 @@ pub async fn execute_edit_action<S: CanvasState + Any + Send>(
key: KeyEvent, key: KeyEvent,
state: &mut S, state: &mut S,
ideal_cursor_column: &mut usize, ideal_cursor_column: &mut usize,
grpc_client: &mut GrpcClient, ) -> Result<String> {
current_position: &mut u64,
total_count: u64,
) -> Result<String, Box<dyn std::error::Error>> {
match action { match action {
"insert_char" => { "insert_char" => {
if let KeyCode::Char(c) = key.code { if let KeyCode::Char(c) = key.code {
@@ -120,7 +114,7 @@ pub async fn execute_edit_action<S: CanvasState + Any + Send>(
let num_fields = state.fields().len(); let num_fields = state.fields().len();
if num_fields > 0 { if num_fields > 0 {
let current_field = state.current_field(); 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); state.set_current_field(new_field);
let current_input = state.get_current_input(); let current_input = state.get_current_input();
let max_pos = current_input.len(); let max_pos = current_input.len();
@@ -135,11 +129,7 @@ pub async fn execute_edit_action<S: CanvasState + Any + Send>(
let num_fields = state.fields().len(); let num_fields = state.fields().len();
if num_fields > 0 { if num_fields > 0 {
let current_field = state.current_field(); let current_field = state.current_field();
let new_field = if current_field == 0 { let new_field = current_field.saturating_sub(1);
num_fields - 1
} else {
current_field - 1
};
state.set_current_field(new_field); state.set_current_field(new_field);
let current_input = state.get_current_input(); let current_input = state.get_current_input();
let max_pos = current_input.len(); let max_pos = current_input.len();

View File

@@ -8,14 +8,13 @@ use crate::tui::functions::common::form::SaveOutcome;
use crate::modes::handlers::event::EventOutcome; use crate::modes::handlers::event::EventOutcome;
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
use std::any::Any; use std::any::Any;
use anyhow::Result;
pub async fn execute_common_action<S: CanvasState + Any>( pub async fn execute_common_action<S: CanvasState + Any>(
action: &str, action: &str,
state: &mut S, state: &mut S,
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
current_position: &mut u64, ) -> Result<EventOutcome> {
total_count: u64,
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
match action { match action {
"save" | "revert" => { "save" | "revert" => {
if !state.has_unsaved_changes() { if !state.has_unsaved_changes() {
@@ -29,8 +28,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
let save_result = save( let save_result = save(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
).await; ).await;
match save_result { match save_result {
@@ -49,8 +46,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
let revert_result = revert( let revert_result = revert(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
).await; ).await;
match revert_result { match revert_result {
@@ -76,10 +71,7 @@ pub async fn execute_edit_action<S: CanvasState>(
key: KeyEvent, key: KeyEvent,
state: &mut S, state: &mut S,
ideal_cursor_column: &mut usize, ideal_cursor_column: &mut usize,
grpc_client: &mut GrpcClient, ) -> Result<String> {
current_position: &mut u64,
total_count: u64,
) -> Result<String, Box<dyn std::error::Error>> {
match action { match action {
"insert_char" => { "insert_char" => {
if let KeyCode::Char(c) = key.code { if let KeyCode::Char(c) = key.code {

View File

@@ -2,3 +2,4 @@
pub mod admin_nav; pub mod admin_nav;
pub mod add_table_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,278 +6,200 @@ use crate::state::{
}; };
use crossterm::event::{KeyEvent}; use crossterm::event::{KeyEvent};
use ratatui::widgets::TableState; 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( pub fn handle_add_table_navigation(
key: KeyEvent, key: KeyEvent,
config: &Config, config: &Config,
app_state: &mut AppState, app_state: &mut AppState,
add_table_state: &mut AddTableState, add_table_state: &mut AddTableState,
grpc_client: GrpcClient,
save_result_sender: SaveTableResultSender,
command_message: &mut String, command_message: &mut String,
) -> bool { ) -> bool {
let action = config.get_general_action(key.code, key.modifiers); let action = config.get_general_action(key.code, key.modifiers);
let current_focus = add_table_state.current_focus; let current_focus = add_table_state.current_focus;
let mut handled = true; // Assume handled unless logic determines otherwise let mut handled = true;
let mut new_focus = current_focus; // Initialize new_focus let mut new_focus = current_focus;
// Define focus groups for horizontal navigation if matches!(current_focus, AddTableFocus::InsideColumnsTable | AddTableFocus::InsideIndexesTable | AddTableFocus::InsideLinksTable) {
let is_left_pane_focus = matches!(current_focus, if matches!(action.as_deref(), Some("next_option") | Some("previous_option")) {
AddTableFocus::ColumnsTable | AddTableFocus::IndexesTable | AddTableFocus::LinksTable *command_message = "Press Esc to exit table item navigation first.".to_string();
); return true;
let is_right_pane_general_focus = matches!(current_focus, // Non-canvas elements in right pane }
AddTableFocus::AddColumnButton | AddTableFocus::SaveButton | AddTableFocus::CancelButton }
);
let is_canvas_input_focus = matches!(current_focus,
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType
);
match action.as_deref() { match action.as_deref() {
// --- Vertical Navigation (Up/Down) --- Some("exit_table_scroll") => {
match current_focus {
AddTableFocus::InsideColumnsTable => {
add_table_state.column_table_state.select(None);
new_focus = AddTableFocus::ColumnsTable;
// *command_message = "Exited Columns Table".to_string(); // Minimal change: remove message
}
AddTableFocus::InsideIndexesTable => {
add_table_state.index_table_state.select(None);
new_focus = AddTableFocus::IndexesTable;
// *command_message = "Exited Indexes Table".to_string();
}
AddTableFocus::InsideLinksTable => {
add_table_state.link_table_state.select(None);
new_focus = AddTableFocus::LinksTable;
// *command_message = "Exited Links Table".to_string();
}
_ => handled = false,
}
}
Some("move_up") => { Some("move_up") => {
match current_focus { match current_focus {
AddTableFocus::InputTableName => new_focus = AddTableFocus::CancelButton, // Wrap top (right pane) 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::InputColumnName => new_focus = AddTableFocus::InputTableName,
AddTableFocus::InputColumnType => new_focus = AddTableFocus::InputColumnName, AddTableFocus::InputColumnType => new_focus = AddTableFocus::InputColumnName,
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType, AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
AddTableFocus::ColumnsTable => { // Left pane navigation AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
if !navigate_table_up(&mut add_table_state.column_table_state, add_table_state.columns.len()) { AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
// If at top of columns, potentially wrap to bottom of left pane (LinksTable) or stay? Let's stay for now. AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
// Or maybe move to AddColumnButton? Let's try moving up from right pane instead. AddTableFocus::InsideColumnsTable => { navigate_table_up(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
new_focus = AddTableFocus::AddColumnButton; // Tentative: move focus up from right pane 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::IndexesTable => {
if !navigate_table_up(&mut add_table_state.index_table_state, add_table_state.indexes.len()) {
new_focus = AddTableFocus::ColumnsTable;
}
}
AddTableFocus::LinksTable => {
if !navigate_table_up(&mut add_table_state.link_table_state, add_table_state.links.len()) {
new_focus = AddTableFocus::IndexesTable;
}
}
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable, // Move up to left pane bottom
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton, AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton, AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
} }
} }
Some("move_down") => { Some("move_down") => {
match current_focus { match current_focus {
AddTableFocus::InputTableName => new_focus = AddTableFocus::InputColumnName, AddTableFocus::InputTableName => new_focus = AddTableFocus::InputColumnName,
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputColumnType, AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputColumnType,
AddTableFocus::InputColumnType => new_focus = AddTableFocus::AddColumnButton, AddTableFocus::InputColumnType => {
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable, // Move down to left pane top add_table_state.last_canvas_field = 2;
AddTableFocus::ColumnsTable => { // Left pane navigation new_focus = AddTableFocus::AddColumnButton;
if !navigate_table_down(&mut add_table_state.column_table_state, add_table_state.columns.len()) { },
new_focus = AddTableFocus::IndexesTable; // Move to next left pane item AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
} AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
} AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
AddTableFocus::IndexesTable => { AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
if !navigate_table_down(&mut add_table_state.index_table_state, add_table_state.indexes.len()) { AddTableFocus::InsideColumnsTable => { navigate_table_down(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
new_focus = AddTableFocus::LinksTable; 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::LinksTable => {
if !navigate_table_down(&mut add_table_state.link_table_state, add_table_state.links.len()) {
new_focus = AddTableFocus::SaveButton; // Move down to right pane bottom
}
}
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton, AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton, AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
AddTableFocus::CancelButton => new_focus = AddTableFocus::InputTableName, // Wrap bottom (right pane) AddTableFocus::CancelButton => {
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
// *command_message = "At bottom of form.".to_string(); // Remove message
}
} }
} }
Some("next_option") => { // This logic should already be non-wrapping
// --- Horizontal Navigation (Left/Right) --- match current_focus {
Some("next_option") => { // 'l' or Right: Move from Left Pane to Right Pane AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
if is_left_pane_focus { { new_focus = AddTableFocus::AddColumnButton; }
new_focus = match current_focus { AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
// Map left pane items to corresponding right pane items (approximate vertical alignment) AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
AddTableFocus::ColumnsTable => AddTableFocus::InputTableName, AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
AddTableFocus::IndexesTable => AddTableFocus::InputColumnName, // Or AddColumnButton? AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
AddTableFocus::LinksTable => AddTableFocus::SaveButton, AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
_ => current_focus, // Should not happen based on is_left_pane_focus AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
}; AddTableFocus::CancelButton => { /* *command_message = "At last focusable area.".to_string(); */ } // No change in focus
} else if is_right_pane_general_focus || is_canvas_input_focus { _ => handled = false,
// If already in right pane, maybe wrap Save -> Cancel or stay? Let's handle Save->Cancel only.
if current_focus == AddTableFocus::SaveButton {
new_focus = AddTableFocus::CancelButton;
}
} }
} }
Some("previous_option") => { // 'h' or Left: Move from Right Pane to Left Pane Some("previous_option") => { // This logic should already be non-wrapping
if is_right_pane_general_focus { match current_focus {
new_focus = match current_focus { AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
// Map right pane items back to left pane items (approximate vertical alignment) { /* *command_message = "At first focusable area.".to_string(); */ } // No change in focus
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType | AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable, // Go to top of left pane AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
AddTableFocus::SaveButton | AddTableFocus::CancelButton => AddTableFocus::LinksTable, // Go to bottom of left pane AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
_ => current_focus, // Should not happen AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
}; AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
} else if is_left_pane_focus { AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
// If already in left pane, pressing 'h' could wrap to Cancel button? AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
new_focus = AddTableFocus::CancelButton; // Wrap left-to-right bottom AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
_ => handled = false,
} }
} }
Some("next_field") => {
// --- Tab / Shift+Tab Navigation (Keep as vertical cycle) ---
Some("next_field") => { // Tab
new_focus = match current_focus { new_focus = match current_focus {
AddTableFocus::InputTableName => AddTableFocus::InputColumnName, AddTableFocus::InputTableName => AddTableFocus::InputColumnName, AddTableFocus::InputColumnName => AddTableFocus::InputColumnType, AddTableFocus::InputColumnType => AddTableFocus::AddColumnButton, AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable,
AddTableFocus::InputColumnName => AddTableFocus::InputColumnType, AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::IndexesTable, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::LinksTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::SaveButton,
AddTableFocus::InputColumnType => AddTableFocus::AddColumnButton, AddTableFocus::SaveButton => AddTableFocus::DeleteSelectedButton, AddTableFocus::DeleteSelectedButton => AddTableFocus::CancelButton, AddTableFocus::CancelButton => AddTableFocus::InputTableName,
AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable,
AddTableFocus::ColumnsTable => AddTableFocus::IndexesTable,
AddTableFocus::IndexesTable => AddTableFocus::LinksTable,
AddTableFocus::LinksTable => AddTableFocus::SaveButton,
AddTableFocus::SaveButton => AddTableFocus::DeleteSelectedButton,
AddTableFocus::DeleteSelectedButton => AddTableFocus::CancelButton,
AddTableFocus::CancelButton => AddTableFocus::InputTableName, // Wrap
}; };
} }
Some("prev_field") => { // Shift+Tab Some("prev_field") => {
new_focus = match current_focus { new_focus = match current_focus {
AddTableFocus::InputTableName => AddTableFocus::CancelButton, // Wrap AddTableFocus::InputTableName => AddTableFocus::CancelButton, AddTableFocus::InputColumnName => AddTableFocus::InputTableName, AddTableFocus::InputColumnType => AddTableFocus::InputColumnName, AddTableFocus::AddColumnButton => AddTableFocus::InputColumnType,
AddTableFocus::InputColumnName => AddTableFocus::InputTableName, AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::AddColumnButton, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::ColumnsTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::IndexesTable,
AddTableFocus::InputColumnType => AddTableFocus::InputColumnName, AddTableFocus::SaveButton => AddTableFocus::LinksTable, AddTableFocus::DeleteSelectedButton => AddTableFocus::SaveButton, AddTableFocus::CancelButton => AddTableFocus::DeleteSelectedButton,
AddTableFocus::AddColumnButton => AddTableFocus::InputColumnType,
AddTableFocus::ColumnsTable => AddTableFocus::AddColumnButton,
AddTableFocus::IndexesTable => AddTableFocus::ColumnsTable,
AddTableFocus::LinksTable => AddTableFocus::IndexesTable,
AddTableFocus::SaveButton => AddTableFocus::LinksTable,
AddTableFocus::DeleteSelectedButton => AddTableFocus::SaveButton,
AddTableFocus::CancelButton => AddTableFocus::DeleteSelectedButton,
}; };
} }
// --- Selection ---
Some("select") => { Some("select") => {
match current_focus { match current_focus {
AddTableFocus::AddColumnButton => { 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 */ }
if let Some(focus_after_add) = handle_add_column_action(add_table_state, command_message) { 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 */ }
new_focus = focus_after_add; 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::SaveButton => { 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 } */ }
*command_message = "Action: Save Table (Not Implemented)".to_string(); 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 */ }}
// TODO: Implement logic 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::DeleteSelectedButton => { AddTableFocus::CancelButton => { *command_message = "Action: Cancel Add Table (Not Implemented)".to_string(); }
*command_message = "Action: Delete selected".to_string(); _ => { handled = false; }
// TODO: Implement logic
}
AddTableFocus::CancelButton => {
*command_message = "Action: Cancel Add Table".to_string();
// TODO: Implement logic
}
AddTableFocus::ColumnsTable => {
if let Some(index) = add_table_state.column_table_state.selected() {
*command_message = format!("Selected column index {}", index);
} else { *command_message = "No column selected".to_string(); }
}
AddTableFocus::IndexesTable => {
if let Some(index) = add_table_state.index_table_state.selected() {
*command_message = format!("Selected index index {}", index);
} else { *command_message = "No index selected".to_string(); }
}
AddTableFocus::LinksTable => {
if let Some(index) = add_table_state.link_table_state.selected() {
*command_message = format!("Selected link index {}", index);
} else { *command_message = "No link selected".to_string(); }
}
_ => { // Input fields
*command_message = format!("Select on {:?}", current_focus);
handled = false; // Let main loop handle edit mode toggle maybe
}
} }
// 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, _ => handled = false,
} }
// Update focus state if it changed and was handled
if handled && current_focus != new_focus { if handled && current_focus != new_focus {
add_table_state.current_focus = new_focus; add_table_state.current_focus = new_focus;
*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
}
// --- THIS IS THE KEY PART ---
// Check if the *new* focus target is one of the canvas input fields
let new_is_canvas_input_focus = matches!(new_focus, let new_is_canvas_input_focus = matches!(new_focus,
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType
); );
// Set focus_outside_canvas based on whether the new focus is NOT an input field app_state.ui.focus_outside_canvas = !new_is_canvas_input_focus;
app_state.ui.focus_outside_canvas = !new_is_canvas_input_focus; // <--- Sets the flag correctly
// --- END KEY PART ---
// Select first item when focusing a table
match add_table_state.current_focus {
AddTableFocus::ColumnsTable 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));
}
AddTableFocus::IndexesTable 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));
}
AddTableFocus::LinksTable 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));
}_ => {}
}
} else if !handled {
// ...
} }
// 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 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
} else {
false // Was at the top
}
}
None => {
table_state.select(Some(item_count - 1)); // Select last item
true
}
}
}
// 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
} else {
false // Was at the bottom
}
}
None => {
table_state.select(Some(0)); // Select first item
true
}
}
}

View File

@@ -1,213 +1,351 @@
// src/functions/modes/navigation/admin_nav.rs // 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::config::binds::config::Config;
use crate::state::{ use crate::state::app::buffer::{BufferState, AppView};
app::state::AppState, use crate::state::pages::add_table::{AddTableState, LinkDefinition};
pages::admin::{AdminFocus, AdminState}, use ratatui::widgets::ListState;
}; use crate::state::pages::add_logic::{AddLogicState, AddLogicFocus}; // Added AddLogicFocus import
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::state::app::buffer::AppView; // Helper functions list_select_next and list_select_previous remain the same
use crate::state::app::buffer::BufferState; fn list_select_next(list_state: &mut ListState, item_count: usize) {
use crate::state::pages::add_table::AddTableState; 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( pub fn handle_admin_navigation(
key: KeyEvent, key: crossterm::event::KeyEvent,
config: &Config, config: &Config,
app_state: &mut AppState, app_state: &mut AppState,
admin_state: &mut AdminState, admin_state: &mut AdminState,
buffer_state: &mut BufferState, buffer_state: &mut BufferState,
command_message: &mut String, command_message: &mut String,
) -> bool { ) -> 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 current_focus = admin_state.current_focus;
let profile_count = app_state.profile_tree.profiles.len(); let profile_count = app_state.profile_tree.profiles.len();
let mut handled = false;
match action { match current_focus {
// --- Vertical Navigation (Up/Down) --- AdminFocus::ProfilesPane => {
Some("move_up") => { match action.as_deref() {
match current_focus { Some("select") => {
AdminFocus::Profiles => { admin_state.current_focus = AdminFocus::InsideProfilesList;
if profile_count > 0 { if !app_state.profile_tree.profiles.is_empty() {
// Updates navigation state, resets table state if admin_state.profile_list_state.selected().is_none() {
admin_state.previous_profile(profile_count); admin_state.profile_list_state.select(Some(0));
*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();
}
} }
} }
*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::InsideProfilesList => {
AdminFocus::Profiles => { match action.as_deref() {
Some("move_up") => {
if profile_count > 0 { if profile_count > 0 {
// Updates navigation state, resets table state list_select_previous(&mut admin_state.profile_list_state, profile_count);
admin_state.next_profile(profile_count); *command_message = "".to_string();
*command_message = "Navigated profiles".to_string(); handled = true;
} }
} }
AdminFocus::Tables => { Some("move_down") => {
if let Some(nav_profile_idx) = admin_state.profile_list_state.selected() { if profile_count > 0 {
if let Some(profile) = app_state.profile_tree.profiles.get(nav_profile_idx) { list_select_next(&mut admin_state.profile_list_state, profile_count);
let table_count = profile.tables.len(); *command_message = "".to_string();
if table_count > 0 { handled = true;
admin_state.next_table(table_count); }
*command_message = "Navigated tables".to_string(); }
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 { } 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 => { Some("move_down") => {
// Set the persistent selection to the currently navigated item let current_profile_idx = admin_state.selected_profile_index
if let Some(nav_idx) = admin_state.table_list_state.selected() { .or_else(|| admin_state.profile_list_state.selected());
admin_state.selected_table_index = Some(nav_idx); // Set persistent selection if let Some(p_idx) = current_profile_idx {
*command_message = format!("Selected table index {}", nav_idx); if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
} else { if !profile.tables.is_empty() {
*command_message = "No table highlighted".to_string(); list_select_next(&mut admin_state.table_list_state, profile.tables.len());
} *command_message = "".to_string();
// We don't change focus here for now. 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 => { Some("select") => { // This is for persistently selecting a table with [*]
*command_message = "Action: Add Logic (Not Implemented)".to_string(); admin_state.selected_table_index = admin_state.table_list_state.selected();
// TODO: Trigger action for Button 1 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 => { Some("exit_table_scroll") => {
// --- Prepare AddTableState based on persistent selections --- admin_state.current_focus = AdminFocus::Tables;
if let Some(p_idx) = admin_state.selected_profile_index { *command_message = "Focus: Tables Pane".to_string();
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) { handled = true;
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
} }
_ => handled = false,
} }
true // Event handled
} }
// --- Other General Keys (Ignore for admin nav) --- AdminFocus::Button1 => { // Add Logic Button
Some("toggle_sidebar") | Some("toggle_buffer_list") | Some("next_field") | Some("prev_field") => { match action.as_deref() {
// These are handled globally or not applicable here. Some("select") => { // Typically "Enter" key
false 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 --- AdminFocus::Button2 => { // Add Table Button
_ => false, // Event not handled by admin navigation 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 auth_ro;
pub mod form_ro; pub mod form_ro;
pub mod add_table_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 // src/functions/modes/read_only/add_table_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker; use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::add_table::AddTableState; 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 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 // 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. // For now, duplicating them here for simplicity. Consider refactoring later.
@@ -74,47 +74,42 @@ pub async fn execute_action(
ideal_cursor_column: &mut usize, ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker, key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String, // Keep for potential messages command_message: &mut String, // Keep for potential messages
) -> Result<String, Box<dyn Error>> { ) -> Result<String> {
// Use the CanvasState trait methods implemented for AddTableState // Use the CanvasState trait methods implemented for AddTableState
match action { match action {
"move_up" => { "move_up" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
let num_fields = AddTableState::INPUT_FIELD_COUNT; 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) let current_field = state.current_field(); // Gets the index (0, 1, or 2)
if current_field > 0 { if current_field > 0 {
// This handles moving from field 2 -> 1, or 1 -> 0 // This handles moving from field 2 -> 1, or 1 -> 0
let new_field = current_field - 1; let new_field = current_field - 1;
state.set_current_field(new_field); 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 { } else {
// --- THIS IS WHERE THE FIX GOES ---
// current_field is 0 (InputTableName), and user pressed Up. // current_field is 0 (InputTableName), and user pressed Up.
// We need to move focus *outside* the canvas. // Forbid moving up. Do not change focus or cursor.
*command_message = "At top of form.".to_string();
// 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 ---
} }
// If we moved within the canvas (e.g., 1 -> 0), return empty string Ok(command_message.clone())
Ok("".to_string())
} }
"move_down" => { "move_down" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
let num_fields = AddTableState::INPUT_FIELD_COUNT; 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 current_field = state.current_field();
let last_field_index = num_fields - 1; 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 max_cursor_pos = current_input.len(); // Allow cursor at end
let new_pos = (*ideal_cursor_column).min(max_cursor_pos); let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos); state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column
*command_message = "".to_string();
} else { } else {
// Move focus outside canvas when moving down from the last field // Move focus outside canvas when moving down from the last field
app_state.ui.focus_outside_canvas = true; app_state.ui.focus_outside_canvas = true;
// Set focus to the first element outside canvas (AddColumnButton) // Set focus to the first element outside canvas (AddColumnButton)
state.current_focus = crate::state::pages::add_table::AddTableFocus::AddColumnButton; state.current_focus =
key_sequence_tracker.reset(); crate::state::pages::add_table::AddTableFocus::AddColumnButton;
return Ok("Focus moved below canvas".to_string()); *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" => { "move_first_line" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
if AddTableState::INPUT_FIELD_COUNT > 0 { if AddTableState::INPUT_FIELD_COUNT > 0 {
@@ -145,7 +143,8 @@ pub async fn execute_action(
state.set_current_cursor_pos(new_pos); state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column *ideal_cursor_column = new_pos; // Update ideal column
} }
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_last_line" => { "move_last_line" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
@@ -159,14 +158,16 @@ pub async fn execute_action(
state.set_current_cursor_pos(new_pos); state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column *ideal_cursor_column = new_pos; // Update ideal column
} }
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_left" => { "move_left" => {
let current_pos = state.current_cursor_pos(); let current_pos = state.current_cursor_pos();
let new_pos = current_pos.saturating_sub(1); let new_pos = current_pos.saturating_sub(1);
state.set_current_cursor_pos(new_pos); state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; *ideal_cursor_column = new_pos;
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_right" => { "move_right" => {
let current_input = state.get_current_input(); let current_input = state.get_current_input();
@@ -177,68 +178,90 @@ pub async fn execute_action(
state.set_current_cursor_pos(new_pos); state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; *ideal_cursor_column = new_pos;
} }
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_word_next" => { "move_word_next" => {
let current_input = state.get_current_input(); 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 let final_pos = new_pos.min(current_input.len()); // Allow cursor at end
state.set_current_cursor_pos(final_pos); state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos; *ideal_cursor_column = final_pos;
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_word_end" => { "move_word_end" => {
let current_input = state.get_current_input(); let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos(); let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos); let new_pos = find_word_end(current_input, current_pos);
// If find_word_end returns current_pos, try starting search from next char // 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() { let final_pos =
find_word_end(current_input, current_pos + 1) if new_pos == current_pos && current_pos < current_input.len() {
} else { find_word_end(current_input, current_pos + 1)
new_pos } else {
}; new_pos
};
let max_valid_index = current_input.len(); // Allow cursor at end let max_valid_index = current_input.len(); // Allow cursor at end
let clamped_pos = final_pos.min(max_valid_index); let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos); state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos; *ideal_cursor_column = clamped_pos;
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_word_prev" => { "move_word_prev" => {
let current_input = state.get_current_input(); 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); state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; *ideal_cursor_column = new_pos;
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_word_end_prev" => { "move_word_end_prev" => {
let current_input = state.get_current_input(); 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); state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; *ideal_cursor_column = new_pos;
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_line_start" => { "move_line_start" => {
state.set_current_cursor_pos(0); state.set_current_cursor_pos(0);
*ideal_cursor_column = 0; *ideal_cursor_column = 0;
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_line_end" => { "move_line_end" => {
let current_input = state.get_current_input(); let current_input = state.get_current_input();
let new_pos = current_input.len(); // Allow cursor at end let new_pos = current_input.len(); // Allow cursor at end
state.set_current_cursor_pos(new_pos); state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = 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) // Actions handled by main event loop (mode changes)
"enter_edit_mode_before" | "enter_edit_mode_after" | "enter_command_mode" | "exit_highlight_mode" => { "enter_edit_mode_before" | "enter_edit_mode_after"
key_sequence_tracker.reset(); | "enter_command_mode" | "exit_highlight_mode" => {
Ok("Mode change handled by main loop".to_string()) 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(); key_sequence_tracker.reset();
command_message.clear(); // Clear message for unhandled actions *command_message =
Ok(format!("Unknown read-only action: {}", action)) format!("Unknown read-only action: {}", action);
}, Ok(command_message.clone())
}
} }
} }

View File

@@ -3,7 +3,7 @@
use crate::config::binds::key_sequences::KeySequenceTracker; use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::canvas_state::CanvasState; use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use std::error::Error; use anyhow::Result;
#[derive(PartialEq)] #[derive(PartialEq)]
enum CharType { enum CharType {
@@ -19,7 +19,7 @@ pub async fn execute_action<S: CanvasState>(
ideal_cursor_column: &mut usize, ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker, key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String, command_message: &mut String,
) -> Result<String, Box<dyn Error>> { ) -> Result<String> {
match action { match action {
"previous_entry" | "next_entry" => { "previous_entry" | "next_entry" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
@@ -252,28 +252,6 @@ fn find_next_word_start(text: &str, current_pos: usize) -> usize {
pos 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 { fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect(); let chars: Vec<char> = text.chars().collect();
let len = chars.len(); 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 mut pos = current_pos.min(len - 1);
let original_pos = pos;
let current_type = get_char_type(chars[pos]); let current_type = get_char_type(chars[pos]);
if current_type != CharType::Whitespace { if current_type != CharType::Whitespace {
while pos < len && get_char_type(chars[pos]) == current_type { while pos < len && get_char_type(chars[pos]) == current_type {

View File

@@ -2,7 +2,7 @@
use crate::config::binds::key_sequences::KeySequenceTracker; use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::canvas_state::CanvasState; use crate::state::pages::canvas_state::CanvasState;
use std::error::Error; use anyhow::Result;
#[derive(PartialEq)] #[derive(PartialEq)]
enum CharType { enum CharType {
@@ -17,7 +17,7 @@ pub async fn execute_action<S: CanvasState>(
ideal_cursor_column: &mut usize, ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker, key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String, command_message: &mut String,
) -> Result<String, Box<dyn Error>> { ) -> Result<String> {
match action { match action {
"previous_entry" | "next_entry" => { "previous_entry" | "next_entry" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
@@ -238,28 +238,6 @@ fn find_next_word_start(text: &str, current_pos: usize) -> usize {
pos 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 { fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect(); let chars: Vec<char> = text.chars().collect();
let len = chars.len(); 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 mut pos = current_pos.min(len - 1);
let original_pos = pos;
let current_type = get_char_type(chars[pos]); let current_type = get_char_type(chars[pos]);
if current_type != CharType::Whitespace { if current_type != CharType::Whitespace {
while pos < len && get_char_type(chars[pos]) == current_type { while pos < len && get_char_type(chars[pos]) == current_type {

View File

@@ -7,6 +7,7 @@ pub mod components;
pub mod modes; pub mod modes;
pub mod functions; pub mod functions;
pub mod services; pub mod services;
pub mod utils;
pub use ui::run_ui; pub use ui::run_ui;

View File

@@ -1,10 +1,16 @@
// client/src/main.rs // client/src/main.rs
use client::run_ui; use client::run_ui;
use dotenvy::dotenv; use dotenvy::dotenv;
use std::error::Error; use anyhow::Result;
use tracing_subscriber;
use std::env;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<()> {
if env::var("ENABLE_TRACING").is_ok() {
tracing_subscriber::fmt::init();
}
dotenv().ok(); dotenv().ok();
run_ui().await run_ui().await
} }

View File

@@ -7,10 +7,11 @@ use crate::services::grpc_client::GrpcClient;
use crate::services::auth::AuthClient; use crate::services::auth::AuthClient;
use crate::modes::handlers::event::EventOutcome; use crate::modes::handlers::event::EventOutcome;
use crate::tui::functions::common::form::SaveOutcome; use crate::tui::functions::common::form::SaveOutcome;
use anyhow::{Context, Result};
use crate::tui::functions::common::{ use crate::tui::functions::common::{
form::{save as form_save, revert as form_revert}, form::{save as form_save, revert as form_revert},
login::{save as login_save, revert as login_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( pub async fn handle_core_action(
@@ -23,24 +24,17 @@ pub async fn handle_core_action(
auth_client: &mut AuthClient, auth_client: &mut AuthClient,
terminal: &mut TerminalCore, terminal: &mut TerminalCore,
app_state: &mut AppState, app_state: &mut AppState,
current_position: &mut u64, ) -> Result<EventOutcome> {
total_count: u64,
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
match action { match action {
"save" => { "save" => {
if app_state.ui.show_login { if app_state.ui.show_login {
let message = login_save(auth_state, login_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 if app_state.ui.show_register {
let message = register_save(register_state, auth_client, app_state).await?;
Ok(EventOutcome::Ok(message)) Ok(EventOutcome::Ok(message))
} else { } else {
let save_outcome = form_save( let save_outcome = form_save(
form_state, form_state,
grpc_client, grpc_client,
current_position, ).await.context("Register save action failed")?;
total_count,
).await?;
let message = match save_outcome { let message = match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(), SaveOutcome::NoChange => "No changes to save.".to_string(),
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(), SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
@@ -55,15 +49,11 @@ pub async fn handle_core_action(
}, },
"save_and_quit" => { "save_and_quit" => {
let message = if app_state.ui.show_login { let message = if app_state.ui.show_login {
login_save(auth_state, login_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 if app_state.ui.show_register {
register_save(register_state, auth_client, app_state).await?
} else { } else {
let save_outcome = form_save( let save_outcome = form_save(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
).await?; ).await?;
match save_outcome { match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(), SaveOutcome::NoChange => "No changes to save.".to_string(),
@@ -85,9 +75,7 @@ pub async fn handle_core_action(
let message = form_revert( let message = form_revert(
form_state, form_state,
grpc_client, grpc_client,
current_position, ).await.context("Form revert x action failed")?;
total_count,
).await?;
Ok(EventOutcome::Ok(message)) Ok(EventOutcome::Ok(message))
} }
}, },

View File

@@ -5,47 +5,50 @@ use crate::state::pages::{
auth::{LoginState, RegisterState}, auth::{LoginState, RegisterState},
canvas_state::CanvasState, canvas_state::CanvasState,
}; };
use crate::state::pages::form::FormState; use crate::state::pages::form::FormState; // <<< ADD THIS LINE
use crate::state::pages::add_table::AddTableState; // Added // AddLogicState is already imported
// AddTableState is already imported
use crate::state::pages::admin::AdminState;
use crate::modes::handlers::event::EventOutcome; use crate::modes::handlers::event::EventOutcome;
use crate::functions::modes::edit::{auth_e, form_e}; use crate::functions::modes::edit::{add_logic_e, auth_e, form_e, add_table_e};
use crate::functions::modes::edit::add_table_e; // Added
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use anyhow::Result;
use crossterm::event::KeyEvent; // Removed KeyCode, KeyModifiers as they were unused
use tracing::debug;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditEventOutcome { pub enum EditEventOutcome {
Message(String), // Return a message, stay in Edit mode Message(String),
ExitEditMode, // Signal to exit Edit mode ExitEditMode,
} }
pub async fn handle_edit_event( pub async fn handle_edit_event(
key: KeyEvent, key: KeyEvent,
config: &Config, config: &Config,
form_state: &mut FormState, form_state: &mut FormState, // Now FormState is in scope
login_state: &mut LoginState, login_state: &mut LoginState,
register_state: &mut RegisterState, register_state: &mut RegisterState,
add_table_state: &mut AddTableState, // Added admin_state: &mut AdminState,
ideal_cursor_column: &mut usize, ideal_cursor_column: &mut usize,
current_position: &mut u64, current_position: &mut u64,
total_count: u64, total_count: u64,
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
app_state: &AppState, app_state: &AppState,
) -> Result<EditEventOutcome, Box<dyn std::error::Error>> { ) -> Result<EditEventOutcome> {
// Global command mode check (should ideally be handled before calling this function) // --- Global command mode check ---
if let Some("enter_command_mode") = config.get_action_for_key_in_mode( if let Some("enter_command_mode") = config.get_action_for_key_in_mode(
&config.keybindings.global, &config.keybindings.global, // Assuming command mode can be entered globally
key.code, key.code,
key.modifiers, key.modifiers,
) { ) {
// This mode change should likely be handled in event.rs // This check might be redundant if EventHandler already prevents entering Edit mode
// Returning a message here might prevent the mode switch. // when command_mode is true. However, it's a safeguard.
// Consider if this check is necessary here.
return Ok(EditEventOutcome::Message( return Ok(EditEventOutcome::Message(
"Command mode entry handled globally.".to_string(), "Cannot enter command mode from edit mode here.".to_string(),
)); ));
} }
// --- Common actions (save, revert) ---
if let Some(action) = config.get_action_for_key_in_mode( if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.common, &config.keybindings.common,
key.code, key.code,
@@ -53,295 +56,197 @@ pub async fn handle_edit_event(
).as_deref() { ).as_deref() {
if matches!(action, "save" | "revert") { if matches!(action, "save" | "revert") {
let message_string: String = if app_state.ui.show_login { let message_string: String = if app_state.ui.show_login {
auth_e::execute_common_action( auth_e::execute_common_action(action, login_state, grpc_client, current_position, total_count).await?
action,
login_state,
grpc_client,
current_position,
total_count,
)
.await?
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
// Keeping this block as requested auth_e::execute_common_action(action, register_state, grpc_client, current_position, total_count).await?
auth_e::execute_common_action(
action,
register_state,
grpc_client,
current_position,
total_count,
)
.await? // Results in String on success
} else if app_state.ui.show_add_table { } else if app_state.ui.show_add_table {
// Placeholder - common actions for AddTable might be different // TODO: Implement common actions for AddTable if needed
format!( format!("Action '{}' not implemented for Add Table in edit mode.", action)
"Action '{}' not fully implemented for Add Table view here.", } else if app_state.ui.show_add_logic {
action // TODO: Implement common actions for AddLogic if needed
) format!("Action '{}' not implemented for Add Logic in edit mode.", action)
} else { // Assuming FormState otherwise } else { // Assuming Form view
let outcome = form_e::execute_common_action( let outcome = form_e::execute_common_action(action, form_state, grpc_client).await?;
action,
form_state,
grpc_client,
current_position,
total_count,
)
.await?;
match outcome { match outcome {
EventOutcome::Ok(msg) => msg, EventOutcome::Ok(msg) | EventOutcome::DataSaved(_, msg) => msg,
EventOutcome::DataSaved(_, msg) => msg, _ => format!("Unexpected outcome from common action: {:?}", outcome),
_ => format!(
"Unexpected outcome from common action: {:?}",
outcome
),
} }
}; };
return Ok(EditEventOutcome::Message(message_string)); return Ok(EditEventOutcome::Message(message_string));
} }
} }
// Edit-specific actions // --- Edit-specific actions ---
if let Some(action) = if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers).as_deref() {
config.get_edit_action_for_key(key.code, key.modifiers) // --- Handle "enter_decider" (Enter key) ---
.as_deref() { if action_str == "enter_decider" {
if action == "exit" { let effective_action = if app_state.ui.show_register
// Handle exiting suggestion mode in Register view first && register_state.in_suggestion_mode
if app_state.ui.show_register && register_state.in_suggestion_mode { && register_state.current_field() == 4 { // Role field
let msg = auth_e::execute_edit_action( "select_suggestion"
"exit_suggestion_mode", // Specific action for suggestion exit } else if app_state.ui.show_add_logic
key, && admin_state.add_logic_state.in_target_column_suggestion_mode
register_state, && admin_state.add_logic_state.current_field() == 1 { // Target Column field
ideal_cursor_column, "select_suggestion"
grpc_client, } else {
current_position, "next_field" // Default action for Enter
total_count, };
)
.await?; let msg = if app_state.ui.show_login {
return Ok(EditEventOutcome::Message(msg)); auth_e::execute_edit_action(effective_action, key, login_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action(effective_action, key, &mut admin_state.add_table_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_logic {
add_logic_e::execute_edit_action(effective_action, key, &mut admin_state.add_logic_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).await?
} else { // Form view
form_e::execute_edit_action(effective_action, key, form_state, ideal_cursor_column).await?
};
return Ok(EditEventOutcome::Message(msg));
}
// --- Handle "exit" (Escape key) ---
if action_str == "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).await?;
return Ok(EditEventOutcome::Message(msg));
} else if app_state.ui.show_add_logic && admin_state.add_logic_state.in_target_column_suggestion_mode {
admin_state.add_logic_state.in_target_column_suggestion_mode = false;
admin_state.add_logic_state.show_target_column_suggestions = false;
admin_state.add_logic_state.selected_target_column_suggestion_index = None;
return Ok(EditEventOutcome::Message("Exited column suggestions".to_string()));
} else { } else {
// Signal exit from Edit mode
return Ok(EditEventOutcome::ExitEditMode); return Ok(EditEventOutcome::ExitEditMode);
} }
} }
// Special handling for role field suggestions (Register view only) // --- Autocomplete for AddLogicState Target Column ---
if app_state.ui.show_register && register_state.current_field() == 4 { if app_state.ui.show_add_logic && admin_state.add_logic_state.current_field() == 1 { // Target Column field
// Check if Tab was pressed to *enter* suggestion mode if action_str == "suggestion_down" { // "Tab" is mapped to suggestion_down
if !register_state.in_suggestion_mode if !admin_state.add_logic_state.in_target_column_suggestion_mode {
&& key.code == KeyCode::Tab // Attempt to open suggestions
&& key.modifiers == KeyModifiers::NONE if let Some(profile_name) = admin_state.add_logic_state.profile_name.clone().into() {
{ if let Some(table_name) = admin_state.add_logic_state.selected_table_name.clone() {
register_state.update_role_suggestions(); debug!("Fetching table structure for autocomplete: Profile='{}', Table='{}'", profile_name, table_name);
if !register_state.role_suggestions.is_empty() { match grpc_client.get_table_structure(profile_name, table_name).await {
register_state.in_suggestion_mode = true; Ok(ts_response) => {
register_state.selected_suggestion_index = Some(0); // Select first suggestion admin_state.add_logic_state.table_columns_for_suggestions =
return Ok(EditEventOutcome::Message( ts_response.columns.into_iter().map(|c| c.name).collect();
"Suggestions shown".to_string(), admin_state.add_logic_state.update_target_column_suggestions();
)); if !admin_state.add_logic_state.target_column_suggestions.is_empty() {
} else { admin_state.add_logic_state.in_target_column_suggestion_mode = true;
return Ok(EditEventOutcome::Message( // update_target_column_suggestions handles initial selection
"No suggestions available".to_string(), return Ok(EditEventOutcome::Message("Column suggestions shown".to_string()));
)); } else {
return Ok(EditEventOutcome::Message("No column suggestions for current input".to_string()));
}
}
Err(e) => {
debug!("Error fetching table structure: {}", e);
admin_state.add_logic_state.table_columns_for_suggestions.clear(); // Clear old data on error
admin_state.add_logic_state.update_target_column_suggestions();
return Ok(EditEventOutcome::Message(format!("Error fetching columns: {}", e)));
}
}
} else {
return Ok(EditEventOutcome::Message("No table selected for column suggestions".to_string()));
}
} else { // Should not happen if AddLogic is properly initialized
return Ok(EditEventOutcome::Message("Profile name missing for column suggestions".to_string()));
}
} else { // Already in suggestion mode, navigate down
let msg = add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
} }
} } else if admin_state.add_logic_state.in_target_column_suggestion_mode && action_str == "suggestion_up" {
// Handle suggestion navigation/selection if already in suggestion mode let msg = add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?;
if register_state.in_suggestion_mode
&& matches!(
action,
"suggestion_down"
| "suggestion_up"
| "select_suggestion"
)
{
let msg = auth_e::execute_edit_action(
action, // Pass the specific suggestion action
key,
register_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
)
.await?;
return Ok(EditEventOutcome::Message(msg)); return Ok(EditEventOutcome::Message(msg));
} }
} }
// Execute other edit actions based on the current view // --- Autocomplete for RegisterState Role Field ---
if app_state.ui.show_register && register_state.current_field() == 4 { // Role field
if !register_state.in_suggestion_mode && action_str == "suggestion_down" { // Tab
register_state.update_role_suggestions();
if !register_state.role_suggestions.is_empty() {
register_state.in_suggestion_mode = true;
// update_role_suggestions should handle initial selection
return Ok(EditEventOutcome::Message("Role suggestions shown".to_string()));
} else {
// If Tab doesn't open suggestions, it might fall through to "next_field"
// or you might want specific behavior. For now, let it fall through.
}
}
if register_state.in_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up") {
let msg = auth_e::execute_edit_action(action_str, key, register_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
}
}
// --- Dispatch other edit actions ---
let msg = if app_state.ui.show_login { let msg = if app_state.ui.show_login {
auth_e::execute_edit_action( auth_e::execute_edit_action(action_str, key, login_state, ideal_cursor_column).await?
action,
key,
login_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
)
.await?
} else if app_state.ui.show_add_table { } else if app_state.ui.show_add_table {
add_table_e::execute_edit_action( add_table_e::execute_edit_action(action_str, key, &mut admin_state.add_table_state, ideal_cursor_column).await?
action, } else if app_state.ui.show_add_logic {
key, // If not a suggestion action handled above for AddLogic
add_table_state, if !(admin_state.add_logic_state.in_target_column_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up")) {
ideal_cursor_column, add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
// Pass other necessary params if add_table_e needs them } else { String::new() /* Already handled */ }
)
.await?
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
auth_e::execute_edit_action( if !(register_state.in_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up")) {
action, auth_e::execute_edit_action(action_str, key, register_state, ideal_cursor_column).await?
key, } else { String::new() /* Already handled */ }
register_state, } else { // Form view
ideal_cursor_column, form_e::execute_edit_action(action_str, key, form_state, ideal_cursor_column).await?
grpc_client,
current_position,
total_count,
)
.await?
} else {
// Assuming FormState otherwise
form_e::execute_edit_action(
action,
key,
form_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
)
.await?
}; };
return Ok(EditEventOutcome::Message(msg)); return Ok(EditEventOutcome::Message(msg));
} }
// --- Character insertion --- // --- Character insertion ---
if let KeyCode::Char(c) = key.code { // If character insertion happens while in suggestion mode, exit suggestion mode first.
// Exit suggestion mode in Register view if a character is typed let mut exited_suggestion_mode_for_typing = false;
if app_state.ui.show_register && register_state.in_suggestion_mode { if app_state.ui.show_register && register_state.in_suggestion_mode {
register_state.in_suggestion_mode = false; register_state.in_suggestion_mode = false;
register_state.show_role_suggestions = false; register_state.show_role_suggestions = false;
register_state.selected_suggestion_index = None; register_state.selected_suggestion_index = None;
} exited_suggestion_mode_for_typing = true;
}
// Execute insert_char action based on the current view if app_state.ui.show_add_logic && admin_state.add_logic_state.in_target_column_suggestion_mode {
let msg = if app_state.ui.show_login { admin_state.add_logic_state.in_target_column_suggestion_mode = false;
auth_e::execute_edit_action( admin_state.add_logic_state.show_target_column_suggestions = false;
"insert_char", admin_state.add_logic_state.selected_target_column_suggestion_index = None;
key, // Pass the key event containing the char exited_suggestion_mode_for_typing = true;
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(
"insert_char",
key,
add_table_state,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
auth_e::execute_edit_action(
"insert_char",
key,
register_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
)
.await?
} else {
// Assuming FormState otherwise
form_e::execute_edit_action(
"insert_char",
key,
form_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count,
)
.await?
};
// Update role suggestions after insertion if needed (Register view)
if app_state.ui.show_register && register_state.current_field() == 4 {
register_state.update_role_suggestions();
}
return Ok(EditEventOutcome::Message(msg));
} }
// --- Handle Backspace/Delete --- let mut char_insert_msg = if app_state.ui.show_login {
if matches!(key.code, KeyCode::Backspace | KeyCode::Delete) { auth_e::execute_edit_action("insert_char", key, login_state, ideal_cursor_column).await?
// Exit suggestion mode in Register view } else if app_state.ui.show_add_table {
if app_state.ui.show_register && register_state.in_suggestion_mode { add_table_e::execute_edit_action("insert_char", key, &mut admin_state.add_table_state, ideal_cursor_column).await?
register_state.in_suggestion_mode = false; } else if app_state.ui.show_add_logic {
register_state.show_role_suggestions = false; add_logic_e::execute_edit_action("insert_char", key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
register_state.selected_suggestion_index = None; } else if app_state.ui.show_register {
} auth_e::execute_edit_action("insert_char", key, register_state, ideal_cursor_column).await?
} else { // Form view
form_e::execute_edit_action("insert_char", key, form_state, ideal_cursor_column).await?
};
let action_str = if key.code == KeyCode::Backspace { // After character insertion, update suggestions if applicable
"delete_char_backward" if app_state.ui.show_register && register_state.current_field() == 4 {
} else { register_state.update_role_suggestions();
"delete_char_forward" // If we just exited suggestion mode by typing, don't immediately show them again unless Tab is pressed.
}; // However, update_role_suggestions will set show_role_suggestions if matches are found.
// This is fine, as the render logic checks in_suggestion_mode.
// Execute delete action based on the current view }
let result_msg: String = if app_state.ui.show_login { if app_state.ui.show_add_logic && admin_state.add_logic_state.current_field() == 1 {
auth_e::execute_edit_action( admin_state.add_logic_state.update_target_column_suggestions();
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 {
// Assuming FormState otherwise
form_e::execute_edit_action(
action_str,
key,
form_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count
).await?
};
// Update role suggestions after deletion if needed (Register view)
if app_state.ui.show_register && register_state.current_field() == 4 {
register_state.update_role_suggestions();
}
return Ok(EditEventOutcome::Message(result_msg));
} }
// Default return if no other handler matched if exited_suggestion_mode_for_typing && char_insert_msg.is_empty() {
Ok(EditEventOutcome::Message("".to_string())) char_insert_msg = "Suggestions hidden".to_string();
}
Ok(EditEventOutcome::Message(char_insert_msg))
} }

View File

@@ -6,10 +6,12 @@ use crate::services::grpc_client::GrpcClient;
use crate::state::pages::{canvas_state::CanvasState, auth::RegisterState}; use crate::state::pages::{canvas_state::CanvasState, auth::RegisterState};
use crate::state::pages::auth::LoginState; use crate::state::pages::auth::LoginState;
use crate::state::pages::form::FormState; use crate::state::pages::form::FormState;
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::add_table::AddTableState; use crate::state::pages::add_table::AddTableState;
use crate::state::app::state::AppState; 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 crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use anyhow::Result;
pub async fn handle_read_only_event( pub async fn handle_read_only_event(
app_state: &mut AppState, app_state: &mut AppState,
@@ -19,14 +21,13 @@ pub async fn handle_read_only_event(
login_state: &mut LoginState, login_state: &mut LoginState,
register_state: &mut RegisterState, register_state: &mut RegisterState,
add_table_state: &mut AddTableState, add_table_state: &mut AddTableState,
add_logic_state: &mut AddLogicState,
key_sequence_tracker: &mut KeySequenceTracker, key_sequence_tracker: &mut KeySequenceTracker,
current_position: &mut u64,
total_count: u64,
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
command_message: &mut String, command_message: &mut String,
edit_mode_cooldown: &mut bool, edit_mode_cooldown: &mut bool,
ideal_cursor_column: &mut usize, 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) { if config.is_enter_edit_mode_before(key.code, key.modifiers) {
*edit_mode_cooldown = true; *edit_mode_cooldown = true;
*command_message = "Entering Edit mode".to_string(); *command_message = "Entering Edit mode".to_string();
@@ -36,6 +37,7 @@ pub async fn handle_read_only_event(
if config.is_enter_edit_mode_after(key.code, key.modifiers) { if config.is_enter_edit_mode_after(key.code, key.modifiers) {
// Determine target state to adjust cursor // Determine target state to adjust cursor
let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state } let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state }
else if app_state.ui.show_add_logic { add_logic_state }
else if app_state.ui.show_register { register_state } else if app_state.ui.show_register { register_state }
else if app_state.ui.show_add_table { add_table_state } else if app_state.ui.show_add_table { add_table_state }
else { form_state }; else { form_state };
@@ -59,10 +61,6 @@ pub async fn handle_read_only_event(
"previous_entry", "previous_entry",
"next_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() { if key.modifiers.is_empty() {
key_sequence_tracker.add_key(key.code); key_sequence_tracker.add_key(key.code);
@@ -74,12 +72,10 @@ pub async fn handle_read_only_event(
action, action,
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
ideal_cursor_column, ideal_cursor_column,
) )
.await? .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? crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table { } else if app_state.ui.show_add_table {
add_table_ro::execute_action( add_table_ro::execute_action(
@@ -90,6 +86,15 @@ pub async fn handle_read_only_event(
key_sequence_tracker, key_sequence_tracker,
command_message, command_message,
).await? ).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{ } else if app_state.ui.show_register{
auth_ro::execute_action( auth_ro::execute_action(
action, action,
@@ -134,12 +139,10 @@ pub async fn handle_read_only_event(
action, action,
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
ideal_cursor_column, ideal_cursor_column,
) )
.await? .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? crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table { } else if app_state.ui.show_add_table {
add_table_ro::execute_action( add_table_ro::execute_action(
@@ -150,7 +153,16 @@ pub async fn handle_read_only_event(
key_sequence_tracker, key_sequence_tracker,
command_message, command_message,
).await? ).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( auth_ro::execute_action(
action, action,
app_state, app_state,
@@ -159,7 +171,7 @@ pub async fn handle_read_only_event(
key_sequence_tracker, key_sequence_tracker,
command_message, command_message,
).await? ).await?
} else if app_state.ui.show_login { // Handle login general actions } else if app_state.ui.show_login {
auth_ro::execute_action( auth_ro::execute_action(
action, action,
app_state, app_state,
@@ -193,8 +205,6 @@ pub async fn handle_read_only_event(
action, action,
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
ideal_cursor_column, ideal_cursor_column,
) )
.await? .await?
@@ -209,7 +219,16 @@ pub async fn handle_read_only_event(
key_sequence_tracker, key_sequence_tracker,
command_message, command_message,
).await? ).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( auth_ro::execute_action(
action, action,
app_state, app_state,
@@ -218,7 +237,7 @@ pub async fn handle_read_only_event(
key_sequence_tracker, key_sequence_tracker,
command_message, command_message,
).await? ).await?
} else if app_state.ui.show_login { // Handle login general actions } else if app_state.ui.show_login {
auth_ro::execute_action( auth_ro::execute_action(
action, action,
app_state, app_state,

View File

@@ -10,7 +10,7 @@ use crate::tui::terminal::core::TerminalCore;
use crate::tui::functions::common::form::{save, revert}; use crate::tui::functions::common::form::{save, revert};
use crate::modes::handlers::event::EventOutcome; use crate::modes::handlers::event::EventOutcome;
use crate::tui::functions::common::form::SaveOutcome; use crate::tui::functions::common::form::SaveOutcome;
use std::error::Error; use anyhow::Result;
pub async fn handle_command_event( pub async fn handle_command_event(
key: KeyEvent, key: KeyEvent,
@@ -26,7 +26,7 @@ pub async fn handle_command_event(
terminal: &mut TerminalCore, terminal: &mut TerminalCore,
current_position: &mut u64, current_position: &mut u64,
total_count: u64, total_count: u64,
) -> Result<EventOutcome, Box<dyn Error>> { ) -> Result<EventOutcome> {
// Exit command mode (via configurable keybinding) // Exit command mode (via configurable keybinding)
if config.is_exit_command_mode(key.code, key.modifiers) { if config.is_exit_command_mode(key.code, key.modifiers) {
command_input.clear(); command_input.clear();
@@ -84,7 +84,7 @@ async fn process_command(
terminal: &mut TerminalCore, terminal: &mut TerminalCore,
current_position: &mut u64, current_position: &mut u64,
total_count: u64, total_count: u64,
) -> Result<EventOutcome, Box<dyn Error>> { ) -> Result<EventOutcome> {
// Clone the trimmed command to avoid borrow issues // Clone the trimmed command to avoid borrow issues
let command = command_input.trim().to_string(); let command = command_input.trim().to_string();
if command.is_empty() { if command.is_empty() {
@@ -119,8 +119,6 @@ async fn process_command(
let outcome = save( let outcome = save(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
).await?; ).await?;
let message = match outcome { let message = match outcome {
SaveOutcome::CreatedNew(_) => "New entry created".to_string(), SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
@@ -134,8 +132,6 @@ async fn process_command(
let message = revert( let message = revert(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
).await?; ).await?;
command_input.clear(); command_input.clear();
Ok(EventOutcome::Ok(message)) Ok(EventOutcome::Ok(message))

View File

@@ -3,6 +3,7 @@ use crate::tui::terminal::core::TerminalCore;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState}; use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState};
use crate::state::pages::canvas_state::CanvasState; use crate::state::pages::canvas_state::CanvasState;
use anyhow::Result;
pub struct CommandHandler; pub struct CommandHandler;
@@ -19,7 +20,7 @@ impl CommandHandler {
form_state: &FormState, form_state: &FormState,
login_state: &LoginState, login_state: &LoginState,
register_state: &RegisterState, register_state: &RegisterState,
) -> Result<(bool, String), Box<dyn std::error::Error>> { ) -> Result<(bool, String)> {
match action { match action {
"quit" => self.handle_quit(terminal, app_state, form_state, login_state, register_state).await, "quit" => self.handle_quit(terminal, app_state, form_state, login_state, register_state).await,
"force_quit" => self.handle_force_quit(terminal).await, "force_quit" => self.handle_force_quit(terminal).await,
@@ -35,7 +36,7 @@ impl CommandHandler {
form_state: &FormState, form_state: &FormState,
login_state: &LoginState, login_state: &LoginState,
register_state: &RegisterState, register_state: &RegisterState,
) -> Result<(bool, String), Box<dyn std::error::Error>> { ) -> Result<(bool, String)> {
// Use actual unsaved changes state instead of is_saved flag // Use actual unsaved changes state instead of is_saved flag
let has_unsaved = if app_state.ui.show_login { let has_unsaved = if app_state.ui.show_login {
login_state.has_unsaved_changes() login_state.has_unsaved_changes()
@@ -56,7 +57,7 @@ impl CommandHandler {
async fn handle_force_quit( async fn handle_force_quit(
&self, &self,
terminal: &mut TerminalCore, terminal: &mut TerminalCore,
) -> Result<(bool, String), Box<dyn std::error::Error>> { ) -> Result<(bool, String)> {
terminal.cleanup()?; terminal.cleanup()?;
Ok((true, "Force exiting without saving.".into())) Ok((true, "Force exiting without saving.".into()))
} }
@@ -64,7 +65,7 @@ impl CommandHandler {
async fn handle_save_quit( async fn handle_save_quit(
&mut self, &mut self,
terminal: &mut TerminalCore, terminal: &mut TerminalCore,
) -> Result<(bool, String), Box<dyn std::error::Error>> { ) -> Result<(bool, String)> {
terminal.cleanup()?; terminal.cleanup()?;
Ok((true, "State saved. Exiting.".into())) Ok((true, "State saved. Exiting.".into()))
} }

View File

@@ -1,3 +1,4 @@
// src/client/modes/general.rs // src/client/modes/general.rs
pub mod navigation; pub mod navigation;
pub mod dialog; 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::multieko2::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,13 +3,14 @@
use crossterm::event::{Event, KeyCode}; use crossterm::event::{Event, KeyCode};
use crate::config::binds::config::Config; use crate::config::binds::config::Config;
use crate::ui::handlers::context::DialogPurpose; 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::app::buffer::BufferState;
use crate::state::pages::auth::AuthState; use crate::state::pages::auth::{LoginState, RegisterState};
use crate::state::pages::auth::LoginState; use crate::state::pages::admin::AdminState;
use crate::state::pages::auth::RegisterState;
use crate::modes::handlers::event::EventOutcome; use crate::modes::handlers::event::EventOutcome;
use crate::tui::functions::common::{login, register}; 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. /// Handles key events specifically when a dialog is active.
/// Returns Some(Result<EventOutcome, Error>) if the event was handled (consumed), /// Returns Some(Result<EventOutcome, Error>) if the event was handled (consumed),
@@ -18,11 +19,11 @@ pub async fn handle_dialog_event(
event: &Event, event: &Event,
config: &Config, config: &Config,
app_state: &mut AppState, app_state: &mut AppState,
auth_state: &mut AuthState,
login_state: &mut LoginState, login_state: &mut LoginState,
register_state: &mut RegisterState, register_state: &mut RegisterState,
buffer_state: &mut BufferState, buffer_state: &mut BufferState,
) -> Option<Result<EventOutcome, Box<dyn std::error::Error>>> { admin_state: &mut AdminState,
) -> Option<Result<EventOutcome>> {
if let Event::Key(key) = event { if let Event::Key(key) = event {
// Always allow Esc to dismiss // Always allow Esc to dismiss
if key.code == KeyCode::Esc { if key.code == KeyCode::Esc {
@@ -114,6 +115,40 @@ pub async fn handle_dialog_event(
} }
} }
} }
DialogPurpose::ConfirmDeleteColumns => {
match selected_index {
0 => { // "Confirm" button selected
let outcome_message = handle_delete_selected_columns(&mut admin_state.add_table_state);
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok(outcome_message)));
}
1 => { // "Cancel" button selected
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Deletion cancelled.".to_string())));
}
_ => { /* Handle unexpected index */ }
}
}
DialogPurpose::SaveTableSuccess => {
match selected_index {
0 => { // "OK" button selected
app_state.hide_dialog();
buffer_state.update_history(AppView::Admin); // Navigate back
return Some(Ok(EventOutcome::Ok("Save success dialog dismissed.".to_string())));
}
_ => { /* Handle unexpected index */ }
}
}
DialogPurpose::SaveLogicSuccess => {
match selected_index {
0 => { // "OK" button selected
app_state.hide_dialog();
buffer_state.update_history(AppView::Admin);
return Some(Ok(EventOutcome::Ok("Save success dialog dismissed.".to_string())));
}
_ => { /* Handle unexpected index */ }
}
}
} }
} }
_ => {} // Ignore other general actions when dialog is shown _ => {} // Ignore other general actions when dialog is shown

View File

@@ -11,6 +11,8 @@ use crate::state::pages::admin::AdminState;
use crate::state::pages::canvas_state::CanvasState; use crate::state::pages::canvas_state::CanvasState;
use crate::ui::handlers::context::UiContext; use crate::ui::handlers::context::UiContext;
use crate::modes::handlers::event::EventOutcome; use crate::modes::handlers::event::EventOutcome;
use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState};
use anyhow::Result;
pub async fn handle_navigation_event( pub async fn handle_navigation_event(
key: KeyEvent, key: KeyEvent,
@@ -24,7 +26,13 @@ pub async fn handle_navigation_event(
command_mode: &mut bool, command_mode: &mut bool,
command_input: &mut String, command_input: &mut String,
command_message: &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) { if let Some(action) = config.get_general_action(key.code, key.modifiers) {
match action { match action {
"move_up" => { "move_up" => {

View File

@@ -1,47 +1,50 @@
// src/modes/handlers/event.rs // src/modes/handlers/event.rs
use crossterm::event::Event;
use crossterm::cursor::SetCursorStyle;
use crate::services::grpc_client::GrpcClient;
use crate::services::auth::AuthClient;
use crate::config::binds::config::Config; use crate::config::binds::config::Config;
use crate::ui::handlers::rat_state::UiStateHandler; use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::ui::handlers::context::UiContext;
use crate::functions::common::buffer; use crate::functions::common::buffer;
use crate::tui::{ use crate::functions::modes::navigation::add_logic_nav;
terminal::core::TerminalCore, use crate::functions::modes::navigation::add_logic_nav::SaveLogicResultSender;
functions::{ use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
common::{ use crate::functions::modes::navigation::{add_table_nav, admin_nav};
form::SaveOutcome, use crate::modes::general::command_navigation::{
login, handle_command_navigation_event, NavigationState,
register,
},
},
{intro, admin},
}; };
use crate::modes::{
canvas::{common_mode, edit, read_only},
common::{command_mode, commands::CommandHandler},
general::{dialog, navigation},
handlers::mode_manager::{AppMode, ModeManager},
};
use crate::services::auth::AuthClient;
use crate::services::grpc_client::GrpcClient;
use crate::state::{ use crate::state::{
app::{ app::{
buffer::{AppView, BufferState},
highlight::HighlightState, highlight::HighlightState,
state::AppState, state::AppState,
buffer::{AppView, BufferState},
}, },
pages::{ pages::{
auth::{AuthState, LoginState, RegisterState},
admin::AdminState, admin::AdminState,
auth::{AuthState, LoginState, RegisterState},
canvas_state::CanvasState, canvas_state::CanvasState,
add_table::AddTableState,
form::FormState, form::FormState,
intro::IntroState, intro::IntroState,
}, },
}; };
use crate::modes::{ use crate::tui::functions::common::login::LoginResult;
common::{command_mode, commands::CommandHandler}, use crate::tui::functions::common::register::RegisterResult;
handlers::mode_manager::{ModeManager, AppMode}, use crate::tui::{
canvas::{edit, read_only, common_mode}, functions::common::{form::SaveOutcome, login, register},
highlight::highlight, terminal::core::TerminalCore,
general::{navigation, dialog}, {admin, intro},
}; };
use crate::functions::modes::navigation::{admin_nav, add_table_nav}; use crate::ui::handlers::context::UiContext;
use crate::config::binds::key_sequences::KeySequenceTracker; use crate::ui::handlers::rat_state::UiStateHandler;
use anyhow::Result;
use crossterm::cursor::SetCursorStyle;
use crossterm::event::KeyCode;
use crossterm::event::{Event, KeyEvent};
use tokio::sync::mpsc;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventOutcome { pub enum EventOutcome {
@@ -49,6 +52,16 @@ pub enum EventOutcome {
Exit(String), Exit(String),
DataSaved(SaveOutcome, String), DataSaved(SaveOutcome, String),
ButtonSelected { context: UiContext, index: usize }, ButtonSelected { context: UiContext, index: usize },
TableSelected { path: String },
}
impl EventOutcome {
pub fn get_message_if_ok(&self) -> String {
match self {
EventOutcome::Ok(msg) => msg.clone(),
_ => String::new(),
}
}
} }
pub struct EventHandler { pub struct EventHandler {
@@ -61,10 +74,20 @@ pub struct EventHandler {
pub ideal_cursor_column: usize, pub ideal_cursor_column: usize,
pub key_sequence_tracker: KeySequenceTracker, pub key_sequence_tracker: KeySequenceTracker,
pub auth_client: AuthClient, pub auth_client: AuthClient,
pub login_result_sender: mpsc::Sender<LoginResult>,
pub register_result_sender: mpsc::Sender<RegisterResult>,
pub save_table_result_sender: SaveTableResultSender,
pub save_logic_result_sender: SaveLogicResultSender,
pub navigation_state: NavigationState,
} }
impl EventHandler { impl EventHandler {
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> { pub async fn new(
login_result_sender: mpsc::Sender<LoginResult>,
register_result_sender: mpsc::Sender<RegisterResult>,
save_table_result_sender: SaveTableResultSender,
save_logic_result_sender: SaveLogicResultSender,
) -> Result<Self> {
Ok(EventHandler { Ok(EventHandler {
command_mode: false, command_mode: false,
command_input: String::new(), command_input: String::new(),
@@ -73,11 +96,25 @@ impl EventHandler {
highlight_state: HighlightState::Off, highlight_state: HighlightState::Off,
edit_mode_cooldown: false, edit_mode_cooldown: false,
ideal_cursor_column: 0, ideal_cursor_column: 0,
key_sequence_tracker: KeySequenceTracker::new(800), key_sequence_tracker: KeySequenceTracker::new(400),
auth_client: AuthClient::new().await?, auth_client: AuthClient::new().await?,
login_result_sender,
register_result_sender,
save_table_result_sender,
save_logic_result_sender,
navigation_state: NavigationState::new(),
}) })
} }
pub fn is_navigation_active(&self) -> bool {
self.navigation_state.active
}
pub fn activate_find_file(&mut self, options: Vec<String>) {
self.navigation_state.activate_find_file(options);
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_event( pub async fn handle_event(
&mut self, &mut self,
event: Event, event: Event,
@@ -93,10 +130,26 @@ impl EventHandler {
admin_state: &mut AdminState, admin_state: &mut AdminState,
buffer_state: &mut BufferState, buffer_state: &mut BufferState,
app_state: &mut AppState, app_state: &mut AppState,
total_count: u64, ) -> Result<EventOutcome> {
current_position: &mut u64, let mut current_mode = ModeManager::derive_mode(app_state, self, admin_state);
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
let current_mode = ModeManager::derive_mode(app_state, self); if current_mode == AppMode::General && self.navigation_state.active {
if let Event::Key(key_event) = event {
let outcome =
handle_command_navigation_event(&mut self.navigation_state, key_event, config)
.await?;
if !self.navigation_state.active {
self.command_message = outcome.get_message_if_ok();
current_mode = ModeManager::derive_mode(app_state, self, admin_state);
}
app_state.update_mode(current_mode);
return Ok(outcome);
}
app_state.update_mode(current_mode);
return Ok(EventOutcome::Ok(String::new()));
}
app_state.update_mode(current_mode); app_state.update_mode(current_mode);
let current_view = { let current_view = {
@@ -105,45 +158,41 @@ impl EventHandler {
else if ui.show_login { AppView::Login } else if ui.show_login { AppView::Login }
else if ui.show_register { AppView::Register } else if ui.show_register { AppView::Register }
else if ui.show_admin { AppView::Admin } else if ui.show_admin { AppView::Admin }
else if ui.show_add_logic { AppView::AddLogic }
else if ui.show_add_table { AppView::AddTable } else if ui.show_add_table { AppView::AddTable }
else if ui.show_form { else if ui.show_form { AppView::Form }
let form_name = app_state.selected_profile.clone().unwrap_or_else(|| "Data Form".to_string());
AppView::Form(form_name)
}
else { AppView::Scratch } else { AppView::Scratch }
}; };
buffer_state.update_history(current_view); buffer_state.update_history(current_view);
if app_state.ui.dialog.dialog_show { if app_state.ui.dialog.dialog_show {
if let Some(dialog_result) = dialog::handle_dialog_event( if let Event::Key(key_event) = event {
&event, config, app_state, auth_state, login_state, register_state, buffer_state if let Some(dialog_result) = dialog::handle_dialog_event(
).await { &Event::Key(key_event), config, app_state, login_state,
return dialog_result; register_state, buffer_state, admin_state,
).await {
return dialog_result;
}
} else if let Event::Resize(_, _) = event {
} }
return Ok(EventOutcome::Ok(String::new())); return Ok(EventOutcome::Ok(String::new()));
} }
if let Event::Key(key) = event { if let Event::Key(key_event) = event {
let key_code = key.code; let key_code = key_event.code;
let modifiers = key.modifiers; let modifiers = key_event.modifiers;
if UiStateHandler::toggle_sidebar(&mut app_state.ui, config, key_code, modifiers) { if UiStateHandler::toggle_sidebar(&mut app_state.ui, config, key_code, modifiers) {
let message = format!("Sidebar {}", let message = format!("Sidebar {}", if app_state.ui.show_sidebar { "shown" } else { "hidden" });
if app_state.ui.show_sidebar { "shown" } else { "hidden" }
);
return Ok(EventOutcome::Ok(message)); return Ok(EventOutcome::Ok(message));
} }
if UiStateHandler::toggle_buffer_list(&mut app_state.ui, config, key_code, modifiers) { if UiStateHandler::toggle_buffer_list(&mut app_state.ui, config, key_code, modifiers) {
let message = format!("Buffer {}", let message = format!("Buffer {}", if app_state.ui.show_buffer_list { "shown" } else { "hidden" });
if app_state.ui.show_buffer_list { "shown" } else { "hidden" }
);
return Ok(EventOutcome::Ok(message)); return Ok(EventOutcome::Ok(message));
} }
if !matches!(current_mode, AppMode::Edit | AppMode::Command) { if !matches!(current_mode, AppMode::Edit | AppMode::Command) {
if let Some(action) = config.get_action_for_key_in_mode( if let Some(action) = config.get_action_for_key_in_mode(&config.keybindings.global, key_code, modifiers) {
&config.keybindings.global, key_code, modifiers
) {
match action { match action {
"next_buffer" => { "next_buffer" => {
if buffer::switch_buffer(buffer_state, true) { if buffer::switch_buffer(buffer_state, true) {
@@ -155,6 +204,11 @@ impl EventHandler {
return Ok(EventOutcome::Ok("Switched to previous buffer".to_string())); return Ok(EventOutcome::Ok("Switched to previous buffer".to_string()));
} }
} }
"close_buffer" => {
let current_table_name = app_state.current_view_table_name.as_deref();
let message = buffer_state.close_buffer_with_intro_fallback(current_table_name);
return Ok(EventOutcome::Ok(message));
}
_ => {} _ => {}
} }
} }
@@ -162,142 +216,96 @@ impl EventHandler {
match current_mode { match current_mode {
AppMode::General => { AppMode::General => {
// Prioritize Admin Panel navigation if it's visible if app_state.ui.show_admin && auth_state.role.as_deref() == Some("admin") {
if app_state.ui.show_admin if admin_nav::handle_admin_navigation(key_event, config, app_state, admin_state, buffer_state, &mut self.command_message) {
&& auth_state.role.as_deref() == Some("admin") { return Ok(EventOutcome::Ok(self.command_message.clone()));
if admin_nav::handle_admin_navigation( }
key, }
config,
app_state, if app_state.ui.show_add_logic {
admin_state, let client_clone = grpc_client.clone();
buffer_state, let sender_clone = self.save_logic_result_sender.clone();
&mut self.command_message, if add_logic_nav::handle_add_logic_navigation(
key_event, config, app_state, &mut admin_state.add_logic_state,
&mut self.is_edit_mode, buffer_state, client_clone, sender_clone, &mut self.command_message,
) { ) {
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} }
} }
// --- Add Table Page Navigation ---
if app_state.ui.show_add_table {
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
if add_table_nav::handle_add_table_navigation(
key,
config,
app_state,
&mut admin_state.add_table_state,
&mut self.command_message,
) { if app_state.ui.show_add_table {
return Ok(EventOutcome::Ok(self.command_message.clone())); let client_clone = grpc_client.clone();
} let sender_clone = self.save_table_result_sender.clone();
if add_table_nav::handle_add_table_navigation(
key_event, config, app_state, &mut admin_state.add_table_state,
client_clone, sender_clone, &mut self.command_message,
) {
return Ok(EventOutcome::Ok(self.command_message.clone()));
} }
} }
let nav_outcome = navigation::handle_navigation_event( let nav_outcome = navigation::handle_navigation_event(
key, key_event, config, form_state, app_state, login_state, register_state,
config, intro_state, admin_state, &mut self.command_mode, &mut self.command_input,
form_state, &mut self.command_message, &mut self.navigation_state,
app_state,
login_state,
register_state,
intro_state,
admin_state,
&mut self.command_mode,
&mut self.command_input,
&mut self.command_message,
).await; ).await;
match nav_outcome { match nav_outcome {
Ok(EventOutcome::ButtonSelected { context, index }) => { Ok(EventOutcome::ButtonSelected { context, index }) => {
let mut message = String::from("Selected"); let message = match context {
match context {
UiContext::Intro => { UiContext::Intro => {
intro::handle_intro_selection(app_state, buffer_state, index); intro::handle_intro_selection(app_state, buffer_state, index);
if app_state.ui.show_admin { if app_state.ui.show_admin && !app_state.profile_tree.profiles.is_empty() {
if !app_state.profile_tree.profiles.is_empty() { admin_state.profile_list_state.select(Some(0));
admin_state.profile_list_state.select(Some(0));
}
} }
message = format!("Intro Option {} selected", index); format!("Intro Option {} selected", index)
}
UiContext::Login => {
message = match index {
0 => login::save(auth_state, login_state, &mut self.auth_client, app_state).await?,
1 => login::back_to_main(login_state, app_state, buffer_state).await,
_ => "Invalid Login Option".to_string(),
};
}
UiContext::Register => {
message = match index {
0 => register::save(register_state, &mut self.auth_client, app_state).await?,
1 => register::back_to_login(register_state, app_state, buffer_state).await,
_ => "Invalid Login Option".to_string(),
};
} }
UiContext::Login => match index {
0 => login::initiate_login(login_state, app_state, self.auth_client.clone(), self.login_result_sender.clone()),
1 => login::back_to_main(login_state, app_state, buffer_state).await,
_ => "Invalid Login Option".to_string(),
},
UiContext::Register => match index {
0 => register::initiate_registration(register_state, app_state, self.auth_client.clone(), self.register_result_sender.clone()),
1 => register::back_to_login(register_state, app_state, buffer_state).await,
_ => "Invalid Login Option".to_string(),
},
UiContext::Admin => { UiContext::Admin => {
admin::handle_admin_selection(app_state, admin_state); admin::handle_admin_selection(app_state, admin_state);
message = format!("Admin Option {} selected", index); format!("Admin Option {} selected", index)
} }
UiContext::Dialog => { UiContext::Dialog => "Internal error: Unexpected dialog state".to_string(),
message = "Internal error: Unexpected dialog state".to_string(); };
}
}
return Ok(EventOutcome::Ok(message)); return Ok(EventOutcome::Ok(message));
} }
other => return other, other => return other,
} }
}, }
AppMode::ReadOnly => { AppMode::ReadOnly => {
// Check for Linewise highlight first if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") && ModeManager::can_enter_highlight_mode(current_mode) {
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") let current_field_index = if app_state.ui.show_login { login_state.current_field() } else if app_state.ui.show_register { register_state.current_field() } else { form_state.current_field() };
&& ModeManager::can_enter_highlight_mode(current_mode)
{
let current_field_index = if app_state.ui.show_login { login_state.current_field() }
else if app_state.ui.show_register { register_state.current_field() }
else { form_state.current_field() };
self.highlight_state = HighlightState::Linewise { anchor_line: current_field_index }; self.highlight_state = HighlightState::Linewise { anchor_line: current_field_index };
self.command_message = "-- LINE HIGHLIGHT --".to_string(); self.command_message = "-- LINE HIGHLIGHT --".to_string();
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} } else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode") && ModeManager::can_enter_highlight_mode(current_mode) {
// Check for Character-wise highlight let current_field_index = if app_state.ui.show_login { login_state.current_field() } else if app_state.ui.show_register { register_state.current_field() } else { form_state.current_field() };
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode") let current_cursor_pos = if app_state.ui.show_login { login_state.current_cursor_pos() } else if app_state.ui.show_register { register_state.current_cursor_pos() } else { form_state.current_cursor_pos() };
&& ModeManager::can_enter_highlight_mode(current_mode)
{
let current_field_index = if app_state.ui.show_login { login_state.current_field() }
else if app_state.ui.show_register { register_state.current_field() }
else { form_state.current_field() };
let current_cursor_pos = if app_state.ui.show_login { login_state.current_cursor_pos() }
else if app_state.ui.show_register { register_state.current_cursor_pos() }
else { form_state.current_cursor_pos() };
let anchor = (current_field_index, current_cursor_pos); let anchor = (current_field_index, current_cursor_pos);
self.highlight_state = HighlightState::Characterwise { anchor }; self.highlight_state = HighlightState::Characterwise { anchor };
self.command_message = "-- HIGHLIGHT --".to_string(); self.command_message = "-- HIGHLIGHT --".to_string();
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} } else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_before") && ModeManager::can_enter_edit_mode(current_mode) {
// Check for entering edit mode (before cursor)
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_before")
&& ModeManager::can_enter_edit_mode(current_mode) {
self.is_edit_mode = true; self.is_edit_mode = true;
self.edit_mode_cooldown = true; self.edit_mode_cooldown = true;
self.command_message = "Edit mode".to_string(); self.command_message = "Edit mode".to_string();
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?; terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} } else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after") && ModeManager::can_enter_edit_mode(current_mode) {
// Check for entering edit mode (after cursor) let current_input = if app_state.ui.show_login || app_state.ui.show_register { login_state.get_current_input() } else { form_state.get_current_input() };
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after") let current_cursor_pos = if app_state.ui.show_login || app_state.ui.show_register { login_state.current_cursor_pos() } else { form_state.current_cursor_pos() };
&& ModeManager::can_enter_edit_mode(current_mode) {
let current_input = if app_state.ui.show_login || app_state.ui.show_register{
login_state.get_current_input()
} else {
form_state.get_current_input()
};
let current_cursor_pos = if app_state.ui.show_login || app_state.ui.show_register{
login_state.current_cursor_pos()
} else {
form_state.current_cursor_pos()
};
if !current_input.is_empty() && current_cursor_pos < current_input.len() { if !current_input.is_empty() && current_cursor_pos < current_input.len() {
if app_state.ui.show_login || app_state.ui.show_register{ if app_state.ui.show_login || app_state.ui.show_register {
login_state.set_current_cursor_pos(current_cursor_pos + 1); login_state.set_current_cursor_pos(current_cursor_pos + 1);
self.ideal_cursor_column = login_state.current_cursor_pos(); self.ideal_cursor_column = login_state.current_cursor_pos();
} else { } else {
@@ -311,84 +319,73 @@ impl EventHandler {
self.command_message = "Edit mode (after cursor)".to_string(); self.command_message = "Edit mode (after cursor)".to_string();
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?; terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} } else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_command_mode") && ModeManager::can_enter_command_mode(current_mode) {
// Check for entering command mode self.command_mode = true;
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_command_mode") self.command_input.clear();
&& ModeManager::can_enter_command_mode(current_mode) { self.command_message.clear();
self.command_mode = true; return Ok(EventOutcome::Ok(String::new()));
self.command_input.clear();
self.command_message.clear();
return Ok(EventOutcome::Ok(String::new()));
} }
// Check for common actions (save, quit, etc.) only if no mode change happened
if let Some(action) = config.get_common_action(key_code, modifiers) { if let Some(action) = config.get_common_action(key_code, modifiers) {
match action { match action {
"save" | "force_quit" | "save_and_quit" | "revert" => { "save" | "force_quit" | "save_and_quit" | "revert" => {
return common_mode::handle_core_action( return common_mode::handle_core_action(
action, action, form_state, auth_state, login_state, register_state,
form_state, grpc_client, &mut self.auth_client, terminal, app_state,
auth_state,
login_state,
register_state,
grpc_client,
&mut self.auth_client,
terminal,
app_state,
current_position,
total_count,
).await; ).await;
}, }
_ => {} _ => {}
} }
} }
// If no mode change or specific common action handled, delegate to read_only handler
let (_should_exit, message) = read_only::handle_read_only_event( let (_should_exit, message) = read_only::handle_read_only_event(
app_state, app_state,
key, key_event,
config, config,
form_state, form_state,
login_state, login_state,
register_state, register_state,
&mut admin_state.add_table_state, &mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
&mut self.key_sequence_tracker, &mut self.key_sequence_tracker,
current_position, // No more current_position or total_count arguments
total_count,
grpc_client, grpc_client,
&mut self.command_message, &mut self.command_message,
&mut self.edit_mode_cooldown, &mut self.edit_mode_cooldown,
&mut self.ideal_cursor_column, &mut self.ideal_cursor_column,
).await?; )
// Note: handle_read_only_event should ignore mode entry keys internally now .await?;
return Ok(EventOutcome::Ok(message)); return Ok(EventOutcome::Ok(message));
}, // End AppMode::ReadOnly }
AppMode::Highlight => { AppMode::Highlight => {
// --- Handle Highlight Mode Specific Keys --- if config.get_highlight_action_for_key(key_code, modifiers) == Some("exit_highlight_mode") {
// 1. Check for Exit first self.highlight_state = HighlightState::Off;
if config.get_highlight_action_for_key(key_code, modifiers) == Some("exit_highlight_mode") { self.command_message = "Exited highlight mode".to_string();
self.highlight_state = HighlightState::Off; terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
self.command_message = "Exited highlight mode".to_string(); return Ok(EventOutcome::Ok(self.command_message.clone()));
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; } else if config.get_highlight_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") {
return Ok(EventOutcome::Ok(self.command_message.clone())); if let HighlightState::Characterwise { anchor } = self.highlight_state {
} self.highlight_state = HighlightState::Linewise { anchor_line: anchor.0 };
// 2. Check for Switch to Linewise self.command_message = "-- LINE HIGHLIGHT --".to_string();
else if config.get_highlight_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") { return Ok(EventOutcome::Ok(self.command_message.clone()));
// Only switch if currently characterwise }
if let HighlightState::Characterwise { anchor } = self.highlight_state {
self.highlight_state = HighlightState::Linewise { anchor_line: anchor.0 };
self.command_message = "-- LINE HIGHLIGHT --".to_string();
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
return Ok(EventOutcome::Ok("".to_string())); return Ok(EventOutcome::Ok("".to_string()));
} }
let (_should_exit, message) = read_only::handle_read_only_event( let (_should_exit, message) = read_only::handle_read_only_event(
app_state, key, config, form_state, login_state, app_state,
register_state, &mut admin_state.add_table_state, &mut self.key_sequence_tracker, key_event,
current_position, total_count, grpc_client, config,
&mut self.command_message, &mut self.edit_mode_cooldown, form_state,
login_state,
register_state,
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
&mut self.key_sequence_tracker,
grpc_client,
&mut self.command_message,
&mut self.edit_mode_cooldown,
&mut self.ideal_cursor_column, &mut self.ideal_cursor_column,
) )
.await?; .await?;
@@ -396,73 +393,35 @@ impl EventHandler {
} }
AppMode::Edit => { AppMode::Edit => {
// First, check for common actions (save, revert, etc.) that apply in Edit mode
// These might take precedence or have different behavior than the edit handler
if let Some(action) = config.get_common_action(key_code, modifiers) { if let Some(action) = config.get_common_action(key_code, modifiers) {
// Handle common actions like save, revert, force_quit, save_and_quit
// Ensure these actions return EventOutcome directly if they might exit the app
match action { match action {
"save" | "force_quit" | "save_and_quit" | "revert" => { "save" | "force_quit" | "save_and_quit" | "revert" => {
// This call likely returns EventOutcome, handle it directly
return common_mode::handle_core_action( return common_mode::handle_core_action(
action, action, form_state, auth_state, login_state, register_state,
form_state, grpc_client, &mut self.auth_client, terminal, app_state,
auth_state,
login_state,
register_state,
grpc_client,
&mut self.auth_client,
terminal,
app_state,
current_position,
total_count,
).await; ).await;
}, }
// Handle other common actions if necessary
_ => {} _ => {}
} }
// If a common action was handled but didn't return/exit,
// we might want to stop further processing for this key event.
// Depending on the action, you might return Ok(EventOutcome::Ok(...)) here.
// For now, assume common actions either exit or don't prevent further processing.
} }
// If no common action took precedence, delegate to the edit-specific handler let mut current_position = form_state.current_position;
let total_count = form_state.total_count;
let edit_result = edit::handle_edit_event( let edit_result = edit::handle_edit_event(
key, key_event, config, form_state, login_state, register_state, admin_state,
config, &mut self.ideal_cursor_column, &mut current_position, total_count,
form_state, grpc_client, app_state,
login_state,
register_state,
&mut admin_state.add_table_state,
&mut self.ideal_cursor_column,
current_position,
total_count,
grpc_client,
app_state,
).await; ).await;
match edit_result { match edit_result {
Ok(edit::EditEventOutcome::ExitEditMode) => { Ok(edit::EditEventOutcome::ExitEditMode) => {
// The edit handler signaled to exit the mode
self.is_edit_mode = false; self.is_edit_mode = false;
self.edit_mode_cooldown = true; self.edit_mode_cooldown = true;
let has_changes = if app_state.ui.show_login { login_state.has_unsaved_changes() } let has_changes = if app_state.ui.show_login { login_state.has_unsaved_changes() } else if app_state.ui.show_register { register_state.has_unsaved_changes() } else { form_state.has_unsaved_changes() };
else if app_state.ui.show_register { register_state.has_unsaved_changes() } self.command_message = if has_changes { "Exited edit mode (unsaved changes remain)".to_string() } else { "Read-only mode".to_string() };
else { form_state.has_unsaved_changes() };
self.command_message = if has_changes {
"Exited edit mode (unsaved changes remain)".to_string()
} else {
"Read-only mode".to_string()
};
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
// Adjust cursor position if needed let current_input = if app_state.ui.show_login { login_state.get_current_input() } else if app_state.ui.show_register { register_state.get_current_input() } else { form_state.get_current_input() };
let current_input = if app_state.ui.show_login { login_state.get_current_input() } let current_cursor_pos = if app_state.ui.show_login { login_state.current_cursor_pos() } else if app_state.ui.show_register { register_state.current_cursor_pos() } else { form_state.current_cursor_pos() };
else if app_state.ui.show_register { register_state.get_current_input() }
else { form_state.get_current_input() };
let current_cursor_pos = if app_state.ui.show_login { login_state.current_cursor_pos() }
else if app_state.ui.show_register { register_state.current_cursor_pos() }
else { form_state.current_cursor_pos() };
if !current_input.is_empty() && current_cursor_pos >= current_input.len() { if !current_input.is_empty() && current_cursor_pos >= current_input.len() {
let new_pos = current_input.len() - 1; let new_pos = current_input.len() - 1;
let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state } else if app_state.ui.show_register { register_state } else { form_state }; let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state } else if app_state.ui.show_register { register_state } else { form_state };
@@ -472,48 +431,110 @@ impl EventHandler {
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} }
Ok(edit::EditEventOutcome::Message(msg)) => { Ok(edit::EditEventOutcome::Message(msg)) => {
// Stay in edit mode, update message if not empty if !msg.is_empty() { self.command_message = msg; }
if !msg.is_empty() { self.key_sequence_tracker.reset();
self.command_message = msg;
}
self.key_sequence_tracker.reset(); // Reset sequence tracker on successful edit action
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} }
Err(e) => { Err(e) => { return Err(e.into()); }
// Handle error from the edit handler
return Err(e);
}
} }
}, // End AppMode::Edit }
AppMode::Command => { AppMode::Command => {
let outcome = command_mode::handle_command_event( if config.is_exit_command_mode(key_code, modifiers) {
key, self.command_input.clear();
config, self.command_message.clear();
app_state, self.command_mode = false;
login_state, self.key_sequence_tracker.reset();
register_state, return Ok(EventOutcome::Ok("Exited command mode".to_string()));
form_state,
&mut self.command_input,
&mut self.command_message,
grpc_client,
command_handler,
terminal,
current_position,
total_count,
).await?;
if let EventOutcome::Ok(msg) = &outcome {
if msg == "Exited command mode" {
self.command_mode = false;
}
} }
return Ok(outcome);
if config.is_command_execute(key_code, modifiers) {
let mut current_position = form_state.current_position;
let total_count = form_state.total_count;
let outcome = command_mode::handle_command_event(
key_event, config, app_state, login_state, register_state, form_state,
&mut self.command_input, &mut self.command_message, grpc_client,
command_handler, terminal, &mut current_position, total_count,
).await?;
form_state.current_position = current_position;
self.command_mode = false;
self.key_sequence_tracker.reset();
let new_mode = ModeManager::derive_mode(app_state, self, admin_state);
app_state.update_mode(new_mode);
return Ok(outcome);
}
if key_code == KeyCode::Backspace {
self.command_input.pop();
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok(String::new()));
}
if let KeyCode::Char(c) = key_code {
if c == 'f' {
self.key_sequence_tracker.add_key(key_code);
let sequence = self.key_sequence_tracker.get_sequence();
if config.matches_key_sequence_generalized(&sequence) == Some("find_file_palette_toggle") {
if app_state.ui.show_form || app_state.ui.show_intro {
// --- START FIX ---
let mut all_table_paths: Vec<String> = app_state
.profile_tree
.profiles
.iter()
.flat_map(|profile| {
profile.tables.iter().map(move |table| {
format!("{}/{}", profile.name, table.name)
})
})
.collect();
all_table_paths.sort();
self.navigation_state.activate_find_file(all_table_paths);
// --- END FIX ---
self.command_mode = false;
self.command_input.clear();
self.command_message.clear();
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok("Table selection palette activated".to_string()));
} else {
self.key_sequence_tracker.reset();
self.command_input.push('f');
if sequence.len() > 1 && sequence[0] == KeyCode::Char('f') {
self.command_input.push('f');
}
self.command_message = "Find File not available in this view.".to_string();
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
}
if config.is_key_sequence_prefix(&sequence) {
return Ok(EventOutcome::Ok(String::new()));
}
}
if c != 'f' && !self.key_sequence_tracker.current_sequence.is_empty() {
self.key_sequence_tracker.reset();
}
self.command_input.push(c);
return Ok(EventOutcome::Ok(String::new()));
}
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok(String::new()));
} }
} }
} else if let Event::Resize(_, _) = event {
return Ok(EventOutcome::Ok("Resized".to_string()));
} }
self.edit_mode_cooldown = false; self.edit_mode_cooldown = false;
Ok(EventOutcome::Ok(self.command_message.clone())) Ok(EventOutcome::Ok(self.command_message.clone()))
} }
fn is_processed_command(&self, command: &str) -> bool {
matches!(command, "w" | "q" | "q!" | "wq" | "r")
}
} }

View File

@@ -1,8 +1,8 @@
// src/modes/handlers/mode_manager.rs // src/modes/handlers/mode_manager.rs
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::modes::handlers::event::EventHandler; use crate::modes::handlers::event::EventHandler;
use crate::state::pages::add_logic::AddLogicFocus;
use crate::state::app::highlight::HighlightState; use crate::state::app::highlight::HighlightState;
use crate::state::pages::add_table::AddTableFocus;
use crate::state::pages::admin::AdminState; use crate::state::pages::admin::AdminState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -18,7 +18,15 @@ pub struct ModeManager;
impl ModeManager { impl ModeManager {
// Determine current mode based on app state // 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 { if event_handler.command_mode {
return AppMode::Command; return AppMode::Command;
} }
@@ -27,16 +35,28 @@ impl ModeManager {
return AppMode::Highlight; return AppMode::Highlight;
} }
if app_state.ui.focus_outside_canvas {
return AppMode::General;
}
let is_canvas_view = app_state.ui.show_login let is_canvas_view = app_state.ui.show_login
|| app_state.ui.show_register || app_state.ui.show_register
|| app_state.ui.show_form || 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 { if app_state.ui.focus_outside_canvas {
AppMode::General AppMode::General
} else { } else {
@@ -46,20 +66,30 @@ impl ModeManager {
AppMode::ReadOnly 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 { } else {
AppMode::General AppMode::General
} }
} }
// Mode transition rules // Mode transition rules
pub fn can_enter_command_mode(current_mode: AppMode) -> bool { pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
!matches!(current_mode, AppMode::Edit) // Can't enter from Edit mode !matches!(current_mode, AppMode::Edit)
} }
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool { 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 { pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight) 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::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::state::pages::auth::{LoginState, RegisterState}; 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::state::pages::form::FormState;
use crate::modes::handlers::event::EventOutcome; 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 crossterm::event::KeyEvent;
use anyhow::Result;
/// Handles events when in Highlight mode. /// Handles events when in Highlight mode.
/// Currently, it mostly delegates to the read_only handler for movement. /// 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, form_state: &mut FormState,
login_state: &mut LoginState, login_state: &mut LoginState,
register_state: &mut RegisterState, register_state: &mut RegisterState,
add_table_state: &mut AddTableState, admin_state: &mut AdminState,
key_sequence_tracker: &mut KeySequenceTracker, key_sequence_tracker: &mut KeySequenceTracker,
current_position: &mut u64, current_position: &mut u64,
total_count: u64, total_count: u64,
@@ -30,7 +31,7 @@ pub async fn handle_highlight_event(
command_message: &mut String, command_message: &mut String,
edit_mode_cooldown: &mut bool, edit_mode_cooldown: &mut bool,
ideal_cursor_column: &mut usize, ideal_cursor_column: &mut usize,
) -> Result<EventOutcome, Box<dyn std::error::Error>> { ) -> Result<EventOutcome> {
// Delegate movement and other actions to the read_only handler // Delegate movement and other actions to the read_only handler
// The rendering logic will use the highlight_anchor to draw the selection // The rendering logic will use the highlight_anchor to draw the selection
let (should_exit, message) = read_only::handle_read_only_event( let (should_exit, message) = read_only::handle_read_only_event(
@@ -40,10 +41,9 @@ pub async fn handle_highlight_event(
form_state, form_state,
login_state, login_state,
register_state, register_state,
add_table_state, &mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
key_sequence_tracker, key_sequence_tracker,
current_position,
total_count,
grpc_client, grpc_client,
command_message, // Pass the message buffer command_message, // Pass the message buffer
edit_mode_cooldown, edit_mode_cooldown,

View File

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

View File

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

View File

@@ -1,69 +1,200 @@
// src/services/grpc_client.rs // src/services/grpc_client.rs
use tonic::transport::Channel; use tonic::transport::Channel;
use common::proto::multieko2::adresar::adresar_client::AdresarClient; use common::proto::multieko2::common::{CountResponse, Empty};
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::table_structure_service_client::TableStructureServiceClient;
use common::proto::multieko2::table_structure::TableStructureResponse; use common::proto::multieko2::table_structure::{GetTableStructureRequest, TableStructureResponse};
use common::proto::multieko2::table_definition::{ use common::proto::multieko2::table_definition::{
table_definition_client::TableDefinitionClient, table_definition_client::TableDefinitionClient,
ProfileTreeResponse PostTableDefinitionRequest, ProfileTreeResponse, TableDefinitionResponse,
}; };
use common::proto::multieko2::table_script::{
table_script_client::TableScriptClient,
PostTableScriptRequest, TableScriptResponse,
};
use common::proto::multieko2::tables_data::{
tables_data_client::TablesDataClient,
GetTableDataByPositionRequest,
GetTableDataResponse,
GetTableDataCountRequest,
PostTableDataRequest, PostTableDataResponse, PutTableDataRequest,
PutTableDataResponse,
};
use anyhow::{Context, Result}; // Added Context
use std::collections::HashMap; // NEW
#[derive(Clone)] #[derive(Clone)]
pub struct GrpcClient { pub struct GrpcClient {
adresar_client: AdresarClient<Channel>,
table_structure_client: TableStructureServiceClient<Channel>, table_structure_client: TableStructureServiceClient<Channel>,
table_definition_client: TableDefinitionClient<Channel>, table_definition_client: TableDefinitionClient<Channel>,
table_script_client: TableScriptClient<Channel>,
tables_data_client: TablesDataClient<Channel>, // NEW
} }
impl GrpcClient { impl GrpcClient {
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> { pub async fn new() -> Result<Self> {
let adresar_client = AdresarClient::connect("http://[::1]:50051").await?; let table_structure_client = TableStructureServiceClient::connect(
let table_structure_client = TableStructureServiceClient::connect("http://[::1]:50051").await?; "http://[::1]:50051",
let table_definition_client = TableDefinitionClient::connect("http://[::1]:50051").await?; )
.await
.context("Failed to connect to TableStructureService")?;
let table_definition_client = TableDefinitionClient::connect(
"http://[::1]:50051",
)
.await
.context("Failed to connect to TableDefinitionService")?;
let table_script_client =
TableScriptClient::connect("http://[::1]:50051")
.await
.context("Failed to connect to TableScriptService")?;
let tables_data_client =
TablesDataClient::connect("http://[::1]:50051")
.await
.context("Failed to connect to TablesDataService")?; // NEW
Ok(Self { Ok(Self {
adresar_client, // adresar_client, // REMOVE
table_structure_client, table_structure_client,
table_definition_client, table_definition_client,
table_script_client,
tables_data_client, // NEW
}) })
} }
pub async fn get_adresar_count(&mut self) -> Result<u64, Box<dyn std::error::Error>> { pub async fn get_table_structure(
let request = tonic::Request::new(Empty::default()); &mut self,
let response: CountResponse = self.adresar_client.get_adresar_count(request).await?.into_inner(); profile_name: String,
Ok(response.count as u64) table_name: String,
} ) -> Result<TableStructureResponse> {
let grpc_request = GetTableStructureRequest {
pub async fn get_adresar_by_position(&mut self, position: u64) -> Result<AdresarResponse, Box<dyn std::error::Error>> { profile_name,
let request = tonic::Request::new(PositionRequest { position: position as i64 }); table_name,
let response: AdresarResponse = self.adresar_client.get_adresar_by_position(request).await?.into_inner(); };
Ok(response) let request = tonic::Request::new(grpc_request);
} let response = self
.table_structure_client
pub async fn post_adresar(&mut self, request: PostAdresarRequest) -> Result<tonic::Response<AdresarResponse>, Box<dyn std::error::Error>> { .get_table_structure(request)
let request = tonic::Request::new(request); .await
let response = self.adresar_client.post_adresar(request).await?; .context("gRPC GetTableStructure call failed")?;
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?;
Ok(response.into_inner()) 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 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())
}
// NEW Methods for TablesData service
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())
}
pub async fn post_table_data(
&mut self,
profile_name: String,
table_name: String,
data: HashMap<String, String>,
) -> 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, String>,
) -> 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()) Ok(response.into_inner())
} }
} }

View File

@@ -3,110 +3,240 @@
use crate::services::grpc_client::GrpcClient; use crate::services::grpc_client::GrpcClient;
use crate::state::pages::form::FormState; use crate::state::pages::form::FormState;
use crate::tui::functions::common::form::SaveOutcome; use crate::tui::functions::common::form::SaveOutcome;
use crate::state::pages::add_logic::AddLogicState;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::utils::columns::filter_user_columns;
use anyhow::{Context, Result};
pub struct UiService; pub struct UiService;
impl UiService { impl UiService {
pub async fn initialize_app_state( pub async fn initialize_add_logic_table_data(
grpc_client: &mut GrpcClient,
add_logic_state: &mut AddLogicState,
profile_tree: &common::proto::multieko2::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
))
}
}
/// Fetches columns for a specific table (used for table.column autocomplete)
pub async fn fetch_columns_for_table(
grpc_client: &mut GrpcClient,
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) => {
let column_names: Vec<String> = response.columns
.into_iter()
.map(|col| col.name)
.collect();
Ok(filter_user_columns(column_names))
}
Err(e) => {
tracing::warn!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
Err(e.into())
}
}
}
pub async fn initialize_app_state_and_form(
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
app_state: &mut AppState, app_state: &mut AppState,
) -> Result<Vec<String>, Box<dyn std::error::Error>> { ) -> Result<(String, String, Vec<String>)> {
// Fetch profile tree let profile_tree = grpc_client
let profile_tree = grpc_client.get_profile_tree().await?; .get_profile_tree()
.await
.context("Failed to get profile tree")?;
app_state.profile_tree = profile_tree; app_state.profile_tree = profile_tree;
// Fetch table structure // Determine initial table to load (e.g., first table of first profile, or a default)
let table_structure = grpc_client.get_table_structure().await?; let initial_profile_name = app_state
.profile_tree
.profiles
.first()
.map(|p| p.name.clone())
.unwrap_or_else(|| "default".to_string());
let initial_table_name = app_state
.profile_tree
.profiles
.first()
.and_then(|p| p.tables.first().map(|t| t.name.clone()))
.unwrap_or_else(|| "2025_company_data1".to_string()); // Fallback if no tables
app_state.set_current_view_table(
initial_profile_name.clone(),
initial_table_name.clone(),
);
let table_structure = grpc_client
.get_table_structure(
initial_profile_name.clone(),
initial_table_name.clone(),
)
.await
.context(format!(
"Failed to get initial table structure for {}.{}",
initial_profile_name, initial_table_name
))?;
// Extract the column names from the response
let column_names: Vec<String> = table_structure let column_names: Vec<String> = table_structure
.columns .columns
.iter() .iter()
.map(|col| col.name.clone()) .map(|col| col.name.clone())
.collect(); .collect();
Ok(column_names) let filtered_columns = filter_user_columns(column_names);
Ok((initial_profile_name, initial_table_name, filtered_columns))
} }
pub async fn initialize_adresar_count( pub async fn fetch_and_set_table_count(
grpc_client: &mut GrpcClient, 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(())
}
pub async fn update_adresar_count(
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, form_state: &mut FormState,
position: u64, ) -> Result<()> {
) -> Result<String, Box<dyn std::error::Error>> { let total_count = grpc_client
match grpc_client.get_adresar_by_position(position).await { .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) => { Ok(response) => {
// Set the ID properly // FIX: Pass the current position as the second argument
form_state.id = response.id; form_state.update_from_response(&response.data, form_state.current_position);
Ok(format!(
// Update form values dynamically "Loaded entry {}/{} for table {}.{}",
form_state.values = vec![ form_state.current_position,
response.firma, form_state.total_count,
response.kz, form_state.profile_name,
response.drc, form_state.table_name
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))
} }
Err(e) => { Err(e) => {
Ok(format!("Error loading entry: {}", 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
))
} }
} }
} }
/// Handles the consequences of a save operation, like updating counts.
pub async fn handle_save_outcome( pub async fn handle_save_outcome(
save_outcome: SaveOutcome, save_outcome: SaveOutcome,
grpc_client: &mut GrpcClient, _grpc_client: &mut GrpcClient,
app_state: &mut AppState, _app_state: &mut AppState,
form_state: &mut FormState, // Needed to potentially update position/ID form_state: &mut FormState,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<()> {
match save_outcome { match save_outcome {
SaveOutcome::CreatedNew(new_id) => { SaveOutcome::CreatedNew(new_id) => {
// A new record was created, update the count! form_state.id = new_id;
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)
} }
SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => { SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => {
// No count update needed for these outcomes // No action needed
} }
} }
Ok(()) Ok(())
} }
} }

View File

@@ -1,6 +1,5 @@
// src/state/app/buffer.rs // src/state/app/buffer.rs
/// Represents the distinct views or "buffers" the user can navigate.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppView { pub enum AppView {
Intro, Intro,
@@ -8,12 +7,14 @@ pub enum AppView {
Register, Register,
Admin, Admin,
AddTable, AddTable,
Form(String), AddLogic,
Form,
Scratch, Scratch,
} }
impl AppView { impl AppView {
/// Returns the display name for the view. /// Returns the display name for the view.
/// For Form, pass the current table name to get dynamic naming.
pub fn display_name(&self) -> &str { pub fn display_name(&self) -> &str {
match self { match self {
AppView::Intro => "Intro", AppView::Intro => "Intro",
@@ -21,13 +22,25 @@ impl AppView {
AppView::Register => "Register", AppView::Register => "Register",
AppView::Admin => "Admin_Panel", AppView::Admin => "Admin_Panel",
AppView::AddTable => "Add_Table", AppView::AddTable => "Add_Table",
AppView::Form(name) => name.as_str(), AppView::AddLogic => "Add_Logic",
AppView::Form => "Form",
AppView::Scratch => "*scratch*", 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)] #[derive(Debug, Clone)]
pub struct BufferState { pub struct BufferState {
pub history: Vec<AppView>, pub history: Vec<AppView>,
@@ -37,23 +50,17 @@ pub struct BufferState {
impl Default for BufferState { impl Default for BufferState {
fn default() -> Self { fn default() -> Self {
Self { Self {
history: vec![AppView::Intro], // Start with Intro view history: vec![AppView::Intro],
active_index: 0, active_index: 0,
} }
} }
} }
impl BufferState { 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) { pub fn update_history(&mut self, view: AppView) {
let existing_pos = self.history.iter().position(|v| v == &view); let existing_pos = self.history.iter().position(|v| v == &view);
match existing_pos { match existing_pos {
Some(pos) => { Some(pos) => self.active_index = pos,
self.active_index = pos;
}
None => { None => {
self.history.push(view.clone()); self.history.push(view.clone());
self.active_index = self.history.len() - 1; 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> { pub fn get_active_view(&self) -> Option<&AppView> {
self.history.get(self.active_index) 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 { pub fn close_active_buffer(&mut self) -> bool {
let current_index = self.active_index; if self.history.is_empty() {
self.history.push(AppView::Intro);
// Rule 1: Cannot close Intro buffer. self.active_index = 0;
if matches!(self.history.get(current_index), Some(AppView::Intro)) {
return false; return false;
} }
// Rule 2: Cannot close if only Intro would remain (or already remains). let current_index = self.active_index;
// This check implicitly covers the case where len <= 1.
if self.history.len() <= 1 {
return false;
}
self.history.remove(current_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 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

@@ -4,6 +4,7 @@ use std::env;
use common::proto::multieko2::table_definition::ProfileTreeResponse; use common::proto::multieko2::table_definition::ProfileTreeResponse;
use crate::modes::handlers::mode_manager::AppMode; use crate::modes::handlers::mode_manager::AppMode;
use crate::ui::handlers::context::DialogPurpose; use crate::ui::handlers::context::DialogPurpose;
use anyhow::Result;
pub struct DialogState { pub struct DialogState {
pub dialog_show: bool, pub dialog_show: bool,
@@ -12,6 +13,7 @@ pub struct DialogState {
pub dialog_buttons: Vec<String>, pub dialog_buttons: Vec<String>,
pub dialog_active_button_index: usize, pub dialog_active_button_index: usize,
pub purpose: Option<DialogPurpose>, pub purpose: Option<DialogPurpose>,
pub is_loading: bool,
} }
pub struct UiState { pub struct UiState {
@@ -20,6 +22,7 @@ pub struct UiState {
pub show_intro: bool, pub show_intro: bool,
pub show_admin: bool, pub show_admin: bool,
pub show_add_table: bool, pub show_add_table: bool,
pub show_add_logic: bool,
pub show_form: bool, pub show_form: bool,
pub show_login: bool, pub show_login: bool,
pub show_register: bool, pub show_register: bool,
@@ -30,46 +33,51 @@ pub struct UiState {
pub struct AppState { pub struct AppState {
// Core editor state // Core editor state
pub current_dir: String, pub current_dir: String,
pub total_count: u64,
pub current_position: u64,
pub profile_tree: ProfileTreeResponse, pub profile_tree: ProfileTreeResponse,
pub selected_profile: Option<String>, pub selected_profile: Option<String>,
pub current_mode: AppMode, pub current_mode: AppMode,
pub current_view_profile_name: Option<String>,
pub current_view_table_name: Option<String>,
pub focused_button_index: usize, pub focused_button_index: usize,
pub pending_table_structure_fetch: Option<(String, String)>,
// UI preferences // UI preferences
pub ui: UiState, pub ui: UiState,
#[cfg(feature = "ui-debug")]
pub debug_info: String,
} }
impl AppState { impl AppState {
pub fn new() -> Result<Self, Box<dyn std::error::Error>> { pub fn new() -> Result<Self> {
let current_dir = env::current_dir()? let current_dir = env::current_dir()?
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
Ok(AppState { Ok(AppState {
current_dir, current_dir,
total_count: 0,
current_position: 0,
profile_tree: ProfileTreeResponse::default(), profile_tree: ProfileTreeResponse::default(),
selected_profile: None, selected_profile: None,
current_view_profile_name: None,
current_view_table_name: None,
current_mode: AppMode::General, current_mode: AppMode::General,
focused_button_index: 0, focused_button_index: 0,
pending_table_structure_fetch: None,
ui: UiState::default(), ui: UiState::default(),
#[cfg(feature = "ui-debug")]
debug_info: String::new(),
}) })
} }
// 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;
}
pub fn update_mode(&mut self, mode: AppMode) { pub fn update_mode(&mut self, mode: AppMode) {
self.current_mode = mode; self.current_mode = mode;
} }
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);
}
// Add dialog helper methods // Add dialog helper methods
/// Shows a dialog with the given title, message, and buttons. /// Shows a dialog with the given title, message, and buttons.
@@ -86,10 +94,42 @@ impl AppState {
self.ui.dialog.dialog_buttons = buttons; self.ui.dialog.dialog_buttons = buttons;
self.ui.dialog.dialog_active_button_index = 0; self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = Some(purpose); self.ui.dialog.purpose = Some(purpose);
self.ui.dialog.is_loading = false;
self.ui.dialog.dialog_show = true; self.ui.dialog.dialog_show = true;
self.ui.focus_outside_canvas = true; 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_active_button_index = 0;
self.ui.dialog.purpose = None; // Purpose is set when loading finishes
self.ui.dialog.is_loading = true;
self.ui.dialog.dialog_show = true;
self.ui.focus_outside_canvas = true; // Keep focus management consistent
}
/// Updates the content of an existing dialog, typically after loading.
pub fn update_dialog_content(
&mut self,
message: &str,
buttons: Vec<String>,
purpose: DialogPurpose,
) {
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.purpose = Some(purpose);
self.ui.dialog.is_loading = false; // Loading finished
// Keep dialog_show = true
// Keep focus_outside_canvas = true
}
}
/// Hides the dialog and clears its content. /// Hides the dialog and clears its content.
pub fn hide_dialog(&mut self) { pub fn hide_dialog(&mut self) {
self.ui.dialog.dialog_show = false; self.ui.dialog.dialog_show = false;
@@ -99,6 +139,7 @@ impl AppState {
self.ui.dialog.dialog_active_button_index = 0; self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = None; self.ui.dialog.purpose = None;
self.ui.focus_outside_canvas = false; self.ui.focus_outside_canvas = false;
self.ui.dialog.is_loading = false;
} }
/// Sets the active button index, wrapping around if necessary. /// Sets the active button index, wrapping around if necessary.
@@ -136,6 +177,7 @@ impl Default for UiState {
show_intro: true, show_intro: true,
show_admin: false, show_admin: false,
show_add_table: false, show_add_table: false,
show_add_logic: false,
show_form: false, show_form: false,
show_login: false, show_login: false,
show_register: false, show_register: false,
@@ -156,6 +198,7 @@ impl Default for DialogState {
dialog_buttons: Vec::new(), dialog_buttons: Vec::new(),
dialog_active_button_index: 0, dialog_active_button_index: 0,
purpose: None, purpose: None,
is_loading: false,
} }
} }
} }

View File

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

View File

@@ -0,0 +1,346 @@
// src/state/pages/add_logic.rs
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
use crate::state::pages::canvas_state::CanvasState;
use crate::components::common::text_editor::{TextEditor, VimState};
use std::cell::RefCell;
use std::rc::Rc;
use tui_textarea::TextArea;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AddLogicFocus {
#[default]
InputLogicName,
InputTargetColumn,
InputDescription,
ScriptContentPreview,
InsideScriptContent,
SaveButton,
CancelButton,
}
#[derive(Clone, Debug)]
pub struct AddLogicState {
pub profile_name: String,
pub selected_table_id: Option<i64>,
pub selected_table_name: Option<String>,
pub logic_name_input: String,
pub target_column_input: String,
pub script_content_editor: Rc<RefCell<TextArea<'static>>>,
pub description_input: String,
pub current_focus: AddLogicFocus,
pub last_canvas_field: usize,
pub logic_name_cursor_pos: usize,
pub target_column_cursor_pos: usize,
pub description_cursor_pos: usize,
pub has_unsaved_changes: bool,
pub editor_keybinding_mode: EditorKeybindingMode,
pub vim_state: VimState,
// New fields for Target Column Autocomplete
pub table_columns_for_suggestions: Vec<String>, // All columns for the table
pub target_column_suggestions: Vec<String>, // Filtered suggestions
pub show_target_column_suggestions: bool,
pub selected_target_column_suggestion_index: Option<usize>,
pub in_target_column_suggestion_mode: bool,
// Script Editor Autocomplete
pub script_editor_autocomplete_active: bool,
pub script_editor_suggestions: Vec<String>,
pub script_editor_selected_suggestion_index: Option<usize>,
pub script_editor_trigger_position: Option<(usize, usize)>, // (line, column)
pub all_table_names: Vec<String>,
pub script_editor_filter_text: String,
// New fields for same-profile table names and column autocomplete
pub same_profile_table_names: Vec<String>, // Tables from same profile only
pub script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
}
impl AddLogicState {
pub fn new(editor_config: &EditorConfig) -> Self {
let editor = TextEditor::new_textarea(editor_config);
AddLogicState {
profile_name: "default".to_string(),
selected_table_id: None,
selected_table_name: None,
logic_name_input: String::new(),
target_column_input: String::new(),
script_content_editor: Rc::new(RefCell::new(editor)),
description_input: String::new(),
current_focus: AddLogicFocus::InputLogicName,
last_canvas_field: 2,
logic_name_cursor_pos: 0,
target_column_cursor_pos: 0,
description_cursor_pos: 0,
has_unsaved_changes: false,
editor_keybinding_mode: editor_config.keybinding_mode.clone(),
vim_state: VimState::default(),
table_columns_for_suggestions: Vec::new(),
target_column_suggestions: Vec::new(),
show_target_column_suggestions: false,
selected_target_column_suggestion_index: None,
in_target_column_suggestion_mode: false,
script_editor_autocomplete_active: false,
script_editor_suggestions: Vec::new(),
script_editor_selected_suggestion_index: None,
script_editor_trigger_position: None,
all_table_names: Vec::new(),
script_editor_filter_text: String::new(),
same_profile_table_names: Vec::new(),
script_editor_awaiting_column_autocomplete: None,
}
}
pub const INPUT_FIELD_COUNT: usize = 3;
/// Updates the target_column_suggestions based on current input.
pub fn update_target_column_suggestions(&mut self) {
let current_input = self.target_column_input.to_lowercase();
if self.table_columns_for_suggestions.is_empty() {
self.target_column_suggestions.clear();
self.show_target_column_suggestions = false;
self.selected_target_column_suggestion_index = None;
return;
}
if current_input.is_empty() {
self.target_column_suggestions = self.table_columns_for_suggestions.clone();
} else {
self.target_column_suggestions = self
.table_columns_for_suggestions
.iter()
.filter(|name| name.to_lowercase().contains(&current_input))
.cloned()
.collect();
}
self.show_target_column_suggestions = !self.target_column_suggestions.is_empty();
if self.show_target_column_suggestions {
if let Some(selected_idx) = self.selected_target_column_suggestion_index {
if selected_idx >= self.target_column_suggestions.len() {
self.selected_target_column_suggestion_index = Some(0);
}
} else {
self.selected_target_column_suggestion_index = Some(0);
}
} else {
self.selected_target_column_suggestion_index = None;
}
}
/// Updates script editor suggestions based on current filter text
pub fn update_script_editor_suggestions(&mut self) {
let mut suggestions = vec!["sql".to_string()];
if self.selected_table_name.is_some() {
suggestions.extend(self.table_columns_for_suggestions.clone());
}
let current_selected_table_name = self.selected_table_name.as_deref();
suggestions.extend(
self.same_profile_table_names
.iter()
.filter(|tn| Some(tn.as_str()) != current_selected_table_name)
.cloned()
);
if self.script_editor_filter_text.is_empty() {
self.script_editor_suggestions = suggestions;
} else {
let filter_lower = self.script_editor_filter_text.to_lowercase();
self.script_editor_suggestions = suggestions
.into_iter()
.filter(|suggestion| suggestion.to_lowercase().contains(&filter_lower))
.collect();
}
// Update selection index
if self.script_editor_suggestions.is_empty() {
self.script_editor_selected_suggestion_index = None;
self.script_editor_autocomplete_active = false;
} else if let Some(selected_idx) = self.script_editor_selected_suggestion_index {
if selected_idx >= self.script_editor_suggestions.len() {
self.script_editor_selected_suggestion_index = Some(0);
}
} else {
self.script_editor_selected_suggestion_index = Some(0);
}
}
/// Checks if a suggestion is a table name (for triggering column autocomplete)
pub fn is_table_name_suggestion(&self, suggestion: &str) -> bool {
// Not "sql"
if suggestion == "sql" {
return false;
}
if self.table_columns_for_suggestions.contains(&suggestion.to_string()) {
return false;
}
self.same_profile_table_names.contains(&suggestion.to_string())
}
/// Sets table columns for autocomplete suggestions
pub fn set_table_columns(&mut self, columns: Vec<String>) {
self.table_columns_for_suggestions = columns.clone();
if !columns.is_empty() {
self.update_target_column_suggestions();
}
}
/// Sets all available table names for autocomplete suggestions
pub fn set_all_table_names(&mut self, table_names: Vec<String>) {
self.all_table_names = table_names;
}
/// Sets table names from the same profile for autocomplete suggestions
pub fn set_same_profile_table_names(&mut self, table_names: Vec<String>) {
self.same_profile_table_names = table_names;
}
/// Triggers waiting for column autocomplete for a specific table
pub fn trigger_column_autocomplete_for_table(&mut self, table_name: String) {
self.script_editor_awaiting_column_autocomplete = Some(table_name);
}
/// Updates autocomplete with columns for a specific table
pub fn set_columns_for_table_autocomplete(&mut self, columns: Vec<String>) {
self.script_editor_suggestions = columns;
self.script_editor_selected_suggestion_index = if self.script_editor_suggestions.is_empty() {
None
} else {
Some(0)
};
self.script_editor_autocomplete_active = !self.script_editor_suggestions.is_empty();
self.script_editor_awaiting_column_autocomplete = None;
}
/// Deactivates script editor autocomplete and clears related state
pub fn deactivate_script_editor_autocomplete(&mut self) {
self.script_editor_autocomplete_active = false;
self.script_editor_suggestions.clear();
self.script_editor_selected_suggestion_index = None;
self.script_editor_trigger_position = None;
self.script_editor_filter_text.clear();
}
}
impl Default for AddLogicState {
fn default() -> Self {
Self::new(&EditorConfig::default())
}
}
impl CanvasState for AddLogicState {
fn current_field(&self) -> usize {
match self.current_focus {
AddLogicFocus::InputLogicName => 0,
AddLogicFocus::InputTargetColumn => 1,
AddLogicFocus::InputDescription => 2,
_ => self.last_canvas_field,
}
}
fn current_cursor_pos(&self) -> usize {
match self.current_focus {
AddLogicFocus::InputLogicName => self.logic_name_cursor_pos,
AddLogicFocus::InputTargetColumn => self.target_column_cursor_pos,
AddLogicFocus::InputDescription => self.description_cursor_pos,
_ => 0,
}
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn inputs(&self) -> Vec<&String> {
vec![
&self.logic_name_input,
&self.target_column_input,
&self.description_input,
]
}
fn get_current_input(&self) -> &str {
match self.current_focus {
AddLogicFocus::InputLogicName => &self.logic_name_input,
AddLogicFocus::InputTargetColumn => &self.target_column_input,
AddLogicFocus::InputDescription => &self.description_input,
_ => "",
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_focus {
AddLogicFocus::InputLogicName => &mut self.logic_name_input,
AddLogicFocus::InputTargetColumn => &mut self.target_column_input,
AddLogicFocus::InputDescription => &mut self.description_input,
_ => &mut self.logic_name_input,
}
}
fn fields(&self) -> Vec<&str> {
vec!["Logic Name", "Target Column", "Description"]
}
fn set_current_field(&mut self, index: usize) {
let new_focus = match index {
0 => AddLogicFocus::InputLogicName,
1 => AddLogicFocus::InputTargetColumn,
2 => AddLogicFocus::InputDescription,
_ => return,
};
if self.current_focus != new_focus {
if self.current_focus == AddLogicFocus::InputTargetColumn {
self.in_target_column_suggestion_mode = false;
self.show_target_column_suggestions = false;
}
self.current_focus = new_focus;
self.last_canvas_field = index;
}
}
fn set_current_cursor_pos(&mut self, pos: usize) {
match self.current_focus {
AddLogicFocus::InputLogicName => {
self.logic_name_cursor_pos = pos.min(self.logic_name_input.len());
}
AddLogicFocus::InputTargetColumn => {
self.target_column_cursor_pos = pos.min(self.target_column_input.len());
}
AddLogicFocus::InputDescription => {
self.description_cursor_pos = pos.min(self.description_input.len());
}
_ => {}
}
}
fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed;
}
fn get_suggestions(&self) -> Option<&[String]> {
if self.current_field() == 1
&& self.in_target_column_suggestion_mode
&& self.show_target_column_suggestions
{
Some(&self.target_column_suggestions)
} else {
None
}
}
fn get_selected_suggestion_index(&self) -> Option<usize> {
if self.current_field() == 1
&& self.in_target_column_suggestion_mode
&& self.show_target_column_suggestions
{
self.selected_target_column_suggestion_index
} else {
None
}
}
}

View File

@@ -1,17 +1,26 @@
// src/state/pages/add_table.rs // src/state/pages/add_table.rs
use crate::state::pages::canvas_state::CanvasState; use crate::state::pages::canvas_state::CanvasState;
use ratatui::widgets::TableState; use ratatui::widgets::TableState;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnDefinition { pub struct ColumnDefinition {
pub name: String, pub name: String,
pub data_type: String, pub data_type: String,
pub selected: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IndexDefinition {
pub name: String,
pub selected: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkDefinition { pub struct LinkDefinition {
pub linked_table_name: String, pub linked_table_name: String,
pub is_required: bool, pub is_required: bool,
pub selected: bool,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -25,6 +34,10 @@ pub enum AddTableFocus {
ColumnsTable, ColumnsTable,
IndexesTable, IndexesTable,
LinksTable, LinksTable,
// Inside Tables (Scrolling Focus)
InsideColumnsTable,
InsideIndexesTable,
InsideLinksTable,
// Buttons // Buttons
SaveButton, SaveButton,
DeleteSelectedButton, DeleteSelectedButton,
@@ -39,9 +52,10 @@ pub struct AddTableState {
pub column_name_input: String, pub column_name_input: String,
pub column_type_input: String, pub column_type_input: String,
pub columns: Vec<ColumnDefinition>, pub columns: Vec<ColumnDefinition>,
pub indexes: Vec<String>, pub indexes: Vec<IndexDefinition>,
pub links: Vec<LinkDefinition>, pub links: Vec<LinkDefinition>,
pub current_focus: AddTableFocus, pub current_focus: AddTableFocus,
pub last_canvas_field: usize,
pub column_table_state: TableState, pub column_table_state: TableState,
pub index_table_state: TableState, pub index_table_state: TableState,
pub link_table_state: TableState, pub link_table_state: TableState,
@@ -64,6 +78,7 @@ impl Default for AddTableState {
indexes: Vec::new(), indexes: Vec::new(),
links: Vec::new(), links: Vec::new(),
current_focus: AddTableFocus::InputTableName, current_focus: AddTableFocus::InputTableName,
last_canvas_field: 2,
column_table_state: TableState::default(), column_table_state: TableState::default(),
index_table_state: TableState::default(), index_table_state: TableState::default(),
link_table_state: TableState::default(), link_table_state: TableState::default(),
@@ -87,7 +102,7 @@ impl CanvasState for AddTableState {
AddTableFocus::InputColumnName => 1, AddTableFocus::InputColumnName => 1,
AddTableFocus::InputColumnType => 2, AddTableFocus::InputColumnType => 2,
// If focus is elsewhere, default to the first field for canvas rendering logic // If focus is elsewhere, default to the first field for canvas rendering logic
_ => 0, _ => self.last_canvas_field,
} }
} }
@@ -132,10 +147,20 @@ impl CanvasState for AddTableState {
} }
fn set_current_field(&mut self, index: usize) { fn set_current_field(&mut self, index: usize) {
// Update both current focus and last canvas field
self.current_focus = match index { self.current_focus = match index {
0 => AddTableFocus::InputTableName, 0 => {
1 => AddTableFocus::InputColumnName, self.last_canvas_field = 0;
2 => AddTableFocus::InputColumnType, AddTableFocus::InputTableName
},
1 => {
self.last_canvas_field = 1;
AddTableFocus::InputColumnName
},
2 => {
self.last_canvas_field = 2;
AddTableFocus::InputColumnType
},
_ => self.current_focus, // Stay on current focus if index is out of bounds _ => self.current_focus, // Stay on current focus if index is out of bounds
}; };
} }

View File

@@ -2,13 +2,16 @@
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use crate::state::pages::add_table::AddTableState; use crate::state::pages::add_table::AddTableState;
use crate::state::pages::add_logic::AddLogicState;
// Define the focus states for the admin panel panes // Define the focus states for the admin panel panes
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AdminFocus { pub enum AdminFocus {
#[default] // Default focus is on the profiles list #[default] // Default focus is on the profiles list
Profiles, ProfilesPane,
InsideProfilesList,
Tables, Tables,
InsideTablesList,
Button1, Button1,
Button2, Button2,
Button3, Button3,
@@ -23,6 +26,7 @@ pub struct AdminState {
pub selected_table_index: Option<usize>, // Index with [*] in tables (persistent) pub selected_table_index: Option<usize>, // Index with [*] in tables (persistent)
pub current_focus: AdminFocus, // Tracks which pane is focused pub current_focus: AdminFocus, // Tracks which pane is focused
pub add_table_state: AddTableState, pub add_table_state: AddTableState,
pub add_logic_state: AddLogicState,
} }
impl AdminState { impl AdminState {

View File

@@ -29,6 +29,7 @@ pub struct LoginState {
pub current_field: usize, pub current_field: usize,
pub current_cursor_pos: usize, pub current_cursor_pos: usize,
pub has_unsaved_changes: bool, pub has_unsaved_changes: bool,
pub login_request_pending: bool,
} }
/// Represents the state of the Registration form UI /// Represents the state of the Registration form UI
@@ -71,6 +72,7 @@ impl LoginState {
current_field: 0, current_field: 0,
current_cursor_pos: 0, current_cursor_pos: 0,
has_unsaved_changes: false, has_unsaved_changes: false,
login_request_pending: false,
} }
} }
} }

View File

@@ -1,4 +1,6 @@
// src/state/pages/form.rs // src/state/pages/form.rs
use std::collections::HashMap; // NEW
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::Frame; use ratatui::Frame;
@@ -7,7 +9,13 @@ use crate::state::pages::canvas_state::CanvasState;
pub struct FormState { pub struct FormState {
pub id: i64, pub id: i64,
pub fields: Vec<String>, // NEW fields for dynamic table context
pub profile_name: String,
pub table_name: String,
pub total_count: u64,
pub current_position: u64, // 1-based index, 0 or total_count + 1 for new entry
pub fields: Vec<String>, // Already dynamic, which is good
pub values: Vec<String>, pub values: Vec<String>,
pub current_field: usize, pub current_field: usize,
pub has_unsaved_changes: bool, pub has_unsaved_changes: bool,
@@ -15,11 +23,22 @@ pub struct FormState {
} }
impl FormState { impl FormState {
/// Create a new FormState with dynamic fields. /// Creates a new, empty FormState for a given table.
pub fn new(fields: Vec<String>) -> Self { /// The position defaults to 1, representing either the first record
let values = vec![String::new(); fields.len()]; // Initialize values for each field /// or the position for a new entry if the table is empty.
pub fn new(
profile_name: String,
table_name: String,
fields: Vec<String>,
) -> Self {
let values = vec![String::new(); fields.len()];
FormState { FormState {
id: 0, id: 0, // Default to 0, indicating a new or unloaded record
profile_name,
table_name,
total_count: 0, // Will be fetched after initialization
// FIX: Default to 1. A position of 0 is an invalid state.
current_position: 1,
fields, fields,
values, values,
current_field: 0, current_field: 0,
@@ -35,31 +54,41 @@ impl FormState {
theme: &Theme, theme: &Theme,
is_edit_mode: bool, is_edit_mode: bool,
highlight_state: &HighlightState, highlight_state: &HighlightState,
total_count: u64,
current_position: u64,
) { ) {
let fields: Vec<&str> = self.fields.iter().map(|s| s.as_str()).collect(); let fields_str_slice: Vec<&str> =
let values: Vec<&String> = self.values.iter().collect(); self.fields.iter().map(|s| s.as_str()).collect();
let values_str_slice: Vec<&String> = self.values.iter().collect();
crate::components::form::form::render_form( crate::components::form::form::render_form(
f, f,
area, area,
self, self, // Pass self as CanvasState
&fields, &fields_str_slice,
&self.current_field, &self.current_field,
&values, &values_str_slice,
&self.table_name,
theme, theme,
is_edit_mode, is_edit_mode,
highlight_state, highlight_state,
total_count, self.total_count,
current_position, self.current_position,
); );
} }
/// Resets the form to a state for creating a new entry.
/// It clears all values and sets the position to be one after the last record.
pub fn reset_to_empty(&mut self) { pub fn reset_to_empty(&mut self) {
self.id = 0; // Reset ID to 0 for new entries self.id = 0;
self.values.iter_mut().for_each(|v| v.clear()); // Clear all values self.values.iter_mut().for_each(|v| v.clear());
self.current_field = 0;
self.current_cursor_pos = 0;
self.has_unsaved_changes = false; self.has_unsaved_changes = false;
// Set the position for a new entry.
if self.total_count > 0 {
self.current_position = self.total_count + 1;
} else {
self.current_position = 1; // If table is empty, new record is at position 1
}
} }
pub fn get_current_input(&self) -> &str { pub fn get_current_input(&self) -> &str {
@@ -75,15 +104,47 @@ impl FormState {
.expect("Invalid current_field index") .expect("Invalid current_field index")
} }
pub fn update_from_response(&mut self, response: common::proto::multieko2::adresar::AdresarResponse) { /// Updates the form's values from a data response and sets its position.
self.id = response.id; /// This is the single source of truth for populating the form after a data fetch.
self.values = vec![ pub fn update_from_response(
response.firma, response.kz, response.drc, &mut self,
response.ulica, response.psc, response.mesto, response_data: &HashMap<String, String>,
response.stat, response.banka, response.ucet, // FIX: Add new_position to make this method authoritative.
response.skladm, response.ico, response.kontakt, new_position: u64,
response.telefon, response.skladu, response.fax, ) {
]; // Create a new vector for the values, ensuring they are in the correct order.
self.values = self.fields.iter().map(|field_from_schema| {
// For each field from our schema, find the corresponding key in the
// response data by doing a case-insensitive comparison.
response_data
.iter()
.find(|(key_from_data, _)| key_from_data.eq_ignore_ascii_case(field_from_schema))
.map(|(_, value)| value.clone()) // If found, clone its value.
.unwrap_or_default() // If not found, use an empty string.
}).collect();
// Now, do the same case-insensitive lookup for the 'id' field.
let id_str_opt = response_data
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("id"))
.map(|(_, v)| v);
if let Some(id_str) = id_str_opt {
if let Ok(parsed_id) = id_str.parse::<i64>() {
self.id = parsed_id;
} else {
tracing::error!( "Failed to parse 'id' field '{}' for table {}.{}", id_str, self.profile_name, self.table_name);
self.id = 0;
}
} else {
self.id = 0;
}
// FIX: Set the position from the provided parameter.
self.current_position = new_position;
self.has_unsaved_changes = false;
self.current_field = 0;
self.current_cursor_pos = 0;
} }
} }
@@ -105,31 +166,26 @@ impl CanvasState for FormState {
} }
fn get_current_input(&self) -> &str { fn get_current_input(&self) -> &str {
self.values // Re-use the struct's own method
.get(self.current_field) FormState::get_current_input(self)
.map(|s| s.as_str())
.unwrap_or("")
} }
fn get_current_input_mut(&mut self) -> &mut String { fn get_current_input_mut(&mut self) -> &mut String {
self.values // Re-use the struct's own method
.get_mut(self.current_field) FormState::get_current_input_mut(self)
.expect("Invalid current_field index")
} }
fn fields(&self) -> Vec<&str> { fn fields(&self) -> Vec<&str> {
self.fields.iter().map(|s| s.as_str()).collect() self.fields.iter().map(|s| s.as_str()).collect()
} }
// --- Implement the setter methods ---
fn set_current_field(&mut self, index: usize) { fn set_current_field(&mut self, index: usize) {
if index < self.fields.len() { // Basic bounds check if index < self.fields.len() {
self.current_field = index; self.current_field = index;
} }
} }
fn set_current_cursor_pos(&mut self, pos: usize) { fn set_current_cursor_pos(&mut self, pos: usize) {
// Optional: Add validation based on current input length if needed
self.current_cursor_pos = pos; self.current_cursor_pos = pos;
} }
@@ -137,12 +193,11 @@ impl CanvasState for FormState {
self.has_unsaved_changes = changed; self.has_unsaved_changes = changed;
} }
// --- Autocomplete Support (Not Used for FormState) ---
fn get_suggestions(&self) -> Option<&[String]> { fn get_suggestions(&self) -> Option<&[String]> {
None // FormState doesn't provide suggestions None
} }
fn get_selected_suggestion_index(&self) -> Option<usize> { fn get_selected_suggestion_index(&self) -> Option<usize> {
None // FormState doesn't have selected suggestions None
} }
} }

View File

@@ -2,5 +2,6 @@
pub mod form; pub mod form;
pub mod login; pub mod login;
pub mod logout;
pub mod register; pub mod register;
pub mod add_table; pub mod add_table;

View File

@@ -1,7 +1,15 @@
// src/tui/functions/common/add_table.rs // src/tui/functions/common/add_table.rs
use crate::state::pages::add_table::{ use crate::state::pages::add_table::{
AddTableFocus, AddTableState, ColumnDefinition, AddTableFocus, AddTableState, ColumnDefinition, IndexDefinition,
}; };
use crate::services::GrpcClient;
use anyhow::{anyhow, Result};
use common::proto::multieko2::table_definition::{
PostTableDefinitionRequest,
ColumnDefinition as ProtoColumnDefinition,
TableLink as ProtoTableLink,
};
use tracing::debug;
/// Handles the logic for adding a column when the "Add" button is activated. /// Handles the logic for adding a column when the "Add" button is activated.
/// ///
@@ -36,9 +44,17 @@ pub fn handle_add_column_action(
let new_column = ColumnDefinition { let new_column = ColumnDefinition {
name: column_name_in.to_string(), name: column_name_in.to_string(),
data_type: column_type_in.to_string(), data_type: column_type_in.to_string(),
selected: false,
}; };
add_table_state.columns.push(new_column.clone()); // Clone for msg add_table_state.columns.push(new_column.clone()); // Clone for msg
msg.push_str(&format!("Column '{}' added.", new_column.name)); msg.push_str(&format!("Column '{}' added.", new_column.name));
// Add corresponding index definition (initially unselected)
let new_index = IndexDefinition {
name: column_name_in.to_string(),
selected: false,
};
add_table_state.indexes.push(new_index);
*command_message = msg; *command_message = msg;
// Clear all inputs and reset cursors // Clear all inputs and reset cursors
@@ -73,3 +89,110 @@ pub fn handle_add_column_action(
} }
} }
} }
/// Handles deleting columns marked as selected in the AddTableState.
pub fn handle_delete_selected_columns(
add_table_state: &mut AddTableState,
) -> String {
let initial_count = add_table_state.columns.len();
// Keep only the columns that are NOT selected
let initial_selected_indices: std::collections::HashSet<String> = add_table_state
.columns
.iter()
.filter(|col| col.selected)
.map(|col| col.name.clone())
.collect();
add_table_state.columns.retain(|col| !col.selected);
let deleted_count = initial_count - add_table_state.columns.len();
if deleted_count > 0 {
add_table_state.indexes.retain(|index| !initial_selected_indices.contains(&index.name));
add_table_state.has_unsaved_changes = true;
// Reset selection highlight as indices have changed
add_table_state.column_table_state.select(None);
// Optionally, select the first item if the list is not empty
// if !add_table_state.columns.is_empty() {
// add_table_state.column_table_state.select(Some(0));
// }
add_table_state.index_table_state.select(None);
format!("Deleted {} selected column(s).", deleted_count)
} else {
"No columns marked for deletion.".to_string()
}
}
/// Prepares and sends the request to save the new table definition via gRPC.
pub async fn handle_save_table_action(
grpc_client: &mut GrpcClient,
add_table_state: &AddTableState,
) -> Result<String> {
// --- Basic Validation ---
if add_table_state.table_name.is_empty() {
return Err(anyhow!("Table name cannot be empty."));
}
if add_table_state.columns.is_empty() {
return Err(anyhow!("Table must have at least one column."));
}
// --- Prepare Proto Data ---
let proto_columns: Vec<ProtoColumnDefinition> = add_table_state
.columns
.iter()
.map(|col| ProtoColumnDefinition {
name: col.name.clone(),
field_type: col.data_type.clone(), // Assuming data_type maps directly
})
.collect();
let proto_indexes: Vec<String> = add_table_state
.indexes
.iter()
.filter(|idx| idx.selected) // Only include selected indexes
.map(|idx| idx.name.clone())
.collect();
let proto_links: Vec<ProtoTableLink> = add_table_state
.links
.iter()
.filter(|link| link.selected) // Only include selected links
.map(|link| ProtoTableLink {
linked_table_name: link.linked_table_name.clone(),
// Assuming 'required' maps directly, adjust if needed
// For now, the proto only seems to use linked_table_name based on example
// If your proto evolves, map link.is_required here.
required: false, // Set based on your proto definition/needs
})
.collect();
// --- Create Request ---
let request = PostTableDefinitionRequest {
table_name: add_table_state.table_name.clone(),
columns: proto_columns,
indexes: proto_indexes,
links: proto_links,
profile_name: add_table_state.profile_name.clone(),
};
debug!("Sending PostTableDefinitionRequest: {:?}", request);
// --- Call gRPC Service ---
match grpc_client.post_table_definition(request).await {
Ok(response) => {
if response.success {
Ok(format!(
"Table '{}' saved successfully.",
add_table_state.table_name
))
} else {
// Use the SQL message from the response if available, otherwise generic error
let error_message = if !response.sql.is_empty() {
format!("Server failed to save table: {}", response.sql)
} else {
"Server failed to save table (unknown reason).".to_string()
};
Err(anyhow!(error_message))
}
}
Err(e) => Err(anyhow!("gRPC call failed: {}", e)),
}
}

View File

@@ -2,113 +2,130 @@
use crate::services::grpc_client::GrpcClient; use crate::services::grpc_client::GrpcClient;
use crate::state::pages::form::FormState; use crate::state::pages::form::FormState;
use common::proto::multieko2::adresar::{PostAdresarRequest, PutAdresarRequest}; use anyhow::{Context, Result}; // Added Context
use std::collections::HashMap; // NEW
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveOutcome { pub enum SaveOutcome {
NoChange, // Nothing needed saving NoChange,
UpdatedExisting, // An existing record was updated UpdatedExisting,
CreatedNew(i64), // A new record was created (include its new ID) CreatedNew(i64), // Keep the ID
} }
/// Shared logic for saving the current form state // MODIFIED save function
pub async fn save( pub async fn save(
form_state: &mut FormState, form_state: &mut FormState,
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
current_position: &mut u64, ) -> Result<SaveOutcome> {
total_count: u64,
) -> Result<SaveOutcome, Box<dyn std::error::Error>> { // <-- Return SaveOutcome
if !form_state.has_unsaved_changes { if !form_state.has_unsaved_changes {
return Ok(SaveOutcome::NoChange); // Early exit if no changes return Ok(SaveOutcome::NoChange);
} }
let is_new = *current_position == total_count + 1;
let outcome = if is_new { let data_map: HashMap<String, String> = form_state.fields.iter()
let post_request = PostAdresarRequest { .zip(form_state.values.iter())
firma: form_state.values[0].clone(), .map(|(field, value)| (field.clone(), value.clone()))
kz: form_state.values[1].clone(), .collect();
drc: form_state.values[2].clone(),
ulica: form_state.values[3].clone(), let outcome: SaveOutcome;
psc: form_state.values[4].clone(),
mesto: form_state.values[5].clone(), let is_new_entry = form_state.id == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) || (form_state.total_count == 0 && form_state.current_position == 1) ;
stat: form_state.values[6].clone(),
banka: form_state.values[7].clone(),
ucet: form_state.values[8].clone(), if is_new_entry {
skladm: form_state.values[9].clone(), let response = grpc_client
ico: form_state.values[10].clone(), .post_table_data(
kontakt: form_state.values[11].clone(), form_state.profile_name.clone(),
telefon: form_state.values[12].clone(), form_state.table_name.clone(),
skladu: form_state.values[13].clone(), data_map,
fax: form_state.values[14].clone(), )
}; .await
let response = grpc_client.post_adresar(post_request).await?; .context("Failed to post new table data")?;
let new_id = response.into_inner().id;
form_state.id = new_id; if response.success {
SaveOutcome::CreatedNew(new_id) // <-- Return CreatedNew with ID form_state.id = response.inserted_id;
// After creating a new entry, total_count increases, and current_position becomes this new total_count
form_state.total_count += 1;
form_state.current_position = form_state.total_count;
outcome = SaveOutcome::CreatedNew(response.inserted_id);
} else {
return Err(anyhow::anyhow!(
"Server failed to insert data: {}",
response.message
));
}
} else { } else {
let put_request = PutAdresarRequest { // This assumes form_state.id is valid for an existing record
id: form_state.id, if form_state.id == 0 {
firma: form_state.values[0].clone(), return Err(anyhow::anyhow!(
kz: form_state.values[1].clone(), "Cannot update record: ID is 0, but not classified as new entry."
drc: form_state.values[2].clone(), ));
ulica: form_state.values[3].clone(), }
psc: form_state.values[4].clone(), let response = grpc_client
mesto: form_state.values[5].clone(), .put_table_data(
stat: form_state.values[6].clone(), form_state.profile_name.clone(),
banka: form_state.values[7].clone(), form_state.table_name.clone(),
ucet: form_state.values[8].clone(), form_state.id,
skladm: form_state.values[9].clone(), data_map,
ico: form_state.values[10].clone(), )
kontakt: form_state.values[11].clone(), .await
telefon: form_state.values[12].clone(), .context("Failed to put (update) table data")?;
skladu: form_state.values[13].clone(),
fax: form_state.values[14].clone(), if response.success {
}; outcome = SaveOutcome::UpdatedExisting;
let _ = grpc_client.put_adresar(put_request).await?; } else {
SaveOutcome::UpdatedExisting return Err(anyhow::anyhow!(
}; "Server failed to update data: {}",
response.message
));
}
}
form_state.has_unsaved_changes = false; form_state.has_unsaved_changes = false;
Ok(outcome) Ok(outcome)
} }
/// Discard changes since last save
pub async fn revert( pub async fn revert(
form_state: &mut FormState, form_state: &mut FormState, // Takes &mut FormState to update it
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
current_position: &mut u64, ) -> Result<String> {
total_count: u64, if form_state.id == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) || (form_state.total_count == 0 && form_state.current_position == 1) {
) -> Result<String, Box<dyn std::error::Error>> { let old_total_count = form_state.total_count; // Preserve for correct new position
let is_new = *current_position == total_count + 1; form_state.reset_to_empty(); // reset_to_empty will clear values and set id=0
form_state.total_count = old_total_count; // Restore total_count
if is_new { if form_state.total_count > 0 { // Correctly set current_position for new
// Clear all fields for new entries form_state.current_position = form_state.total_count + 1;
form_state.values.iter_mut().for_each(|v| *v = String::new()); } else {
form_state.has_unsaved_changes = false; form_state.current_position = 1;
}
return Ok("New entry cleared".to_string()); return Ok("New entry cleared".to_string());
} }
let data = grpc_client.get_adresar_by_position(*current_position).await?; if form_state.current_position == 0 || form_state.current_position > form_state.total_count {
if form_state.total_count > 0 {
form_state.current_position = 1;
} else {
// No records to revert to, effectively a new entry state.
form_state.reset_to_empty();
return Ok("No saved data to revert to; form cleared.".to_string());
}
}
// Update form fields with saved values let response = grpc_client
form_state.values = vec![ .get_table_data_by_position(
data.firma, form_state.profile_name.clone(),
data.kz, form_state.table_name.clone(),
data.drc, form_state.current_position as i32,
data.ulica, )
data.psc, .await
data.mesto, .context(format!(
data.stat, "Failed to get table data by position {} for table {}.{}",
data.banka, form_state.current_position,
data.ucet, form_state.profile_name,
data.skladm, form_state.table_name
data.ico, ))?;
data.kontakt,
data.telefon,
data.skladu,
data.fax,
];
form_state.has_unsaved_changes = false; // FIX: Pass the current position as the second argument
form_state.update_from_response(&response.data, form_state.current_position);
Ok("Changes discarded, reloaded last saved version".to_string()) Ok("Changes discarded, reloaded last saved version".to_string())
} }

View File

@@ -5,8 +5,21 @@ use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState; use crate::state::pages::auth::LoginState;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::state::app::buffer::{AppView, BufferState}; use crate::state::app::buffer::{AppView, BufferState};
use crate::config::storage::storage::{StoredAuthData, save_auth_data};
use crate::state::pages::canvas_state::CanvasState; use crate::state::pages::canvas_state::CanvasState;
use crate::ui::handlers::context::DialogPurpose; use crate::ui::handlers::context::DialogPurpose;
use common::proto::multieko2::auth::LoginResponse;
use anyhow::{Context, Result};
use tokio::spawn;
use tokio::sync::mpsc;
use tracing::{info, error};
#[derive(Debug)]
pub enum LoginResult {
Success(LoginResponse),
Failure(String),
ConnectionError(String),
}
/// Attempts to log the user in using the provided credentials via gRPC. /// Attempts to log the user in using the provided credentials via gRPC.
/// Updates AuthState and AppState on success or failure. /// Updates AuthState and AppState on success or failure.
@@ -15,30 +28,47 @@ pub async fn save(
login_state: &mut LoginState, login_state: &mut LoginState,
auth_client: &mut AuthClient, auth_client: &mut AuthClient,
app_state: &mut AppState, app_state: &mut AppState,
) -> Result<String, Box<dyn std::error::Error>> { ) -> Result<String> {
let identifier = login_state.username.clone(); let identifier = login_state.username.clone();
let password = login_state.password.clone(); let password = login_state.password.clone();
// --- Client-side validation ---
// Prevent login attempt if the identifier field is empty or whitespace.
if identifier.trim().is_empty() {
let error_message = "Username/Email cannot be empty.".to_string();
app_state.show_dialog(
"Login Failed",
&error_message,
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
login_state.error_message = Some(error_message.clone());
return Err(anyhow::anyhow!(error_message));
}
// Clear previous error/dialog state before attempting // Clear previous error/dialog state before attempting
login_state.error_message = None; login_state.error_message = None;
// Use the helper to ensure dialog is hidden and cleared properly app_state.hide_dialog(); // Hide any previous dialog
app_state.hide_dialog();
// Call the gRPC login method // Call the gRPC login method
match auth_client.login(identifier, password).await { match auth_client.login(identifier.clone(), password).await
.with_context(|| format!("gRPC login attempt failed for identifier: {}", identifier))
{
Ok(response) => { Ok(response) => {
// Store authentication details on success // Store authentication details using correct field names
auth_state.auth_token = Some(response.access_token.clone()); auth_state.auth_token = Some(response.access_token.clone());
auth_state.user_id = Some(response.user_id.clone()); auth_state.user_id = Some(response.user_id.clone());
auth_state.role = Some(response.role.clone()); auth_state.role = Some(response.role.clone());
auth_state.decoded_username = Some(response.username.clone()); auth_state.decoded_username = Some(response.username.clone());
login_state.set_has_unsaved_changes(false); login_state.set_has_unsaved_changes(false);
login_state.error_message = None;
// Format the success message using response data
let success_message = format!( let success_message = format!(
"Login Successful!\n\n\ "Login Successful!\n\n\
Username: {}\n\ Username: {}\n\
User ID: {}\n\ User ID: {}\n\
Role: {}", Role: {}",
response.username, response.username,
response.user_id, response.user_id,
response.role response.role
@@ -50,23 +80,24 @@ pub async fn save(
vec!["Menu".to_string(), "Exit".to_string()], vec!["Menu".to_string(), "Exit".to_string()],
DialogPurpose::LoginSuccess, DialogPurpose::LoginSuccess,
); );
login_state.password.clear();
login_state.username.clear();
login_state.current_cursor_pos = 0;
Ok("Login successful, details shown in dialog.".to_string()) Ok("Login successful, details shown in dialog.".to_string())
} }
Err(e) => { Err(e) => {
let error_message = format!("{}", e); let error_message = format!("{}", e);
// Use the helper method to configure and show the dialog
app_state.show_dialog( app_state.show_dialog(
"Login Failed", "Login Failed",
&error_message, &error_message,
vec!["OK".to_string()], vec!["OK".to_string()],
DialogPurpose::LoginFailed, DialogPurpose::LoginFailed,
); );
login_state.error_message = Some(error_message.clone());
login_state.set_has_unsaved_changes(true); login_state.set_has_unsaved_changes(true);
login_state.username.clear();
Ok(format!("Login failed: {}", error_message)) login_state.password.clear();
Err(e)
} }
} }
} }
@@ -74,13 +105,14 @@ pub async fn save(
/// Reverts the login form fields to empty and returns to the previous screen (Intro). /// Reverts the login form fields to empty and returns to the previous screen (Intro).
pub async fn revert( pub async fn revert(
login_state: &mut LoginState, login_state: &mut LoginState,
app_state: &mut AppState, _app_state: &mut AppState, // Keep signature consistent if needed elsewhere
) -> String { ) -> String {
// Clear the input fields // Clear the input fields
login_state.username.clear(); login_state.username.clear();
login_state.password.clear(); login_state.password.clear();
login_state.error_message = None; login_state.error_message = None;
login_state.set_has_unsaved_changes(false); login_state.set_has_unsaved_changes(false);
login_state.login_request_pending = false; // Ensure flag is reset on revert
"Login reverted".to_string() "Login reverted".to_string()
} }
@@ -95,9 +127,10 @@ pub async fn back_to_main(
login_state.password.clear(); login_state.password.clear();
login_state.error_message = None; login_state.error_message = None;
login_state.set_has_unsaved_changes(false); login_state.set_has_unsaved_changes(false);
login_state.login_request_pending = false; // Ensure flag is reset
// Ensure dialog is hidden if revert is called // Ensure dialog is hidden if revert is called
app_state.hide_dialog(); // Uncomment if needed app_state.hide_dialog();
// Navigation logic // Navigation logic
buffer_state.close_active_buffer(); buffer_state.close_active_buffer();
@@ -109,3 +142,99 @@ pub async fn back_to_main(
"Returned to main menu".to_string() "Returned to main menu".to_string()
} }
/// Validates input, shows loading, and spawns the login task.
pub fn initiate_login(
login_state: &LoginState,
app_state: &mut AppState,
mut auth_client: AuthClient,
sender: mpsc::Sender<LoginResult>,
) -> String {
let username = login_state.username.clone();
let password = login_state.password.clone();
// 1. Client-side validation
if username.trim().is_empty() {
app_state.show_dialog(
"Login Failed",
"Username/Email cannot be empty.",
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
"Username cannot be empty.".to_string()
} else {
// 2. Show Loading Dialog
app_state.show_loading_dialog("Logging In", "Please wait...");
// 3. Spawn the login task
spawn(async move {
// Use the passed-in (and moved) auth_client directly
let login_outcome = match auth_client.login(username.clone(), password).await
.with_context(|| format!("Spawned login task failed for identifier: {}", username))
{
Ok(response) => LoginResult::Success(response),
Err(e) => LoginResult::Failure(format!("{}", e)),
};
// Send result back to the main UI thread
if let Err(e) = sender.send(login_outcome).await {
error!("Failed to send login result: {}", e);
}
});
// 4. Return immediately
"Login initiated.".to_string()
}
}
/// Handles the result received from the login task.
/// Returns true if a redraw is needed.
pub fn handle_login_result(
result: LoginResult,
app_state: &mut AppState,
auth_state: &mut AuthState,
login_state: &mut LoginState,
) -> bool {
match result {
LoginResult::Success(response) => {
auth_state.auth_token = Some(response.access_token.clone());
auth_state.user_id = Some(response.user_id.clone());
auth_state.role = Some(response.role.clone());
auth_state.decoded_username = Some(response.username.clone());
// --- NEW: Save auth data to file ---
let data_to_store = StoredAuthData {
access_token: response.access_token.clone(),
user_id: response.user_id.clone(),
role: response.role.clone(),
username: response.username.clone(),
};
if let Err(e) = save_auth_data(&data_to_store) {
error!("Failed to save auth data to file: {}", e);
// Continue anyway - user is still logged in for this session
}
// --- END NEW ---
let success_message = format!(
"Login Successful!\n\nUsername: {}\nUser ID: {}\nRole: {}",
response.username, response.user_id, response.role
);
app_state.update_dialog_content(
&success_message,
vec!["Menu".to_string(), "Exit".to_string()],
DialogPurpose::LoginSuccess,
);
info!(message = %success_message, "Login successful");
}
LoginResult::Failure(err_msg) | LoginResult::ConnectionError(err_msg) => {
app_state.update_dialog_content(&err_msg, vec!["OK".to_string()], DialogPurpose::LoginFailed);
login_state.error_message = Some(err_msg.clone());
error!(error = %err_msg, "Login failed/connection error");
}
}
login_state.username.clear();
login_state.password.clear();
login_state.set_has_unsaved_changes(false);
login_state.current_cursor_pos = 0;
true // Request redraw as dialog content changed
}

View File

@@ -0,0 +1,47 @@
// src/tui/functions/common/logout.rs
use crate::config::storage::delete_auth_data;
use crate::state::pages::auth::AuthState;
use crate::state::app::state::AppState;
use crate::state::app::buffer::{AppView, BufferState};
use crate::ui::handlers::context::DialogPurpose;
use tracing::{error, info};
pub fn logout(
auth_state: &mut AuthState,
app_state: &mut AppState,
buffer_state: &mut BufferState,
) -> String {
// Clear auth state in memory
auth_state.auth_token = None;
auth_state.user_id = None;
auth_state.role = None;
auth_state.decoded_username = None;
// Delete stored auth data
if let Err(e) = delete_auth_data() {
error!("Failed to delete stored auth data: {}", e);
// Continue anyway - user is logged out in memory
}
// Navigate to intro screen
buffer_state.history = vec![AppView::Intro];
buffer_state.active_index = 0;
// Reset UI state
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index = 0;
// Hide any open dialogs
app_state.hide_dialog();
// Show logout confirmation dialog
app_state.show_dialog(
"Logged Out",
"You have been successfully logged out.",
vec!["OK".to_string()],
DialogPurpose::LoginSuccess, // Reuse or create a new purpose
);
info!("User logged out successfully.");
"Logged out successfully".to_string()
}

View File

@@ -8,108 +8,17 @@ use crate::state::{
}; };
use crate::ui::handlers::context::DialogPurpose; use crate::ui::handlers::context::DialogPurpose;
use crate::state::app::buffer::{AppView, BufferState}; use crate::state::app::buffer::{AppView, BufferState};
use common::proto::multieko2::auth::AuthResponse;
use anyhow::Context;
use tokio::spawn;
use tokio::sync::mpsc;
use tracing::{info, error};
/// Attempts to register the user using the provided details via gRPC. #[derive(Debug)]
/// Updates RegisterState and AppState on success or failure. pub enum RegisterResult {
pub async fn save( Success(AuthResponse),
register_state: &mut RegisterState, Failure(String),
auth_client: &mut AuthClient, ConnectionError(String),
app_state: &mut AppState,
) -> Result<String, Box<dyn std::error::Error>> {
let username = register_state.username.clone();
let email = register_state.email.clone();
// Handle optional passwords: send None if empty, Some(value) otherwise
let password = if register_state.password.is_empty() {
None
} else {
Some(register_state.password.clone())
};
let password_confirmation = if register_state.password_confirmation.is_empty() {
None
} else {
Some(register_state.password_confirmation.clone())
};
let role = if register_state.role.is_empty() {
None
} else {
Some(register_state.role.clone())
};
// Basic client-side validation (example)
if username.is_empty() {
app_state.show_dialog(
"Registration Failed",
"Username cannot be empty.",
vec!["OK".to_string()],
DialogPurpose::RegisterFailed,
);
register_state.error_message = Some("Username cannot be empty.".to_string());
return Ok("Registration failed: Username cannot be empty.".to_string());
}
if password.is_some() && password != password_confirmation {
app_state.show_dialog(
"Registration Failed",
"Passwords do not match.",
vec!["OK".to_string()],
DialogPurpose::RegisterFailed,
);
register_state.error_message = Some("Passwords do not match.".to_string());
return Ok("Registration failed: Passwords do not match.".to_string());
}
// Clear previous error/dialog state before attempting
register_state.error_message = None;
app_state.hide_dialog();
// Call the gRPC register method
match auth_client.register(username, email, password, password_confirmation, role).await {
Ok(response) => {
// Clear fields on success? Optional, maybe wait for dialog confirmation.
// register_state.username.clear();
// register_state.email.clear();
// register_state.password.clear();
// register_state.password_confirmation.clear();
register_state.set_has_unsaved_changes(false);
let success_message = format!(
"Registration Successful!\n\n\
User ID: {}\n\
Username: {}\n\
Email: {}\n\
Role: {}",
response.id,
response.username,
response.email,
response.role
);
// Show success dialog
app_state.show_dialog(
"Registration Success",
&success_message,
vec!["OK".to_string()], // Simple OK for now
DialogPurpose::RegisterSuccess,
);
Ok("Registration successful, details shown in dialog.".to_string())
}
Err(e) => {
let error_message = format!("{}", e);
register_state.error_message = Some(error_message.clone());
register_state.set_has_unsaved_changes(true); // Keep changes on error
// Show error dialog
app_state.show_dialog(
"Registration Failed",
&error_message,
vec!["OK".to_string()],
DialogPurpose::RegisterFailed,
);
Ok(format!("Registration failed: {}", error_message))
}
}
} }
/// Clears the registration form fields. /// Clears the registration form fields.
@@ -152,3 +61,83 @@ pub async fn back_to_login(
"Returned to main menu".to_string() "Returned to main menu".to_string()
} }
/// Validates input, shows loading, and spawns the registration task.
pub fn initiate_registration(
register_state: &RegisterState,
app_state: &mut AppState,
mut auth_client: AuthClient,
sender: mpsc::Sender<RegisterResult>,
) -> String {
// Clone necessary data
let username = register_state.username.clone();
let email = register_state.email.clone();
let password = register_state.password.clone();
let password_confirmation = register_state.password_confirmation.clone();
let role = register_state.role.clone();
// 1. Client-side validation
if username.trim().is_empty() {
app_state.show_dialog("Registration Failed", "Username cannot be empty.", vec!["OK".to_string()], DialogPurpose::RegisterFailed);
"Username cannot be empty.".to_string()
} else if !password.is_empty() && password != password_confirmation {
app_state.show_dialog("Registration Failed", "Passwords do not match.", vec!["OK".to_string()], DialogPurpose::RegisterFailed);
"Passwords do not match.".to_string()
} else {
// 2. Show Loading Dialog
app_state.show_loading_dialog("Registering", "Please wait...");
// 3. Spawn the registration task
spawn(async move {
let password_opt = if password.is_empty() { None } else { Some(password) };
let password_conf_opt = if password_confirmation.is_empty() { None } else { Some(password_confirmation) };
let role_opt = if role.is_empty() { None } else { Some(role) };
let register_outcome = match auth_client.register(username.clone(), email, password_opt, password_conf_opt, role_opt).await
.with_context(|| format!("Spawned register task failed for username: {}", username))
{
Ok(response) => RegisterResult::Success(response),
Err(e) => RegisterResult::Failure(format!("{}", e)),
};
// Send result back to the main UI thread
if let Err(e) = sender.send(register_outcome).await {
error!("Failed to send registration result: {}", e);
}
});
// 4. Return immediately
"Registration initiated.".to_string()
}
}
/// Handles the result received from the registration task.
/// Returns true if a redraw is needed.
pub fn handle_registration_result(
result: RegisterResult,
app_state: &mut AppState,
register_state: &mut RegisterState,
) -> bool {
match result {
RegisterResult::Success(response) => {
let success_message = format!(
"Registration Successful!\n\nUser ID: {}\nUsername: {}\nEmail: {}\nRole: {}",
response.id, response.username, response.email, response.role
);
app_state.update_dialog_content(
&success_message,
vec!["OK".to_string()],
DialogPurpose::RegisterSuccess,
);
info!(message = %success_message, "Registration successful");
}
RegisterResult::Failure(err_msg) | RegisterResult::ConnectionError(err_msg) => {
app_state.update_dialog_content(
&err_msg,
vec!["OK".to_string()],
DialogPurpose::RegisterFailed,
);
register_state.error_message = Some(err_msg.clone());
error!(error = %err_msg, "Registration failed/connection error");
}
}
register_state.set_has_unsaved_changes(false); // Clear flag after processing
true // Request redraw as dialog content changed
}

View File

@@ -1,18 +1,15 @@
// src/tui/functions/form.rs // src/tui/functions/form.rs
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState; use crate::state::pages::form::FormState;
use crate::services::grpc_client::GrpcClient; use crate::services::grpc_client::GrpcClient;
use crate::state::pages::canvas_state::CanvasState; use anyhow::{anyhow, Result};
pub async fn handle_action( pub async fn handle_action(
action: &str, action: &str,
form_state: &mut FormState, form_state: &mut FormState,
grpc_client: &mut GrpcClient, _grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
ideal_cursor_column: &mut usize, ideal_cursor_column: &mut usize,
) -> Result<String, Box<dyn std::error::Error>> { ) -> Result<String> {
// TODO store unsaved changes without deleting form state values
// First check for unsaved changes in both cases
if form_state.has_unsaved_changes() { if form_state.has_unsaved_changes() {
return Ok( return Ok(
"Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating." "Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating."
@@ -20,70 +17,29 @@ pub async fn handle_action(
); );
} }
let total_count = form_state.total_count;
match action { match action {
"previous_entry" => { "previous_entry" => {
let new_position = current_position.saturating_sub(1); // Only decrement if the current position is greater than the first record.
if new_position >= 1 { // This prevents wrapping from 1 to total_count.
*current_position = new_position; // It also correctly handles moving from "New Entry" (total_count + 1) to the last record.
let response = grpc_client.get_adresar_by_position(*current_position).await?; if form_state.current_position > 1 {
form_state.current_position -= 1;
// Direct field assignments *ideal_cursor_column = 0;
form_state.id = response.id;
form_state.values = vec![
response.firma, response.kz, response.drc,
response.ulica, response.psc, response.mesto,
response.stat, response.banka, response.ucet,
response.skladm, response.ico, response.kontakt,
response.telefon, response.skladu, response.fax,
];
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else { 0 };
form_state.current_cursor_pos = std::cmp::min(*ideal_cursor_column, max_cursor_pos);
form_state.has_unsaved_changes = false;
Ok(format!("Loaded form entry {}", *current_position))
} else {
Ok("Already at first form entry".into())
} }
} }
"next_entry" => { "next_entry" => {
if *current_position <= total_count { // Only increment if the current position is not yet at the "New Entry" stage.
*current_position += 1; // The "New Entry" position is total_count + 1.
if *current_position <= total_count { // This allows moving from the last record to "New Entry", but stops there.
let response = grpc_client.get_adresar_by_position(*current_position).await?; if form_state.current_position <= total_count {
form_state.current_position += 1;
// Direct field assignments *ideal_cursor_column = 0;
form_state.id = response.id;
form_state.values = vec![
response.firma, response.kz, response.drc,
response.ulica, response.psc, response.mesto,
response.stat, response.banka, response.ucet,
response.skladm, response.ico, response.kontakt,
response.telefon, response.skladu, response.fax,
];
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else { 0 };
form_state.current_cursor_pos = std::cmp::min(*ideal_cursor_column, max_cursor_pos);
form_state.has_unsaved_changes = false;
Ok(format!("Loaded form entry {}", *current_position))
} else {
form_state.reset_to_empty();
form_state.current_field = 0;
form_state.current_cursor_pos = 0;
*ideal_cursor_column = 0;
Ok("New form entry mode".into())
}
} else {
Ok("Already at last entry".into())
} }
} }
_ => Err("Unknown form action".into()) _ => return Err(anyhow!("Unknown form action: {}", action)),
} }
}
Ok(String::new())
}

View File

@@ -13,10 +13,7 @@ pub fn handle_intro_selection(
index: usize, index: usize,
) { ) {
let target_view = match index { let target_view = match index {
0 => { 0 => AppView::Form,
let form_name = app_state.selected_profile.clone().unwrap_or_else(|| "Data Form".to_string());
AppView::Form(form_name)
}
1 => AppView::Admin, 1 => AppView::Admin,
2 => AppView::Login, 2 => AppView::Login,
3 => AppView::Register, 3 => AppView::Register,

View File

@@ -1,6 +1,8 @@
// src/tui/functions/login.rs // src/tui/functions/login.rs
pub async fn handle_action(action: &str,) -> Result<String, Box<dyn std::error::Error>> { use anyhow::{anyhow, Result};
pub async fn handle_action(action: &str,) -> Result<String> {
match action { match action {
"previous_entry" => { "previous_entry" => {
Ok("Previous entry at tui/functions/login.rs not implemented".into()) Ok("Previous entry at tui/functions/login.rs not implemented".into())
@@ -8,6 +10,6 @@ pub async fn handle_action(action: &str,) -> Result<String, Box<dyn std::error::
"next_entry" => { "next_entry" => {
Ok("Next entry at tui/functions/login.rs not implemented".into()) Ok("Next entry at tui/functions/login.rs not implemented".into())
} }
_ => Err("Unknown login action".into()) _ => Err(anyhow!("Unknown login action: {}", action))
} }
} }

View File

@@ -7,13 +7,14 @@ use crossterm::{
}; };
use ratatui::{backend::CrosstermBackend, Terminal}; use ratatui::{backend::CrosstermBackend, Terminal};
use std::io::{self, stdout, Write}; use std::io::{self, stdout, Write};
use anyhow::Result;
pub struct TerminalCore { pub struct TerminalCore {
terminal: Terminal<CrosstermBackend<io::Stdout>>, terminal: Terminal<CrosstermBackend<io::Stdout>>,
} }
impl TerminalCore { impl TerminalCore {
pub fn new() -> Result<Self, Box<dyn std::error::Error>> { pub fn new() -> Result<Self> {
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = stdout(); let mut stdout = stdout();
execute!( execute!(
@@ -27,7 +28,7 @@ impl TerminalCore {
Ok(Self { terminal }) Ok(Self { terminal })
} }
pub fn draw<F>(&mut self, f: F) -> Result<(), Box<dyn std::error::Error>> pub fn draw<F>(&mut self, f: F) -> Result<()>
where where
F: FnOnce(&mut ratatui::Frame), F: FnOnce(&mut ratatui::Frame),
{ {
@@ -35,7 +36,7 @@ impl TerminalCore {
Ok(()) Ok(())
} }
pub fn cleanup(&mut self) -> Result<(), Box<dyn std::error::Error>> { pub fn cleanup(&mut self) -> Result<()> {
let backend = self.terminal.backend_mut(); let backend = self.terminal.backend_mut();
execute!( execute!(
backend, backend,
@@ -56,7 +57,7 @@ impl TerminalCore {
pub fn set_cursor_style( pub fn set_cursor_style(
&mut self, &mut self,
style: SetCursorStyle, style: SetCursorStyle,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<()> {
execute!( execute!(
self.terminal.backend_mut(), self.terminal.backend_mut(),
style, style,
@@ -65,7 +66,7 @@ impl TerminalCore {
Ok(()) Ok(())
} }
pub fn show_cursor(&mut self) -> Result<(), Box<dyn std::error::Error>> { pub fn show_cursor(&mut self) -> Result<()> {
execute!( execute!(
self.terminal.backend_mut(), self.terminal.backend_mut(),
Show Show
@@ -73,7 +74,7 @@ impl TerminalCore {
Ok(()) Ok(())
} }
pub fn hide_cursor(&mut self) -> Result<(), Box<dyn std::error::Error>> { pub fn hide_cursor(&mut self) -> Result<()> {
execute!( execute!(
self.terminal.backend_mut(), self.terminal.backend_mut(),
Hide Hide

View File

@@ -1,6 +1,7 @@
// src/tui/terminal/event_reader.rs // src/tui/terminal/event_reader.rs
use crossterm::event::{self, Event}; use crossterm::event::{self, Event};
use anyhow::Result;
pub struct EventReader; pub struct EventReader;
@@ -9,7 +10,7 @@ impl EventReader {
Self Self
} }
pub fn read_event(&self) -> Result<Event, Box<dyn std::error::Error>> { pub fn read_event(&self) -> Result<Event> {
Ok(event::read()?) Ok(event::read()?)
} }
} }

View File

@@ -0,0 +1,34 @@
# UI Redraw Logic (`needs_redraw` Flag)
## Problem
The main UI loop in `client/src/ui/handlers/ui.rs` uses `crossterm_event::poll` with a short timeout to remain responsive to both user input and asynchronous operations (like login results arriving via channels). However, calling `terminal.draw()` unconditionally in every loop iteration caused constant UI refreshes and high CPU usage, even when idle.
## Solution
A boolean flag, `needs_redraw`, was introduced in the main loop scope to control when the UI is actually redrawn.
## Mechanism
1. **Initialization:** `needs_redraw` is initialized to `true` before the loop starts to ensure the initial UI state is drawn.
2. **Conditional Drawing:** The `terminal.draw(...)` call is wrapped in an `if needs_redraw { ... }` block.
3. **Flag Reset:** Immediately after a successful `terminal.draw(...)` call, `needs_redraw` is set back to `false`.
4. **Triggering Redraws:** The `needs_redraw` flag is explicitly set to `true` only when a redraw is actually required.
## When `needs_redraw` Must Be Set to `true`
To ensure the UI stays up-to-date without unnecessary refreshes, `needs_redraw = true;` **must** be set in the following situations:
1. **After Handling User Input:** When `crossterm_event::poll` returns `true`, indicating a keyboard/mouse event was received and processed by `event_handler.handle_event`.
2. **During Active Loading States:** If an asynchronous operation is in progress and a visual indicator (like a loading dialog) is active (e.g., checking `if app_state.ui.dialog.is_loading`). This keeps the loading state visible while waiting for the result.
3. **After Processing Async Results:** When the result of an asynchronous operation (e.g., received from an `mpsc::channel` like `login_result_receiver`) is processed and the application state is updated (e.g., dialog content changed, data updated).
4. **After Internal State Changes:** If any logic *outside* the direct event handling block modifies state that needs to be visually reflected (e.g., the position change logic loading new data into the form).
## Rationale
This approach balances UI responsiveness for asynchronous tasks and user input with CPU efficiency by avoiding redraws when the application state is static.
## Maintenance Note
When adding new asynchronous operations or internal logic that modifies UI-relevant state outside the main event handler, developers **must remember** to set `needs_redraw = true` at the appropriate point after the state change to ensure the UI updates correctly. Failure to do so can result in a stale UI.

View File

@@ -15,7 +15,9 @@ pub enum DialogPurpose {
LoginFailed, LoginFailed,
RegisterSuccess, RegisterSuccess,
RegisterFailed, RegisterFailed,
ConfirmDeleteColumns,
SaveTableSuccess,
SaveLogicSuccess,
// TODO in the future: // TODO in the future:
// ConfirmQuit, // ConfirmQuit,
} }

View File

@@ -1,4 +1,4 @@
// src/ui/handlers/rat_state.rs // client/src/ui/handlers/rat_state.rs
use crossterm::event::{KeyCode, KeyModifiers}; use crossterm::event::{KeyCode, KeyModifiers};
use crate::config::binds::config::Config; use crate::config::binds::config::Config;
use crate::state::app::state::UiState; use crate::state::app::state::UiState;

View File

@@ -1,4 +1,4 @@
// src/ui/handlers/render.rs // client/src/ui/handlers/render.rs
use crate::components::{ use crate::components::{
render_background, render_background,
@@ -9,11 +9,16 @@ use crate::components::{
handlers::sidebar::{self, calculate_sidebar_layout}, handlers::sidebar::{self, calculate_sidebar_layout},
form::form::render_form, form::form::render_form,
admin::render_add_table, admin::render_add_table,
admin::add_logic::render_add_logic,
auth::{login::render_login, register::render_register}, auth::{login::render_login, register::render_register},
common::find_file_palette,
}; };
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::{
use ratatui::Frame; layout::{Constraint, Direction, Layout},
Frame,
};
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState; use crate::state::pages::form::FormState;
use crate::state::pages::auth::AuthState; use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState; use crate::state::pages::auth::LoginState;
@@ -23,7 +28,9 @@ use crate::state::app::buffer::BufferState;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState; use crate::state::pages::admin::AdminState;
use crate::state::app::highlight::HighlightState; use crate::state::app::highlight::HighlightState;
use crate::modes::general::command_navigation::NavigationState;
#[allow(clippy::too_many_arguments)]
pub fn render_ui( pub fn render_ui(
f: &mut Frame, f: &mut Frame,
form_state: &mut FormState, form_state: &mut FormState,
@@ -34,165 +41,175 @@ pub fn render_ui(
admin_state: &mut AdminState, admin_state: &mut AdminState,
buffer_state: &BufferState, buffer_state: &BufferState,
theme: &Theme, theme: &Theme,
is_edit_mode: bool, is_event_handler_edit_mode: bool,
highlight_state: &HighlightState, highlight_state: &HighlightState,
total_count: u64, event_handler_command_input: &str,
current_position: u64, event_handler_command_mode_active: bool,
event_handler_command_message: &str,
navigation_state: &NavigationState,
current_dir: &str, current_dir: &str,
command_input: &str,
command_mode: bool,
command_message: &str,
current_fps: f64, current_fps: f64,
app_state: &AppState, app_state: &AppState,
) { ) {
render_background(f, f.area(), theme); render_background(f, f.area(), theme);
// Adjust layout based on whether buffer list is shown const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15;
let constraints = if app_state.ui.show_buffer_list {
vec![ let mut bottom_area_constraints: Vec<Constraint> = vec![Constraint::Length(1)];
Constraint::Length(1), // Buffer list
Constraint::Min(1), // Main content let command_palette_area_height = if navigation_state.active {
Constraint::Length(1), // Status line 1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT
Constraint::Length(1), // Command line } else if event_handler_command_mode_active {
] 1
} else { } else {
vec![ 0 // Neither is active
Constraint::Min(1), // Main content
Constraint::Length(1), // Status line (no buffer list)
Constraint::Length(1), // Command line
]
}; };
let root = Layout::default() if command_palette_area_height > 0 {
bottom_area_constraints.push(Constraint::Length(command_palette_area_height));
}
let mut main_layout_constraints = vec![Constraint::Min(1)];
if app_state.ui.show_buffer_list {
main_layout_constraints.insert(0, Constraint::Length(1));
}
main_layout_constraints.extend(bottom_area_constraints);
let root_chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(constraints) .constraints(main_layout_constraints)
.split(f.area()); .split(f.area());
let mut buffer_list_area = None; let mut chunk_idx = 0;
let main_content_area; let buffer_list_area = if app_state.ui.show_buffer_list {
let status_line_area; let area = Some(root_chunks[chunk_idx]);
let command_line_area; chunk_idx += 1;
area
// Assign areas based on layout
if app_state.ui.show_buffer_list {
buffer_list_area = Some(root[0]);
main_content_area = root[1];
status_line_area = root[2];
command_line_area = root[3];
} else { } else {
main_content_area = root[0]; None
status_line_area = root[1]; };
command_line_area = root[2];
} let main_content_area = root_chunks[chunk_idx];
chunk_idx += 1;
let status_line_area = root_chunks[chunk_idx];
chunk_idx += 1;
let command_render_area = if command_palette_area_height > 0 {
if root_chunks.len() > chunk_idx {
Some(root_chunks[chunk_idx])
} else {
None
}
} else {
None
};
if app_state.ui.show_intro { if app_state.ui.show_intro {
render_intro(f, intro_state, main_content_area, theme); render_intro(f, intro_state, main_content_area, theme);
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
render_register( render_register(
f, f, main_content_area, theme, register_state, app_state,
main_content_area, register_state.current_field() < 4,
theme,
register_state,
app_state,
register_state.current_field < 4,
highlight_state, highlight_state,
); );
} else if app_state.ui.show_add_table { } else if app_state.ui.show_add_table {
render_add_table( render_add_table(
f, f, main_content_area, theme, app_state, &mut admin_state.add_table_state,
main_content_area, is_event_handler_edit_mode,
theme,
app_state,
&mut admin_state.add_table_state,
login_state.current_field < 3,
highlight_state, highlight_state,
); );
} else if app_state.ui.show_add_logic {
render_add_logic(
f, main_content_area, theme, app_state, &mut admin_state.add_logic_state,
is_event_handler_edit_mode, highlight_state,
);
} else if app_state.ui.show_login { } else if app_state.ui.show_login {
render_login( render_login(
f, f, main_content_area, theme, login_state, app_state,
main_content_area, login_state.current_field() < 2,
theme,
login_state,
app_state,
login_state.current_field < 2,
highlight_state, highlight_state,
); );
} else if app_state.ui.show_admin { } else if app_state.ui.show_admin {
crate::components::admin::admin_panel::render_admin_panel( crate::components::admin::admin_panel::render_admin_panel(
f, f, app_state, auth_state, admin_state, main_content_area, theme,
app_state, &app_state.profile_tree, &app_state.selected_profile,
auth_state,
admin_state,
main_content_area,
theme,
&app_state.profile_tree,
&app_state.selected_profile,
);
} else if app_state.ui.show_form {
let (sidebar_area, form_area) = calculate_sidebar_layout(
app_state.ui.show_sidebar,
main_content_area
); );
} else if app_state.ui.show_form {
let (sidebar_area, form_actual_area) = calculate_sidebar_layout(
app_state.ui.show_sidebar, main_content_area
);
if let Some(sidebar_rect) = sidebar_area { if let Some(sidebar_rect) = sidebar_area {
sidebar::render_sidebar( sidebar::render_sidebar(
f, f, sidebar_rect, theme, &app_state.profile_tree, &app_state.selected_profile
sidebar_rect,
theme,
&app_state.profile_tree,
&app_state.selected_profile
); );
} }
let available_width = form_actual_area.width;
// This change makes the form stay stationary when toggling sidebar let form_render_area = if available_width >= 80 {
let available_width = form_area.width; Layout::default().direction(Direction::Horizontal)
let form_constraint = if available_width >= 80 { .constraints([Constraint::Min(0), Constraint::Length(80), Constraint::Min(0)])
// Use main_content_area for centering when enough space .split(form_actual_area)[1]
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0),
Constraint::Length(80),
Constraint::Min(0),
])
.split(main_content_area)[1]
} else { } else {
// Use form_area (post sidebar) when limited space Layout::default().direction(Direction::Horizontal)
Layout::default() .constraints([Constraint::Min(0), Constraint::Length(available_width), Constraint::Min(0)])
.direction(Direction::Horizontal) .split(form_actual_area)[1]
.constraints([
Constraint::Min(0),
Constraint::Length(80.min(available_width)),
Constraint::Min(0),
])
.split(form_area)[1]
}; };
let fields_vec: Vec<&str> = form_state.fields.iter().map(AsRef::as_ref).collect();
let values_vec: Vec<&String> = form_state.values.iter().collect();
// Convert fields to &[&str] and values to &[&String] // --- START FIX ---
let fields: Vec<&str> = form_state.fields.iter().map(|s| s.as_str()).collect(); // Add the missing `&form_state.table_name` argument to this function call.
let values: Vec<&String> = form_state.values.iter().collect();
render_form( render_form(
f, f,
form_constraint, form_render_area,
form_state, form_state,
&fields, &fields_vec,
&form_state.current_field, &form_state.current_field,
&values, &values_vec,
&form_state.table_name, // <-- THIS ARGUMENT WAS MISSING
theme, theme,
is_edit_mode, is_event_handler_edit_mode,
highlight_state, highlight_state,
total_count, form_state.total_count,
current_position, form_state.current_position,
); );
// --- END FIX ---
} }
// Render buffer list if enabled and area is available
if let Some(area) = buffer_list_area { if let Some(area) = buffer_list_area {
if app_state.ui.show_buffer_list { render_buffer_list(f, area, theme, buffer_state, app_state);
render_buffer_list(f, area, theme, buffer_state); }
render_status_line(
f,
status_line_area,
current_dir,
theme,
is_event_handler_edit_mode,
current_fps,
app_state,
);
if let Some(palette_or_command_area) = command_render_area { // Use the calculated area
if navigation_state.active {
find_file_palette::render_find_file_palette(
f,
palette_or_command_area, // Use the correct area
theme,
navigation_state, // Pass the navigation_state directly
);
} else if event_handler_command_mode_active {
render_command_line(
f,
palette_or_command_area, // Use the correct area
event_handler_command_input,
true, // Assuming it's always active when this branch is hit
theme,
event_handler_command_message,
);
} }
} }
render_status_line(f, status_line_area, current_dir, theme, is_edit_mode, current_fps);
render_command_line(f, command_line_area, command_input, command_mode, theme, command_message);
} }

View File

@@ -4,6 +4,7 @@ use crate::config::binds::config::Config;
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use crate::services::grpc_client::GrpcClient; use crate::services::grpc_client::GrpcClient;
use crate::services::ui_service::UiService; use crate::services::ui_service::UiService;
use crate::config::storage::storage::load_auth_data;
use crate::modes::common::commands::CommandHandler; use crate::modes::common::commands::CommandHandler;
use crate::modes::handlers::event::{EventHandler, EventOutcome}; use crate::modes::handlers::event::{EventHandler, EventOutcome};
use crate::modes::handlers::mode_manager::{AppMode, ModeManager}; use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
@@ -13,24 +14,46 @@ use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState; use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState; use crate::state::pages::auth::RegisterState;
use crate::state::pages::admin::AdminState; use crate::state::pages::admin::AdminState;
use crate::state::pages::admin::AdminFocus;
use crate::state::pages::intro::IntroState; use crate::state::pages::intro::IntroState;
use crate::state::app::buffer::BufferState; use crate::state::app::buffer::BufferState;
use crate::state::app::buffer::AppView; use crate::state::app::buffer::AppView;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
// Import SaveOutcome
use crate::tui::terminal::{EventReader, TerminalCore}; use crate::tui::terminal::{EventReader, TerminalCore};
use crate::ui::handlers::render::render_ui; use crate::ui::handlers::render::render_ui;
use crate::tui::functions::common::login::LoginResult;
use crate::tui::functions::common::register::RegisterResult;
use crate::ui::handlers::context::DialogPurpose;
use crate::tui::functions::common::login;
use crate::tui::functions::common::register;
use crate::utils::columns::filter_user_columns;
use std::time::Instant; use std::time::Instant;
use anyhow::{anyhow, Context, Result};
use crossterm::cursor::SetCursorStyle; use crossterm::cursor::SetCursorStyle;
use crossterm::event as crossterm_event;
use tracing::{error, info, warn};
use tokio::sync::mpsc;
pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> { pub async fn run_ui() -> Result<()> {
let config = Config::load()?; let config = Config::load().context("Failed to load configuration")?;
let theme = Theme::from_str(&config.colors.theme); let theme = Theme::from_str(&config.colors.theme);
let mut terminal = TerminalCore::new()?; let mut terminal = TerminalCore::new().context("Failed to initialize terminal")?;
let mut grpc_client = GrpcClient::new().await?; let mut grpc_client = GrpcClient::new().await.context("Failed to create GrpcClient")?;
let mut command_handler = CommandHandler::new(); let mut command_handler = CommandHandler::new();
let mut event_handler = EventHandler::new().await?; let (login_result_sender, mut login_result_receiver) = mpsc::channel::<LoginResult>(1);
let (register_result_sender, mut register_result_receiver) = mpsc::channel::<RegisterResult>(1);
let (save_table_result_sender, mut save_table_result_receiver) = mpsc::channel::<Result<String>>(1);
let (save_logic_result_sender, _save_logic_result_receiver) = mpsc::channel::<Result<String>>(1);
let mut event_handler = EventHandler::new(
login_result_sender.clone(),
register_result_sender.clone(),
save_table_result_sender.clone(),
save_logic_result_sender.clone(),
)
.await
.context("Failed to create event handler")?;
let event_reader = EventReader::new(); let event_reader = EventReader::new();
let mut auth_state = AuthState::default(); let mut auth_state = AuthState::default();
@@ -39,120 +62,74 @@ pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> {
let mut intro_state = IntroState::default(); let mut intro_state = IntroState::default();
let mut admin_state = AdminState::default(); let mut admin_state = AdminState::default();
let mut buffer_state = BufferState::default(); let mut buffer_state = BufferState::default();
let mut app_state = AppState::new()?; let mut app_state = AppState::new().context("Failed to create initial app state")?;
// Initialize app state with profile tree and table structure let mut auto_logged_in = false;
let column_names = match load_auth_data() {
UiService::initialize_app_state(&mut grpc_client, &mut app_state) Ok(Some(stored_data)) => {
.await?; auth_state.auth_token = Some(stored_data.access_token);
let mut form_state = FormState::new(column_names); auth_state.user_id = Some(stored_data.user_id);
auth_state.role = Some(stored_data.role);
auth_state.decoded_username = Some(stored_data.username);
auto_logged_in = true;
info!("Auth data loaded from file. User is auto-logged in.");
}
Ok(None) => {
info!("No stored auth data found. User will see intro/login.");
}
Err(e) => {
error!("Failed to load auth data: {}", e);
}
}
// Fetch the total count of Adresar entries let (initial_profile, initial_table, initial_columns_from_service) =
UiService::initialize_adresar_count(&mut grpc_client, &mut app_state) UiService::initialize_app_state_and_form(&mut grpc_client, &mut app_state)
.await?; .await
form_state.reset_to_empty(); .context("Failed to initialize app state and form")?;
let filtered_columns = filter_user_columns(initial_columns_from_service);
let mut form_state = FormState::new(
initial_profile.clone(),
initial_table.clone(),
filtered_columns,
);
UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state)
.await
.context(format!(
"Failed to fetch initial count for table {}.{}",
initial_profile, initial_table
))?;
if form_state.total_count > 0 {
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
event_handler.command_message = format!("Error loading initial data: {}", e);
}
} else {
form_state.reset_to_empty();
}
if auto_logged_in {
buffer_state.history = vec![AppView::Form];
buffer_state.active_index = 0;
info!("Initial view set to Form due to auto-login.");
}
// --- FPS Calculation State ---
let mut last_frame_time = Instant::now(); let mut last_frame_time = Instant::now();
let mut current_fps = 0.0; let mut current_fps = 0.0;
let mut needs_redraw = true;
let mut prev_view_profile_name = app_state.current_view_profile_name.clone();
let mut prev_view_table_name = app_state.current_view_table_name.clone();
let mut table_just_switched = false;
loop { loop {
// Determine edit mode based on EventHandler state let position_before_event = form_state.current_position;
let is_edit_mode = event_handler.is_edit_mode; let mut event_processed = false;
if crossterm_event::poll(std::time::Duration::from_millis(1))? {
// --- Synchronize UI View from Active Buffer --- let event = event_reader.read_event().context("Failed to read terminal event")?;
if let Some(active_view) = buffer_state.get_active_view() { event_processed = true;
// Reset all flags first let event_outcome_result = event_handler.handle_event(
app_state.ui.show_intro = false;
app_state.ui.show_login = false;
app_state.ui.show_register = false;
app_state.ui.show_admin = false;
app_state.ui.show_add_table = false;
app_state.ui.show_form = false;
match active_view {
AppView::Intro => app_state.ui.show_intro = true,
AppView::Login => app_state.ui.show_login = true,
AppView::Register => app_state.ui.show_register = true,
AppView::Admin => {
app_state.ui.show_admin = true;
let profile_names = app_state.profile_tree.profiles.iter()
.map(|p| p.name.clone())
.collect();
admin_state.set_profiles(profile_names);
}
AppView::AddTable => app_state.ui.show_add_table = true,
AppView::Form(_) => app_state.ui.show_form = true,
AppView::Scratch => {} // Or show a scratchpad component
}
}
// --- End Synchronization ---
terminal.draw(|f| {
render_ui(
f,
&mut form_state,
&mut auth_state,
&login_state,
&register_state,
&intro_state,
&mut admin_state,
&buffer_state,
&theme,
is_edit_mode,
&event_handler.highlight_state,
app_state.total_count,
app_state.current_position,
&app_state.current_dir,
&event_handler.command_input,
event_handler.command_mode,
&event_handler.command_message,
current_fps,
&app_state,
);
})?;
// --- Cursor Visibility Logic ---
let current_mode = ModeManager::derive_mode(&app_state, &event_handler);
match current_mode {
AppMode::Edit => {
terminal.show_cursor()?;
}
AppMode::Highlight => {
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
terminal.show_cursor()?;
}
AppMode::ReadOnly => {
if !app_state.ui.focus_outside_canvas {
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
} else {
terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?;
}
terminal.show_cursor()?;
}
AppMode::General => {
if app_state.ui.focus_outside_canvas {
terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?;
terminal.show_cursor()?;
} else {
terminal.hide_cursor()?;
}
}
AppMode::Command => {
terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?;
terminal.show_cursor()?;
}
}
// --- End Cursor Visibility Logic ---
let total_count = app_state.total_count; // Keep track for save logic
let mut current_position = app_state.current_position;
let position_before_event = current_position;
let event = event_reader.read_event()?;
// Get the outcome from the event handler
let event_outcome_result = event_handler
.handle_event(
event, event,
&config, &config,
&mut terminal, &mut terminal,
@@ -166,169 +143,417 @@ pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> {
&mut admin_state, &mut admin_state,
&mut buffer_state, &mut buffer_state,
&mut app_state, &mut app_state,
total_count, ).await;
&mut current_position,
)
.await;
// Update position based on handler's modification let mut should_exit = false;
app_state.current_position = current_position; match event_outcome_result {
Ok(outcome) => match outcome {
// --- Centralized Consequence Handling --- EventOutcome::Ok(message) => {
let mut should_exit = false; if !message.is_empty() {
match event_outcome_result { event_handler.command_message = message;
// Handle the Result first }
Ok(outcome) => match outcome { }
// Handle the Ok variant containing EventOutcome EventOutcome::Exit(message) => {
EventOutcome::Ok(message) => {
if !message.is_empty() {
event_handler.command_message = message; event_handler.command_message = message;
should_exit = true;
} }
} EventOutcome::DataSaved(save_outcome, message) => {
EventOutcome::Exit(message) => { event_handler.command_message = message;
event_handler.command_message = message; if let Err(e) = UiService::handle_save_outcome(
should_exit = true; save_outcome,
} &mut grpc_client,
EventOutcome::DataSaved(save_outcome, message) => { &mut app_state,
event_handler.command_message = message; // Show save status &mut form_state,
).await {
event_handler.command_message =
format!("Error handling save outcome: {}", e);
}
}
EventOutcome::ButtonSelected { .. } => {}
EventOutcome::TableSelected { path } => {
let parts: Vec<&str> = path.split('/').collect();
if parts.len() == 2 {
let profile_name = parts[0].to_string();
let table_name = parts[1].to_string();
// *** Delegate outcome handling to UiService *** app_state.set_current_view_table(profile_name, table_name);
if let Err(e) = UiService::handle_save_outcome( buffer_state.update_history(AppView::Form);
save_outcome, event_handler.command_message = format!("Loading table: {}", path);
&mut grpc_client, } else {
&mut app_state, event_handler.command_message = format!("Invalid table path: {}", path);
&mut form_state, }
)
.await
{
// Handle potential errors from the outcome handler itself
event_handler.command_message =
format!("Error handling save outcome: {}", e);
} }
// No count update needed for UpdatedExisting or NoChange },
Err(e) => {
event_handler.command_message = format!("Error: {}", e);
} }
EventOutcome::ButtonSelected { context, index } => { }
event_handler.command_message = "Internal error: Unexpected button state".to_string(); if should_exit {
} return Ok(());
},
Err(e) => {
// Handle errors from handle_event, e.g., log or display
event_handler.command_message = format!("Error: {}", e);
// Decide if the error is fatal, maybe set should_exit = true;
} }
} }
// --- Position Change Handling (after outcome processing) --- match login_result_receiver.try_recv() {
let position_changed = Ok(result) => {
app_state.current_position != position_before_event; // Calculate after potential update if login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) {
// Recalculate total_count *after* potential update needs_redraw = true;
let current_total_count = app_state.total_count; }
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Login result channel disconnected unexpectedly.");
}
}
match register_result_receiver.try_recv() {
Ok(result) => {
if register::handle_registration_result(result, &mut app_state, &mut register_state) {
needs_redraw = true;
}
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Register result channel disconnected unexpectedly.");
}
}
match save_table_result_receiver.try_recv() {
Ok(result) => {
app_state.hide_dialog();
match result {
Ok(ref success_message) => {
app_state.show_dialog(
"Save Successful",
success_message,
vec!["OK".to_string()],
DialogPurpose::SaveTableSuccess,
);
admin_state.add_table_state.has_unsaved_changes = false;
}
Err(e) => {
event_handler.command_message = format!("Save failed: {}", e);
}
}
needs_redraw = true;
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Save table result channel disconnected unexpectedly.");
}
}
if let Some(active_view) = buffer_state.get_active_view() {
app_state.ui.show_intro = false;
app_state.ui.show_login = false;
app_state.ui.show_register = false;
app_state.ui.show_admin = false;
app_state.ui.show_add_table = false;
app_state.ui.show_add_logic = false;
app_state.ui.show_form = false;
match active_view {
AppView::Intro => app_state.ui.show_intro = true,
AppView::Login => app_state.ui.show_login = true,
AppView::Register => app_state.ui.show_register = true,
AppView::Admin => {
info!("Active view is Admin, refreshing profile tree...");
match grpc_client.get_profile_tree().await {
Ok(refreshed_tree) => {
app_state.profile_tree = refreshed_tree;
}
Err(e) => {
error!("Failed to refresh profile tree for Admin panel: {}", e);
event_handler.command_message = format!("Error refreshing admin data: {}", e);
}
}
app_state.ui.show_admin = true;
let profile_names = app_state.profile_tree.profiles.iter()
.map(|p| p.name.clone())
.collect();
admin_state.set_profiles(profile_names);
if admin_state.current_focus == AdminFocus::default() ||
!matches!(admin_state.current_focus,
AdminFocus::InsideProfilesList |
AdminFocus::Tables | AdminFocus::InsideTablesList |
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3) {
admin_state.current_focus = AdminFocus::ProfilesPane;
}
if admin_state.profile_list_state.selected().is_none() && !app_state.profile_tree.profiles.is_empty() {
admin_state.profile_list_state.select(Some(0));
}
}
AppView::AddTable => app_state.ui.show_add_table = true,
AppView::AddLogic => app_state.ui.show_add_logic = true,
AppView::Form => app_state.ui.show_form = true,
AppView::Scratch => {}
}
}
// Handle position changes and update form state (Only when form is shown)
if app_state.ui.show_form { if app_state.ui.show_form {
if position_changed && !event_handler.is_edit_mode { let current_view_profile = app_state.current_view_profile_name.clone();
let current_input = form_state.get_current_input(); let current_view_table = app_state.current_view_table_name.clone();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1 // Limit to last character in readonly mode
} else {
0
};
form_state.current_cursor_pos =
event_handler.ideal_cursor_column.min(max_cursor_pos);
// Ensure position never exceeds total_count + 1 if prev_view_profile_name != current_view_profile
if app_state.current_position > current_total_count + 1 { || prev_view_table_name != current_view_table
app_state.current_position = current_total_count + 1; {
} if let (Some(prof_name), Some(tbl_name)) =
if app_state.current_position > current_total_count { (current_view_profile.as_ref(), current_view_table.as_ref())
// New entry - reset form
form_state.reset_to_empty();
form_state.current_field = 0;
} else if app_state.current_position >= 1
&& app_state.current_position <= current_total_count
{ {
// Existing entry - load data app_state.show_loading_dialog(
let current_position_to_load = app_state.current_position; // Use a copy "Loading Table",
let load_message = UiService::load_adresar_by_position( &format!("Fetching data for {}.{}...", prof_name, tbl_name),
&mut grpc_client, );
&mut app_state, // Pass app_state mutably if needed by the service needs_redraw = true;
&mut form_state,
current_position_to_load,
)
.await?;
let current_input = form_state.get_current_input(); match grpc_client
let max_cursor_pos = if !event_handler.is_edit_mode .get_table_structure(prof_name.clone(), tbl_name.clone())
&& !current_input.is_empty() .await
{ {
current_input.len() - 1 // In readonly mode, limit to last character Ok(structure_response) => {
} else { let new_columns: Vec<String> = structure_response
current_input.len() .columns
}; .iter()
form_state.current_cursor_pos = event_handler .map(|c| c.name.clone())
.ideal_cursor_column .collect();
.min(max_cursor_pos);
// Don't overwrite message from handle_event if load_message is simple success let filtered_columns = filter_user_columns(new_columns);
if !load_message.starts_with("Loaded entry") form_state = FormState::new(
|| event_handler.command_message.is_empty() prof_name.clone(),
{ tbl_name.clone(),
event_handler.command_message = load_message; filtered_columns,
} );
} else {
// Invalid position (e.g., 0) - reset to first entry or new entry mode if let Err(e) = UiService::fetch_and_set_table_count(
app_state.current_position = &mut grpc_client,
1.min(current_total_count + 1); // Go to 1 or new entry if empty &mut form_state,
if app_state.current_position > total_count { )
form_state.reset_to_empty(); .await
form_state.current_field = 0; {
app_state.update_dialog_content(
&format!("Error fetching count: {}", e),
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
} else if form_state.total_count > 0 {
if let Err(e) = UiService::load_table_data_by_position(
&mut grpc_client,
&mut form_state,
)
.await
{
app_state.update_dialog_content(
&format!("Error loading data: {}", e),
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
} else {
app_state.hide_dialog();
}
} else {
form_state.reset_to_empty();
app_state.hide_dialog();
}
prev_view_profile_name = current_view_profile;
prev_view_table_name = current_view_table;
table_just_switched = true;
}
Err(e) => {
app_state.update_dialog_content(
&format!("Error fetching table structure: {}", e),
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
app_state.current_view_profile_name =
prev_view_profile_name.clone();
app_state.current_view_table_name =
prev_view_table_name.clone();
}
} }
} }
} else if !position_changed && !event_handler.is_edit_mode { needs_redraw = true;
// If position didn't change but we are in read-only, just adjust cursor }
let current_input = form_state.get_current_input(); }
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1 if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
if app_state.ui.show_add_logic {
if admin_state.add_logic_state.profile_name == profile_name &&
admin_state.add_logic_state.selected_table_name.as_deref() == Some(table_name.as_str()) {
info!("Fetching table structure for {}.{}", profile_name, table_name);
let fetch_message = UiService::initialize_add_logic_table_data(
&mut grpc_client,
&mut admin_state.add_logic_state,
&app_state.profile_tree,
).await.unwrap_or_else(|e| {
error!("Error initializing add_logic_table_data: {}", e);
format!("Error fetching table structure: {}", e)
});
if !fetch_message.contains("Error") && !fetch_message.contains("Warning") {
info!("{}", fetch_message);
} else {
event_handler.command_message = fetch_message;
}
needs_redraw = true;
} else {
error!(
"Mismatch in pending_table_structure_fetch: app_state wants {}.{}, but add_logic_state is for {}.{:?}",
profile_name, table_name,
admin_state.add_logic_state.profile_name,
admin_state.add_logic_state.selected_table_name
);
}
} else {
warn!(
"Pending table structure fetch for {}.{} but AddLogic view is not active. Fetch ignored.",
profile_name, table_name
);
}
}
if let Some(table_name) = admin_state.add_logic_state.script_editor_awaiting_column_autocomplete.clone() {
if app_state.ui.show_add_logic {
let profile_name = admin_state.add_logic_state.profile_name.clone();
info!("Fetching columns for table selection: {}.{}", profile_name, table_name);
match UiService::fetch_columns_for_table(&mut grpc_client, &profile_name, &table_name).await {
Ok(columns) => {
admin_state.add_logic_state.set_columns_for_table_autocomplete(columns.clone());
info!("Loaded {} columns for table '{}'", columns.len(), table_name);
event_handler.command_message = format!("Columns for '{}' loaded. Select a column.", table_name);
}
Err(e) => {
error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
admin_state.add_logic_state.script_editor_awaiting_column_autocomplete = None;
admin_state.add_logic_state.deactivate_script_editor_autocomplete();
event_handler.command_message = format!("Error loading columns for '{}': {}", table_name, e);
}
}
needs_redraw = true;
}
}
let position_changed = form_state.current_position != position_before_event;
let mut position_logic_needs_redraw = false;
if app_state.ui.show_form && !table_just_switched {
if position_changed && !event_handler.is_edit_mode {
position_logic_needs_redraw = true;
if form_state.current_position > form_state.total_count {
form_state.reset_to_empty();
event_handler.command_message = format!("New entry for {}.{}", form_state.profile_name, form_state.table_name);
} else {
match UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
Ok(load_message) => {
if event_handler.command_message.is_empty() || !load_message.starts_with("Error") {
event_handler.command_message = load_message;
}
}
Err(e) => {
event_handler.command_message = format!("Error loading data: {}", e);
}
}
}
let current_input_after_load_str = form_state.get_current_input();
let current_input_len_after_load = current_input_after_load_str.chars().count();
let max_cursor_pos = if current_input_len_after_load > 0 {
current_input_len_after_load.saturating_sub(1)
} else { } else {
0 0
}; };
form_state.current_cursor_pos = form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
event_handler.ideal_cursor_column.min(max_cursor_pos);
} else if !position_changed && !event_handler.is_edit_mode {
let current_input_str = form_state.get_current_input();
let current_input_len = current_input_str.chars().count();
let max_cursor_pos = if current_input_len > 0 {
current_input_len.saturating_sub(1)
} else {
0
};
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
} }
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
if !event_handler.is_edit_mode { if !event_handler.is_edit_mode {
let current_input = register_state.get_current_input(); let current_input = register_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
current_input.len() - 1
} else {
0
};
register_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos); register_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
} }
} else if app_state.ui.show_login { } else if app_state.ui.show_login {
if !event_handler.is_edit_mode { if !event_handler.is_edit_mode {
let current_input = login_state.get_current_input(); let current_input = login_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
current_input.len() - 1
} else {
0
};
login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos); login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
} }
} }
// Check exit condition *after* processing outcome if position_logic_needs_redraw {
if should_exit { needs_redraw = true;
// terminal.cleanup()?; // Optional: Drop handles this }
return Ok(());
if app_state.ui.dialog.is_loading {
needs_redraw = true;
}
#[cfg(feature = "ui-debug")]
{
app_state.debug_info = format!(
"Redraw -> event: {}, needs_redraw: {}, pos_changed: {}",
event_processed, needs_redraw, position_changed
);
}
if event_processed || needs_redraw || position_changed {
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
match current_mode {
AppMode::Edit => { terminal.show_cursor()?; }
AppMode::Highlight => { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.show_cursor()?; }
AppMode::ReadOnly => {
if !app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; }
else { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; }
terminal.show_cursor().context("Failed to show cursor in ReadOnly mode")?;
}
AppMode::General => {
if app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor()?; }
else { terminal.hide_cursor()?; }
}
AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; }
}
terminal.draw(|f| {
render_ui(
f,
&mut form_state,
&mut auth_state,
&login_state,
&register_state,
&intro_state,
&mut admin_state,
&buffer_state,
&theme,
event_handler.is_edit_mode,
&event_handler.highlight_state,
&event_handler.command_input,
event_handler.command_mode,
&event_handler.command_message,
&event_handler.navigation_state,
&app_state.current_dir,
current_fps,
&app_state,
);
}).context("Terminal draw call failed")?;
needs_redraw = false;
} }
// --- FPS Calculation ---
let now = Instant::now(); let now = Instant::now();
let frame_duration = now.duration_since(last_frame_time); let frame_duration = now.duration_since(last_frame_time);
last_frame_time = now; last_frame_time = now;
if frame_duration.as_secs_f64() > 1e-6 { if frame_duration.as_secs_f64() > 1e-6 {
current_fps = 1.0 / frame_duration.as_secs_f64(); current_fps = 1.0 / frame_duration.as_secs_f64();
} }
table_just_switched = false;
} }
} }

View File

@@ -0,0 +1,14 @@
// src/utils/columns.rs
pub fn is_system_column(column_name: &str) -> bool {
match column_name {
"id" | "deleted" | "created_at" => true,
name if name.ends_with("_id") => true,
_ => false,
}
}
pub fn filter_user_columns(all_columns: Vec<String>) -> Vec<String> {
all_columns.into_iter()
.filter(|col| !is_system_column(col))
.collect()
}

4
client/src/utils/mod.rs Normal file
View File

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

View File

@@ -5,9 +5,12 @@ edition.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
tonic = "0.12.3" tonic = "0.13.0"
prost = "0.13.5" prost = "0.13.5"
serde = { version = "1.0.218", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
# Search
tantivy = { workspace = true }
[build-dependencies] [build-dependencies]
tonic-build = "0.12.3" tonic-build = "0.13.0"

View File

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

20
common/proto/search.proto Normal file
View File

@@ -0,0 +1,20 @@
// In common/proto/search.proto
syntax = "proto3";
package multieko2.search;
service Searcher {
rpc SearchTable(SearchRequest) returns (SearchResponse);
}
message SearchRequest {
string table_name = 1;
string query = 2;
}
message SearchResponse {
message Hit {
int64 id = 1; // PostgreSQL row ID
float score = 2;
string content_json = 3;
}
repeated Hit hits = 1;
}

View File

@@ -21,7 +21,6 @@ message PostTableDefinitionRequest {
repeated ColumnDefinition columns = 3; repeated ColumnDefinition columns = 3;
repeated string indexes = 4; repeated string indexes = 4;
string profile_name = 5; string profile_name = 5;
optional string linked_table_name = 6;
} }
message ColumnDefinition { message ColumnDefinition {
@@ -36,8 +35,9 @@ message TableDefinitionResponse {
message ProfileTreeResponse { message ProfileTreeResponse {
message Table { message Table {
string name = 1; int64 id = 1;
repeated string depends_on = 2; string name = 2;
repeated string depends_on = 3;
} }
message Profile { message Profile {

View File

@@ -4,18 +4,22 @@ package multieko2.table_structure;
import "common.proto"; import "common.proto";
message GetTableStructureRequest {
string profile_name = 1; // e.g., "default"
string table_name = 2; // e.g., "2025_adresar6"
}
message TableStructureResponse { message TableStructureResponse {
repeated TableColumn columns = 1; repeated TableColumn columns = 1;
} }
message TableColumn { message TableColumn {
string name = 1; string name = 1;
string data_type = 2; string data_type = 2; // e.g., "TEXT", "BIGINT", "VARCHAR(255)", "TIMESTAMPTZ"
bool is_nullable = 3; bool is_nullable = 3;
bool is_primary_key = 4; bool is_primary_key = 4;
} }
service TableStructureService { service TableStructureService {
rpc GetAdresarTableStructure (common.Empty) returns (TableStructureResponse); rpc GetTableStructure (GetTableStructureRequest) returns (TableStructureResponse);
rpc GetUctovnictvoTableStructure (common.Empty) returns (TableStructureResponse);
} }

View File

@@ -1,4 +1,7 @@
// common/src/lib.rs // common/src/lib.rs
pub mod search;
pub mod proto { pub mod proto {
pub mod multieko2 { pub mod multieko2 {
pub mod adresar { pub mod adresar {
@@ -25,6 +28,9 @@ pub mod proto {
pub mod table_script { pub mod table_script {
include!("proto/multieko2.table_script.rs"); include!("proto/multieko2.table_script.rs");
} }
pub mod search {
include!("proto/multieko2.search.rs");
}
pub const FILE_DESCRIPTOR_SET: &[u8] = pub const FILE_DESCRIPTOR_SET: &[u8] =
include_bytes!("proto/descriptor.bin"); include_bytes!("proto/descriptor.bin");
} }

Binary file not shown.

View File

@@ -145,7 +145,7 @@ pub mod adresar_client {
} }
impl<T> AdresarClient<T> impl<T> AdresarClient<T>
where where
T: tonic::client::GrpcService<tonic::body::BoxBody>, T: tonic::client::GrpcService<tonic::body::Body>,
T::Error: Into<StdError>, T::Error: Into<StdError>,
T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static, T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static,
<T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send, <T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send,
@@ -166,13 +166,13 @@ pub mod adresar_client {
F: tonic::service::Interceptor, F: tonic::service::Interceptor,
T::ResponseBody: Default, T::ResponseBody: Default,
T: tonic::codegen::Service< T: tonic::codegen::Service<
http::Request<tonic::body::BoxBody>, http::Request<tonic::body::Body>,
Response = http::Response< Response = http::Response<
<T as tonic::client::GrpcService<tonic::body::BoxBody>>::ResponseBody, <T as tonic::client::GrpcService<tonic::body::Body>>::ResponseBody,
>, >,
>, >,
<T as tonic::codegen::Service< <T as tonic::codegen::Service<
http::Request<tonic::body::BoxBody>, http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync, >>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{ {
AdresarClient::new(InterceptedService::new(inner, interceptor)) AdresarClient::new(InterceptedService::new(inner, interceptor))
@@ -465,7 +465,7 @@ pub mod adresar_server {
B: Body + std::marker::Send + 'static, B: Body + std::marker::Send + 'static,
B::Error: Into<StdError> + std::marker::Send + 'static, B::Error: Into<StdError> + std::marker::Send + 'static,
{ {
type Response = http::Response<tonic::body::BoxBody>; type Response = http::Response<tonic::body::Body>;
type Error = std::convert::Infallible; type Error = std::convert::Infallible;
type Future = BoxFuture<Self::Response, Self::Error>; type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready( fn poll_ready(
@@ -751,7 +751,9 @@ pub mod adresar_server {
} }
_ => { _ => {
Box::pin(async move { Box::pin(async move {
let mut response = http::Response::new(empty_body()); let mut response = http::Response::new(
tonic::body::Body::default(),
);
let headers = response.headers_mut(); let headers = response.headers_mut();
headers headers
.insert( .insert(

View File

@@ -83,7 +83,7 @@ pub mod auth_service_client {
} }
impl<T> AuthServiceClient<T> impl<T> AuthServiceClient<T>
where where
T: tonic::client::GrpcService<tonic::body::BoxBody>, T: tonic::client::GrpcService<tonic::body::Body>,
T::Error: Into<StdError>, T::Error: Into<StdError>,
T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static, T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static,
<T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send, <T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send,
@@ -104,13 +104,13 @@ pub mod auth_service_client {
F: tonic::service::Interceptor, F: tonic::service::Interceptor,
T::ResponseBody: Default, T::ResponseBody: Default,
T: tonic::codegen::Service< T: tonic::codegen::Service<
http::Request<tonic::body::BoxBody>, http::Request<tonic::body::Body>,
Response = http::Response< Response = http::Response<
<T as tonic::client::GrpcService<tonic::body::BoxBody>>::ResponseBody, <T as tonic::client::GrpcService<tonic::body::Body>>::ResponseBody,
>, >,
>, >,
<T as tonic::codegen::Service< <T as tonic::codegen::Service<
http::Request<tonic::body::BoxBody>, http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync, >>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{ {
AuthServiceClient::new(InterceptedService::new(inner, interceptor)) AuthServiceClient::new(InterceptedService::new(inner, interceptor))
@@ -277,7 +277,7 @@ pub mod auth_service_server {
B: Body + std::marker::Send + 'static, B: Body + std::marker::Send + 'static,
B::Error: Into<StdError> + std::marker::Send + 'static, B::Error: Into<StdError> + std::marker::Send + 'static,
{ {
type Response = http::Response<tonic::body::BoxBody>; type Response = http::Response<tonic::body::Body>;
type Error = std::convert::Infallible; type Error = std::convert::Infallible;
type Future = BoxFuture<Self::Response, Self::Error>; type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready( fn poll_ready(
@@ -378,7 +378,9 @@ pub mod auth_service_server {
} }
_ => { _ => {
Box::pin(async move { Box::pin(async move {
let mut response = http::Response::new(empty_body()); let mut response = http::Response::new(
tonic::body::Body::default(),
);
let headers = response.headers_mut(); let headers = response.headers_mut();
headers headers
.insert( .insert(

View File

@@ -0,0 +1,317 @@
// This file is @generated by prost-build.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SearchRequest {
#[prost(string, tag = "1")]
pub table_name: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub query: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SearchResponse {
#[prost(message, repeated, tag = "1")]
pub hits: ::prost::alloc::vec::Vec<search_response::Hit>,
}
/// Nested message and enum types in `SearchResponse`.
pub mod search_response {
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Hit {
/// PostgreSQL row ID
#[prost(int64, tag = "1")]
pub id: i64,
#[prost(float, tag = "2")]
pub score: f32,
#[prost(string, tag = "3")]
pub content_json: ::prost::alloc::string::String,
}
}
/// Generated client implementations.
pub mod searcher_client {
#![allow(
unused_variables,
dead_code,
missing_docs,
clippy::wildcard_imports,
clippy::let_unit_value,
)]
use tonic::codegen::*;
use tonic::codegen::http::Uri;
#[derive(Debug, Clone)]
pub struct SearcherClient<T> {
inner: tonic::client::Grpc<T>,
}
impl SearcherClient<tonic::transport::Channel> {
/// Attempt to create a new client by connecting to a given endpoint.
pub async fn connect<D>(dst: D) -> Result<Self, tonic::transport::Error>
where
D: TryInto<tonic::transport::Endpoint>,
D::Error: Into<StdError>,
{
let conn = tonic::transport::Endpoint::new(dst)?.connect().await?;
Ok(Self::new(conn))
}
}
impl<T> SearcherClient<T>
where
T: tonic::client::GrpcService<tonic::body::Body>,
T::Error: Into<StdError>,
T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static,
<T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send,
{
pub fn new(inner: T) -> Self {
let inner = tonic::client::Grpc::new(inner);
Self { inner }
}
pub fn with_origin(inner: T, origin: Uri) -> Self {
let inner = tonic::client::Grpc::with_origin(inner, origin);
Self { inner }
}
pub fn with_interceptor<F>(
inner: T,
interceptor: F,
) -> SearcherClient<InterceptedService<T, F>>
where
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::Body>,
Response = http::Response<
<T as tonic::client::GrpcService<tonic::body::Body>>::ResponseBody,
>,
>,
<T as tonic::codegen::Service<
http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{
SearcherClient::new(InterceptedService::new(inner, interceptor))
}
/// Compress requests with the given encoding.
///
/// This requires the server to support it otherwise it might respond with an
/// error.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.send_compressed(encoding);
self
}
/// Enable decompressing responses.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.accept_compressed(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_decoding_message_size(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_encoding_message_size(limit);
self
}
pub async fn search_table(
&mut self,
request: impl tonic::IntoRequest<super::SearchRequest>,
) -> std::result::Result<tonic::Response<super::SearchResponse>, tonic::Status> {
self.inner
.ready()
.await
.map_err(|e| {
tonic::Status::unknown(
format!("Service was not ready: {}", e.into()),
)
})?;
let codec = tonic::codec::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static(
"/multieko2.search.Searcher/SearchTable",
);
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("multieko2.search.Searcher", "SearchTable"));
self.inner.unary(req, path, codec).await
}
}
}
/// Generated server implementations.
pub mod searcher_server {
#![allow(
unused_variables,
dead_code,
missing_docs,
clippy::wildcard_imports,
clippy::let_unit_value,
)]
use tonic::codegen::*;
/// Generated trait containing gRPC methods that should be implemented for use with SearcherServer.
#[async_trait]
pub trait Searcher: std::marker::Send + std::marker::Sync + 'static {
async fn search_table(
&self,
request: tonic::Request<super::SearchRequest>,
) -> std::result::Result<tonic::Response<super::SearchResponse>, tonic::Status>;
}
#[derive(Debug)]
pub struct SearcherServer<T> {
inner: Arc<T>,
accept_compression_encodings: EnabledCompressionEncodings,
send_compression_encodings: EnabledCompressionEncodings,
max_decoding_message_size: Option<usize>,
max_encoding_message_size: Option<usize>,
}
impl<T> SearcherServer<T> {
pub fn new(inner: T) -> Self {
Self::from_arc(Arc::new(inner))
}
pub fn from_arc(inner: Arc<T>) -> Self {
Self {
inner,
accept_compression_encodings: Default::default(),
send_compression_encodings: Default::default(),
max_decoding_message_size: None,
max_encoding_message_size: None,
}
}
pub fn with_interceptor<F>(
inner: T,
interceptor: F,
) -> InterceptedService<Self, F>
where
F: tonic::service::Interceptor,
{
InterceptedService::new(Self::new(inner), interceptor)
}
/// Enable decompressing requests with the given encoding.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.accept_compression_encodings.enable(encoding);
self
}
/// Compress responses with the given encoding, if the client supports it.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.send_compression_encodings.enable(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.max_decoding_message_size = Some(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.max_encoding_message_size = Some(limit);
self
}
}
impl<T, B> tonic::codegen::Service<http::Request<B>> for SearcherServer<T>
where
T: Searcher,
B: Body + std::marker::Send + 'static,
B::Error: Into<StdError> + std::marker::Send + 'static,
{
type Response = http::Response<tonic::body::Body>;
type Error = std::convert::Infallible;
type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(
&mut self,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: http::Request<B>) -> Self::Future {
match req.uri().path() {
"/multieko2.search.Searcher/SearchTable" => {
#[allow(non_camel_case_types)]
struct SearchTableSvc<T: Searcher>(pub Arc<T>);
impl<T: Searcher> tonic::server::UnaryService<super::SearchRequest>
for SearchTableSvc<T> {
type Response = super::SearchResponse;
type Future = BoxFuture<
tonic::Response<Self::Response>,
tonic::Status,
>;
fn call(
&mut self,
request: tonic::Request<super::SearchRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move {
<T as Searcher>::search_table(&inner, request).await
};
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = SearchTableSvc(inner);
let codec = tonic::codec::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(
accept_compression_encodings,
send_compression_encodings,
)
.apply_max_message_size_config(
max_decoding_message_size,
max_encoding_message_size,
);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
_ => {
Box::pin(async move {
let mut response = http::Response::new(
tonic::body::Body::default(),
);
let headers = response.headers_mut();
headers
.insert(
tonic::Status::GRPC_STATUS,
(tonic::Code::Unimplemented as i32).into(),
);
headers
.insert(
http::header::CONTENT_TYPE,
tonic::metadata::GRPC_CONTENT_TYPE,
);
Ok(response)
})
}
}
}
}
impl<T> Clone for SearcherServer<T> {
fn clone(&self) -> Self {
let inner = self.inner.clone();
Self {
inner,
accept_compression_encodings: self.accept_compression_encodings,
send_compression_encodings: self.send_compression_encodings,
max_decoding_message_size: self.max_decoding_message_size,
max_encoding_message_size: self.max_encoding_message_size,
}
}
}
/// Generated gRPC service name
pub const SERVICE_NAME: &str = "multieko2.search.Searcher";
impl<T> tonic::server::NamedService for SearcherServer<T> {
const NAME: &'static str = SERVICE_NAME;
}
}

View File

@@ -18,8 +18,6 @@ pub struct PostTableDefinitionRequest {
pub indexes: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, pub indexes: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
#[prost(string, tag = "5")] #[prost(string, tag = "5")]
pub profile_name: ::prost::alloc::string::String, pub profile_name: ::prost::alloc::string::String,
#[prost(string, optional, tag = "6")]
pub linked_table_name: ::core::option::Option<::prost::alloc::string::String>,
} }
#[derive(Clone, PartialEq, ::prost::Message)] #[derive(Clone, PartialEq, ::prost::Message)]
pub struct ColumnDefinition { pub struct ColumnDefinition {
@@ -44,9 +42,11 @@ pub struct ProfileTreeResponse {
pub mod profile_tree_response { pub mod profile_tree_response {
#[derive(Clone, PartialEq, ::prost::Message)] #[derive(Clone, PartialEq, ::prost::Message)]
pub struct Table { pub struct Table {
#[prost(string, tag = "1")] #[prost(int64, tag = "1")]
pub id: i64,
#[prost(string, tag = "2")]
pub name: ::prost::alloc::string::String, pub name: ::prost::alloc::string::String,
#[prost(string, repeated, tag = "2")] #[prost(string, repeated, tag = "3")]
pub depends_on: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, pub depends_on: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
} }
#[derive(Clone, PartialEq, ::prost::Message)] #[derive(Clone, PartialEq, ::prost::Message)]
@@ -99,7 +99,7 @@ pub mod table_definition_client {
} }
impl<T> TableDefinitionClient<T> impl<T> TableDefinitionClient<T>
where where
T: tonic::client::GrpcService<tonic::body::BoxBody>, T: tonic::client::GrpcService<tonic::body::Body>,
T::Error: Into<StdError>, T::Error: Into<StdError>,
T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static, T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static,
<T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send, <T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send,
@@ -120,13 +120,13 @@ pub mod table_definition_client {
F: tonic::service::Interceptor, F: tonic::service::Interceptor,
T::ResponseBody: Default, T::ResponseBody: Default,
T: tonic::codegen::Service< T: tonic::codegen::Service<
http::Request<tonic::body::BoxBody>, http::Request<tonic::body::Body>,
Response = http::Response< Response = http::Response<
<T as tonic::client::GrpcService<tonic::body::BoxBody>>::ResponseBody, <T as tonic::client::GrpcService<tonic::body::Body>>::ResponseBody,
>, >,
>, >,
<T as tonic::codegen::Service< <T as tonic::codegen::Service<
http::Request<tonic::body::BoxBody>, http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync, >>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{ {
TableDefinitionClient::new(InterceptedService::new(inner, interceptor)) TableDefinitionClient::new(InterceptedService::new(inner, interceptor))
@@ -351,7 +351,7 @@ pub mod table_definition_server {
B: Body + std::marker::Send + 'static, B: Body + std::marker::Send + 'static,
B::Error: Into<StdError> + std::marker::Send + 'static, B::Error: Into<StdError> + std::marker::Send + 'static,
{ {
type Response = http::Response<tonic::body::BoxBody>; type Response = http::Response<tonic::body::Body>;
type Error = std::convert::Infallible; type Error = std::convert::Infallible;
type Future = BoxFuture<Self::Response, Self::Error>; type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready( fn poll_ready(
@@ -504,7 +504,9 @@ pub mod table_definition_server {
} }
_ => { _ => {
Box::pin(async move { Box::pin(async move {
let mut response = http::Response::new(empty_body()); let mut response = http::Response::new(
tonic::body::Body::default(),
);
let headers = response.headers_mut(); let headers = response.headers_mut();
headers headers
.insert( .insert(

View File

@@ -45,7 +45,7 @@ pub mod table_script_client {
} }
impl<T> TableScriptClient<T> impl<T> TableScriptClient<T>
where where
T: tonic::client::GrpcService<tonic::body::BoxBody>, T: tonic::client::GrpcService<tonic::body::Body>,
T::Error: Into<StdError>, T::Error: Into<StdError>,
T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static, T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static,
<T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send, <T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send,
@@ -66,13 +66,13 @@ pub mod table_script_client {
F: tonic::service::Interceptor, F: tonic::service::Interceptor,
T::ResponseBody: Default, T::ResponseBody: Default,
T: tonic::codegen::Service< T: tonic::codegen::Service<
http::Request<tonic::body::BoxBody>, http::Request<tonic::body::Body>,
Response = http::Response< Response = http::Response<
<T as tonic::client::GrpcService<tonic::body::BoxBody>>::ResponseBody, <T as tonic::client::GrpcService<tonic::body::Body>>::ResponseBody,
>, >,
>, >,
<T as tonic::codegen::Service< <T as tonic::codegen::Service<
http::Request<tonic::body::BoxBody>, http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync, >>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{ {
TableScriptClient::new(InterceptedService::new(inner, interceptor)) TableScriptClient::new(InterceptedService::new(inner, interceptor))
@@ -225,7 +225,7 @@ pub mod table_script_server {
B: Body + std::marker::Send + 'static, B: Body + std::marker::Send + 'static,
B::Error: Into<StdError> + std::marker::Send + 'static, B::Error: Into<StdError> + std::marker::Send + 'static,
{ {
type Response = http::Response<tonic::body::BoxBody>; type Response = http::Response<tonic::body::Body>;
type Error = std::convert::Infallible; type Error = std::convert::Infallible;
type Future = BoxFuture<Self::Response, Self::Error>; type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready( fn poll_ready(
@@ -283,7 +283,9 @@ pub mod table_script_server {
} }
_ => { _ => {
Box::pin(async move { Box::pin(async move {
let mut response = http::Response::new(empty_body()); let mut response = http::Response::new(
tonic::body::Body::default(),
);
let headers = response.headers_mut(); let headers = response.headers_mut();
headers headers
.insert( .insert(

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