Compare commits

..

117 Commits

Author SHA1 Message Date
filipriec
82e96f7b86 vim or default mode workin properly now 2025-05-24 19:25:35 +02:00
filipriec
7229e2abbd weird highlight is gone 2025-05-24 18:53:06 +02:00
filipriec
4e35043da0 vim keybinings are now working properly well 2025-05-24 18:41:41 +02:00
filipriec
56fe1c2ccc text area working now perfectly well 2025-05-24 16:34:59 +02:00
filipriec
a874edf2a1 text area on focus is now big 2025-05-24 14:24:19 +02:00
filipriec
9d55ec3e43 h and l movements are now working 2025-05-23 16:59:12 +02:00
filipriec
05580ac978 better and better 2025-05-23 15:40:16 +02:00
filipriec
667eb4809d compiled but the readonly and edit mode is not working 2025-05-23 15:22:21 +02:00
filipriec
58fdaa8298 add logic, not working tho 2025-05-23 13:34:49 +02:00
filipriec
5478a2ac27 server changes for the ID in the tree 2025-05-23 13:34:39 +02:00
filipriec
ad37990da9 server is being licensed as AGPL instead of GPL 2025-05-21 10:29:22 +02:00
filipriec
66824030f2 refresh of the admin panel after adding the table 2025-04-23 12:31:47 +02:00
filipriec
90ca8cf97c working dialog is now at the correct place 2025-04-23 12:13:56 +02:00
filipriec
3c8ea28da1 dialog on add table save working 2025-04-23 12:04:54 +02:00
filipriec
5c352eb863 tracing running only when enabled 2025-04-23 11:29:00 +02:00
filipriec
8c312bc163 tracing on add_table 2025-04-23 11:02:17 +02:00
filipriec
6fa8b06063 grpc post request to the table definition from add table, not working, major bug, needs debugging to make it work 2025-04-22 23:22:59 +02:00
filipriec
2992f122bc PROTOBUF CHANGED I HOPE IT WAS ONLY A MISTAKE OTHERWISE IM SCREWING SOMETHING UP AND I HAVE NO CLUE WHAT 2025-04-22 22:38:52 +02:00
filipriec
e507993065 ui imporvements 2025-04-22 21:21:49 +02:00
filipriec
6f22aad6f4 working perfectly well admin panel for admin 2025-04-22 18:17:41 +02:00
filipriec
097264040f admin_panel for admins have now some adjustements 2025-04-22 18:03:22 +02:00
filipriec
2c03ee6af0 add_table ready to be used as a post request, only small changes are needed 2025-04-22 17:36:00 +02:00
filipriec
ec596b2ada indexing now works amazingly well 2025-04-22 16:03:16 +02:00
filipriec
b01ba0b2d9 cargo fix on the server 2025-04-19 19:00:14 +02:00
filipriec
f74a6ef093 upgraded to tonic 0.13 2025-04-19 18:55:26 +02:00
filipriec
ee687fafbe upgrading and updating my repo 2025-04-19 18:36:26 +02:00
filipriec
60ba17cfea TCP connection creation overhead fixed by cloning once created TCP connection. Huge performance gain on login and register. Utilizing gRPC 2025-04-19 15:54:58 +02:00
filipriec
8b3aa5891e sidebar redesigned correctly and properly 2025-04-19 00:53:57 +02:00
filipriec
3ff9399b81 cargo fix 2025-04-18 23:27:14 +02:00
filipriec
d18f7862ab login refactored 2025-04-18 23:26:48 +02:00
filipriec
dc6c1ce43c refactor happend and its perfectly fine 2025-04-18 23:16:22 +02:00
filipriec
8d1adccec6 async registration working 2025-04-18 22:36:30 +02:00
filipriec
420ce71fb2 asnyc gRPC call that needs nonblocking waiting operation with redraws docs describing setup on what to do 2025-04-18 22:15:08 +02:00
filipriec
8e5a269ff0 finally working amazingly well 2025-04-18 22:09:07 +02:00
filipriec
f357d6f0ee working blocked constant redraws 2025-04-18 21:43:54 +02:00
filipriec
a0467d17a8 cleanup 2025-04-18 21:11:49 +02:00
filipriec
ef3ecfc73f we compiled 2025-04-18 21:04:36 +02:00
filipriec
d3fcb23e22 fixing, nothing works lmao 2025-04-18 20:48:39 +02:00
filipriec
5a029283a1 anyhow used 2025-04-18 19:04:05 +02:00
filipriec
09ccad2bd4 time for changing it all 2025-04-18 18:11:12 +02:00
filipriec
bdcc10bd40 auth verification of emptiness 2025-04-18 17:08:38 +02:00
filipriec
2a1fafc3f9 warnings fixed 2025-04-18 16:17:02 +02:00
filipriec
6010b9a0af fixing warnings 2025-04-18 15:50:47 +02:00
filipriec
11e8f87fe6 cargo fix 2025-04-18 14:59:34 +02:00
filipriec
14b81cba19 fixed, removed log library 2025-04-18 14:58:05 +02:00
filipriec
2b37de3b4d login waiting dialog works, THIS COMMIT NEEDS TO BE REFACTORED 2025-04-18 14:44:04 +02:00
filipriec
73d9a6367c canvas edit mode movement fixed 2025-04-18 13:10:39 +02:00
filipriec
c90233b56f dialog in add_table is now working properly well 2025-04-18 12:29:29 +02:00
filipriec
39dcf38462 add_table dialog is now working properly well 2025-04-18 12:20:08 +02:00
filipriec
efa27cd2dd highlight restored 2025-04-18 11:35:15 +02:00
filipriec
ca231964f2 fullscreen on enter for add_table 2025-04-18 11:31:50 +02:00
filipriec
2bb83cb990 gui changes 2025-04-18 11:29:11 +02:00
filipriec
305bcfcf62 deletion of the selected works 2025-04-18 11:15:15 +02:00
filipriec
92a9011f27 buttons are only border and text colors now in add_table 2025-04-18 11:04:13 +02:00
filipriec
e64cebdfc2 deselect have no highlight now 2025-04-18 10:52:18 +02:00
filipriec
0db426d278 h l movement in add_table fixed for now 2025-04-18 10:48:52 +02:00
filipriec
6bfef1c7a0 exit in the general mode is on esc and select is not escaping in the add_table anymore 2025-04-18 10:37:52 +02:00
filipriec
f50fe788cb scrolling in the add table doesnt highlight first item anymore 2025-04-18 09:27:28 +02:00
filipriec
4db78ecf1b working properly well to distinguish enter in the edit mode now 2025-04-18 00:15:34 +02:00
filipriec
f22dd7749f proper scroll behaviour on the page now 2025-04-17 23:37:58 +02:00
filipriec
75af0c3be1 the profile name is now passed from admin panel to the add table page 2025-04-17 21:59:19 +02:00
filipriec
6f36b84f85 delete selected button now in the add table page working 2025-04-17 21:21:37 +02:00
filipriec
e723198b72 best ever, working as intended 2025-04-17 21:07:07 +02:00
filipriec
8f74febff1 combined, full width need a table name that is missing now 2025-04-17 20:41:48 +02:00
filipriec
d9bd6f8e1d working, but full width is not working, lets combine it with the full width now 2025-04-17 20:17:21 +02:00
filipriec
bf55417901 professional layout working 2025-04-17 19:50:48 +02:00
filipriec
9511970a1a removed placeholder 2025-04-17 19:12:28 +02:00
filipriec
5c8557b369 adding stuff to the column 2025-04-17 19:06:54 +02:00
filipriec
5e47c53fcf canvas required fields 2025-04-17 18:28:06 +02:00
filipriec
f7493a8bc4 canvas properly updating table name 2025-04-17 18:17:01 +02:00
filipriec
4f39b93edd logic of the add button, needs redesign 2025-04-17 16:48:03 +02:00
filipriec
ff8b4eb0f6 canvas now working properly well 2025-04-17 15:40:07 +02:00
filipriec
e921862a7f compiled, still not working for canvas 2025-04-17 14:41:09 +02:00
filipriec
4d7177f15a mistake fixed 2025-04-17 14:21:39 +02:00
filipriec
c5d7f56399 readonly and edit functionality to add table 2025-04-17 14:14:36 +02:00
filipriec
57f789290d needs improvements but at least it looks like it exists 2025-04-17 11:53:31 +02:00
filipriec
f34317e504 from template to the working page 2025-04-17 11:11:33 +02:00
filipriec
8159a84447 movement 2025-04-16 23:31:28 +02:00
filipriec
f4db0e384c add table page1 2025-04-16 22:23:30 +02:00
filipriec
69953401b1 NEW PAGE ADD TABLE 2025-04-16 22:07:07 +02:00
filipriec
93a3c246c6 buttons are now added in the admin panel 2025-04-16 21:43:21 +02:00
filipriec
6505e18b0b working admin panel, needs to do buttons for navigation next 2025-04-16 21:22:34 +02:00
filipriec
51ab73014f removing debug statement 2025-04-16 19:19:15 +02:00
filipriec
05d9e6e46b working selection in the admin panel for the admin perfectly well 2025-04-16 19:15:05 +02:00
filipriec
8ea9965ee3 temp debug 2025-04-16 19:10:02 +02:00
filipriec
486df65aa3 better admin panel, main problem not fixed, needs fix now 2025-04-16 18:53:09 +02:00
filipriec
8044696d7c needed fix done 2025-04-16 18:33:00 +02:00
filipriec
04a7d86636 better movement 2025-04-16 16:30:11 +02:00
filipriec
d0e2f31ce8 admin panel improvements 2025-04-16 14:11:52 +02:00
filipriec
50fcb09758 admin panel admin width fixed 2025-04-16 13:58:34 +02:00
filipriec
6d3c09d57a CRUCIAL bug fixed in the admin panel non admin user 2025-04-16 13:43:44 +02:00
filipriec
cc994fb940 admin panel for admin 2025-04-16 13:21:59 +02:00
filipriec
eee12513dd admin panel from scratch 2025-04-16 12:56:57 +02:00
filipriec
055b6a0a43 admin panel bombarded like never before 2025-04-16 12:47:33 +02:00
filipriec
26b899df16 fixed highlight logic 2025-04-16 09:48:39 +02:00
filipriec
afc8e1a1e5 highlight mode to full line highlightmode 2025-04-16 09:08:40 +02:00
filipriec
b6c4d3308d its now using enum fully for the highlight mode 2025-04-16 00:11:41 +02:00
filipriec
af4567aa3d highlight is now working properly well, can keep on going 2025-04-15 23:46:57 +02:00
filipriec
415bc2044d highlight through many lines working 2025-04-15 23:07:55 +02:00
filipriec
91ad2b0caf FIXED CRUCIAL BUG of two same shortcuts defined in the config 2025-04-15 22:28:12 +02:00
filipriec
bc6471fa54 misname fixed, highlight kinda working 2025-04-15 21:38:32 +02:00
filipriec
0704668d8d mistakes in config.toml fixed, needs more fixes before the real implementation 2025-04-15 21:22:13 +02:00
filipriec
2e9f8815d2 HIGHLIGHT MODE 2025-04-15 21:17:04 +02:00
filipriec
f4689125e0 minor changes 2025-04-15 20:28:31 +02:00
filipriec
bbba67a253 buffer for form fix performed 2025-04-15 20:19:30 +02:00
filipriec
800b857e53 buffers are in the layers now 2025-04-15 19:42:12 +02:00
filipriec
921059bed8 buffer logic going hard, we are killing buffers from login and register now 2025-04-15 18:56:11 +02:00
filipriec
e8b585dc07 killing of the buffers now works 2025-04-15 18:43:36 +02:00
filipriec
afce27184a better defaults 2025-04-15 18:37:27 +02:00
filipriec
8d41beef0b killing of the buffer working 2025-04-15 18:33:42 +02:00
filipriec
5e482cd77b buffer close, not kill implemented yet 2025-04-15 18:29:59 +02:00
filipriec
09068fd4e5 FPS counter added 2025-04-15 18:11:26 +02:00
filipriec
d8c2b9089b fixes are now in place properly well 2025-04-15 17:52:58 +02:00
filipriec
bb577bc276 now buffer handling is global but not allowed from edit mode 2025-04-15 16:23:26 +02:00
filipriec
6267e3d593 working switching of the buffers properly well now 2025-04-15 16:03:14 +02:00
filipriec
c091a39802 compiled 2025-04-15 14:12:42 +02:00
filipriec
f94006dd08 buffer its independent, needs fixes 2025-04-15 13:57:17 +02:00
99 changed files with 7345 additions and 1251 deletions

616
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,5 +1,7 @@
# Hey
This is only work in progress, until release 1.0.0 this is for development use cases only.
I run development like this:
Server:
@@ -12,3 +14,7 @@ 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'
```

View File

@@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
anyhow = "1.0.98"
async-trait = "0.1.88"
common = { path = "../common" }
@@ -13,11 +14,14 @@ dirs = "6.0.0"
dotenvy = "0.15.7"
lazy_static = "1.5.0"
prost = "0.13.5"
ratatui = "0.29.0"
serde = { version = "1.0.218", features = ["derive"] }
tokio = { version = "1.43.0", features = ["full", "macros"] }
ratatui = { version = "0.29.0", features = ["crossterm"] }
serde = { version = "1.0.219", features = ["derive"] }
time = "0.3.41"
tokio = { version = "1.44.2", features = ["full", "macros"] }
toml = "0.8.20"
tonic = "0.12.3"
tonic = "0.13.0"
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
unicode-segmentation = "1.12.0"
unicode-width = "0.2.0"

View File

@@ -2,6 +2,8 @@
[keybindings]
enter_command_mode = [":", "ctrl+;"]
next_buffer = ["ctrl+l"]
previous_buffer = ["ctrl+h"]
[keybindings.general]
move_up = ["k", "Up"]
@@ -13,6 +15,7 @@ toggle_sidebar = ["ctrl+t"]
toggle_buffer_list = ["ctrl+b"]
next_field = ["Tab"]
prev_field = ["Shift+Tab"]
exit_table_scroll = ["esc"]
[keybindings.common]
save = ["ctrl+s"]
@@ -47,19 +50,28 @@ move_line_start = ["0"]
move_line_end = ["$"]
move_first_line = ["gg"]
move_last_line = ["x"]
enter_highlight_mode = ["v"]
enter_highlight_mode_linewise = ["ctrl+v"]
[keybindings.highlight]
exit_highlight_mode = ["esc"]
enter_highlight_mode_linewise = ["ctrl+v"]
[keybindings.edit]
exit_edit_mode = ["esc","ctrl+e"]
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
# exit_edit_mode = ["esc","ctrl+e"]
# exit_suggestion_mode = ["esc"]
# select_suggestion = ["enter"]
# next_field = ["enter"]
enter_decider = ["enter"]
prev_field = ["shift+enter"]
exit = ["esc", "ctrl+e"]
delete_char_forward = ["delete"]
delete_char_backward = ["backspace"]
next_field = ["enter"]
prev_field = ["shift+enter"]
move_left = ["left"]
move_right = ["right"]
suggestion_down = ["ctrl+n", "tab"]
suggestion_up = ["ctrl+p", "shift+tab"]
select_suggestion = ["enter"]
exit_suggestion_mode = ["esc"]
[keybindings.command]
exit_command_mode = ["ctrl+g", "esc"]
@@ -71,6 +83,9 @@ force_quit = ["q!"]
save_and_quit = ["wq"]
revert = ["r"]
[editor]
keybinding_mode = "vim" # Options: "default", "vim", "emacs"
[colors]
theme = "dark"
# Options: "light", "dark", "high_contrast"

View File

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

View File

@@ -0,0 +1,237 @@
// 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;
use crate::config::binds::config::EditorKeybindingMode;
use crate::components::common::text_editor::TextEditor;
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);
if add_logic_state.current_focus == AddLogicFocus::InputScriptContent {
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());
let script_title_hint = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
let vim_mode_status = TextEditor::get_vim_mode_status(&add_logic_state.vim_state);
if is_edit_mode {
format!("Script (VIM {}) - Esc for Normal. Tab navigates from Normal.", vim_mode_status)
} else {
format!("Script (VIM {}) - 'i'/'a'/'o' for Insert. Tab to navigate.", vim_mode_status)
}
}
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
if is_edit_mode {
"Script (Editing - Esc to exit edit. Tab navigates after exit.)".to_string()
} else {
"Script (Press Enter or Ctrl+E to edit. Tab to navigate.)".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),
);
// Remove .widget() call - just pass the reference directly
f.render_widget(&*editor_ref, inner_area);
return;
}
// ... rest of the layout code ...
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(9),
Constraint::Min(5),
Constraint::Length(3),
])
.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];
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);
let focus_on_canvas_inputs = matches!(
add_logic_state.current_focus,
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription
);
render_canvas(
f,
canvas_area,
add_logic_state,
&add_logic_state.fields(),
&add_logic_state.current_field(),
&add_logic_state.inputs(),
theme,
is_edit_mode && focus_on_canvas_inputs,
highlight_state,
);
{
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
editor_ref.set_cursor_line_style(Style::default());
let border_style_color = if add_logic_state.current_focus == AddLogicFocus::InputScriptContent {
theme.highlight
} else {
theme.secondary
};
let title_hint = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => "Script Preview (VIM - Focus with Tab, then 'i'/'a'/'o' to edit)",
_ => "Script Preview (Focus with Tab, then Enter/Ctrl+E to edit)",
};
editor_ref.set_block(
Block::default()
.title(title_hint)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_style_color)),
);
// Remove .widget() call here too
f.render_widget(&*editor_ref, script_content_area);
}
let get_button_style = |button_focus: AddLogicFocus, current_focus| {
let is_focused = current_focus == 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, theme: &Theme| {
if is_focused {
Style::default().fg(theme.highlight)
} else {
Style::default().fg(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]);
if app_state.ui.dialog.dialog_show {
dialog::render_dialog(
f,
f.area(),
theme,
&app_state.ui.dialog.dialog_title,
&app_state.ui.dialog.dialog_message,
&app_state.ui.dialog.dialog_buttons,
app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
);
}
}

View File

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

@@ -2,30 +2,23 @@
use crate::config::colors::themes::Theme;
use crate::state::pages::auth::AuthState;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use common::proto::multieko2::table_definition::ProfileTreeResponse;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap},
Frame,
};
/// Renders the view specific to admin users.
fn render_admin_panel_admin(f: &mut Frame, content_chunks: &[Rect], theme: &Theme) {
// Admin-specific view placeholder
let admin_message = Paragraph::new("Admin-specific view. Profile selection not applicable.")
.style(Style::default().fg(theme.fg))
.alignment(Alignment::Center);
// Render only in the right pane for now, leaving left empty
f.render_widget(admin_message, content_chunks[1]);
}
use super::admin_panel_admin::render_admin_panel_admin;
pub fn render_admin_panel(
f: &mut Frame,
app_state: &AppState,
auth_state: &AuthState,
admin_state: &AdminState,
admin_state: &mut AdminState,
area: Rect,
theme: &Theme,
profile_tree: &ProfileTreeResponse,
@@ -45,11 +38,6 @@ pub fn render_admin_panel(
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(inner_area);
// Title
let title = Line::from(Span::styled("Admin Panel", Style::default().fg(theme.highlight)));
let title_widget = Paragraph::new(title).alignment(Alignment::Center);
f.render_widget(title_widget, chunks[0]);
// Content
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
@@ -66,7 +54,13 @@ pub fn render_admin_panel(
selected_profile,
);
} else {
render_admin_panel_admin(f, &content_chunks, theme);
render_admin_panel_admin(
f,
chunks[1],
app_state,
admin_state,
theme,
);
}
}
@@ -98,8 +92,8 @@ fn render_admin_panel_non_admin(
.block(Block::default().title("Profiles"))
.highlight_style(Style::default().bg(theme.highlight).fg(theme.bg));
let mut list_state_clone = admin_state.list_state.clone();
f.render_stateful_widget(list, content_chunks[0], &mut list_state_clone);
let mut profile_list_state_clone = admin_state.profile_list_state.clone();
f.render_stateful_widget(list, content_chunks[0], &mut profile_list_state_clone);
// Profile details - Use selection info from admin_state
if let Some(profile) = admin_state

View File

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

View File

@@ -12,6 +12,7 @@ use ratatui::{
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use crate::state::app::highlight::HighlightState;
pub fn render_login(
f: &mut Frame,
@@ -20,6 +21,7 @@ pub fn render_login(
login_state: &LoginState,
app_state: &AppState,
is_edit_mode: bool,
highlight_state: &HighlightState,
) {
// Main container
let block = Block::default()
@@ -56,6 +58,7 @@ pub fn render_login(
&[&login_state.username, &login_state.password],
theme,
is_edit_mode,
highlight_state,
);
// --- ERROR MESSAGE ---
@@ -139,7 +142,8 @@ pub fn render_login(
&app_state.ui.dialog.dialog_title,
&app_state.ui.dialog.dialog_message,
&app_state.ui.dialog.dialog_buttons, // Pass buttons slice
app_state.ui.dialog.dialog_active_button_index, // Pass active index
app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
);
}
}

View File

@@ -14,6 +14,7 @@ use ratatui::{
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use crate::state::app::highlight::HighlightState;
pub fn render_register(
f: &mut Frame,
@@ -22,6 +23,7 @@ pub fn render_register(
state: &RegisterState, // Use RegisterState
app_state: &AppState,
is_edit_mode: bool,
highlight_state: &HighlightState,
) {
let block = Block::default()
.borders(Borders::ALL)
@@ -64,6 +66,7 @@ pub fn render_register(
&state.inputs().iter().map(|s| *s).collect::<Vec<&String>>(), // Pass inputs directly
theme,
is_edit_mode,
highlight_state,
);
// --- HELP TEXT ---
@@ -149,7 +152,7 @@ pub fn render_register(
let selected = state.get_selected_suggestion_index();
if !suggestions.is_empty() {
if let Some(input_rect) = active_field_rect {
autocomplete::render_autocomplete_dropdown(f, input_rect, f.size(), theme, suggestions, selected);
autocomplete::render_autocomplete_dropdown(f, input_rect, f.area(), theme, suggestions, selected);
}
}
}
@@ -165,6 +168,7 @@ pub fn render_register(
&app_state.ui.dialog.dialog_message,
&app_state.ui.dialog.dialog_buttons,
app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
);
}
}

View File

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

View File

@@ -81,10 +81,10 @@ pub fn render_autocomplete_dropdown(
let list = List::new(items);
// State for managing selection highlight (still needed for logic)
let mut list_state = ListState::default();
list_state.select(selected_index);
let mut profile_list_state = ListState::default();
profile_list_state.select(selected_index);
// Render the list statefully *over* the background block
f.render_stateful_widget(list, dropdown_area, &mut list_state);
f.render_stateful_widget(list, dropdown_area, &mut profile_list_state);
}

View File

@@ -18,6 +18,7 @@ pub fn render_dialog(
dialog_message: &str,
dialog_buttons: &[String],
dialog_active_button_index: usize,
is_loading: bool,
) {
// Calculate required height based on the actual number of lines in the message
let message_lines: Vec<_> = dialog_message.lines().collect();
@@ -63,6 +64,16 @@ pub fn render_dialog(
vertical: 1, // Top/Bottom padding inside border
});
if is_loading {
// --- Loading State ---
let loading_text = Paragraph::new(dialog_message) // Use the message passed for loading
.style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC))
.alignment(Alignment::Center);
// Render loading message centered in the inner area
f.render_widget(loading_text, inner_area);
} else {
// --- Normal State (Message + Buttons) ---
// Layout for Message and Buttons based on actual message height
let mut constraints = vec![
// Allocate space for message, ensuring at least 1 line height
@@ -82,8 +93,7 @@ pub fn render_dialog(
let ellipsis = "...";
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
let processed_lines: Vec<Line> =
message_lines
let processed_lines: Vec<Line> = message_lines
.into_iter()
.map(|line| {
let line_width = UnicodeWidthStr::width(line);
@@ -91,19 +101,23 @@ pub fn render_dialog(
// Truncate with ellipsis
let mut truncated_len = 0;
let mut current_width = 0;
// Iterate over graphemes to handle multi-byte characters correctly
for (idx, grapheme) in line.grapheme_indices(true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if current_width + grapheme_width > available_width.saturating_sub(ellipsis_width) {
break; // Stop before exceeding width needed for text + ellipsis
if current_width + grapheme_width
> available_width.saturating_sub(ellipsis_width)
{
break;
}
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);
Line::from(Span::styled(truncated_line, Style::default().fg(theme.fg)))
let truncated_line =
format!("{}{}", &line[..truncated_len], ellipsis);
Line::from(Span::styled(
truncated_line,
Style::default().fg(theme.fg),
))
} else {
// Line fits, use it as is
Line::from(Span::styled(line, Style::default().fg(theme.fg)))
}
})
@@ -111,15 +125,13 @@ pub fn render_dialog(
let message_paragraph =
Paragraph::new(Text::from(processed_lines)).alignment(Alignment::Center);
// Render message in the first chunk
f.render_widget(message_paragraph, chunks[0]);
f.render_widget(message_paragraph, chunks[0]); // Render message in the first chunk
// Render Buttons if they exist and there's a chunk for them
if !dialog_buttons.is_empty() && chunks.len() > 1 {
let button_area = chunks[1];
let button_count = dialog_buttons.len();
// Use Ratio for potentially more even distribution with few buttons
let button_constraints = std::iter::repeat(Constraint::Ratio(
1,
button_count as u32,
@@ -134,7 +146,6 @@ pub fn render_dialog(
.split(button_area);
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() {
break;
}
@@ -145,12 +156,12 @@ pub fn render_dialog(
Style::default()
.fg(theme.highlight)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.accent), // Highlight border
Style::default().fg(theme.accent),
)
} else {
(
Style::default().fg(theme.fg),
Style::default().fg(theme.border), // Normal border
Style::default().fg(theme.border),
)
};
@@ -168,4 +179,5 @@ pub fn render_dialog(
);
}
}
}
}

View File

@@ -1,11 +1,11 @@
// src/client/components/handlers/status_line.rs
use ratatui::{
widgets::Paragraph,
style::Style,
layout::Rect,
Frame,
text::{Line, Span},
widgets::Paragraph,
};
use unicode_width::UnicodeWidthStr;
use crate::config::colors::themes::Theme;
use std::path::Path;
@@ -15,17 +15,11 @@ pub fn render_status_line(
current_dir: &str,
theme: &Theme,
is_edit_mode: bool,
current_fps: f64,
) {
// Program name and 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]"
};
// Shorten the current directory path
let home_dir = dirs::home_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
let display_dir = if current_dir.starts_with(&home_dir) {
current_dir.replacen(&home_dir, "~", 1)
@@ -33,47 +27,51 @@ pub fn render_status_line(
current_dir.to_string()
};
// Create the full status line text
let full_text = format!("{} | {} | {}", mode_text, display_dir, program_info);
// Check if the full text fits in the available width
let available_width = area.width as usize;
let mut display_text = if full_text.len() <= available_width {
// If it fits, use the full text
full_text
let mode_width = UnicodeWidthStr::width(mode_text);
let program_info_width = UnicodeWidthStr::width(program_info.as_str());
let fps_text = format!("{:.0} FPS", current_fps);
let fps_width = UnicodeWidthStr::width(fps_text.as_str());
let separator = " | ";
let separator_width = UnicodeWidthStr::width(separator);
let fixed_width_with_fps = mode_width + separator_width + separator_width +
program_info_width + separator_width + fps_width;
let show_fps = fixed_width_with_fps < available_width;
let remaining_width_for_dir = available_width.saturating_sub(
mode_width + separator_width + separator_width + program_info_width +
if show_fps { separator_width + fps_width } else { 0 }
);
let dir_display_text = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir {
display_dir
} else {
// If it doesn't fit, prioritize mode and program info, and show only the directory name
let dir_name = Path::new(current_dir)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(current_dir);
format!("{} | {} | {}", mode_text, dir_name, program_info)
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
dir_name.to_string()
} else {
dir_name.chars().take(remaining_width_for_dir).collect()
}
};
// If even the shortened version overflows, truncate it
if display_text.len() > available_width {
display_text = display_text.chars().take(available_width).collect();
}
// Create the status line text using Line and Span
let status_line = Line::from(vec![
let mut spans = vec![
Span::styled(mode_text, Style::default().fg(theme.accent)),
Span::styled(" | ", Style::default().fg(theme.border)),
Span::styled(
display_text.split(" | ").nth(1).unwrap_or(""), // Directory part
Style::default().fg(theme.fg),
),
Span::styled(dir_display_text, Style::default().fg(theme.fg)),
Span::styled(" | ", Style::default().fg(theme.border)),
Span::styled(
program_info,
Style::default()
.fg(theme.secondary)
.add_modifier(ratatui::style::Modifier::BOLD),
),
]);
Span::styled(program_info, Style::default().fg(theme.secondary)),
];
// Render the status line
let paragraph = Paragraph::new(status_line)
if show_fps {
spans.push(Span::styled(" | ", Style::default().fg(theme.border)));
spans.push(Span::styled(fps_text, Style::default().fg(theme.secondary)));
}
let paragraph = Paragraph::new(Line::from(spans))
.style(Style::default().bg(theme.bg));
f.render_widget(paragraph, area);

View File

@@ -0,0 +1,331 @@
// src/components/common/text_editor.rs
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
use ratatui::style::{Color, Style, Modifier};
use tui_textarea::{Input, Key, TextArea, CursorMove, Scrolling};
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

@@ -7,6 +7,7 @@ use ratatui::{
};
use crate::config::colors::themes::Theme;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::highlight::HighlightState;
use crate::components::handlers::canvas::render_canvas;
pub fn render_form(
@@ -18,6 +19,7 @@ pub fn render_form(
inputs: &[&String],
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
total_count: u64,
current_position: u64,
) {
@@ -62,5 +64,6 @@ pub fn render_form(
inputs,
theme,
is_edit_mode,
highlight_state,
);
}

View File

@@ -1,21 +1,22 @@
// src/components/handlers/buffer_list.rs
use crate::config::colors::themes::Theme;
use crate::state::app::state::AppState;
use crate::state::app::buffer::BufferState;
use ratatui::{
layout::{Alignment, Rect},
style::{Style, Stylize},
style::Style,
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use unicode_width::UnicodeWidthStr;
use crate::functions::common::buffer::get_view_layer;
pub fn render_buffer_list(
f: &mut Frame,
area: Rect,
theme: &Theme,
app_state: &AppState,
buffer_state: &BufferState,
) {
// --- Style Definitions ---
let active_style = Style::default()
@@ -26,13 +27,23 @@ pub fn render_buffer_list(
.fg(theme.fg)
.bg(theme.bg);
// --- Determine Active Layer ---
let active_layer = match buffer_state.history.get(buffer_state.active_index) {
Some(view) => get_view_layer(view),
None => 1,
};
// --- Create Spans ---
let mut spans = Vec::new();
let history = &app_state.ui.buffer_history;
let mut current_width = 0;
for (i, view) in history.iter().enumerate() {
let is_active = i == app_state.ui.active_buffer_index;
for (original_index, view) in buffer_state.history.iter().enumerate() {
// Filter: Only process views matching the active layer
if get_view_layer(view) != active_layer {
continue;
}
let is_active = original_index == buffer_state.active_index;
let buffer_name = view.display_name();
let buffer_text = format!(" {} ", buffer_name);
let text_width = UnicodeWidthStr::width(buffer_text.as_str());
@@ -51,10 +62,12 @@ pub fn render_buffer_list(
// --- Filler Span ---
let remaining_width = area.width.saturating_sub(current_width as u16);
if !spans.is_empty() || remaining_width > 0 {
spans.push(Span::styled(
" ".repeat(remaining_width as usize),
inactive_style,
));
}
// --- Render ---
let buffer_line = Line::from(spans);

View File

@@ -2,31 +2,33 @@
use ratatui::{
widgets::{Paragraph, Block, Borders},
layout::{Layout, Constraint, Direction, Rect},
style::Style,
style::{Style, Modifier},
text::{Line, Span},
Frame,
prelude::Alignment,
};
use crate::config::colors::themes::Theme;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::highlight::HighlightState; // Ensure correct import path
use std::cmp::{min, max};
pub fn render_canvas(
f: &mut Frame,
area: Rect,
form_state: &impl CanvasState,
fields: &[&str],
current_field: &usize,
current_field_idx: &usize,
inputs: &[&String],
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState, // Using the enum state
) -> Option<Rect> {
// Split area into columns
// ... (setup code remains the same) ...
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(area);
// Input container styling
let border_style = if form_state.has_unsaved_changes() {
Style::default().fg(theme.warning)
} else if is_edit_mode {
@@ -39,7 +41,6 @@ pub fn render_canvas(
.border_style(border_style)
.style(Style::default().bg(theme.bg));
// Input block dimensions
let input_block = Rect {
x: columns[1].x,
y: columns[1].y,
@@ -49,7 +50,6 @@ pub fn render_canvas(
f.render_widget(&input_container, input_block);
// Input rows layout
let input_area = input_container.inner(input_block);
let input_rows = Layout::default()
.direction(Direction::Vertical)
@@ -72,17 +72,113 @@ pub fn render_canvas(
});
}
// Render inputs and cursor
for (i, input) in inputs.iter().enumerate() {
let is_active = i == *current_field;
let input_display = Paragraph::new(input.as_str())
.alignment(Alignment::Left)
.style(if is_active {
Style::default().fg(theme.highlight)
} else {
Style::default().fg(theme.fg)
});
let is_active = i == *current_field_idx;
let current_cursor_pos = form_state.current_cursor_pos();
let text = input.as_str();
let text_len = text.chars().count();
let line: Line;
// --- Use match on the highlight_state enum ---
match highlight_state {
HighlightState::Off => {
// Not in highlight mode, render normally
line = Line::from(Span::styled(
text,
if is_active { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.fg) }
));
}
HighlightState::Characterwise { anchor } => {
// --- Character-wise Highlight Logic ---
let (anchor_field, anchor_char) = *anchor;
let start_field = min(anchor_field, *current_field_idx);
let end_field = max(anchor_field, *current_field_idx);
// Use start_char and end_char consistently
let (start_char, end_char) = if anchor_field == *current_field_idx {
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
} else if anchor_field < *current_field_idx {
(anchor_char, current_cursor_pos)
} else {
(current_cursor_pos, anchor_char)
};
let highlight_style = Style::default().fg(theme.highlight).bg(theme.highlight_bg).add_modifier(Modifier::BOLD);
let normal_style_in_highlight = Style::default().fg(theme.highlight);
let normal_style_outside = Style::default().fg(theme.fg);
if i >= start_field && i <= end_field {
// This line is within the character-wise highlight range
if start_field == end_field { // Case 1: Single Line Highlight
// Use start_char and end_char here
let clamped_start = start_char.min(text_len);
let clamped_end = end_char.min(text_len); // Use text_len for slicing logic
let before: String = text.chars().take(clamped_start).collect();
let highlighted: String = text.chars().skip(clamped_start).take(clamped_end.saturating_sub(clamped_start) + 1).collect();
// Define 'after' here
let after: String = text.chars().skip(clamped_end + 1).collect();
line = Line::from(vec![
Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight), // Use defined 'after'
]);
} else if i == start_field { // Case 2: Multi-Line Highlight - Start Line
// Use start_char here
let safe_start = start_char.min(text_len);
let before: String = text.chars().take(safe_start).collect();
let highlighted: String = text.chars().skip(safe_start).collect();
line = Line::from(vec![
Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style),
]);
} else if i == end_field { // Case 3: Multi-Line Highlight - End Line (Corrected index)
// Use end_char here
let safe_end_inclusive = if text_len > 0 { end_char.min(text_len - 1) } else { 0 };
let highlighted: String = text.chars().take(safe_end_inclusive + 1).collect();
let after: String = text.chars().skip(safe_end_inclusive + 1).collect();
line = Line::from(vec![
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight),
]);
} else { // Case 4: Multi-Line Highlight - Middle Line (Corrected index)
line = Line::from(Span::styled(text, highlight_style)); // Highlight whole line
}
} else { // Case 5: Line Outside Character-wise Highlight Range
line = Line::from(Span::styled(
text,
// Use normal styling (active or inactive)
if is_active { normal_style_in_highlight } else { normal_style_outside }
));
}
}
HighlightState::Linewise { anchor_line } => {
// --- Linewise Highlight Logic ---
let start_field = min(*anchor_line, *current_field_idx);
let end_field = max(*anchor_line, *current_field_idx);
let highlight_style = Style::default().fg(theme.highlight).bg(theme.highlight_bg).add_modifier(Modifier::BOLD);
let normal_style_in_highlight = Style::default().fg(theme.highlight);
let normal_style_outside = Style::default().fg(theme.fg);
if i >= start_field && i <= end_field {
// Highlight the entire line
line = Line::from(Span::styled(text, highlight_style));
} else {
// Line outside linewise highlight range
line = Line::from(Span::styled(
text,
// Use normal styling (active or inactive)
if is_active { normal_style_in_highlight } else { normal_style_outside }
));
}
}
} // End match highlight_state
let input_display = Paragraph::new(line).alignment(Alignment::Left);
f.render_widget(input_display, input_rows[i]);
if is_active {
@@ -95,3 +191,4 @@ pub fn render_canvas(
active_field_input_rect
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,57 @@
// src/config/binds/config.rs
use serde::Deserialize;
use serde::{Deserialize, Serialize}; // Added Serialize for EditorKeybindingMode if needed elsewhere
use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use crossterm::event::{KeyCode, KeyModifiers};
// NEW: Editor Keybinding Mode Enum
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum EditorKeybindingMode {
#[serde(rename = "default")]
Default,
#[serde(rename = "vim")]
Vim,
#[serde(rename = "emacs")]
Emacs,
}
impl Default for EditorKeybindingMode {
fn default() -> Self {
EditorKeybindingMode::Default
}
}
// NEW: Editor Configuration Struct
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditorConfig {
#[serde(default)]
pub keybinding_mode: EditorKeybindingMode,
#[serde(default = "default_show_line_numbers")]
pub show_line_numbers: bool,
#[serde(default = "default_tab_width")]
pub tab_width: u8,
}
fn default_show_line_numbers() -> bool {
true
}
fn default_tab_width() -> u8 {
4
}
impl Default for EditorConfig {
fn default() -> Self {
EditorConfig {
keybinding_mode: EditorKeybindingMode::default(),
show_line_numbers: default_show_line_numbers(),
tab_width: default_tab_width(),
}
}
}
#[derive(Debug, Deserialize, Default)]
pub struct ColorsConfig {
#[serde(default = "default_theme")]
@@ -21,9 +68,14 @@ pub struct Config {
pub keybindings: ModeKeybindings,
#[serde(default)]
pub colors: ColorsConfig,
// NEW: Add editor configuration
#[serde(default)]
pub editor: EditorConfig,
}
#[derive(Debug, Deserialize)]
// ... (rest of your Config struct and impl Config remains the same)
// Make sure ModeKeybindings is also deserializable if it's not already
#[derive(Debug, Deserialize, Default)] // Added Default here if not present
pub struct ModeKeybindings {
#[serde(default)]
pub general: HashMap<String, Vec<String>>,
@@ -32,6 +84,8 @@ pub struct ModeKeybindings {
#[serde(default)]
pub edit: HashMap<String, Vec<String>>,
#[serde(default)]
pub highlight: HashMap<String, Vec<String>>,
#[serde(default)]
pub command: HashMap<String, Vec<String>>,
#[serde(default)]
pub common: HashMap<String, Vec<String>>,
@@ -41,16 +95,16 @@ pub struct ModeKeybindings {
impl Config {
/// Loads the configuration from "config.toml" in the client crate directory.
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
pub fn load() -> Result<Self> {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let config_path = Path::new(manifest_dir).join("config.toml");
let config_str = std::fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config file at {:?}: {}", config_path, e))?;
let config: Config = toml::from_str(&config_str)?;
.with_context(|| format!("Failed to read config file at {:?}", config_path))?;
let config: Config = toml::from_str(&config_str)
.with_context(|| format!("Failed to parse config file: {}. Check for syntax errors or missing fields like an empty [editor] section if you added it.", config_str))?; // Enhanced error message
Ok(config)
}
pub fn get_general_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_for_key_in_mode(&self.keybindings.general, key, modifiers)
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
@@ -75,6 +129,14 @@ impl Config {
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
}
/// Gets an action for a key in Highlight mode, also checking common/global keybindings.
pub fn get_highlight_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_for_key_in_mode(&self.keybindings.highlight, key, modifiers)
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.common, key, modifiers))
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.read_only, key, modifiers))
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
}
/// Gets an action for a key in Command mode, also checking common keybindings.
pub fn get_command_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_for_key_in_mode(&self.keybindings.command, key, modifiers)

View File

@@ -10,6 +10,8 @@ pub struct Theme {
pub highlight: Color,
pub warning: Color,
pub border: Color,
pub highlight_bg: Color,
pub inactive_highlight_bg: Color,// admin panel no idea what it really is
}
impl Theme {
@@ -31,6 +33,8 @@ impl Theme {
highlight: Color::Rgb(152, 251, 152), // Pastel green
warning: Color::Rgb(255, 182, 193), // Pastel pink
border: Color::Rgb(220, 220, 220), // Light gray border
highlight_bg: Color::Rgb(70, 70, 70), // Darker grey for highlight background
inactive_highlight_bg: Color::Rgb(50, 50, 50),
}
}
@@ -44,6 +48,8 @@ impl Theme {
highlight: Color::Rgb(50, 205, 50), // Bright green
warning: Color::Rgb(255, 99, 71), // Bright red
border: Color::Rgb(100, 100, 100), // Medium gray border
highlight_bg: Color::Rgb(180, 180, 180), // Lighter grey for highlight background
inactive_highlight_bg: Color::Rgb(50, 50, 50),
}
}
@@ -57,6 +63,8 @@ impl Theme {
highlight: Color::Rgb(0, 128, 0), // Green
warning: Color::Rgb(255, 0, 0), // Red
border: Color::Rgb(0, 0, 0), // Black border
highlight_bg: Color::Rgb(180, 180, 180), // Lighter grey for highlight background
inactive_highlight_bg: Color::Rgb(50, 50, 50),
}
}
}

View File

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

View File

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

View File

@@ -6,3 +6,4 @@ pub mod navigation;
pub use read_only::*;
pub use edit::*;
pub use navigation::*;

View File

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

View File

@@ -0,0 +1,277 @@
// src/functions/modes/edit/add_logic_e.rs
use crate::state::pages::add_logic::AddLogicState; // Changed
use crate::state::pages::canvas_state::CanvasState;
use crossterm::event::{KeyCode, KeyEvent};
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 chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos == 0 { return 0; }
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { pos -= 1; }
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace { return 0; }
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace { return 0; }
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type { pos -= 1; }
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace { pos -= 1; }
if pos > 0 { pos - 1 } else { 0 }
}
/// Executes edit actions for the AddLogic view canvas.
pub async fn execute_edit_action(
action: &str,
key: KeyEvent,
state: &mut AddLogicState, // Changed
ideal_cursor_column: &mut usize,
) -> Result<String> {
match action {
"insert_char" => {
if let KeyCode::Char(c) = key.code {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() {
chars.insert(cursor_pos, c);
*field_value = chars.into_iter().collect();
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = state.current_cursor_pos();
}
} else {
return Ok("Error: insert_char called without a char key.".to_string());
}
Ok("".to_string())
}
"delete_char_backward" => {
if state.current_cursor_pos() > 0 {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() {
chars.remove(cursor_pos - 1);
*field_value = chars.into_iter().collect();
let new_pos = cursor_pos - 1;
state.set_current_cursor_pos(new_pos);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = new_pos;
}
}
Ok("".to_string())
}
"delete_char_forward" => {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos < chars.len() {
chars.remove(cursor_pos);
*field_value = chars.into_iter().collect();
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos;
}
Ok("".to_string())
}
"next_field" => {
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed
if num_fields > 0 {
let current_field = state.current_field();
let last_field_index = num_fields - 1;
if current_field < last_field_index { // Prevent cycling
state.set_current_field(current_field + 1);
}
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"prev_field" => {
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed
if num_fields > 0 {
let current_field = state.current_field();
if current_field > 0 { // Prevent cycling
state.set_current_field(current_field - 1);
}
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_left" => {
let new_pos = state.current_cursor_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_pos < current_input.len() {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_up" => { // In edit mode, up/down usually means prev/next field
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_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_down" => { // In edit mode, up/down usually means prev/next field
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed
if num_fields > 0 {
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_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_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();
let new_pos = current_input.len();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_first_line" => {
if AddLogicState::INPUT_FIELD_COUNT > 0 { // Changed
state.set_current_field(0);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_last_line" => {
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed
if num_fields > 0 {
let new_field = num_fields - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".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());
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() { // Ensure not to go past end
find_word_end(current_input, current_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len(); // Allow cursor at end
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".to_string())
}
"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())
}
"exit_edit_mode" | "save" | "revert" => {
Ok("Action handled by main loop".to_string())
}
_ => Ok(format!("Unknown or unhandled edit action: {}", action)),
}
}

View File

@@ -0,0 +1,341 @@
// src/functions/modes/edit/add_table_e.rs
use crate::state::pages::add_table::AddTableState;
use crate::state::pages::canvas_state::CanvasState; // Use trait
use crossterm::event::{KeyCode, KeyEvent};
use anyhow::Result;
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos >= len {
return len;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
pos = find_next_word_start(text, pos);
}
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}
/// Executes edit actions for the AddTable view canvas.
pub async fn execute_edit_action(
action: &str,
key: KeyEvent, // Needed for insert_char
state: &mut AddTableState,
ideal_cursor_column: &mut usize,
// Add other params like grpc_client if needed for future actions (e.g., validation)
) -> Result<String> {
// Use the CanvasState trait methods implemented for AddTableState
match action {
"insert_char" => {
if let KeyCode::Char(c) = key.code {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() {
chars.insert(cursor_pos, c);
*field_value = chars.into_iter().collect();
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = state.current_cursor_pos();
}
} else {
return Ok("Error: insert_char called without a char key.".to_string());
}
Ok("".to_string()) // No message needed for char insertion
}
"delete_char_backward" => {
if state.current_cursor_pos() > 0 {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() {
chars.remove(cursor_pos - 1);
*field_value = chars.into_iter().collect();
let new_pos = cursor_pos - 1;
state.set_current_cursor_pos(new_pos);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = new_pos;
}
}
Ok("".to_string())
}
"delete_char_forward" => {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos < chars.len() {
chars.remove(cursor_pos);
*field_value = chars.into_iter().collect();
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos;
}
Ok("".to_string())
}
"next_field" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let current_field = state.current_field();
let last_field_index = num_fields - 1;
// Prevent cycling forward
if current_field < last_field_index {
state.set_current_field(current_field + 1);
}
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"prev_field" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let current_field = state.current_field();
if current_field > 0 {
state.set_current_field(current_field - 1);
}
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_left" => {
let new_pos = state.current_cursor_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_pos < current_input.len() {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_up" => {
let current_field = state.current_field();
// Prevent moving up from the first 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_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("ahoj".to_string())
}
"move_down" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
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_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_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();
let new_pos = current_input.len();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_first_line" => {
if AddTableState::INPUT_FIELD_COUNT > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_last_line" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let new_field = num_fields - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".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());
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 {
find_word_end(current_input, new_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".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())
}
// Actions handled by main event loop (mode changes, save, revert)
"exit_edit_mode" | "save" | "revert" => {
Ok("Action handled by main loop".to_string())
}
_ => Ok(format!("Unknown or unhandled edit action: {}", action)),
}
}

View File

@@ -7,6 +7,7 @@ use crate::state::pages::auth::RegisterState;
use crate::tui::functions::common::form::{revert, save};
use crossterm::event::{KeyCode, KeyEvent};
use std::any::Any;
use anyhow::Result;
pub async fn execute_common_action<S: CanvasState + Any>(
action: &str,
@@ -14,7 +15,7 @@ pub async fn execute_common_action<S: CanvasState + Any>(
grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
) -> Result<String, Box<dyn std::error::Error>> {
) -> Result<String> {
match action {
"save" | "revert" => {
if !state.has_unsaved_changes() {
@@ -62,10 +63,7 @@ pub async fn execute_edit_action<S: CanvasState + Any + Send>(
key: KeyEvent,
state: &mut S,
ideal_cursor_column: &mut usize,
grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
) -> Result<String, Box<dyn std::error::Error>> {
) -> Result<String> {
match action {
"insert_char" => {
if let KeyCode::Char(c) = key.code {
@@ -120,7 +118,7 @@ pub async fn execute_edit_action<S: CanvasState + Any + Send>(
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = (current_field + 1) % num_fields;
let new_field = (current_field + 1).min(num_fields - 1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
@@ -135,11 +133,7 @@ pub async fn execute_edit_action<S: CanvasState + Any + Send>(
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = if current_field == 0 {
num_fields - 1
} else {
current_field - 1
};
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();

View File

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

View File

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

View File

@@ -0,0 +1,262 @@
// 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 common::proto::multieko2::table_script::PostTableScriptRequest;
use crate::components::common::text_editor::TextEditor;
use tui_textarea::Input as TextAreaInput;
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,
command_message: &mut String,
) -> bool {
let mut handled = false;
let general_action = config.get_general_action(key_event.code, key_event.modifiers);
if add_logic_state.current_focus == AddLogicFocus::InputScriptContent {
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
if *is_edit_mode { // App considers textarea to be in "typing" (Insert) mode
let changed = 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; }
// Check if we've transitioned to Normal mode
if key_event.code == KeyCode::Esc && TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) {
*is_edit_mode = false;
*command_message = "VIM: Normal Mode. Tab to navigate.".to_string();
}
handled = true;
} else { // App considers textarea to be in "navigation" (Normal) mode
match key_event.code {
// Keys to enter Vim Insert mode
KeyCode::Char('i') | KeyCode::Char('a') | KeyCode::Char('o') |
KeyCode::Char('I') | KeyCode::Char('A') | KeyCode::Char('O') => {
*is_edit_mode = true;
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state
);
*command_message = "VIM: Insert Mode.".to_string();
handled = true;
}
_ => {
if general_action.is_none() {
let changed = 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; }
handled = true;
}
}
}
}
}
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
if *is_edit_mode {
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
*is_edit_mode = false;
*command_message = "Exited script edit. Tab to navigate.".to_string();
handled = true;
} else if general_action.is_some() && (general_action.unwrap() == "next_field" || general_action.unwrap() == "prev_field") {
let changed = 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; }
handled = true;
} else {
let changed = 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; }
handled = true;
}
}
}
}
if handled { return true; }
}
// If not handled above (e.g., Tab/Shift+Tab, or Enter when script content not in edit mode),
// process general application-level actions.
let action_str = general_action.map(String::from);
match action_str.as_deref() {
Some("exit_view") | Some("cancel_action") => {
buffer_state.update_history(AppView::Admin);
app_state.ui.show_add_logic = false;
*command_message = "Exited Add Logic".to_string();
*is_edit_mode = false;
handled = true;
}
Some("next_field") | Some("prev_field") => {
let is_next = action_str.as_deref() == Some("next_field");
let previous_focus = add_logic_state.current_focus;
add_logic_state.current_focus = if is_next {
match add_logic_state.current_focus {
AddLogicFocus::InputLogicName => AddLogicFocus::InputTargetColumn,
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputDescription,
AddLogicFocus::InputDescription => AddLogicFocus::InputScriptContent,
AddLogicFocus::InputScriptContent => AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => AddLogicFocus::InputLogicName,
}
} else {
match add_logic_state.current_focus {
AddLogicFocus::InputLogicName => AddLogicFocus::CancelButton,
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputLogicName,
AddLogicFocus::InputDescription => AddLogicFocus::InputTargetColumn,
AddLogicFocus::InputScriptContent => AddLogicFocus::InputDescription,
AddLogicFocus::SaveButton => AddLogicFocus::InputScriptContent,
AddLogicFocus::CancelButton => AddLogicFocus::SaveButton,
}
};
if add_logic_state.current_focus == AddLogicFocus::InputScriptContent {
*is_edit_mode = false;
let mode_hint = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => "'i'/'a'/'o' to insert",
_ => "Enter/Ctrl+E to edit",
};
*command_message = format!("Focus: Script Content. Press {} or Tab.", mode_hint);
} else if matches!(add_logic_state.current_focus, AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription) {
*is_edit_mode = true;
*command_message = format!("Focus: {:?}. Edit mode ON.", add_logic_state.current_focus);
} else {
*is_edit_mode = false;
*command_message = format!("Focus: {:?}", add_logic_state.current_focus);
}
app_state.ui.focus_outside_canvas = !matches!(
add_logic_state.current_focus,
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription | AddLogicFocus::InputScriptContent
);
handled = true;
}
Some("select") => {
match add_logic_state.current_focus {
AddLogicFocus::InputScriptContent => {
*is_edit_mode = true;
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
TextEditor::handle_input(
&mut editor_borrow,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
*command_message = "VIM: Insert Mode.".to_string();
}
_ => {
*command_message = "Entered script edit mode.".to_string();
}
}
handled = true;
}
AddLogicFocus::SaveButton => { handled = true; }
AddLogicFocus::CancelButton => { *is_edit_mode = false; handled = true; }
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 = true;
}
}
}
Some("toggle_edit_mode") => {
match add_logic_state.current_focus {
AddLogicFocus::InputScriptContent => {
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
if *is_edit_mode {
TextEditor::handle_input(
&mut editor_borrow,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
&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. Tab to navigate.".to_string();
} else {
*command_message = "VIM: Still in Insert Mode (toggle error?).".to_string();
}
} else {
TextEditor::handle_input(
&mut editor_borrow,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
*is_edit_mode = true;
*command_message = "VIM: Insert Mode.".to_string();
}
}
_ => {
*is_edit_mode = !*is_edit_mode;
*command_message = format!("Script edit mode: {}", if *is_edit_mode { "ON" } else { "OFF. Tab to navigate." });
}
}
handled = true;
}
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" });
handled = true;
}
_ => { *command_message = "Cannot toggle edit mode here.".to_string(); handled = true; }
}
}
_ => {
if add_logic_state.current_focus == AddLogicFocus::InputScriptContent &&
!*is_edit_mode &&
add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim {
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
let changed = 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; }
handled = true;
}
}
}
handled
}

View File

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

View File

@@ -1 +1,284 @@
// src/functions/modes/navigation/admin_nav.rs
use crate::config::binds::config::Config;
use crate::state::{
app::state::AppState,
pages::admin::{AdminFocus, AdminState},
};
use crossterm::event::KeyEvent;
use crate::state::app::buffer::AppView;
use crate::state::app::buffer::BufferState;
use crate::state::pages::add_table::{AddTableState, LinkDefinition};
use crate::state::pages::add_logic::AddLogicState;
use ratatui::widgets::ListState;
// --- Helper functions for ListState navigation (similar to TableState) ---
fn list_select_next(list_state: &mut ListState, item_count: usize) {
if item_count == 0 {
list_state.select(None);
return;
}
let i = match list_state.selected() {
Some(i) => {
if i >= item_count - 1 { 0 } else { i + 1 }
}
None => 0,
};
list_state.select(Some(i));
}
fn list_select_previous(list_state: &mut ListState, item_count: usize) {
if item_count == 0 {
list_state.select(None);
return;
}
let i = match list_state.selected() {
Some(i) => {
if i == 0 { item_count - 1 } else { i - 1 }
}
None => item_count - 1, // Select last if nothing was selected
};
list_state.select(Some(i));
}
/// Handles navigation events specifically for the Admin Panel view.
/// Returns true if the event was handled, false otherwise.
pub fn handle_admin_navigation(
key: KeyEvent,
config: &Config,
app_state: &mut AppState,
admin_state: &mut AdminState,
buffer_state: &mut BufferState,
command_message: &mut String,
) -> bool {
let action = config.get_general_action(key.code, key.modifiers).map(String::from); // Clone action string
let current_focus = admin_state.current_focus;
let profile_count = app_state.profile_tree.profiles.len();
let mut new_focus = current_focus; // Start with current focus
let mut handled = true; // Assume handled unless logic says otherwise
match action.as_deref() {
// --- Vertical Navigation (Up/Down) ---
Some("move_up") => {
match current_focus {
AdminFocus::Profiles => {
if profile_count > 0 {
admin_state.previous_profile(profile_count);
*command_message = "Navigated profiles list".to_string();
}
}
AdminFocus::Tables => {
*command_message = "Press Enter to select and scroll tables".to_string();
}
AdminFocus::InsideTablesList => {
if let Some(p_idx) = admin_state.profile_list_state.selected().or(admin_state.selected_profile_index) {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
list_select_previous(&mut admin_state.table_list_state, profile.tables.len());
}
}
}
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3 => {}
}
}
Some("move_down") => {
match current_focus {
AdminFocus::Profiles => {
if profile_count > 0 {
admin_state.next_profile(profile_count);
*command_message = "Navigated profiles list".to_string();
}
}
AdminFocus::Tables => {
*command_message = "Press Enter to select and scroll tables".to_string();
}
AdminFocus::InsideTablesList => {
if let Some(p_idx) = admin_state.profile_list_state.selected().or(admin_state.selected_profile_index) {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
list_select_next(&mut admin_state.table_list_state, profile.tables.len());
}
}
}
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3 => {}
}
}
// --- Horizontal Navigation (Focus Change) ---
Some("next_option") | Some("previous_option") => {
let old_focus = admin_state.current_focus;
let is_next = action.as_deref() == Some("next_option");
admin_state.current_focus = match old_focus {
AdminFocus::Profiles => if is_next { AdminFocus::Tables } else { AdminFocus::Button3 },
AdminFocus::Tables => if is_next { AdminFocus::Button1 } else { AdminFocus::Profiles },
AdminFocus::Button1 => if is_next { AdminFocus::Button2 } else { AdminFocus::Tables },
AdminFocus::Button2 => if is_next { AdminFocus::Button3 } else { AdminFocus::Button1 },
AdminFocus::Button3 => if is_next { AdminFocus::Profiles } else { AdminFocus::Button2 },
AdminFocus::InsideTablesList => old_focus,
};
new_focus = admin_state.current_focus; // Update new_focus after changing admin_state.current_focus
*command_message = format!("Focus set to {:?}", new_focus);
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);
}
}
if old_focus == AdminFocus::Tables && new_focus != AdminFocus::Tables && old_focus != AdminFocus::InsideTablesList {
admin_state.table_list_state.select(None);
}
// No change needed for profile_list_state clearing here based on current logic
}
// --- Selection ---
Some("select") => {
match current_focus {
AdminFocus::Profiles => {
if let Some(nav_idx) = admin_state.profile_list_state.selected() {
admin_state.selected_profile_index = Some(nav_idx);
new_focus = AdminFocus::Tables;
admin_state.table_list_state.select(None);
admin_state.selected_table_index = None;
if let Some(profile) = app_state.profile_tree.profiles.get(nav_idx) {
if !profile.tables.is_empty() {
admin_state.table_list_state.select(Some(0));
}
*command_message = format!("Selected profile: {}", app_state.profile_tree.profiles[nav_idx].name);
}
} else {
*command_message = "No profile selected".to_string();
}
}
AdminFocus::Tables => {
new_focus = AdminFocus::InsideTablesList;
if let Some(p_idx) = admin_state.profile_list_state.selected().or(admin_state.selected_profile_index) {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
if admin_state.table_list_state.selected().is_none() && !profile.tables.is_empty() {
admin_state.table_list_state.select(Some(0));
}
}
}
*command_message = "Entered Tables List (Select item with Enter, Exit with Esc)".to_string();
}
AdminFocus::InsideTablesList => {
if let Some(nav_idx) = admin_state.table_list_state.selected() {
admin_state.selected_table_index = Some(nav_idx);
let table_name = admin_state.profile_list_state.selected().or(admin_state.selected_profile_index)
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
.and_then(|p| p.tables.get(nav_idx).map(|t| t.name.clone()))
.unwrap_or_else(|| "N/A".to_string());
*command_message = format!("Selected table: {}", table_name);
} else {
*command_message = "No table highlighted".to_string();
}
}
// In src/functions/modes/navigation/admin_nav.rs
AdminFocus::Button1 => { // Add Logic
let mut logic_state_profile_name = "None (Global)".to_string();
let mut selected_table_id: Option<i64> = None;
let mut selected_table_name_for_logic: Option<String> = None;
if let Some(p_idx) = admin_state.selected_profile_index {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
logic_state_profile_name = profile.name.clone();
// Check for persistently selected table within this profile
if let Some(t_idx) = admin_state.selected_table_index {
if let Some(table) = profile.tables.get(t_idx) {
selected_table_id = None;
selected_table_name_for_logic = Some(table.name.clone());
*command_message = format!("Adding logic for table: {}. CRITICAL: Table ID not found in profile tree response!", table.name);
} else {
*command_message = format!("Selected table index {} out of bounds for profile '{}'. Logic will not be table-specific.", t_idx, profile.name);
}} else {
*command_message = format!("No table selected in profile '{}'. Logic will not be table-specific.", profile.name);
}
} else {
*command_message = "Error: Selected profile index out of bounds, associating with 'None'.".to_string();
}
} else {
*command_message = "No profile selected ([*]), associating Logic with 'None (Global)'.".to_string();
// Keep logic_state_profile_name as "None (Global)"
}
// Create AddLogicState with the loaded config
admin_state.add_logic_state = AddLogicState {
profile_name: logic_state_profile_name.clone(),
editor_keybinding_mode: config.editor.keybinding_mode.clone(), // Add this line
..AddLogicState::default()
};
buffer_state.update_history(AppView::AddLogic);
app_state.ui.focus_outside_canvas = false;
// Rest of the code remains the same...
}
AdminFocus::Button2 => {
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();
let available_links: Vec<LinkDefinition> = profile
.tables
.iter()
.map(|table| LinkDefinition {
linked_table_name: table.name.clone(),
is_required: false,
selected: false,
})
.collect();
let new_add_table_state = AddTableState {
profile_name: selected_profile_name,
links: available_links,
..AddTableState::default()
};
admin_state.add_table_state = new_add_table_state;
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();
}
}
AdminFocus::Button3 => {
*command_message = "Action: Change Table (Not Implemented)".to_string();
}
}
}
Some("exit_table_scroll") => {
match current_focus {
AdminFocus::InsideTablesList => {
new_focus = AdminFocus::Tables;
admin_state.table_list_state.select(None);
*command_message = "Exited Tables List".to_string();
}
_ => handled = false,
}
}
Some("toggle_sidebar") | Some("toggle_buffer_list") | Some("next_field") | Some("prev_field") => {
handled = false;
}
_ => handled = false,
}
if handled && admin_state.current_focus != new_focus { // Check admin_state.current_focus
admin_state.current_focus = new_focus;
if command_message.is_empty() || command_message.starts_with("Focus set to") {
*command_message = format!("Focus set to {:?}", admin_state.current_focus);
}
}
handled
}

View File

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

View File

@@ -0,0 +1,244 @@
// 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, // Changed
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; // Changed
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 {
// Moving up from the first field (InputLogicName)
app_state.ui.focus_outside_canvas = true;
// Focus should go to the element logically above the canvas.
// Based on AddLogicFocus, this might be CancelButton or another element.
// For AddLogic, let's assume it's CancelButton, similar to AddTable.
state.current_focus = crate::state::pages::add_logic::AddLogicFocus::CancelButton; // Changed
key_sequence_tracker.reset();
return Ok("Focus moved above canvas".to_string());
}
Ok("".to_string())
}
"move_down" => {
key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed
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 {
// Moving down from the last field (InputDescription)
app_state.ui.focus_outside_canvas = true;
// Focus should go to the element logically below the canvas.
// This is likely InputScriptContent or SaveButton.
// The add_logic_nav.rs handles transitions to InputScriptContent.
// If moving from canvas directly to buttons, it would be SaveButton.
state.current_focus = crate::state::pages::add_logic::AddLogicFocus::InputScriptContent; // Or SaveButton
key_sequence_tracker.reset();
return Ok("Focus moved to script/button area".to_string());
}
Ok("".to_string())
}
"move_first_line" => {
key_sequence_tracker.reset();
if AddLogicState::INPUT_FIELD_COUNT > 0 { // Changed
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; // Changed
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

@@ -0,0 +1,244 @@
// src/functions/modes/read_only/add_table_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::add_table::AddTableState;
use crate::state::pages::canvas_state::CanvasState; // Use trait for common actions
use crate::state::app::state::AppState;
use anyhow::Result;
// Re-use word navigation helpers if they are public or move them to a common module
// For now, duplicating them here for simplicity. Consider refactoring later.
#[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
}
// Note: find_prev_word_end might need adjustments based on desired behavior.
// This version finds the end of the word *before* the previous word start.
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 the end of the word that starts at prev_start - 1
find_word_end(text, prev_start.saturating_sub(1))
}
/// Executes read-only actions for the AddTable view canvas.
pub async fn execute_action(
action: &str,
app_state: &mut AppState, // Needed for focus_outside_canvas
state: &mut AddTableState,
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String, // Keep for potential messages
) -> Result<String> {
// Use the CanvasState trait methods implemented for AddTableState
match action {
"move_up" => {
key_sequence_tracker.reset();
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); }
let current_field = state.current_field(); // Gets the index (0, 1, or 2)
if current_field > 0 {
// This handles moving from field 2 -> 1, or 1 -> 0
let new_field = current_field - 1;
state.set_current_field(new_field);
// ... (rest of the logic to set cursor position) ...
} else {
// --- THIS IS WHERE THE FIX GOES ---
// current_field is 0 (InputTableName), and user pressed Up.
// We need to move focus *outside* the canvas.
// Set the flag to indicate focus is leaving the canvas
app_state.ui.focus_outside_canvas = true;
// Decide which element gets focus. Based on your layout and the
// downward navigation (CancelButton wraps to InputTableName),
// moving up from InputTableName should likely go to CancelButton.
state.current_focus = crate::state::pages::add_table::AddTableFocus::CancelButton;
// Reset the sequence tracker as the action is complete
key_sequence_tracker.reset();
// Return a message indicating the focus change
return Ok("Focus moved above canvas".to_string());
// --- END FIX ---
}
// If we moved within the canvas (e.g., 1 -> 0), return empty string
Ok("".to_string())
}
"move_down" => {
key_sequence_tracker.reset();
let num_fields = AddTableState::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 = current_input.len(); // Allow cursor at end
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
app_state.ui.focus_outside_canvas = true;
// Set focus to the first element outside canvas (AddColumnButton)
state.current_focus = crate::state::pages::add_table::AddTableFocus::AddColumnButton;
key_sequence_tracker.reset();
return Ok("Focus moved below canvas".to_string());
}
Ok("".to_string())
}
"move_first_line" => {
key_sequence_tracker.reset();
if AddTableState::INPUT_FIELD_COUNT > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_cursor_pos = current_input.len();
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
}
Ok("".to_string())
}
"move_last_line" => {
key_sequence_tracker.reset();
let num_fields = AddTableState::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 = current_input.len();
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
}
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();
// Allow moving cursor one position past the end
if current_pos < current_input.len() {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = new_pos.min(current_input.len()); // Allow cursor at end
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
// If find_word_end returns current_pos, try starting search from next char
let final_pos = if new_pos == current_pos && current_pos < current_input.len() {
find_word_end(current_input, current_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len(); // Allow cursor at end
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
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();
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();
let new_pos = current_input.len(); // Allow cursor at end
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
// Actions handled by main event loop (mode changes)
"enter_edit_mode_before" | "enter_edit_mode_after" | "enter_command_mode" | "exit_highlight_mode" => {
key_sequence_tracker.reset();
Ok("Mode change handled by main loop".to_string())
}
_ => {
key_sequence_tracker.reset();
command_message.clear(); // Clear message for unhandled actions
Ok(format!("Unknown read-only action: {}", action))
},
}
}

View File

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

View File

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

View File

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

View File

@@ -7,10 +7,11 @@ use crate::services::grpc_client::GrpcClient;
use crate::services::auth::AuthClient;
use crate::modes::handlers::event::EventOutcome;
use crate::tui::functions::common::form::SaveOutcome;
use anyhow::{Context, Result};
use crate::tui::functions::common::{
form::{save as form_save, revert as form_revert},
login::{save as login_save, revert as login_revert},
register::{save as register_save, revert as register_revert},
register::{revert as register_revert},
};
pub async fn handle_core_action(
@@ -25,14 +26,11 @@ pub async fn handle_core_action(
app_state: &mut AppState,
current_position: &mut u64,
total_count: u64,
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
) -> Result<EventOutcome> {
match action {
"save" => {
if app_state.ui.show_login {
let message = login_save(auth_state, login_state, auth_client, app_state).await?;
Ok(EventOutcome::Ok(message))
} else if app_state.ui.show_register {
let message = register_save(register_state, auth_client, app_state).await?;
let message = login_save(auth_state, login_state, auth_client, app_state).await.context("Login save action failed")?;
Ok(EventOutcome::Ok(message))
} else {
let save_outcome = form_save(
@@ -40,7 +38,7 @@ pub async fn handle_core_action(
grpc_client,
current_position,
total_count,
).await?;
).await.context("Register save action failed")?;
let message = match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(),
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
@@ -55,9 +53,7 @@ pub async fn handle_core_action(
},
"save_and_quit" => {
let message = if app_state.ui.show_login {
login_save(auth_state, login_state, auth_client, app_state).await?
} else if app_state.ui.show_register {
register_save(register_state, auth_client, app_state).await?
login_save(auth_state, login_state, auth_client, app_state).await.context("Login save n quit action failed")?
} else {
let save_outcome = form_save(
form_state,
@@ -87,7 +83,7 @@ pub async fn handle_core_action(
grpc_client,
current_position,
total_count,
).await?;
).await.context("Form revert x action failed")?;
Ok(EventOutcome::Ok(message))
}
},

View File

@@ -1,206 +1,312 @@
// src/modes/canvas/edit.rs
use crate::config::binds::config::Config;
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::{auth::{LoginState, RegisterState}};
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::{
auth::{LoginState, RegisterState},
canvas_state::CanvasState,
};
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::form::FormState;
use crate::functions::modes::edit::{auth_e, form_e};
use crate::state::pages::add_table::AddTableState;
use crate::state::pages::admin::AdminState;
use crate::modes::handlers::event::EventOutcome;
use crate::functions::modes::edit::{add_logic_e, auth_e, form_e, add_table_e};
use crate::state::app::state::AppState;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditEventOutcome {
Message(String), // Return a message, stay in Edit mode
ExitEditMode, // Signal to exit Edit mode
}
pub async fn handle_edit_event(
key: KeyEvent,
config: &Config,
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
admin_state: &mut AdminState,
ideal_cursor_column: &mut usize,
command_message: &mut String,
current_position: &mut u64,
total_count: u64,
grpc_client: &mut GrpcClient,
app_state: &AppState,
) -> Result<String, Box<dyn std::error::Error>> {
// Global command mode check
) -> Result<EditEventOutcome> {
// Global command mode check (should ideally be handled before calling this function)
if let Some("enter_command_mode") = config.get_action_for_key_in_mode(
&config.keybindings.global,
key.code,
key.modifiers
key.modifiers,
) {
*command_message = "Switching to Command Mode...".to_string();
return Ok(command_message.clone());
return Ok(EditEventOutcome::Message(
"Command mode entry handled globally.".to_string(),
));
}
// Common actions (save/revert)
if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.common,
key.code,
key.modifiers
) {
key.modifiers,
).as_deref() {
if matches!(action, "save" | "revert") {
let message = if app_state.ui.show_login {
let message_string: String = if app_state.ui.show_login {
auth_e::execute_common_action(
action,
login_state,
grpc_client,
current_position,
total_count
).await
total_count,
)
.await?
} else if app_state.ui.show_register {
auth_e::execute_common_action(
action,
register_state,
grpc_client,
current_position,
total_count
).await
total_count,
)
.await?
} else if app_state.ui.show_add_table {
format!(
"Action '{}' not fully implemented for Add Table view here.",
action
)
} else if app_state.ui.show_add_logic {
format!(
"Action '{}' not fully implemented for Add Logic view here.",
action
)
} else {
form_e::execute_common_action(
let outcome = form_e::execute_common_action(
action,
form_state, // Concrete FormState
form_state,
grpc_client,
current_position,
total_count
).await
.map(|outcome| match outcome {
total_count,
)
.await?;
match outcome {
EventOutcome::Ok(msg) => msg,
EventOutcome::Exit(msg) => format!("Exit requested: {}", msg),
EventOutcome::DataSaved(save_outcome, msg) => format!("Data saved ({:?}): {}", save_outcome, msg),
EventOutcome::ButtonSelected { context, index } => {
"Unexpected action in edit mode".to_string()
EventOutcome::DataSaved(_, msg) => msg,
_ => format!(
"Unexpected outcome from common action: {:?}",
outcome
),
}
})
}?;
return Ok(message);
};
return Ok(EditEventOutcome::Message(message_string));
}
}
// Edit-specific actions
if let Some(action) = config.get_edit_action_for_key(key.code, key.modifiers) {
// --- Special Handling for Tab/Shift+Tab in Role Field ---
if let Some(action) =
config.get_edit_action_for_key(key.code, key.modifiers)
.as_deref() {
// Handle enter_decider first
if action == "enter_decider" {
let effective_action = if app_state.ui.show_register
&& register_state.in_suggestion_mode
&& register_state.current_field() == 4 {
"select_suggestion"
} else {
"next_field"
};
let msg = if app_state.ui.show_login {
auth_e::execute_edit_action(
effective_action,
key,
login_state,
ideal_cursor_column,
)
.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_e::execute_edit_action(
effective_action,
key,
form_state,
ideal_cursor_column,
)
.await?
};
return Ok(EditEventOutcome::Message(msg));
}
if action == "exit" {
if app_state.ui.show_register && register_state.in_suggestion_mode {
let msg = auth_e::execute_edit_action(
"exit_suggestion_mode",
key,
register_state,
ideal_cursor_column,
)
.await?;
return Ok(EditEventOutcome::Message(msg));
} else {
return Ok(EditEventOutcome::ExitEditMode);
}
}
// Special handling for role field suggestions (Register view only)
if app_state.ui.show_register && register_state.current_field() == 4 {
if !register_state.in_suggestion_mode && key.code == KeyCode::Tab && key.modifiers == KeyModifiers::NONE {
if !register_state.in_suggestion_mode
&& key.code == KeyCode::Tab
&& key.modifiers == KeyModifiers::NONE
{
register_state.update_role_suggestions();
if !register_state.role_suggestions.is_empty() {
register_state.in_suggestion_mode = true;
register_state.selected_suggestion_index = Some(0); // Select first suggestion
return Ok("Suggestions shown".to_string());
register_state.selected_suggestion_index = Some(0);
return Ok(EditEventOutcome::Message(
"Suggestions shown".to_string(),
));
} else {
return Ok("No suggestions available".to_string());
return Ok(EditEventOutcome::Message(
"No suggestions available".to_string(),
));
}
}
if register_state.in_suggestion_mode
&& matches!(
action,
"suggestion_down" | "suggestion_up"
)
{
let msg = auth_e::execute_edit_action(
action,
key,
register_state,
ideal_cursor_column,
)
.await?;
return Ok(EditEventOutcome::Message(msg));
}
}
// --- End Special Handling ---
return if app_state.ui.show_login {
// Execute other edit actions based on the current view
let msg = if app_state.ui.show_login {
auth_e::execute_edit_action(
action,
key,
login_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count
).await
)
.await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action(
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(
action,
key,
&mut admin_state.add_logic_state,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
auth_e::execute_edit_action(
action,
key,
register_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count
).await
)
.await?
} else {
form_e::execute_edit_action(
action,
key,
form_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count
).await
)
.await?
};
return Ok(EditEventOutcome::Message(msg));
}
// Character insertion
if let KeyCode::Char(_) = key.code {
// If in suggestion mode, exit it before inserting char
// --- Character insertion ---
if app_state.ui.show_register && register_state.in_suggestion_mode {
register_state.in_suggestion_mode = false;
register_state.show_role_suggestions = false;
register_state.selected_suggestion_index = None;
}
let is_role_field = app_state.ui.show_register && register_state.current_field() == 4;
// --- End Autocomplete Trigger ---
return if app_state.ui.show_login {
let msg = if app_state.ui.show_login {
auth_e::execute_edit_action(
"insert_char",
key,
login_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count
).await
)
.await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action(
"insert_char",
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(
"insert_char",
key,
&mut admin_state.add_logic_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
)
.await?
} else {
form_e::execute_edit_action(
"insert_char",
key,
form_state,
ideal_cursor_column,
grpc_client,
current_position,
total_count
).await
)
.await?
};
if app_state.ui.show_register && register_state.current_field() == 4 {
register_state.update_role_suggestions();
}
// Handle Backspace/Delete for Autocomplete Trigger
if matches!(key.code, KeyCode::Backspace | KeyCode::Delete) {
// If in suggestion mode, exit it before deleting char
if app_state.ui.show_register && register_state.in_suggestion_mode {
register_state.in_suggestion_mode = false;
register_state.show_role_suggestions = false;
register_state.selected_suggestion_index = None;
}
let is_role_field = app_state.ui.show_register && register_state.current_field() == 4;
let action_str = if key.code == KeyCode::Backspace { "backspace" } else { "delete_char" };
// Execute the action first
let result = 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 {
// Handle for login/form if needed, assuming auth_e covers RegisterState
Ok("Action not applicable here".to_string()) // Placeholder
}?;
return Ok(result);
}
Ok(command_message.clone())
return Ok(EditEventOutcome::Message(msg));
}

View File

@@ -6,9 +6,12 @@ use crate::services::grpc_client::GrpcClient;
use crate::state::pages::{canvas_state::CanvasState, auth::RegisterState};
use crate::state::pages::auth::LoginState;
use crate::state::pages::form::FormState;
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::add_table::AddTableState;
use crate::state::app::state::AppState;
use crate::functions::modes::read_only::{auth_ro, form_ro};
use crate::functions::modes::read_only::{add_logic_ro, auth_ro, form_ro, add_table_ro};
use crossterm::event::KeyEvent;
use anyhow::Result;
pub async fn handle_read_only_event(
app_state: &mut AppState,
@@ -17,6 +20,8 @@ pub async fn handle_read_only_event(
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
add_table_state: &mut AddTableState,
add_logic_state: &mut AddLogicState,
key_sequence_tracker: &mut KeySequenceTracker,
current_position: &mut u64,
total_count: u64,
@@ -24,7 +29,7 @@ pub async fn handle_read_only_event(
command_message: &mut String,
edit_mode_cooldown: &mut bool,
ideal_cursor_column: &mut usize,
) -> Result<(bool, String), Box<dyn std::error::Error>> {
) -> Result<(bool, String)> {
if config.is_enter_edit_mode_before(key.code, key.modifiers) {
*edit_mode_cooldown = true;
*command_message = "Entering Edit mode".to_string();
@@ -32,34 +37,18 @@ pub async fn handle_read_only_event(
}
if config.is_enter_edit_mode_after(key.code, key.modifiers) {
let (current_input, current_pos) = if app_state.ui.show_login { // Check Login first
(
login_state.get_current_input(),
login_state.current_cursor_pos(),
)
} else if app_state.ui.show_register { // Then check Register
(
register_state.get_current_input(),
register_state.current_cursor_pos(),
)
} else {
(
form_state.get_current_input(),
form_state.current_cursor_pos(),
) // Default to Form
};
// Determine target state to adjust cursor
let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state }
else if app_state.ui.show_add_logic { add_logic_state }
else if app_state.ui.show_register { register_state }
else if app_state.ui.show_add_table { add_table_state }
else { form_state };
let current_input = target_state.get_current_input();
let current_pos = target_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
if app_state.ui.show_login {
login_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = login_state.current_cursor_pos();
} else if app_state.ui.show_register {
register_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = register_state.current_cursor_pos();
} else { // Default to Form
form_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = form_state.current_cursor_pos();
}
target_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = target_state.current_cursor_pos();
}
*edit_mode_cooldown = true;
*command_message = "Entering Edit mode (after cursor)".to_string();
@@ -74,16 +63,12 @@ pub async fn handle_read_only_event(
"previous_entry",
"next_entry",
];
// Add context actions specific to register if needed, otherwise reuse login/form ones
const CONTEXT_ACTIONS_REGISTER: &[&str] = &[
// Add actions like "next_field", "prev_field" if handled differently than general read-only
];
if key.modifiers.is_empty() {
key_sequence_tracker.add_key(key.code);
let sequence = key_sequence_tracker.get_sequence();
if let Some(action) = config.matches_key_sequence_generalized(&sequence) {
if let Some(action) = config.matches_key_sequence_generalized(&sequence).as_deref() {
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
crate::tui::functions::form::handle_action(
action,
@@ -96,6 +81,24 @@ pub async fn handle_read_only_event(
.await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table {
add_table_ro::execute_action(
action,
app_state,
add_table_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register{
auth_ro::execute_action(
action,
@@ -134,7 +137,7 @@ pub async fn handle_read_only_event(
}
if sequence.len() == 1 && !config.is_key_sequence_prefix(&sequence) {
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers) {
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
crate::tui::functions::form::handle_action(
action,
@@ -147,7 +150,25 @@ pub async fn handle_read_only_event(
.await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_register /* && CONTEXT_ACTIONS_REGISTER.contains(&action) */ { // Handle register general actions
} else if app_state.ui.show_add_table {
add_table_ro::execute_action(
action,
app_state,
add_table_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register {
auth_ro::execute_action(
action,
app_state,
@@ -184,7 +205,7 @@ pub async fn handle_read_only_event(
} else {
key_sequence_tracker.reset();
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers) {
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
crate::tui::functions::form::handle_action(
action,
@@ -195,9 +216,27 @@ pub async fn handle_read_only_event(
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_register /* && CONTEXT_ACTIONS_REGISTER.contains(&action) */ { // Handle register general actions
} else if app_state.ui.show_add_table {
add_table_ro::execute_action(
action,
app_state,
add_table_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register {
auth_ro::execute_action(
action,
app_state,

View File

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

View File

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

View File

@@ -3,12 +3,14 @@
use crossterm::event::{Event, KeyCode};
use crate::config::binds::config::Config;
use crate::ui::handlers::context::DialogPurpose;
use crate::state::app::state::AppState;
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
use crate::state::app::{state::AppState, buffer::AppView};
use crate::state::app::buffer::BufferState;
use crate::state::pages::auth::{LoginState, RegisterState};
use crate::state::pages::admin::AdminState;
use crate::modes::handlers::event::EventOutcome;
use crate::tui::functions::common::{login, register};
use crate::tui::functions::common::add_table::handle_delete_selected_columns;
use anyhow::Result;
/// Handles key events specifically when a dialog is active.
/// Returns Some(Result<EventOutcome, Error>) if the event was handled (consumed),
@@ -17,10 +19,11 @@ pub async fn handle_dialog_event(
event: &Event,
config: &Config,
app_state: &mut AppState,
auth_state: &mut AuthState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
) -> Option<Result<EventOutcome, Box<dyn std::error::Error>>> {
buffer_state: &mut BufferState,
admin_state: &mut AdminState,
) -> Option<Result<EventOutcome>> {
if let Event::Key(key) = event {
// Always allow Esc to dismiss
if key.code == KeyCode::Esc {
@@ -62,7 +65,7 @@ pub async fn handle_dialog_event(
match selected_index {
0 => { // "Menu" button selected
app_state.hide_dialog();
let message = login::back_to_main(login_state, app_state).await;
let message = login::back_to_main(login_state, app_state, buffer_state).await;
return Some(Ok(EventOutcome::Ok(message)));
}
1 => {
@@ -91,8 +94,7 @@ pub async fn handle_dialog_event(
match selected_index {
0 => { // "OK" button for RegisterSuccess
app_state.hide_dialog();
// Go back to intro after successful registration dialog
let message = register::back_to_main(register_state, app_state).await;
let message = register::back_to_login(register_state, app_state, buffer_state).await;
return Some(Ok(EventOutcome::Ok(message)));
}
_ => { // Default for RegisterSuccess
@@ -113,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

View File

@@ -11,6 +11,7 @@ use crate::state::pages::admin::AdminState;
use crate::state::pages::canvas_state::CanvasState;
use crate::ui::handlers::context::UiContext;
use crate::modes::handlers::event::EventOutcome;
use anyhow::Result;
pub async fn handle_navigation_event(
key: KeyEvent,
@@ -24,7 +25,7 @@ pub async fn handle_navigation_event(
command_mode: &mut bool,
command_input: &mut String,
command_message: &mut String,
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
) -> Result<EventOutcome> {
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
match action {
"move_up" => {

View File

@@ -1,32 +1,48 @@
// src/modes/handlers/event.rs
use crossterm::event::Event;
use crossterm::cursor::SetCursorStyle;
use crate::tui::terminal::core::TerminalCore;
use crate::services::grpc_client::GrpcClient;
use crate::services::auth::AuthClient;
use crate::modes::common::commands::CommandHandler;
use crate::config::binds::config::Config;
use crate::state::pages::form::FormState;
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
use crate::state::pages::intro::IntroState;
use crate::state::pages::admin::AdminState;
use crate::state::app::state::AppState;
use crate::state::pages::canvas_state::CanvasState;
use crate::ui::handlers::rat_state::UiStateHandler;
use crate::ui::handlers::context::UiContext;
use crate::tui::functions::{intro, admin};
use crate::tui::functions::common::{login, register};
use crate::functions::common::buffer;
use anyhow::Result;
use crate::tui::{
terminal::core::TerminalCore,
functions::{
common::{form::SaveOutcome, login, register},
},
{intro, admin},
};
use crate::state::{
app::{
highlight::HighlightState,
state::AppState,
buffer::{AppView, BufferState},
},
pages::{
auth::{AuthState, LoginState, RegisterState},
admin::AdminState,
canvas_state::CanvasState,
form::FormState,
intro::IntroState,
},
};
use crate::modes::{
common::command_mode,
common::{command_mode, commands::CommandHandler},
handlers::mode_manager::{ModeManager, AppMode},
canvas::{edit, read_only, common_mode},
general::{navigation, dialog},
};
use crate::functions::modes::navigation::{admin_nav, add_table_nav};
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::modes::handlers::mode_manager::{ModeManager, AppMode};
use crate::state::app::state::AppView;
use crate::tui::functions::common::form::SaveOutcome;
use tokio::sync::mpsc;
use crate::tui::functions::common::login::LoginResult;
use crate::tui::functions::common::register::RegisterResult;
use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
use crate::functions::modes::navigation::add_logic_nav::SaveLogicResultSender;
use crate::functions::modes::navigation::add_logic_nav;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventOutcome {
@@ -41,23 +57,38 @@ pub struct EventHandler {
pub command_input: String,
pub command_message: String,
pub is_edit_mode: bool,
pub highlight_state: HighlightState,
pub edit_mode_cooldown: bool,
pub ideal_cursor_column: usize,
pub key_sequence_tracker: KeySequenceTracker,
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,
}
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 {
command_mode: false,
command_input: String::new(),
command_message: String::new(),
is_edit_mode: false,
highlight_state: HighlightState::Off,
edit_mode_cooldown: false,
ideal_cursor_column: 0,
key_sequence_tracker: KeySequenceTracker::new(800),
auth_client: AuthClient::new().await?,
login_result_sender,
register_result_sender,
save_table_result_sender,
save_logic_result_sender,
})
}
@@ -74,38 +105,44 @@ impl EventHandler {
register_state: &mut RegisterState,
intro_state: &mut IntroState,
admin_state: &mut AdminState,
buffer_state: &mut BufferState,
app_state: &mut AppState,
total_count: u64,
current_position: &mut u64,
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
let current_mode = ModeManager::derive_mode(app_state, self);
) -> Result<EventOutcome> {
let current_mode = ModeManager::derive_mode(app_state, self, admin_state);
app_state.update_mode(current_mode);
// Determine the current view, including dynamic names
let current_view = {
let ui = &app_state.ui;
if ui.show_intro { AppView::Intro }
else if ui.show_login { AppView::Login }
else if ui.show_register { AppView::Register }
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_form {
let form_name = app_state.selected_profile.clone().unwrap_or_else(|| "Data Form".to_string());
AppView::Form(form_name)
}
else { AppView::Scratch }
};
app_state.ui.update_buffer_history(current_view);
buffer_state.update_history(current_view);
// --- DIALOG MODALITY ---
if app_state.ui.dialog.dialog_show {
if let Some(dialog_result) = dialog::handle_dialog_event(
&event, config, app_state, auth_state, login_state, register_state
&event,
config,
app_state,
login_state,
register_state,
buffer_state,
admin_state,
).await {
return dialog_result;
}
return Ok(EventOutcome::Ok(String::new()));
}
// --- END DIALOG MODALITY CHECK ---
if let Event::Key(key) = event {
let key_code = key.code;
@@ -123,10 +160,82 @@ impl EventHandler {
);
return Ok(EventOutcome::Ok(message));
}
// --- End Global UI Toggles ---
if !matches!(current_mode, AppMode::Edit | AppMode::Command) {
if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.global, key_code, modifiers
) {
match action {
"next_buffer" => {
if buffer::switch_buffer(buffer_state, true) {
return Ok(EventOutcome::Ok("Switched to next buffer".to_string()));
}
}
"previous_buffer" => {
if buffer::switch_buffer(buffer_state, false) {
return Ok(EventOutcome::Ok("Switched to previous buffer".to_string()));
}
}
_ => {}
}
}
}
match current_mode {
AppMode::General => {
// Prioritize Admin Panel navigation if it's visible
if app_state.ui.show_admin
&& auth_state.role.as_deref() == Some("admin") {
if admin_nav::handle_admin_navigation(
key,
config,
app_state,
admin_state,
buffer_state,
&mut self.command_message,
) {
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
}
// --- Add Logic Page Navigation ---
if app_state.ui.show_add_logic {
let client_clone = grpc_client.clone();
let sender_clone = self.save_logic_result_sender.clone();
if add_logic_nav::handle_add_logic_navigation(
key,
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()));
}
}
// --- Add Table Page Navigation ---
if app_state.ui.show_add_table {
let client_clone = grpc_client.clone();
let sender_clone = self.save_table_result_sender.clone();
if add_table_nav::handle_add_table_navigation(
key,
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(
key,
config,
@@ -142,59 +251,89 @@ impl EventHandler {
).await;
match nav_outcome {
Ok(EventOutcome::ButtonSelected { context, index }) => {
let mut message = String::from("Selected"); // Default message
match context {
let message = match context {
UiContext::Intro => {
intro::handle_intro_selection(app_state, index);
intro::handle_intro_selection(app_state, buffer_state, index);
if app_state.ui.show_admin {
let profile_names = app_state.profile_tree.profiles.iter()
.map(|p| p.name.clone())
.collect();
admin_state.set_profiles(profile_names);
if !app_state.profile_tree.profiles.is_empty() {
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).await,
let login_action_message = 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(),
};
login_action_message
}
UiContext::Register => {
message = match index {
0 => register::save(register_state, &mut self.auth_client, app_state).await?,
1 => register::back_to_main(register_state, app_state).await,
let register_action_message = 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(),
};
register_action_message
}
UiContext::Admin => {
admin::handle_admin_selection(app_state, admin_state);
message = format!("Admin Option {} selected", index);
format!("Admin Option {} selected", index)
}
UiContext::Dialog => {
message = "Internal error: Unexpected dialog state".to_string();
"Internal error: Unexpected dialog state".to_string()
}
}; // Semicolon added here
return Ok(EventOutcome::Ok(message));
}
return Ok(EventOutcome::Ok(message)); // Return Ok with message
}
other => return other, // Pass through Ok, Err, DataSaved directly
other => return other,
}
},
AppMode::ReadOnly => {
if config.is_enter_edit_mode_before(key_code, modifiers) &&
ModeManager::can_enter_edit_mode(current_mode) {
// 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)
{
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.command_message = "-- LINE HIGHLIGHT --".to_string();
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
// Check for Character-wise highlight
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode")
&& 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);
self.highlight_state = HighlightState::Characterwise { anchor };
self.command_message = "-- HIGHLIGHT --".to_string();
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
// 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.edit_mode_cooldown = true;
self.command_message = "Edit mode".to_string();
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
if config.is_enter_edit_mode_after(key_code, modifiers) &&
ModeManager::can_enter_edit_mode(current_mode) {
// Check for entering edit mode (after cursor)
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) {
let current_input = if app_state.ui.show_login || app_state.ui.show_register{
login_state.get_current_input()
} else {
@@ -217,25 +356,22 @@ impl EventHandler {
}
self.is_edit_mode = true;
self.edit_mode_cooldown = true;
app_state.ui.focus_outside_canvas = false;
self.command_message = "Edit mode (after cursor)".to_string();
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
if let Some(action) = config.get_read_only_action_for_key(key_code, modifiers) {
if action == "enter_command_mode" && ModeManager::can_enter_command_mode(current_mode) {
// Check for entering command mode
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_command_mode")
&& ModeManager::can_enter_command_mode(current_mode) {
self.command_mode = true;
self.command_input.clear();
self.command_message.clear();
return Ok(EventOutcome::Ok(String::new()));
}
}
if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.common,
key_code,
modifiers
) {
// Check for common actions (save, quit, etc.) only if no mode change happened
if let Some(action) = config.get_common_action(key_code, modifiers) {
match action {
"save" | "force_quit" | "save_and_quit" | "revert" => {
return common_mode::handle_core_action(
@@ -256,6 +392,7 @@ impl EventHandler {
}
}
// If no mode change or specific common action handled, delegate to read_only handler
let (_should_exit, message) = read_only::handle_read_only_event(
app_state,
key,
@@ -263,6 +400,8 @@ impl EventHandler {
form_state,
login_state,
register_state,
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
&mut self.key_sequence_tracker,
current_position,
total_count,
@@ -271,59 +410,56 @@ impl EventHandler {
&mut self.edit_mode_cooldown,
&mut self.ideal_cursor_column,
).await?;
// Note: handle_read_only_event should ignore mode entry keys internally now
return Ok(EventOutcome::Ok(message));
},
AppMode::Edit => {
if config.is_exit_edit_mode(key_code, modifiers) {
self.is_edit_mode = false;
self.edit_mode_cooldown = true;
let has_changes = if app_state.ui.show_login || app_state.ui.show_register{
login_state.has_unsaved_changes()
} 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()
};
}, // End AppMode::ReadOnly
AppMode::Highlight => {
// --- Handle Highlight Mode Specific Keys ---
// 1. Check for Exit first
if config.get_highlight_action_for_key(key_code, modifiers) == Some("exit_highlight_mode") {
self.highlight_state = HighlightState::Off;
self.command_message = "Exited highlight mode".to_string();
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
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() {
let new_pos = current_input.len() - 1;
if app_state.ui.show_login || app_state.ui.show_register{
login_state.set_current_cursor_pos(new_pos);
self.ideal_cursor_column = login_state.current_cursor_pos();
} else {
form_state.set_current_cursor_pos(new_pos);
self.ideal_cursor_column = form_state.current_cursor_pos();
}
}
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
// 2. Check for Switch to Linewise
else if config.get_highlight_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") {
// 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()));
}
if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.common,
key_code,
modifiers
) {
let (_should_exit, message) = read_only::handle_read_only_event(
app_state, key, config, form_state, login_state,
register_state,
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
&mut self.key_sequence_tracker,
current_position,
total_count,
grpc_client,
&mut self.command_message,
&mut self.edit_mode_cooldown,
&mut self.ideal_cursor_column,
)
.await?;
return Ok(EventOutcome::Ok(message));
}
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) {
// 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 {
"save" | "force_quit" | "save_and_quit" | "revert" => {
// This call likely returns EventOutcome, handle it directly
return common_mode::handle_core_action(
action,
form_state,
@@ -338,27 +474,73 @@ impl EventHandler {
total_count,
).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.
}
let message = edit::handle_edit_event(
// If no common action took precedence, delegate to the edit-specific handler
let edit_result = edit::handle_edit_event(
key,
config,
form_state,
login_state,
register_state,
admin_state,
&mut self.ideal_cursor_column,
&mut self.command_message,
current_position,
total_count,
grpc_client,
app_state,
).await?;
).await;
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok(message));
},
match edit_result {
Ok(edit::EditEventOutcome::ExitEditMode) => {
// The edit handler signaled to exit the mode
self.is_edit_mode = false;
self.edit_mode_cooldown = true;
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() };
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)?;
// 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_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() {
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 };
target_state.set_current_cursor_pos(new_pos);
self.ideal_cursor_column = new_pos;
}
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
Ok(edit::EditEventOutcome::Message(msg)) => {
// Stay in edit mode, update message if not empty
if !msg.is_empty() {
self.command_message = msg;
}
self.key_sequence_tracker.reset(); // Reset sequence tracker on successful edit action
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
Err(e) => {
// Handle error from the edit handler
return Err(e.into());
}
}
}, // End AppMode::Edit
AppMode::Command => {
let outcome = command_mode::handle_command_event(

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ pub mod handlers;
pub mod canvas;
pub mod general;
pub mod common;
pub mod highlight;
pub use handlers::*;
pub use canvas::*;

View File

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

View File

@@ -8,62 +8,83 @@ use common::proto::multieko2::table_structure::table_structure_service_client::T
use common::proto::multieko2::table_structure::TableStructureResponse;
use common::proto::multieko2::table_definition::{
table_definition_client::TableDefinitionClient,
ProfileTreeResponse
ProfileTreeResponse, PostTableDefinitionRequest, TableDefinitionResponse,
};
use common::proto::multieko2::table_script::{
table_script_client::TableScriptClient,
PostTableScriptRequest, TableScriptResponse,
};
use anyhow::Result;
#[derive(Clone)]
pub struct GrpcClient {
adresar_client: AdresarClient<Channel>,
table_structure_client: TableStructureServiceClient<Channel>,
table_definition_client: TableDefinitionClient<Channel>,
table_script_client: TableScriptClient<Channel>,
}
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("http://[::1]:50051").await?;
let table_definition_client = TableDefinitionClient::connect("http://[::1]:50051").await?;
let table_script_client = TableScriptClient::connect("http://[::1]:50051").await?;
Ok(Self {
adresar_client,
table_structure_client,
table_definition_client,
table_script_client,
})
}
pub async fn get_adresar_count(&mut self) -> Result<u64, Box<dyn std::error::Error>> {
pub async fn get_adresar_count(&mut self) -> Result<u64> {
let request = tonic::Request::new(Empty::default());
let response: CountResponse = self.adresar_client.get_adresar_count(request).await?.into_inner();
Ok(response.count as u64)
}
pub async fn get_adresar_by_position(&mut self, position: u64) -> Result<AdresarResponse, Box<dyn std::error::Error>> {
pub async fn get_adresar_by_position(&mut self, position: u64) -> Result<AdresarResponse> {
let request = tonic::Request::new(PositionRequest { position: position as i64 });
let response: AdresarResponse = self.adresar_client.get_adresar_by_position(request).await?.into_inner();
Ok(response)
}
pub async fn post_adresar(&mut self, request: PostAdresarRequest) -> Result<tonic::Response<AdresarResponse>, Box<dyn std::error::Error>> {
pub async fn post_adresar(&mut self, request: PostAdresarRequest) -> Result<tonic::Response<AdresarResponse>> {
let request = tonic::Request::new(request);
let response = self.adresar_client.post_adresar(request).await?;
Ok(response)
}
pub async fn put_adresar(&mut self, request: PutAdresarRequest) -> Result<tonic::Response<AdresarResponse>, Box<dyn std::error::Error>> {
pub async fn put_adresar(&mut self, request: PutAdresarRequest) -> Result<tonic::Response<AdresarResponse>> {
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>> {
pub async fn get_table_structure(&mut self) -> Result<TableStructureResponse> {
let request = tonic::Request::new(Empty::default());
let response = self.table_structure_client.get_adresar_table_structure(request).await?;
Ok(response.into_inner())
}
pub async fn get_profile_tree(&mut self) -> Result<ProfileTreeResponse, Box<dyn std::error::Error>> {
pub async fn get_profile_tree(&mut self) -> Result<ProfileTreeResponse> {
let request = tonic::Request::new(Empty::default());
let response = self.table_definition_client.get_profile_tree(request).await?;
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?;
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?;
Ok(response.into_inner())
}
}

View File

@@ -4,6 +4,7 @@ use crate::services::grpc_client::GrpcClient;
use crate::state::pages::form::FormState;
use crate::tui::functions::common::form::SaveOutcome;
use crate::state::app::state::AppState;
use anyhow::{Context, Result};
pub struct UiService;
@@ -11,9 +12,9 @@ impl UiService {
pub async fn initialize_app_state(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
) -> Result<Vec<String>> {
// Fetch profile tree
let profile_tree = grpc_client.get_profile_tree().await?;
let profile_tree = grpc_client.get_profile_tree().await.context("Failed to get profile tree")?;
app_state.profile_tree = profile_tree;
// Fetch table structure
@@ -32,8 +33,8 @@ impl UiService {
pub async fn initialize_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?;
) -> Result<()> {
let total_count = grpc_client.get_adresar_count().await.context("Failed to get adresar count")?;
app_state.update_total_count(total_count);
app_state.update_current_position(total_count.saturating_add(1)); // Start in new entry mode
Ok(())
@@ -42,18 +43,18 @@ impl UiService {
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?;
) -> Result<()> {
let total_count = grpc_client.get_adresar_count().await.context("Failed to get adresar by position")?;
app_state.update_total_count(total_count);
Ok(())
}
pub async fn load_adresar_by_position(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
_app_state: &mut AppState,
form_state: &mut FormState,
position: u64,
) -> Result<String, Box<dyn std::error::Error>> {
) -> Result<String> {
match grpc_client.get_adresar_by_position(position).await {
Ok(response) => {
// Set the ID properly
@@ -92,8 +93,8 @@ impl UiService {
save_outcome: SaveOutcome,
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
form_state: &mut FormState, // Needed to potentially update position/ID
) -> Result<(), Box<dyn std::error::Error>> {
form_state: &mut FormState,
) -> Result<()> {
match save_outcome {
SaveOutcome::CreatedNew(new_id) => {
// A new record was created, update the count!

View File

@@ -1,3 +1,5 @@
// src/state/app.rs
pub mod state;
pub mod buffer;
pub mod highlight;

View File

@@ -0,0 +1,96 @@
// src/state/app/buffer.rs
/// Represents the distinct views or "buffers" the user can navigate.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppView {
Intro,
Login,
Register,
Admin,
AddTable,
AddLogic,
Form(String),
Scratch,
}
impl AppView {
/// Returns the display name for the view.
pub fn display_name(&self) -> &str {
match self {
AppView::Intro => "Intro",
AppView::Login => "Login",
AppView::Register => "Register",
AppView::Admin => "Admin_Panel",
AppView::AddTable => "Add_Table",
AppView::AddLogic => "Add_Logic",
AppView::Form(name) => name.as_str(),
AppView::Scratch => "*scratch*",
}
}
}
/// Holds the state related to buffer management (navigation history).
#[derive(Debug, Clone)]
pub struct BufferState {
pub history: Vec<AppView>,
pub active_index: usize,
}
impl Default for BufferState {
fn default() -> Self {
Self {
history: vec![AppView::Intro], // Start with Intro view
active_index: 0,
}
}
}
impl BufferState {
/// Updates the buffer history and active index.
/// If the view already exists, it sets it as active.
/// Otherwise, it adds the new view and makes it active.
pub fn update_history(&mut self, view: AppView) {
let existing_pos = self.history.iter().position(|v| v == &view);
match existing_pos {
Some(pos) => {
self.active_index = pos;
}
None => {
self.history.push(view.clone());
self.active_index = self.history.len() - 1;
}
}
}
/// Gets the currently active view.
pub fn get_active_view(&self) -> Option<&AppView> {
self.history.get(self.active_index)
}
/// Removes the currently active buffer from the history, unless it's the Intro buffer.
/// Sets the new active buffer to the one preceding the closed one.
/// # Returns
/// * `true` if a non-Intro buffer was closed.
/// * `false` if the active buffer was Intro or only Intro remained.
pub fn close_active_buffer(&mut self) -> bool {
let current_index = self.active_index;
// Rule 1: Cannot close Intro buffer.
if matches!(self.history.get(current_index), Some(AppView::Intro)) {
return false;
}
// Rule 2: Cannot close if only Intro would remain (or already remains).
// This check implicitly covers the case where len <= 1.
if self.history.len() <= 1 {
return false;
}
self.history.remove(current_index);
self.active_index = current_index.saturating_sub(1).min(self.history.len() - 1);
true
}
}

View File

@@ -0,0 +1,20 @@
// src/state/app/highlight.rs
/// Represents the different states of text highlighting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HighlightState {
/// Highlighting is inactive.
Off,
/// Highlighting character by character. Stores the anchor point (line index, char index).
Characterwise { anchor: (usize, usize) },
/// Highlighting line by line. Stores the anchor line index.
Linewise { anchor_line: usize },
}
impl Default for HighlightState {
/// The default state is no highlighting.
fn default() -> Self {
HighlightState::Off
}
}

View File

@@ -3,32 +3,8 @@
use std::env;
use common::proto::multieko2::table_definition::ProfileTreeResponse;
use crate::modes::handlers::mode_manager::AppMode;
use crate::ui::handlers::context::DialogPurpose;
/// Represents the distinct views or "buffers" the user can navigate.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppView {
Intro,
Login,
Register,
Admin,
Form(String),
Scratch,
}
impl AppView {
/// Returns the display name for the view.
pub fn display_name(&self) -> &str {
match self {
AppView::Intro => "Intro",
AppView::Login => "Login",
AppView::Register => "Register",
AppView::Admin => "Admin Panel",
AppView::Form(name) => name.as_str(),
AppView::Scratch => "*scratch*",
}
}
}
use crate::ui::handlers::context::{DialogPurpose, UiContext};
use anyhow::Result;
pub struct DialogState {
pub dialog_show: bool,
@@ -37,6 +13,7 @@ pub struct DialogState {
pub dialog_buttons: Vec<String>,
pub dialog_active_button_index: usize,
pub purpose: Option<DialogPurpose>,
pub is_loading: bool,
}
pub struct UiState {
@@ -44,12 +21,12 @@ pub struct UiState {
pub show_buffer_list: bool,
pub show_intro: bool,
pub show_admin: bool,
pub show_add_table: bool,
pub show_add_logic: bool,
pub show_form: bool,
pub show_login: bool,
pub show_register: bool,
pub focus_outside_canvas: bool,
pub buffer_history: Vec<AppView>,
pub active_buffer_index: usize,
pub dialog: DialogState,
}
@@ -68,7 +45,7 @@ pub struct AppState {
}
impl AppState {
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
pub fn new() -> Result<Self> {
let current_dir = env::current_dir()?
.to_string_lossy()
.to_string();
@@ -112,10 +89,42 @@ impl AppState {
self.ui.dialog.dialog_buttons = buttons;
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = Some(purpose);
self.ui.dialog.is_loading = false;
self.ui.dialog.dialog_show = 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.
pub fn hide_dialog(&mut self) {
self.ui.dialog.dialog_show = false;
@@ -158,16 +167,16 @@ impl AppState {
impl Default for UiState {
fn default() -> Self {
Self {
show_sidebar: true,
show_sidebar: false,
show_intro: true,
show_admin: false,
show_add_table: false,
show_add_logic: false,
show_form: false,
show_login: false,
show_register: false,
show_buffer_list: false,
show_buffer_list: true,
focus_outside_canvas: false,
buffer_history: vec![AppView::Intro],
active_buffer_index: 0,
dialog: DialogState::default(),
}
}
@@ -183,23 +192,7 @@ impl Default for DialogState {
dialog_buttons: Vec::new(),
dialog_active_button_index: 0,
purpose: None,
}
}
}
impl UiState {
/// Updates the buffer history and active index.
pub fn update_buffer_history(&mut self, view: AppView) {
let existing_pos = self.buffer_history.iter().position(|v| v == &view);
match existing_pos {
Some(pos) => {
self.active_buffer_index = pos;
}
None => {
self.buffer_history.push(view.clone());
self.active_buffer_index = self.buffer_history.len() - 1;
}
is_loading: false,
}
}
}

View File

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

View File

@@ -0,0 +1,159 @@
// 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}; // Add VimState import
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,
InputScriptContent,
InputDescription,
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 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, // Add this field
}
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,
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(), // Add this field initialization
}
}
pub const INPUT_FIELD_COUNT: usize = 3;
}
impl Default for AddLogicState {
fn default() -> Self {
Self::new(&EditorConfig::default())
}
}
// ... rest of the CanvasState implementation remains the same
impl CanvasState for AddLogicState {
fn current_field(&self) -> usize {
match self.current_focus {
AddLogicFocus::InputLogicName => 0,
AddLogicFocus::InputTargetColumn => 1,
AddLogicFocus::InputDescription => 2,
_ => 0,
}
}
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) {
self.current_focus = match index {
0 => AddLogicFocus::InputLogicName,
1 => AddLogicFocus::InputTargetColumn,
2 => AddLogicFocus::InputDescription,
_ => self.current_focus,
};
}
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]> {
None
}
fn get_selected_suggestion_index(&self) -> Option<usize> {
None
}
}

View File

@@ -0,0 +1,178 @@
// src/state/pages/add_table.rs
use crate::state::pages::canvas_state::CanvasState;
use ratatui::widgets::TableState;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnDefinition {
pub name: 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)]
pub struct LinkDefinition {
pub linked_table_name: String,
pub is_required: bool,
pub selected: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AddTableFocus {
#[default]
InputTableName, // Field 0 for CanvasState
InputColumnName, // Field 1 for CanvasState
InputColumnType, // Field 2 for CanvasState
AddColumnButton,
// Result Tables
ColumnsTable,
IndexesTable,
LinksTable,
// Inside Tables (Scrolling Focus)
InsideColumnsTable,
InsideIndexesTable,
InsideLinksTable,
// Buttons
SaveButton,
DeleteSelectedButton,
CancelButton,
}
#[derive(Debug, Clone)]
pub struct AddTableState {
pub profile_name: String,
pub table_name: String,
pub table_name_input: String,
pub column_name_input: String,
pub column_type_input: String,
pub columns: Vec<ColumnDefinition>,
pub indexes: Vec<IndexDefinition>,
pub links: Vec<LinkDefinition>,
pub current_focus: AddTableFocus,
pub column_table_state: TableState,
pub index_table_state: TableState,
pub link_table_state: TableState,
pub table_name_cursor_pos: usize,
pub column_name_cursor_pos: usize,
pub column_type_cursor_pos: usize,
pub has_unsaved_changes: bool,
}
impl Default for AddTableState {
fn default() -> Self {
// Initialize with some dummy data for demonstration
AddTableState {
profile_name: "default".to_string(),
table_name: String::new(),
table_name_input: String::new(),
column_name_input: String::new(),
column_type_input: String::new(),
columns: Vec::new(),
indexes: Vec::new(),
links: Vec::new(),
current_focus: AddTableFocus::InputTableName,
column_table_state: TableState::default(),
index_table_state: TableState::default(),
link_table_state: TableState::default(),
table_name_cursor_pos: 0,
column_name_cursor_pos: 0,
column_type_cursor_pos: 0,
has_unsaved_changes: false,
}
}
}
impl AddTableState {
pub const INPUT_FIELD_COUNT: usize = 3;
}
// Implement CanvasState for the input fields
impl CanvasState for AddTableState {
fn current_field(&self) -> usize {
match self.current_focus {
AddTableFocus::InputTableName => 0,
AddTableFocus::InputColumnName => 1,
AddTableFocus::InputColumnType => 2,
// If focus is elsewhere, default to the first field for canvas rendering logic
_ => 0,
}
}
fn current_cursor_pos(&self) -> usize {
match self.current_focus {
AddTableFocus::InputTableName => self.table_name_cursor_pos,
AddTableFocus::InputColumnName => self.column_name_cursor_pos,
AddTableFocus::InputColumnType => self.column_type_cursor_pos,
_ => 0, // Default if focus is not on an input field
}
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn inputs(&self) -> Vec<&String> {
vec![&self.table_name_input, &self.column_name_input, &self.column_type_input]
}
fn get_current_input(&self) -> &str {
match self.current_focus {
AddTableFocus::InputTableName => &self.table_name_input,
AddTableFocus::InputColumnName => &self.column_name_input,
AddTableFocus::InputColumnType => &self.column_type_input,
_ => "", // Should not happen if called correctly
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_focus {
AddTableFocus::InputTableName => &mut self.table_name_input,
AddTableFocus::InputColumnName => &mut self.column_name_input,
AddTableFocus::InputColumnType => &mut self.column_type_input,
_ => &mut self.table_name_input,
}
}
fn fields(&self) -> Vec<&str> {
// These must match the order used in render_add_table
vec!["Table name", "Name", "Type"]
}
fn set_current_field(&mut self, index: usize) {
self.current_focus = match index {
0 => AddTableFocus::InputTableName,
1 => AddTableFocus::InputColumnName,
2 => AddTableFocus::InputColumnType,
_ => self.current_focus, // Stay on current focus if index is out of bounds
};
}
fn set_current_cursor_pos(&mut self, pos: usize) {
match self.current_focus {
AddTableFocus::InputTableName => self.table_name_cursor_pos = pos,
AddTableFocus::InputColumnName => self.column_name_cursor_pos = pos,
AddTableFocus::InputColumnType => self.column_type_cursor_pos = pos,
_ => {} // Do nothing if focus is not on an input field
}
}
fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed;
}
// --- Autocomplete Support (Not needed for this form yet) ---
fn get_suggestions(&self) -> Option<&[String]> {
None
}
fn get_selected_suggestion_index(&self) -> Option<usize> {
None
}
}

View File

@@ -1,65 +1,181 @@
// src/state/pages/admin.rs
use ratatui::widgets::ListState;
use crate::state::pages::add_table::AddTableState;
use crate::state::pages::add_logic::AddLogicState;
// Define the focus states for the admin panel panes
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AdminFocus {
#[default] // Default focus is on the profiles list
Profiles,
Tables,
InsideTablesList,
Button1,
Button2,
Button3,
}
#[derive(Default, Clone, Debug)]
pub struct AdminState {
pub profiles: Vec<String>,
pub list_state: ListState,
pub profiles: Vec<String>, // Holds profile names (used by non-admin view)
pub profile_list_state: ListState, // Tracks navigation highlight (>) in profiles
pub table_list_state: ListState, // Tracks navigation highlight (>) in tables
pub selected_profile_index: Option<usize>, // Index with [*] in profiles (persistent)
pub selected_table_index: Option<usize>, // Index with [*] in tables (persistent)
pub current_focus: AdminFocus, // Tracks which pane is focused
pub add_table_state: AddTableState,
pub add_logic_state: AddLogicState,
}
impl AdminState {
/// Gets the index of the currently selected item.
pub fn get_selected_index(&self) -> Option<usize> {
self.list_state.selected()
self.profile_list_state.selected()
}
/// Gets the name of the currently selected profile.
pub fn get_selected_profile_name(&self) -> Option<&String> {
self.list_state.selected().and_then(|i| self.profiles.get(i))
self.profile_list_state.selected().and_then(|i| self.profiles.get(i))
}
/// Populates the profile list and updates/resets the selection.
pub fn set_profiles(&mut self, new_profiles: Vec<String>) {
let current_selection_index = self.list_state.selected();
let current_selection_index = self.profile_list_state.selected();
self.profiles = new_profiles;
if self.profiles.is_empty() {
self.list_state.select(None);
self.profile_list_state.select(None);
} else {
let new_selection = match current_selection_index {
Some(index) => Some(index.min(self.profiles.len() - 1)),
None => Some(0),
};
self.list_state.select(new_selection);
self.profile_list_state.select(new_selection);
}
}
/// Selects the next profile in the list, wrapping around.
pub fn next(&mut self) {
if self.profiles.is_empty() {
self.list_state.select(None);
self.profile_list_state.select(None);
return;
}
let i = match self.list_state.selected() {
let i = match self.profile_list_state.selected() {
Some(i) => if i >= self.profiles.len() - 1 { 0 } else { i + 1 },
None => 0,
};
self.list_state.select(Some(i));
self.profile_list_state.select(Some(i));
}
/// Selects the previous profile in the list, wrapping around.
pub fn previous(&mut self) {
if self.profiles.is_empty() {
self.list_state.select(None);
self.profile_list_state.select(None);
return;
}
let i = match self.list_state.selected() {
let i = match self.profile_list_state.selected() {
Some(i) => if i == 0 { self.profiles.len() - 1 } else { i - 1 },
None => self.profiles.len() - 1,
};
self.list_state.select(Some(i));
self.profile_list_state.select(Some(i));
}
/// Gets the index of the currently selected profile.
pub fn get_selected_profile_index(&self) -> Option<usize> {
self.profile_list_state.selected()
}
/// Gets the index of the currently selected table.
pub fn get_selected_table_index(&self) -> Option<usize> {
self.table_list_state.selected()
}
/// Selects a profile by index and resets table selection.
pub fn select_profile(&mut self, index: Option<usize>) {
self.profile_list_state.select(index);
self.table_list_state.select(None);
}
/// Selects a table by index.
pub fn select_table(&mut self, index: Option<usize>) {
self.table_list_state.select(index);
}
/// Selects the next profile, wrapping around.
/// `profile_count` should be the total number of profiles available.
pub fn next_profile(&mut self, profile_count: usize) {
if profile_count == 0 {
return;
}
let i = match self.get_selected_profile_index() {
Some(i) => {
if i >= profile_count - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.select_profile(Some(i)); // Use the helper method
}
/// Selects the previous profile, wrapping around.
/// `profile_count` should be the total number of profiles available.
pub fn previous_profile(&mut self, profile_count: usize) {
if profile_count == 0 {
return;
}
let i = match self.get_selected_profile_index() {
Some(i) => {
if i == 0 {
profile_count - 1
} else {
i - 1
}
}
None => 0, // Or profile_count - 1 if you prefer wrapping from None
};
self.select_profile(Some(i)); // Use the helper method
}
/// Selects the next table, wrapping around.
/// `table_count` should be the number of tables in the *currently selected* profile.
pub fn next_table(&mut self, table_count: usize) {
if table_count == 0 {
return;
}
let i = match self.get_selected_table_index() {
Some(i) => {
if i >= table_count - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.select_table(Some(i));
}
/// Selects the previous table, wrapping around.
/// `table_count` should be the number of tables in the *currently selected* profile.
pub fn previous_table(&mut self, table_count: usize) {
if table_count == 0 {
return;
}
let i = match self.get_selected_table_index() {
Some(i) => {
if i == 0 {
table_count - 1
} else {
i - 1
}
}
None => 0, // Or table_count - 1
};
self.select_table(Some(i));
}
}

View File

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

View File

@@ -2,12 +2,13 @@
use crate::config::colors::themes::Theme;
use ratatui::layout::Rect;
use ratatui::Frame;
use crate::state::app::highlight::HighlightState;
use crate::state::pages::canvas_state::CanvasState;
pub struct FormState {
pub id: i64,
pub fields: Vec<String>, // Use Vec<String> for dynamic field names
pub values: Vec<String>, // Store field values dynamically
pub fields: Vec<String>,
pub values: Vec<String>,
pub current_field: usize,
pub has_unsaved_changes: bool,
pub current_cursor_pos: usize,
@@ -33,6 +34,7 @@ impl FormState {
area: Rect,
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
total_count: u64,
current_position: u64,
) {
@@ -48,6 +50,7 @@ impl FormState {
&values,
theme,
is_edit_mode,
highlight_state,
total_count,
current_position,
);

View File

@@ -3,3 +3,4 @@
pub mod form;
pub mod login;
pub mod register;
pub mod add_table;

View File

@@ -0,0 +1,198 @@
// src/tui/functions/common/add_table.rs
use crate::state::pages::add_table::{
AddTableFocus, AddTableState, ColumnDefinition, IndexDefinition, LinkDefinition,
};
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.
///
/// Takes the mutable state and command message string.
/// Returns `Some(AddTableFocus)` indicating the desired focus state after a successful add,
/// or `None` if the action failed (e.g., validation error).
pub fn handle_add_column_action(
add_table_state: &mut AddTableState,
command_message: &mut String,
) -> Option<AddTableFocus> {
// Trim and create owned Strings from inputs
let table_name_in = add_table_state.table_name_input.trim();
let column_name_in = add_table_state.column_name_input.trim();
let column_type_in = add_table_state.column_type_input.trim();
// Validate all inputs needed for this combined action
let has_table_name = !table_name_in.is_empty();
let has_column_name = !column_name_in.is_empty();
let has_column_type = !column_type_in.is_empty();
match (has_table_name, has_column_name, has_column_type) {
// Case 1: Both column fields have input (Table name is optional here)
(_, true, true) => {
let mut msg = String::new();
// Optionally update table name if provided
if has_table_name {
add_table_state.table_name = table_name_in.to_string();
msg.push_str(&format!("Table name set to '{}'. ", add_table_state.table_name));
}
// Add the column
let new_column = ColumnDefinition {
name: column_name_in.to_string(),
data_type: column_type_in.to_string(),
selected: false,
};
add_table_state.columns.push(new_column.clone()); // Clone for msg
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;
// Clear all inputs and reset cursors
add_table_state.table_name_input.clear();
add_table_state.column_name_input.clear();
add_table_state.column_type_input.clear();
add_table_state.table_name_cursor_pos = 0;
add_table_state.column_name_cursor_pos = 0;
add_table_state.column_type_cursor_pos = 0;
add_table_state.has_unsaved_changes = true;
Some(AddTableFocus::InputColumnName) // Focus for next column
}
// Case 2: Only one column field has input (Error)
(_, true, false) | (_, false, true) => {
*command_message = "Both Column Name and Type are required to add a column.".to_string();
None // Indicate validation failure
}
// Case 3: Only Table name has input (No column input)
(true, false, false) => {
add_table_state.table_name = table_name_in.to_string();
*command_message = format!("Table name set to '{}'.", add_table_state.table_name);
// Clear only table name input
add_table_state.table_name_input.clear();
add_table_state.table_name_cursor_pos = 0;
add_table_state.has_unsaved_changes = true;
Some(AddTableFocus::InputTableName) // Keep focus here
}
// Case 4: All fields are empty
(false, false, false) => {
*command_message = "No input provided.".to_string();
None
}
}
}
/// 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

@@ -3,6 +3,7 @@
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::form::FormState;
use common::proto::multieko2::adresar::{PostAdresarRequest, PutAdresarRequest};
use anyhow::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveOutcome {
@@ -17,7 +18,7 @@ pub async fn save(
grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
) -> Result<SaveOutcome, Box<dyn std::error::Error>> { // <-- Return SaveOutcome
) -> Result<SaveOutcome> { // <-- Return SaveOutcome
if !form_state.has_unsaved_changes {
return Ok(SaveOutcome::NoChange); // Early exit if no changes
}
@@ -78,7 +79,7 @@ pub async fn revert(
grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
) -> Result<String, Box<dyn std::error::Error>> {
) -> Result<String> {
let is_new = *current_position == total_count + 1;
if is_new {

View File

@@ -4,8 +4,21 @@ use crate::services::auth::AuthClient;
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::app::state::AppState;
use crate::state::app::buffer::{AppView, BufferState};
use crate::state::pages::canvas_state::CanvasState;
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.
/// Updates AuthState and AppState on success or failure.
@@ -14,25 +27,42 @@ pub async fn save(
login_state: &mut LoginState,
auth_client: &mut AuthClient,
app_state: &mut AppState,
) -> Result<String, Box<dyn std::error::Error>> {
) -> Result<String> {
let identifier = login_state.username.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
login_state.error_message = None;
// Use the helper to ensure dialog is hidden and cleared properly
app_state.hide_dialog();
app_state.hide_dialog(); // Hide any previous dialog
// 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) => {
// Store authentication details on success
// Store authentication details using correct field names
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());
login_state.set_has_unsaved_changes(false);
login_state.error_message = None;
// Format the success message using response data
let success_message = format!(
"Login Successful!\n\n\
Username: {}\n\
@@ -49,23 +79,24 @@ pub async fn save(
vec!["Menu".to_string(), "Exit".to_string()],
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())
}
Err(e) => {
let error_message = format!("{}", e);
// Use the helper method to configure and show the dialog
app_state.show_dialog(
"Login Failed",
&error_message,
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
login_state.error_message = Some(error_message.clone());
login_state.set_has_unsaved_changes(true);
Ok(format!("Login failed: {}", error_message))
login_state.username.clear();
login_state.password.clear();
Err(e)
}
}
}
@@ -73,13 +104,14 @@ pub async fn save(
/// Reverts the login form fields to empty and returns to the previous screen (Intro).
pub async fn revert(
login_state: &mut LoginState,
app_state: &mut AppState,
_app_state: &mut AppState, // Keep signature consistent if needed elsewhere
) -> String {
// Clear the input fields
login_state.username.clear();
login_state.password.clear();
login_state.error_message = None;
login_state.set_has_unsaved_changes(false);
login_state.login_request_pending = false; // Ensure flag is reset on revert
"Login reverted".to_string()
}
@@ -87,19 +119,21 @@ pub async fn revert(
pub async fn back_to_main(
login_state: &mut LoginState,
app_state: &mut AppState,
buffer_state: &mut BufferState,
) -> String {
// Clear the input fields
login_state.username.clear();
login_state.password.clear();
login_state.error_message = None;
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
app_state.hide_dialog(); // Uncomment if needed
app_state.hide_dialog();
// Navigation logic (currently disabled in original code)
app_state.ui.show_login = false;
app_state.ui.show_intro = true;
// Navigation logic
buffer_state.close_active_buffer();
buffer_state.update_history(AppView::Intro);
// Reset focus state
app_state.ui.focus_outside_canvas = false;
@@ -107,3 +141,85 @@ pub async fn back_to_main(
"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());
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

@@ -1,116 +1,24 @@
// src/tui/functions/common/register.rs
use crate::{
services::auth::AuthClient,
state::{
use crate::services::auth::AuthClient;
use crate::state::{
pages::auth::RegisterState,
app::state::AppState,
pages::canvas_state::CanvasState,
},
ui::handlers::context::DialogPurpose,
};
use crate::ui::handlers::context::DialogPurpose;
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.
/// Updates RegisterState and AppState on success or failure.
pub async fn save(
register_state: &mut RegisterState,
auth_client: &mut AuthClient,
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))
}
}
#[derive(Debug)]
pub enum RegisterResult {
Success(AuthResponse),
Failure(String),
ConnectionError(String),
}
/// Clears the registration form fields.
@@ -131,9 +39,10 @@ pub async fn revert(
}
/// Clears the form and returns to the intro screen.
pub async fn back_to_main(
pub async fn back_to_login(
register_state: &mut RegisterState,
app_state: &mut AppState,
buffer_state: &mut BufferState,
) -> String {
// Clear fields first
let _ = revert(register_state, app_state).await;
@@ -142,8 +51,8 @@ pub async fn back_to_main(
app_state.hide_dialog();
// Navigation logic
app_state.ui.show_register = false;
app_state.ui.show_intro = true;
buffer_state.close_active_buffer();
buffer_state.update_history(AppView::Login);
// Reset focus state
app_state.ui.focus_outside_canvas = false;
@@ -152,3 +61,83 @@ pub async fn back_to_main(
"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

@@ -2,6 +2,7 @@
use crate::state::pages::form::FormState;
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::canvas_state::CanvasState;
use anyhow::{anyhow, Result};
pub async fn handle_action(
action: &str,
@@ -10,7 +11,7 @@ pub async fn handle_action(
current_position: &mut u64,
total_count: u64,
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() {
@@ -84,6 +85,7 @@ pub async fn handle_action(
Ok("Already at last entry".into())
}
}
_ => Err("Unknown form action".into())
_ => Err(anyhow!("Unknown form action: {}", action))
}
}

View File

@@ -1,31 +1,33 @@
// src/tui/functions/intro.rs
use crate::state::app::state::AppState;
use crate::state::app::buffer::{AppView, BufferState};
pub fn handle_intro_selection(app_state: &mut AppState, index: usize) {
match index { // Use index directly
0 => { // Continue
app_state.ui.show_form = true;
app_state.ui.show_admin = false;
app_state.ui.show_login = false;
/// Handles intro screen selection by updating view history and managing focus state.
/// 0: Continue (restores last form or default)
/// 1: Admin view
/// 2: Login view
/// 3: Register view (with focus reset)
pub fn handle_intro_selection(
app_state: &mut AppState,
buffer_state: &mut BufferState,
index: usize,
) {
let target_view = match index {
0 => {
let form_name = app_state.selected_profile.clone().unwrap_or_else(|| "Data Form".to_string());
AppView::Form(form_name)
}
1 => { // Admin
app_state.ui.show_form = false;
app_state.ui.show_admin = true;
app_state.ui.show_login = false;
}
2 => { // Login
app_state.ui.show_form = false;
app_state.ui.show_admin = false;
app_state.ui.show_login = true;
}
3 => { // Register
app_state.ui.show_intro = false;
app_state.ui.show_register = true;
1 => AppView::Admin,
2 => AppView::Login,
3 => AppView::Register,
_ => return,
};
buffer_state.update_history(target_view);
// Register view requires focus reset
if index == 3 {
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index = 0;
}
_ => {}
}
app_state.ui.show_intro = false;
}

View File

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

View File

@@ -1,6 +1,7 @@
// src/tui/terminal/event_reader.rs
use crossterm::event::{self, Event};
use anyhow::Result;
pub struct EventReader;
@@ -9,7 +10,7 @@ impl EventReader {
Self
}
pub fn read_event(&self) -> Result<Event, Box<dyn std::error::Error>> {
pub fn read_event(&self) -> Result<Event> {
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,6 +15,9 @@ pub enum DialogPurpose {
LoginFailed,
RegisterSuccess,
RegisterFailed,
ConfirmDeleteColumns,
SaveTableSuccess,
SaveLogicSuccess,
// TODO in the future:
// ConfirmQuit,
}

View File

@@ -8,6 +8,8 @@ use crate::components::{
intro::intro::render_intro,
handlers::sidebar::{self, calculate_sidebar_layout},
form::form::render_form,
admin::render_add_table,
admin::add_logic::render_add_logic,
auth::{login::render_login, register::render_register},
};
use crate::config::colors::themes::Theme;
@@ -18,8 +20,10 @@ use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
use crate::state::pages::intro::IntroState;
use crate::state::app::buffer::BufferState;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::state::app::highlight::HighlightState;
pub fn render_ui(
f: &mut Frame,
@@ -29,14 +33,17 @@ pub fn render_ui(
register_state: &RegisterState,
intro_state: &IntroState,
admin_state: &mut AdminState,
buffer_state: &BufferState,
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
total_count: u64,
current_position: u64,
current_dir: &str,
command_input: &str,
command_mode: bool,
command_message: &str,
current_fps: f64,
app_state: &AppState,
) {
render_background(f, f.area(), theme);
@@ -88,7 +95,28 @@ pub fn render_ui(
theme,
register_state,
app_state,
register_state.current_field < 4
register_state.current_field < 4,
highlight_state,
);
} else if app_state.ui.show_add_table {
render_add_table(
f,
main_content_area,
theme,
app_state,
&mut admin_state.add_table_state,
login_state.current_field < 3,
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_edit_mode, // Pass the general edit mode status
highlight_state,
);
} else if app_state.ui.show_login {
render_login(
@@ -97,11 +125,13 @@ pub fn render_ui(
theme,
login_state,
app_state,
login_state.current_field < 2
login_state.current_field < 2,
highlight_state,
);
} else if app_state.ui.show_admin {
crate::components::admin::admin_panel::render_admin_panel(
f,
app_state,
auth_state,
admin_state,
main_content_area,
@@ -162,6 +192,7 @@ pub fn render_ui(
&values,
theme,
is_edit_mode,
highlight_state,
total_count,
current_position,
);
@@ -170,9 +201,9 @@ pub fn render_ui(
// Render buffer list if enabled and area is available
if let Some(area) = buffer_list_area {
if app_state.ui.show_buffer_list {
render_buffer_list(f, area, theme, app_state);
render_buffer_list(f, area, theme, buffer_state);
}
}
render_status_line(f, status_line_area, current_dir, theme, is_edit_mode);
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

@@ -2,11 +2,11 @@
use crate::config::binds::config::Config;
use crate::config::colors::themes::Theme;
use crate::modes::common::commands::CommandHandler;
use crate::modes::handlers::event::{EventHandler, EventOutcome}; // Import EventOutcome
use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
use crate::services::grpc_client::GrpcClient;
use crate::services::ui_service::UiService;
use crate::modes::common::commands::CommandHandler;
use crate::modes::handlers::event::{EventHandler, EventOutcome};
use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState;
use crate::state::pages::auth::AuthState;
@@ -14,20 +14,49 @@ use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
use crate::state::pages::admin::AdminState;
use crate::state::pages::intro::IntroState;
use crate::state::app::buffer::BufferState;
use crate::state::app::buffer::AppView;
use crate::state::app::state::AppState;
// Import SaveOutcome
use crate::tui::terminal::{EventReader, TerminalCore};
use crate::ui::handlers::render::render_ui;
use crate::tui::functions::common::login::LoginResult;
use crate::tui::functions::common::register::RegisterResult;
use crate::tui::functions::common::add_table::handle_save_table_action;
use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
use crate::ui::handlers::context::{DialogPurpose, UiContext};
use crate::tui::functions::common::login;
use crate::tui::functions::common::register;
use std::time::Instant;
use anyhow::{Context, Result};
use crossterm::cursor::SetCursorStyle;
use crossterm::event as crossterm_event;
use tracing::{error, info};
use tokio::sync::mpsc;
pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load()?;
pub async fn run_ui() -> Result<()> {
let config = Config::load().context("Failed to load configuration")?;
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 command_handler = CommandHandler::new();
let mut event_handler = EventHandler::new().await?;
// --- Channel for Login Results ---
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, mut 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 mut auth_state = AuthState::default();
@@ -35,23 +64,69 @@ pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> {
let mut register_state = RegisterState::default();
let mut intro_state = IntroState::default();
let mut admin_state = AdminState::default();
let mut app_state = AppState::new()?;
let mut buffer_state = BufferState::default();
let mut app_state = AppState::new().context("Failed to create initial app state")?;
// Initialize app state with profile tree and table structure
let column_names =
UiService::initialize_app_state(&mut grpc_client, &mut app_state)
.await?;
.await.context("Failed to initialize app state from UI service")?;
let mut form_state = FormState::new(column_names);
// Fetch the total count of Adresar entries
UiService::initialize_adresar_count(&mut grpc_client, &mut app_state)
.await?;
UiService::initialize_adresar_count(&mut grpc_client, &mut app_state).await?;
form_state.reset_to_empty();
loop {
// Determine edit mode based on EventHandler state
let is_edit_mode = event_handler.is_edit_mode;
// --- FPS Calculation State ---
let mut last_frame_time = Instant::now();
let mut current_fps = 0.0;
let mut needs_redraw = true;
loop {
// --- Synchronize UI View from Active Buffer ---
if let Some(active_view) = buffer_state.get_active_view() {
// Reset all flags first
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);
}
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 => {} // Or show a scratchpad component
}
}
// --- End Synchronization ---
// --- 3. Draw UI ---
// Draw the current state *first*. This ensures the loading dialog
// set in the *previous* iteration gets rendered before the pending
// action check below.
if needs_redraw {
terminal.draw(|f| {
render_ui(
f,
@@ -61,56 +136,59 @@ pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> {
&register_state,
&intro_state,
&mut admin_state,
&buffer_state,
&theme,
is_edit_mode,
event_handler.is_edit_mode, // Use event_handler's state
&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,
);
})?;
}).context("Terminal draw call failed")?;
needs_redraw = false;
}
// --- Cursor Visibility Logic ---
let current_mode = ModeManager::derive_mode(&app_state, &event_handler);
// (Keep existing cursor logic here - depends on state drawn above)
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
match current_mode {
AppMode::Edit => {
terminal.show_cursor()?;
}
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()?;
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()?;
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")?; }
}
// --- End Cursor Visibility Logic ---
let total_count = app_state.total_count; // Keep track for save logic
let total_count = app_state.total_count;
let mut current_position = app_state.current_position;
let position_before_event = current_position;
// --- Determine if redraw is needed based on active login ---
// Always redraw if the loading dialog is currently showing.
if app_state.ui.dialog.is_loading {
needs_redraw = true;
}
let event = event_reader.read_event()?;
// Get the outcome from the event handler
let event_outcome_result = event_handler
.handle_event(
// --- 1. Handle Terminal Events ---
let mut event_outcome_result = Ok(EventOutcome::Ok(String::new()));
let mut event_processed = false;
// Poll for events *after* drawing and checking pending actions
if crossterm_event::poll(std::time::Duration::from_millis(1))? {
let event = event_reader.read_event().context("Failed to read terminal event")?;
event_processed = true; // Mark that we received and will process an event
event_outcome_result = event_handler.handle_event(
event,
&config,
&mut terminal,
@@ -122,24 +200,83 @@ pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> {
&mut register_state,
&mut intro_state,
&mut admin_state,
&mut buffer_state,
&mut app_state,
total_count,
&mut current_position,
)
.await;
).await;
}
if event_processed {
needs_redraw = true;
}
// Update position based on handler's modification
// This happens *after* the event is handled
app_state.current_position = current_position;
// --- Check for Login Results from Channel ---
match login_result_receiver.try_recv() {
Ok(result) => {
if login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) {
needs_redraw = true;
}
}
Err(mpsc::error::TryRecvError::Empty) => { /* No message waiting */ }
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Login result channel disconnected unexpectedly.");
// Optionally show an error dialog here
}
}
// --- Check for Register Results from Channel ---
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) => { /* No message waiting */ }
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Register result channel disconnected unexpectedly.");
}
}
// --- Check for Save Table Results ---
match save_table_result_receiver.try_recv() {
Ok(result) => {
app_state.hide_dialog(); // Hide loading indicator
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);
// Optionally show an error dialog instead of just command message
}
}
needs_redraw = true;
}
Err(mpsc::error::TryRecvError::Empty) => {} // No message
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Save table result channel disconnected unexpectedly.");
}
}
// --- Centralized Consequence Handling ---
let mut should_exit = false;
match event_outcome_result {
// Handle the Result first
Ok(outcome) => match outcome {
// Handle the Ok variant containing EventOutcome
EventOutcome::Ok(message) => {
if !message.is_empty() {
event_handler.command_message = message;
// Update command message only if event handling produced one
// Avoid overwriting messages potentially set by pending actions
// event_handler.command_message = message;
}
}
EventOutcome::Exit(message) => {
@@ -148,8 +285,6 @@ pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> {
}
EventOutcome::DataSaved(save_outcome, message) => {
event_handler.command_message = message; // Show save status
// *** Delegate outcome handling to UiService ***
if let Err(e) = UiService::handle_save_outcome(
save_outcome,
&mut grpc_client,
@@ -158,30 +293,27 @@ pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> {
)
.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
}
EventOutcome::ButtonSelected { context, index } => {
event_handler.command_message = "Internal error: Unexpected button state".to_string();
EventOutcome::ButtonSelected { context: _, index: _ } => {
// This case should ideally be fully handled within handle_event
// If initiate_login was called, it returned early.
// If not, the message was set and returned via Ok(message).
// Log if necessary, but likely no action needed here.
// log::warn!("ButtonSelected outcome reached main loop unexpectedly.");
}
},
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;
}
}
} // --- End Consequence Handling ---
// --- Position Change Handling (after outcome processing) ---
let position_changed =
app_state.current_position != position_before_event; // Calculate after potential update
// Recalculate total_count *after* potential update
// --- Position Change Handling (after outcome processing and pending actions) ---
let position_changed = app_state.current_position != position_before_event;
let current_total_count = app_state.total_count;
// Handle position changes and update form state (Only when form is shown)
let mut position_logic_needs_redraw = false;
if app_state.ui.show_form {
if position_changed && !event_handler.is_edit_mode {
let current_input = form_state.get_current_input();
@@ -192,6 +324,7 @@ pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> {
};
form_state.current_cursor_pos =
event_handler.ideal_cursor_column.min(max_cursor_pos);
position_logic_needs_redraw = true;
// Ensure position never exceeds total_count + 1
if app_state.current_position > current_total_count + 1 {
@@ -212,7 +345,7 @@ pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> {
&mut form_state,
current_position_to_load,
)
.await?;
.await.with_context(|| format!("Failed to load adresar by position: {}", current_position_to_load))?;
let current_input = form_state.get_current_input();
let max_cursor_pos = if !event_handler.is_edit_mode
@@ -272,12 +405,23 @@ pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> {
login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
}
}
if position_logic_needs_redraw {
needs_redraw = true;
}
// --- End Position Change Handling ---
// Check exit condition *after* processing outcome
// Check exit condition *after* all processing for the iteration
if should_exit {
// terminal.cleanup()?; // Optional: Drop handles this
return Ok(());
}
// --- FPS Calculation ---
let now = Instant::now();
let frame_duration = now.duration_since(last_frame_time);
last_frame_time = now;
if frame_duration.as_secs_f64() > 1e-6 {
current_fps = 1.0 / frame_duration.as_secs_f64();
}
} // End main loop
}

View File

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

View File

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

Binary file not shown.

View File

@@ -145,7 +145,7 @@ pub mod adresar_client {
}
impl<T> AdresarClient<T>
where
T: tonic::client::GrpcService<tonic::body::BoxBody>,
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,
@@ -166,13 +166,13 @@ pub mod adresar_client {
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
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<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{
AdresarClient::new(InterceptedService::new(inner, interceptor))
@@ -465,7 +465,7 @@ pub mod adresar_server {
B: Body + 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 Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(
@@ -751,7 +751,9 @@ pub mod adresar_server {
}
_ => {
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();
headers
.insert(

View File

@@ -83,7 +83,7 @@ pub mod auth_service_client {
}
impl<T> AuthServiceClient<T>
where
T: tonic::client::GrpcService<tonic::body::BoxBody>,
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,
@@ -104,13 +104,13 @@ pub mod auth_service_client {
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
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<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{
AuthServiceClient::new(InterceptedService::new(inner, interceptor))
@@ -277,7 +277,7 @@ pub mod auth_service_server {
B: Body + 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 Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(
@@ -378,7 +378,9 @@ pub mod auth_service_server {
}
_ => {
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();
headers
.insert(

View File

@@ -18,8 +18,6 @@ pub struct PostTableDefinitionRequest {
pub indexes: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
#[prost(string, tag = "5")]
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)]
pub struct ColumnDefinition {
@@ -44,9 +42,11 @@ pub struct ProfileTreeResponse {
pub mod profile_tree_response {
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Table {
#[prost(string, tag = "1")]
#[prost(int64, tag = "1")]
pub id: i64,
#[prost(string, tag = "2")]
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>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
@@ -99,7 +99,7 @@ pub mod table_definition_client {
}
impl<T> TableDefinitionClient<T>
where
T: tonic::client::GrpcService<tonic::body::BoxBody>,
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,
@@ -120,13 +120,13 @@ pub mod table_definition_client {
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
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<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{
TableDefinitionClient::new(InterceptedService::new(inner, interceptor))
@@ -351,7 +351,7 @@ pub mod table_definition_server {
B: Body + 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 Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(
@@ -504,7 +504,9 @@ pub mod table_definition_server {
}
_ => {
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();
headers
.insert(

View File

@@ -45,7 +45,7 @@ pub mod table_script_client {
}
impl<T> TableScriptClient<T>
where
T: tonic::client::GrpcService<tonic::body::BoxBody>,
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,
@@ -66,13 +66,13 @@ pub mod table_script_client {
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
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<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{
TableScriptClient::new(InterceptedService::new(inner, interceptor))
@@ -225,7 +225,7 @@ pub mod table_script_server {
B: Body + 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 Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(
@@ -283,7 +283,9 @@ pub mod table_script_server {
}
_ => {
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();
headers
.insert(

View File

@@ -43,7 +43,7 @@ pub mod table_structure_service_client {
}
impl<T> TableStructureServiceClient<T>
where
T: tonic::client::GrpcService<tonic::body::BoxBody>,
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,
@@ -64,13 +64,13 @@ pub mod table_structure_service_client {
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
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<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{
TableStructureServiceClient::new(InterceptedService::new(inner, interceptor))
@@ -260,7 +260,7 @@ pub mod table_structure_service_server {
B: Body + 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 Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(
@@ -375,7 +375,9 @@ pub mod table_structure_service_server {
}
_ => {
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();
headers
.insert(

View File

@@ -118,7 +118,7 @@ pub mod tables_data_client {
}
impl<T> TablesDataClient<T>
where
T: tonic::client::GrpcService<tonic::body::BoxBody>,
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,
@@ -139,13 +139,13 @@ pub mod tables_data_client {
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
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<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{
TablesDataClient::new(InterceptedService::new(inner, interceptor))
@@ -469,7 +469,7 @@ pub mod tables_data_server {
B: Body + 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 Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(
@@ -757,7 +757,9 @@ pub mod tables_data_server {
}
_ => {
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();
headers
.insert(

View File

@@ -112,7 +112,7 @@ pub mod uctovnictvo_client {
}
impl<T> UctovnictvoClient<T>
where
T: tonic::client::GrpcService<tonic::body::BoxBody>,
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,
@@ -133,13 +133,13 @@ pub mod uctovnictvo_client {
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
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<
http::Request<tonic::body::BoxBody>,
http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{
UctovnictvoClient::new(InterceptedService::new(inner, interceptor))
@@ -436,7 +436,7 @@ pub mod uctovnictvo_server {
B: Body + 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 Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(
@@ -681,7 +681,9 @@ pub mod uctovnictvo_server {
}
_ => {
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();
headers
.insert(

View File

@@ -2,7 +2,7 @@
name = "server"
version.workspace = true
edition.workspace = true
license.workspace = true
license = "AGPL-3.0-or-later"
[dependencies]
common = { path = "../common" }
@@ -10,14 +10,14 @@ common = { path = "../common" }
chrono = { version = "0.4.40", features = ["serde"] }
dotenvy = "0.15.7"
prost = "0.13.5"
serde = { version = "1.0.218", features = ["derive"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sqlx = { version = "0.8.3", features = ["chrono", "postgres", "runtime-tokio", "runtime-tokio-native-tls", "time", "uuid"] }
tokio = { version = "1.43.0", features = ["full", "macros"] }
tonic = "0.12.3"
tonic-reflection = "0.12.3"
sqlx = { version = "0.8.5", features = ["chrono", "postgres", "runtime-tokio", "runtime-tokio-native-tls", "time", "uuid"] }
tokio = { version = "1.44.2", features = ["full", "macros"] }
tonic = "0.13.0"
tonic-reflection = "0.13.0"
tracing = "0.1.41"
time = { version = "0.3.39", features = ["local-offset"] }
time = { version = "0.3.41", features = ["local-offset"] }
steel-derive = { git = "https://github.com/mattwparas/steel.git", branch = "master", package = "steel-derive" }
steel-core = { git = "https://github.com/mattwparas/steel.git", version = "0.6.0", features = ["anyhow", "dylibs", "sync"] }
thiserror = "2.0.12"
@@ -34,6 +34,6 @@ name = "server"
path = "src/lib.rs"
[dev-dependencies]
tokio = { version = "1.43", features = ["full", "test-util"] }
rstest = "0.24.0"
tokio = { version = "1.44", features = ["full", "test-util"] }
rstest = "0.25.0"
lazy_static = "1.5.0"

661
server/LICENSE Normal file
View File

@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@@ -1,8 +1,8 @@
// src/auth/handlers/login.rs
use bcrypt::verify;
use tonic::{Request, Response, Status};
use tonic::{Response, Status};
use crate::db::PgPool;
use crate::auth::{models::AuthError, logic::jwt}; // Fixed import path
use crate::auth::logic::jwt; // Fixed import path
use common::proto::multieko2::auth::{LoginRequest, LoginResponse};
pub async fn login(

View File

@@ -1,6 +1,6 @@
// src/auth/middleware.rs
use tonic::{metadata::MetadataValue, service::Interceptor, Status};
use crate::auth::{logic::jwt, models::AuthError};
use tonic::{service::Interceptor, Status};
use crate::auth::logic::jwt;
pub struct AuthInterceptor;

View File

@@ -1,6 +1,6 @@
// src/auth/models.rs
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use validator::Validate;
#[derive(Debug, Validate, Deserialize)]

View File

@@ -26,6 +26,7 @@ pub async fn get_profile_tree(
let tables = sqlx::query!(
r#"
SELECT
td.id as table_id,
td.table_name,
COALESCE(
json_agg(
@@ -40,7 +41,7 @@ pub async fn get_profile_tree(
LEFT JOIN table_definition_links tdl ON td.id = tdl.source_table_id
LEFT JOIN table_definitions ltd ON tdl.linked_table_id = ltd.id
WHERE td.profile_id = $1
GROUP BY td.table_name
GROUP BY td.id, td.table_name
"#,
profile.id
)
@@ -59,6 +60,7 @@ pub async fn get_profile_tree(
.unwrap_or_default();
Table {
id: record.table_id,
name: record.table_name,
depends_on: dependencies
.into_iter()