Compare commits
214 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1ebe4732f | ||
|
|
7b7f3ca05a | ||
|
|
234613f831 | ||
|
|
f6d84e70cc | ||
|
|
5cd324b6ae | ||
|
|
a7457f5749 | ||
|
|
a5afc75099 | ||
|
|
625c9b3e09 | ||
|
|
e20623ed53 | ||
|
|
aa9adf7348 | ||
|
|
2e82aba0d1 | ||
|
|
b7a3f0f8d9 | ||
|
|
38c82389f7 | ||
|
|
cb0a2bee17 | ||
|
|
dc99131794 | ||
|
|
5c23f61a10 | ||
|
|
f87e3c03cb | ||
|
|
d346670839 | ||
|
|
560d8b7234 | ||
|
|
b297c2b311 | ||
|
|
d390c567d5 | ||
|
|
029e614b9c | ||
|
|
f9a78e4eec | ||
|
|
d8758f7531 | ||
|
|
4e86ecff84 | ||
|
|
070d091e07 | ||
|
|
7403b3c3f8 | ||
|
|
1b1e7b7205 | ||
|
|
1b8f19f1ce | ||
|
|
2a14eadf34 | ||
|
|
fd36cd5795 | ||
|
|
f4286ac3c9 | ||
|
|
92d5eb4844 | ||
|
|
87b9f6ab87 | ||
|
|
06d98aab5c | ||
|
|
298f56a53c | ||
|
|
714a5f2f1c | ||
|
|
4e29d0084f | ||
|
|
63f1b4da2e | ||
|
|
9477f53432 | ||
|
|
ed786f087c | ||
|
|
8e22ea05ff | ||
|
|
8414657224 | ||
|
|
e25213ed1b | ||
|
|
4843b0778c | ||
|
|
f5fae98c69 | ||
|
|
6faf0a4a31 | ||
|
|
011fafc0ff | ||
|
|
8ebe74484c | ||
|
|
3eb9523103 | ||
|
|
3dfa922b9e | ||
|
|
248d54a30f | ||
|
|
b30fef4ccd | ||
|
|
a9c4527318 | ||
|
|
c31f08d5b8 | ||
|
|
9e0fa9ddb1 | ||
|
|
8fcd28832d | ||
|
|
cccf029464 | ||
|
|
512e7fb9e7 | ||
|
|
0e69df8282 | ||
|
|
eb5532c200 | ||
|
|
49ed1dfe33 | ||
|
|
62d1c3f7f5 | ||
|
|
b49dce3334 | ||
|
|
8ace9bc4d1 | ||
|
|
ce490007ed | ||
|
|
eb96c64e26 | ||
|
|
2ac96a8486 | ||
|
|
b8e6cc22af | ||
|
|
634a01f618 | ||
|
|
6abea062ba | ||
|
|
f50887a326 | ||
|
|
3c0af05a3c | ||
|
|
c9131d4457 | ||
|
|
2af79a3ef2 | ||
|
|
afd9228efa | ||
|
|
495d77fda5 | ||
|
|
679bb3b6ab | ||
|
|
350c522d19 | ||
|
|
4760f42589 | ||
|
|
50d15e321f | ||
|
|
a3e7fd8f0a | ||
|
|
645172747a | ||
|
|
7c4ac1eebc | ||
|
|
4b4301ad49 | ||
|
|
b60e03eb70 | ||
|
|
2c7bda3ff1 | ||
|
|
eeaaa3635b | ||
|
|
e61cbb3956 | ||
|
|
f9841f2ef3 | ||
|
|
dc232b2523 | ||
|
|
b086b3e236 | ||
|
|
387e1a0fe0 | ||
|
|
08e01d41f2 | ||
|
|
f5edf52571 | ||
|
|
02c62213c3 | ||
|
|
d0722fbbbe | ||
|
|
4ec569342d | ||
|
|
9540d9ccb9 | ||
|
|
6b5cbe854b | ||
|
|
59ed52814e | ||
|
|
3488ab4f6b | ||
|
|
6e2fc5349b | ||
|
|
ea88c2686d | ||
|
|
3df4baec92 | ||
|
|
ff74e1aaa1 | ||
|
|
b0c865ab76 | ||
|
|
3dbc086f10 | ||
|
|
e9b4b34fb4 | ||
|
|
668eeee197 | ||
|
|
799d8471c9 | ||
|
|
f77c16dec9 | ||
|
|
45026cac6a | ||
|
|
edf6ab5bca | ||
|
|
462b1f14e2 | ||
|
|
7a8f18b116 | ||
|
|
d255e4abb6 | ||
|
|
b770240f0d | ||
|
|
43b064673b | ||
|
|
bf2726c151 | ||
|
|
f3cd921c76 | ||
|
|
913f6b6b64 | ||
|
|
3463a52960 | ||
|
|
116db3566f | ||
|
|
32210a5f7c | ||
|
|
d8f9372bbd | ||
|
|
6e1997fd9d | ||
|
|
4e7213d1aa | ||
|
|
5afb427bb4 | ||
|
|
685361a11a | ||
|
|
bd7c97ca91 | ||
|
|
81235c67dc | ||
|
|
65e8e03224 | ||
|
|
85eb3adec7 | ||
|
|
5d0f958a68 | ||
|
|
b82f50b76b | ||
|
|
0ab11a9bf9 | ||
|
|
d28c310704 | ||
|
|
2e1d7fdf2b | ||
|
|
82e96f7b86 | ||
|
|
7229e2abbd | ||
|
|
4e35043da0 | ||
|
|
56fe1c2ccc | ||
|
|
a874edf2a1 | ||
|
|
9d55ec3e43 | ||
|
|
05580ac978 | ||
|
|
667eb4809d | ||
|
|
58fdaa8298 | ||
|
|
5478a2ac27 | ||
|
|
ad37990da9 | ||
|
|
66824030f2 | ||
|
|
90ca8cf97c | ||
|
|
3c8ea28da1 | ||
|
|
5c352eb863 | ||
|
|
8c312bc163 | ||
|
|
6fa8b06063 | ||
|
|
2992f122bc | ||
|
|
e507993065 | ||
|
|
6f22aad6f4 | ||
|
|
097264040f | ||
|
|
2c03ee6af0 | ||
|
|
ec596b2ada | ||
|
|
b01ba0b2d9 | ||
|
|
f74a6ef093 | ||
|
|
ee687fafbe | ||
|
|
60ba17cfea | ||
|
|
8b3aa5891e | ||
|
|
3ff9399b81 | ||
|
|
d18f7862ab | ||
|
|
dc6c1ce43c | ||
|
|
8d1adccec6 | ||
|
|
420ce71fb2 | ||
|
|
8e5a269ff0 | ||
|
|
f357d6f0ee | ||
|
|
a0467d17a8 | ||
|
|
ef3ecfc73f | ||
|
|
d3fcb23e22 | ||
|
|
5a029283a1 | ||
|
|
09ccad2bd4 | ||
|
|
bdcc10bd40 | ||
|
|
2a1fafc3f9 | ||
|
|
6010b9a0af | ||
|
|
11e8f87fe6 | ||
|
|
14b81cba19 | ||
|
|
2b37de3b4d | ||
|
|
73d9a6367c | ||
|
|
c90233b56f | ||
|
|
39dcf38462 | ||
|
|
efa27cd2dd | ||
|
|
ca231964f2 | ||
|
|
2bb83cb990 | ||
|
|
305bcfcf62 | ||
|
|
92a9011f27 | ||
|
|
e64cebdfc2 | ||
|
|
0db426d278 | ||
|
|
6bfef1c7a0 | ||
|
|
f50fe788cb | ||
|
|
4db78ecf1b | ||
|
|
f22dd7749f | ||
|
|
75af0c3be1 | ||
|
|
6f36b84f85 | ||
|
|
e723198b72 | ||
|
|
8f74febff1 | ||
|
|
d9bd6f8e1d | ||
|
|
bf55417901 | ||
|
|
9511970a1a | ||
|
|
5c8557b369 | ||
|
|
5e47c53fcf | ||
|
|
f7493a8bc4 | ||
|
|
4f39b93edd | ||
|
|
ff8b4eb0f6 | ||
|
|
e921862a7f | ||
|
|
4d7177f15a | ||
|
|
c5d7f56399 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
/target
|
||||
.env
|
||||
/tantivy_indexes
|
||||
server/tantivy_indexes
|
||||
|
||||
1283
Cargo.lock
generated
1283
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
28
Cargo.toml
28
Cargo.toml
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["client", "server", "common"]
|
||||
members = ["client", "server", "common", "search"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
@@ -16,4 +16,28 @@ categories = ["command-line-interface"]
|
||||
|
||||
# [workspace.metadata]
|
||||
# TODO:
|
||||
# documentation = "https://docs.rs/accounting-client"`
|
||||
# documentation = "https://docs.rs/accounting-client"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Async and gRPC
|
||||
tokio = { version = "1.44.2", features = ["full"] }
|
||||
tonic = "0.13.0"
|
||||
prost = "0.13.5"
|
||||
async-trait = "0.1.88"
|
||||
prost-types = "0.13.0"
|
||||
|
||||
# Data Handling & Serialization
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
time = "0.3.41"
|
||||
|
||||
# Utilities & Error Handling
|
||||
anyhow = "1.0.98"
|
||||
dotenvy = "0.15.7"
|
||||
lazy_static = "1.5.0"
|
||||
tracing = "0.1.41"
|
||||
|
||||
# Search crate
|
||||
tantivy = "0.24.1"
|
||||
|
||||
common = { path = "./common" }
|
||||
|
||||
@@ -14,3 +14,12 @@ Client:
|
||||
cargo watch -x 'run --package client -- client'
|
||||
```
|
||||
|
||||
Client with tracing:
|
||||
```
|
||||
ENABLE_TRACING=1 RUST_LOG=client=debug cargo watch -x 'run --package client -- client'
|
||||
```
|
||||
|
||||
Client with debug that cant be traced
|
||||
```
|
||||
cargo run --package client --features ui-debug -- client
|
||||
```
|
||||
|
||||
@@ -5,20 +5,35 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.98"
|
||||
async-trait = "0.1.88"
|
||||
common = { path = "../common" }
|
||||
|
||||
prost-types = { workspace = true }
|
||||
crossterm = "0.28.1"
|
||||
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"] }
|
||||
ratatui = { version = "0.29.0", features = ["crossterm"] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
time = "0.3.41"
|
||||
tokio = { version = "1.43.0", features = ["full", "macros"] }
|
||||
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"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ui-debug = []
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.25.0"
|
||||
tokio-test = "0.4.4"
|
||||
uuid = { version = "1.17.0", features = ["v4"] }
|
||||
futures = "0.3.31"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
enter_command_mode = [":", "ctrl+;"]
|
||||
next_buffer = ["ctrl+l"]
|
||||
previous_buffer = ["ctrl+h"]
|
||||
close_buffer = ["ctrl+k"]
|
||||
|
||||
[keybindings.general]
|
||||
move_up = ["k", "Up"]
|
||||
@@ -15,6 +16,8 @@ toggle_sidebar = ["ctrl+t"]
|
||||
toggle_buffer_list = ["ctrl+b"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
exit_table_scroll = ["esc"]
|
||||
open_search = ["ctrl+f"]
|
||||
|
||||
[keybindings.common]
|
||||
save = ["ctrl+s"]
|
||||
@@ -60,16 +63,18 @@ enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
# 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_left = [""]
|
||||
move_right = ["right"]
|
||||
suggestion_down = ["ctrl+n", "tab"]
|
||||
suggestion_up = ["ctrl+p", "shift+tab"]
|
||||
select_suggestion = ["enter"]
|
||||
trigger_autocomplete = ["left"]
|
||||
|
||||
[keybindings.command]
|
||||
exit_command_mode = ["ctrl+g", "esc"]
|
||||
@@ -80,6 +85,10 @@ quit = ["q"]
|
||||
force_quit = ["q!"]
|
||||
save_and_quit = ["wq"]
|
||||
revert = ["r"]
|
||||
find_file_palette_toggle = ["ff"]
|
||||
|
||||
[editor]
|
||||
keybinding_mode = "vim" # Options: "default", "vim", "emacs"
|
||||
|
||||
[colors]
|
||||
theme = "dark"
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
pub mod admin_panel;
|
||||
pub mod admin_panel_admin;
|
||||
pub mod add_table;
|
||||
pub mod add_logic;
|
||||
|
||||
pub use admin_panel::*;
|
||||
pub use admin_panel_admin::*;
|
||||
pub use add_table::*;
|
||||
pub use add_logic::*;
|
||||
|
||||
313
client/src/components/admin/add_logic.rs
Normal file
313
client/src/components/admin/add_logic.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
// src/components/admin/add_logic.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_logic::{AddLogicFocus, AddLogicState};
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::components::handlers::canvas::render_canvas;
|
||||
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
|
||||
use crate::config::binds::config::EditorKeybindingMode;
|
||||
|
||||
pub fn render_add_logic(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
app_state: &AppState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) {
|
||||
let main_block = Block::default()
|
||||
.title(" Add New Logic Script ")
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.style(Style::default().bg(theme.bg));
|
||||
let inner_area = main_block.inner(area);
|
||||
f.render_widget(main_block, area);
|
||||
|
||||
// Handle full-screen script editing
|
||||
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
|
||||
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
|
||||
let border_style_color = if is_edit_mode { theme.highlight } else { theme.secondary };
|
||||
let border_style = Style::default().fg(border_style_color);
|
||||
|
||||
editor_ref.set_cursor_line_style(Style::default());
|
||||
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
|
||||
let script_title_hint = match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => {
|
||||
let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(&add_logic_state.vim_state);
|
||||
format!("Script {}", vim_mode_status)
|
||||
}
|
||||
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
|
||||
if is_edit_mode {
|
||||
"Script (Editing)".to_string()
|
||||
} else {
|
||||
"Script".to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
editor_ref.set_block(
|
||||
Block::default()
|
||||
.title(Span::styled(script_title_hint, Style::default().fg(theme.fg)))
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(border_style),
|
||||
);
|
||||
f.render_widget(&*editor_ref, inner_area);
|
||||
|
||||
// Drop the editor borrow before accessing autocomplete state
|
||||
drop(editor_ref);
|
||||
|
||||
// === SCRIPT EDITOR AUTOCOMPLETE RENDERING ===
|
||||
if add_logic_state.script_editor_autocomplete_active && !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
// Get the current cursor position from textarea
|
||||
let current_cursor = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
|
||||
};
|
||||
|
||||
let (cursor_line, cursor_col) = current_cursor;
|
||||
|
||||
// Account for TextArea's block borders (1 for each side)
|
||||
let block_offset_x = 1;
|
||||
let block_offset_y = 1;
|
||||
|
||||
// Position autocomplete at current cursor position
|
||||
// Add 1 to column to position dropdown right after the cursor
|
||||
let autocomplete_x = cursor_col + 1;
|
||||
let autocomplete_y = cursor_line;
|
||||
|
||||
let input_rect = Rect {
|
||||
x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)),
|
||||
y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)),
|
||||
width: 1, // Minimum width for positioning
|
||||
height: 1,
|
||||
};
|
||||
|
||||
// Render autocomplete dropdown
|
||||
autocomplete::render_autocomplete_dropdown(
|
||||
f,
|
||||
input_rect,
|
||||
f.area(), // Full frame area for clamping
|
||||
theme,
|
||||
&add_logic_state.script_editor_suggestions,
|
||||
add_logic_state.script_editor_selected_suggestion_index,
|
||||
);
|
||||
}
|
||||
|
||||
return; // Exit early for fullscreen mode
|
||||
}
|
||||
|
||||
// Regular layout with preview
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Top info
|
||||
Constraint::Length(9), // Canvas for 3 inputs (each 1 line + 1 padding = 2 lines * 3 + 2 border = 8, +1 for good measure)
|
||||
Constraint::Min(5), // Script preview
|
||||
Constraint::Length(3), // Buttons
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
let top_info_area = main_chunks[0];
|
||||
let canvas_area = main_chunks[1];
|
||||
let script_content_area = main_chunks[2];
|
||||
let buttons_area = main_chunks[3];
|
||||
|
||||
// Top info
|
||||
let profile_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
format!("Profile: {}", add_logic_state.profile_name),
|
||||
Style::default().fg(theme.fg),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
"Table: {}",
|
||||
add_logic_state
|
||||
.selected_table_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| add_logic_state.selected_table_id
|
||||
.map(|id| format!("ID {}", id))
|
||||
.unwrap_or_else(|| "Global (Not Selected)".to_string()))
|
||||
),
|
||||
Style::default().fg(theme.fg),
|
||||
)),
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
);
|
||||
f.render_widget(profile_text, top_info_area);
|
||||
|
||||
// Canvas
|
||||
let focus_on_canvas_inputs = matches!(
|
||||
add_logic_state.current_focus,
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription
|
||||
);
|
||||
// Call render_canvas and get the active_field_rect
|
||||
let active_field_rect = render_canvas(
|
||||
f,
|
||||
canvas_area,
|
||||
add_logic_state, // Pass the whole state as it impl CanvasState
|
||||
&add_logic_state.fields(),
|
||||
&add_logic_state.current_field(),
|
||||
&add_logic_state.inputs(),
|
||||
theme,
|
||||
is_edit_mode && focus_on_canvas_inputs, // is_edit_mode for canvas fields
|
||||
highlight_state,
|
||||
);
|
||||
|
||||
// --- Render Autocomplete for Target Column ---
|
||||
// `is_edit_mode` here refers to the general edit mode of the EventHandler
|
||||
if is_edit_mode && add_logic_state.current_field() == 1 { // Target Column field
|
||||
if let Some(suggestions) = add_logic_state.get_suggestions() { // Uses CanvasState impl
|
||||
let selected = add_logic_state.get_selected_suggestion_index();
|
||||
if !suggestions.is_empty() { // Only render if there are suggestions to show
|
||||
if let Some(input_rect) = active_field_rect {
|
||||
autocomplete::render_autocomplete_dropdown(
|
||||
f,
|
||||
input_rect,
|
||||
f.area(), // Full frame area for clamping
|
||||
theme,
|
||||
suggestions,
|
||||
selected,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Script content preview
|
||||
{
|
||||
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
|
||||
editor_ref.set_cursor_line_style(Style::default());
|
||||
|
||||
let is_script_preview_focused = add_logic_state.current_focus == AddLogicFocus::ScriptContentPreview;
|
||||
|
||||
if is_script_preview_focused {
|
||||
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
} else {
|
||||
let underscore_cursor_style = Style::default()
|
||||
.add_modifier(Modifier::UNDERLINED)
|
||||
.fg(theme.secondary);
|
||||
editor_ref.set_cursor_style(underscore_cursor_style);
|
||||
}
|
||||
|
||||
let border_style_color = if is_script_preview_focused {
|
||||
theme.highlight
|
||||
} else {
|
||||
theme.secondary
|
||||
};
|
||||
|
||||
let title_text = "Script Preview"; // Title doesn't need to change based on focus here
|
||||
|
||||
let title_style = if is_script_preview_focused {
|
||||
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg)
|
||||
};
|
||||
|
||||
editor_ref.set_block(
|
||||
Block::default()
|
||||
.title(Span::styled(title_text, title_style))
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(border_style_color)),
|
||||
);
|
||||
f.render_widget(&*editor_ref, script_content_area);
|
||||
}
|
||||
|
||||
// Buttons
|
||||
let get_button_style = |button_focus: AddLogicFocus, current_focus_state: AddLogicFocus| {
|
||||
let is_focused = current_focus_state == button_focus;
|
||||
let base_style = Style::default().fg(if is_focused {
|
||||
theme.highlight
|
||||
} else {
|
||||
theme.secondary
|
||||
});
|
||||
if is_focused {
|
||||
base_style.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
base_style
|
||||
}
|
||||
};
|
||||
|
||||
let get_button_border_style = |is_focused: bool, current_theme: &Theme| {
|
||||
if is_focused {
|
||||
Style::default().fg(current_theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(current_theme.secondary)
|
||||
}
|
||||
};
|
||||
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.split(buttons_area);
|
||||
|
||||
let save_button = Paragraph::new(" Save Logic ")
|
||||
.style(get_button_style(
|
||||
AddLogicFocus::SaveButton,
|
||||
add_logic_state.current_focus,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_logic_state.current_focus == AddLogicFocus::SaveButton,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(save_button, button_chunks[0]);
|
||||
|
||||
let cancel_button = Paragraph::new(" Cancel ")
|
||||
.style(get_button_style(
|
||||
AddLogicFocus::CancelButton,
|
||||
add_logic_state.current_focus,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_logic_state.current_focus == AddLogicFocus::CancelButton,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(cancel_button, button_chunks[1]);
|
||||
|
||||
// Dialog
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
dialog::render_dialog(
|
||||
f,
|
||||
f.area(),
|
||||
theme,
|
||||
&app_state.ui.dialog.dialog_title,
|
||||
&app_state.ui.dialog.dialog_message,
|
||||
&app_state.ui.dialog.dialog_buttons,
|
||||
app_state.ui.dialog.dialog_active_button_index,
|
||||
app_state.ui.dialog.is_loading,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,47 @@
|
||||
// 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::{Style, Stylize},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table},
|
||||
Frame,
|
||||
};
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::components::handlers::canvas::render_canvas;
|
||||
use crate::state::pages::add_table::AddTableState;
|
||||
use crate::components::common::dialog;
|
||||
|
||||
/// Renders a placeholder page for adding tables.
|
||||
/// 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,
|
||||
app_state: &AppState, // Currently unused, might be needed later
|
||||
add_table_state: &mut AddTableState,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
is_edit_mode: bool, // Determines if canvas inputs are in edit mode
|
||||
highlight_state: &HighlightState, // For text highlighting in canvas
|
||||
) {
|
||||
// Main block for the whole page
|
||||
// --- 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))
|
||||
@@ -33,148 +49,524 @@ pub fn render_add_table(
|
||||
let inner_area = main_block.inner(area);
|
||||
f.render_widget(main_block, area);
|
||||
|
||||
// Split the inner area horizontally: Left info pane | Right input/action pane
|
||||
let horizontal_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50), // Left Pane
|
||||
Constraint::Percentage(50), // Right Pane
|
||||
].as_ref())
|
||||
.split(inner_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
|
||||
}
|
||||
|
||||
let left_pane = horizontal_chunks[0];
|
||||
let right_pane = horizontal_chunks[1];
|
||||
// --- 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
|
||||
}
|
||||
|
||||
// --- Left Pane ---
|
||||
let left_vertical_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Profile & Table Name header
|
||||
Constraint::Min(5), // Columns section (expandable)
|
||||
Constraint::Length(1), // Separator
|
||||
Constraint::Min(3), // Indexes section (expandable)
|
||||
Constraint::Length(1), // Separator
|
||||
Constraint::Min(3), // Links section (expandable)
|
||||
].as_ref())
|
||||
.split(left_pane);
|
||||
// --- 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"]
|
||||
|
||||
// Profile & Table Name section
|
||||
let profile_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled("profile: default", theme.fg)), // Placeholder
|
||||
Line::from(Span::styled("table name: [tablename]", theme.fg)), // Placeholder
|
||||
])
|
||||
.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()
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
.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,
|
||||
);
|
||||
f.render_widget(profile_text, left_vertical_chunks[0]);
|
||||
|
||||
// Columns section
|
||||
let columns_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled("Name Type", theme.accent)), // Header
|
||||
])
|
||||
.block(Block::default().title(Span::styled(" Columns ", theme.fg)));
|
||||
f.render_widget(columns_text, left_vertical_chunks[1]);
|
||||
|
||||
// Indexes section
|
||||
let indexes_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled("Column name", theme.accent)), // Header
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Indexes ", theme.fg))
|
||||
.borders(Borders::TOP) // Separator from Columns
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
);
|
||||
f.render_widget(indexes_text, left_vertical_chunks[3]);
|
||||
|
||||
// Links section
|
||||
let links_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled("Linked table Required", theme.accent)), // Header
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Span::styled(" Links ", theme.fg))
|
||||
.borders(Borders::TOP) // Separator from Indexes
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
);
|
||||
f.render_widget(links_text, left_vertical_chunks[5]);
|
||||
|
||||
// --- Right Pane ---
|
||||
let right_vertical_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(5), // Area for render_canvas (3 fields + 2 border)
|
||||
Constraint::Length(3), // Add Button Area
|
||||
Constraint::Min(1), // Spacer
|
||||
Constraint::Length(3), // Save/Cancel buttons area
|
||||
].as_ref())
|
||||
.split(right_pane);
|
||||
|
||||
let canvas_area = right_vertical_chunks[0];
|
||||
let add_button_area = right_vertical_chunks[1];
|
||||
let bottom_buttons_area = right_vertical_chunks[3];
|
||||
|
||||
// --- Use render_canvas for Inputs ---
|
||||
// --- Canvas Rendering (Column Definition Input) ---
|
||||
let _active_field_rect = render_canvas(
|
||||
f,
|
||||
canvas_area,
|
||||
add_table_state,
|
||||
&[
|
||||
"Table name",
|
||||
"Name",
|
||||
"Type",
|
||||
],
|
||||
&add_table_state.fields(),
|
||||
&add_table_state.current_field(),
|
||||
&add_table_state.inputs().iter().map(|s| *s).collect::<Vec<&String>>(),
|
||||
&add_table_state.inputs(),
|
||||
theme,
|
||||
is_edit_mode,
|
||||
is_edit_mode && focus_on_canvas_inputs,
|
||||
highlight_state,
|
||||
);
|
||||
|
||||
// Add Button (Placeholder)
|
||||
// --- 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(Style::default().fg(theme.secondary))
|
||||
.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(Style::default().fg(theme.secondary)),
|
||||
.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);
|
||||
|
||||
// Bottom Buttons Area (Save, Cancel)
|
||||
// --- 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(50), // Save Button
|
||||
Constraint::Percentage(50), // Cancel Button
|
||||
].as_ref())
|
||||
Constraint::Percentage(33), // Save Button
|
||||
Constraint::Percentage(34), // Delete Button
|
||||
Constraint::Percentage(33), // Cancel Button
|
||||
])
|
||||
.split(bottom_buttons_area);
|
||||
|
||||
// Save Button (Placeholder)
|
||||
let save_button = Paragraph::new(" Save table ")
|
||||
.style(Style::default().fg(theme.secondary))
|
||||
.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(Style::default().fg(theme.secondary)),
|
||||
.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]);
|
||||
|
||||
// Cancel Button (Placeholder)
|
||||
let cancel_button = Paragraph::new(" Cancel ")
|
||||
.style(Style::default().fg(theme.secondary))
|
||||
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(Style::default().fg(theme.secondary)),
|
||||
.border_style(get_button_border_style(
|
||||
add_table_state.current_focus == AddTableFocus::DeleteSelectedButton, // Pass bool
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(cancel_button, bottom_button_chunks[1]);
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ 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},
|
||||
|
||||
@@ -6,12 +6,11 @@ use crate::state::app::state::AppState;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
text::{Line, Span, Text}, // Added Text
|
||||
widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Renders the view specific to admin users with a three-pane layout.
|
||||
pub fn render_admin_panel_admin(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
@@ -19,15 +18,13 @@ pub fn render_admin_panel_admin(
|
||||
admin_state: &mut AdminState,
|
||||
theme: &Theme,
|
||||
) {
|
||||
// Split vertically: Top for panes, Bottom for buttons
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(1)].as_ref()) // 1 line for buttons
|
||||
.constraints([Constraint::Min(0), Constraint::Length(1)].as_ref())
|
||||
.split(area);
|
||||
let panes_area = main_chunks[0];
|
||||
let buttons_area = main_chunks[1];
|
||||
|
||||
// Split the top area into three panes: Profiles | Tables | Dependencies
|
||||
let pane_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
@@ -35,208 +32,148 @@ pub fn render_admin_panel_admin(
|
||||
Constraint::Percentage(40), // Tables
|
||||
Constraint::Percentage(35), // Dependencies
|
||||
].as_ref())
|
||||
.split(panes_area); // Use the whole area directly
|
||||
.split(panes_area);
|
||||
|
||||
let profiles_pane = pane_chunks[0];
|
||||
let tables_pane = pane_chunks[1];
|
||||
let deps_pane = pane_chunks[2];
|
||||
|
||||
// --- Profiles Pane (Left) ---
|
||||
let profile_focus = admin_state.current_focus == AdminFocus::Profiles;
|
||||
let profile_border_style = if profile_focus {
|
||||
let profile_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::ProfilesPane | AdminFocus::InsideProfilesList);
|
||||
let profile_border_style = if profile_pane_has_focus {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.border)
|
||||
};
|
||||
|
||||
// Block for the profiles pane
|
||||
let profiles_block = Block::default()
|
||||
.title(" Profiles ")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(profile_border_style);
|
||||
let profiles_inner_area = profiles_block.inner(profiles_pane); // Get inner area for list
|
||||
f.render_widget(profiles_block, profiles_pane); // Render the block itself
|
||||
|
||||
// Create profile list items
|
||||
let profile_list_items: Vec<ListItem> = app_state
|
||||
.profile_tree
|
||||
.profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, profile)| {
|
||||
// Check persistent selection for prefix, navigation state for style/highlight
|
||||
let is_selected = admin_state.selected_profile_index == Some(idx); // Use persistent state for [*]
|
||||
let is_navigated = admin_state.profile_list_state.selected() == Some(idx); // Use nav state for highlight/>
|
||||
let prefix = if is_selected { "[*] " } else { "[ ] " };
|
||||
let style = if is_selected { // Style based on selection too
|
||||
Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg)
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(prefix, style),
|
||||
Span::styled(&profile.name, style)
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build and render profile list inside the block's inner area
|
||||
let profiles_block = Block::default().title(" Profiles ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(profile_border_style);
|
||||
let profiles_inner_area = profiles_block.inner(profiles_pane);
|
||||
f.render_widget(profiles_block, profiles_pane);
|
||||
let profile_list_items: Vec<ListItem> = app_state.profile_tree.profiles.iter().enumerate().map(|(idx, profile)| {
|
||||
let is_persistently_selected = admin_state.selected_profile_index == Some(idx);
|
||||
let is_nav_highlighted = admin_state.profile_list_state.selected() == Some(idx) && admin_state.current_focus == AdminFocus::InsideProfilesList;
|
||||
let prefix = if is_persistently_selected { "[*] " } else { "[ ] " };
|
||||
let item_style = if is_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
|
||||
else if is_persistently_selected { Style::default().fg(theme.accent) }
|
||||
else { Style::default().fg(theme.fg) };
|
||||
ListItem::new(Line::from(vec![Span::styled(prefix, item_style), Span::styled(&profile.name, item_style)]))
|
||||
}).collect();
|
||||
let profile_list = List::new(profile_list_items)
|
||||
// Highlight style depends on focus AND navigation state
|
||||
.highlight_style(if profile_focus { // Use focus state
|
||||
Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
})
|
||||
.highlight_symbol(if profile_focus { "> " } else { " " });
|
||||
|
||||
.highlight_style(if admin_state.current_focus == AdminFocus::InsideProfilesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() })
|
||||
.highlight_symbol(if admin_state.current_focus == AdminFocus::InsideProfilesList { "> " } else { " " });
|
||||
f.render_stateful_widget(profile_list, profiles_inner_area, &mut admin_state.profile_list_state);
|
||||
|
||||
|
||||
// --- Tables Pane (Middle) ---
|
||||
let table_focus = admin_state.current_focus == AdminFocus::Tables;
|
||||
let table_border_style = if table_focus {
|
||||
Style::default().fg(theme.highlight)
|
||||
let table_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::Tables | AdminFocus::InsideTablesList);
|
||||
let table_border_style = if table_pane_has_focus { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.border) };
|
||||
|
||||
let profile_to_display_tables_for_idx: Option<usize>;
|
||||
if admin_state.current_focus == AdminFocus::InsideProfilesList {
|
||||
profile_to_display_tables_for_idx = admin_state.profile_list_state.selected();
|
||||
} else {
|
||||
Style::default().fg(theme.border)
|
||||
};
|
||||
profile_to_display_tables_for_idx = admin_state.selected_profile_index
|
||||
.or_else(|| admin_state.profile_list_state.selected());
|
||||
}
|
||||
let tables_pane_title_profile_name = profile_to_display_tables_for_idx
|
||||
.and_then(|idx| app_state.profile_tree.profiles.get(idx))
|
||||
.map_or("None Selected", |p| p.name.as_str());
|
||||
let tables_block = Block::default().title(format!(" Tables (Profile: {}) ", tables_pane_title_profile_name)).borders(Borders::ALL).border_type(BorderType::Rounded).border_style(table_border_style);
|
||||
let tables_inner_area = tables_block.inner(tables_pane);
|
||||
f.render_widget(tables_block, tables_pane);
|
||||
|
||||
// Get selected profile information
|
||||
let navigated_profile_idx = admin_state.profile_list_state.selected(); // Use nav state for display
|
||||
let selected_profile_name = app_state
|
||||
.profile_tree
|
||||
.profiles
|
||||
.get(navigated_profile_idx.unwrap_or(usize::MAX)) // Use nav state for title
|
||||
.map_or("None", |p| &p.name);
|
||||
|
||||
// Block for the tables pane
|
||||
let tables_block = Block::default()
|
||||
.title(format!(" Tables (Profile: {}) ", selected_profile_name))
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(table_border_style);
|
||||
let tables_inner_area = tables_block.inner(tables_pane); // Get inner area for list
|
||||
f.render_widget(tables_block, tables_pane); // Render the block itself
|
||||
|
||||
// Create table list items and get dependencies for the selected table
|
||||
let (table_list_items, selected_table_deps): (Vec<ListItem>, Vec<String>) = if let Some(
|
||||
profile, // Get profile based on NAVIGATED profile index
|
||||
) = navigated_profile_idx.and_then(|idx| app_state.profile_tree.profiles.get(idx)) {
|
||||
let items: Vec<ListItem> = profile
|
||||
.tables
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, table)| { // Renamed i to idx for clarity
|
||||
// Check persistent selection for prefix, navigation state for style/highlight
|
||||
let is_selected = admin_state.selected_table_index == Some(idx); // Use persistent state for [*]
|
||||
let is_navigated = admin_state.table_list_state.selected() == Some(idx); // Use nav state for highlight/>
|
||||
let prefix = if is_selected { "[*] " } else { "[ ] " };
|
||||
let style = if is_navigated { // Style based on navigation highlight
|
||||
Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg)
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(prefix, style),
|
||||
Span::styled(&table.name, style),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Get dependencies only for the PERSISTENTLY selected table in the PERSISTENTLY selected profile
|
||||
let chosen_profile_idx = admin_state.selected_profile_index; // Use persistent profile selection
|
||||
let deps = chosen_profile_idx // Start with the chosen profile index
|
||||
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx)) // Get the chosen profile
|
||||
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx))) // Get the chosen table using its index
|
||||
.map_or(Vec::new(), |t| t.depends_on.clone()); // If found, clone its depends_on, otherwise return empty Vec
|
||||
|
||||
(items, deps)
|
||||
} else {
|
||||
// Default when no profile is selected
|
||||
(vec![ListItem::new("Select a profile to see tables")], vec![])
|
||||
};
|
||||
|
||||
// Build and render table list inside the block's inner area
|
||||
let table_list = List::new(table_list_items)
|
||||
// Highlight style depends on focus AND navigation state
|
||||
.highlight_style(if table_focus { // Use focus state
|
||||
Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
|
||||
let table_list_items_for_display: Vec<ListItem> =
|
||||
if let Some(profile_data_for_tables) = profile_to_display_tables_for_idx
|
||||
.and_then(|idx| app_state.profile_tree.profiles.get(idx)) {
|
||||
profile_data_for_tables.tables.iter().enumerate().map(|(idx, table)| {
|
||||
let is_table_persistently_selected = admin_state.selected_table_index == Some(idx) &&
|
||||
profile_to_display_tables_for_idx == admin_state.selected_profile_index;
|
||||
let is_table_nav_highlighted = admin_state.table_list_state.selected() == Some(idx) &&
|
||||
admin_state.current_focus == AdminFocus::InsideTablesList;
|
||||
let prefix = if is_table_persistently_selected { "[*] " } else { "[ ] " };
|
||||
let style = if is_table_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
|
||||
else if is_table_persistently_selected { Style::default().fg(theme.accent) }
|
||||
else { Style::default().fg(theme.fg) };
|
||||
ListItem::new(Line::from(vec![Span::styled(prefix, style), Span::styled(&table.name, style)]))
|
||||
}).collect()
|
||||
} else {
|
||||
Style::default()
|
||||
})
|
||||
.highlight_symbol(if table_focus { "> " } else { " " }); // Focus indicator
|
||||
|
||||
vec![ListItem::new("Select a profile to see tables")]
|
||||
};
|
||||
let table_list = List::new(table_list_items_for_display)
|
||||
.highlight_style(if admin_state.current_focus == AdminFocus::InsideTablesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() })
|
||||
.highlight_symbol(if admin_state.current_focus == AdminFocus::InsideTablesList { "> " } else { " " });
|
||||
f.render_stateful_widget(table_list, tables_inner_area, &mut admin_state.table_list_state);
|
||||
|
||||
// --- Dependencies Pane (Right) ---
|
||||
// Get name based on PERSISTENT selections
|
||||
let chosen_profile_idx = admin_state.selected_profile_index; // Use persistent profile selection
|
||||
let selected_table_name = chosen_profile_idx
|
||||
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
|
||||
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx))) // Use persistent table selection
|
||||
.map_or("N/A", |t| &t.name); // Get name of the selected table
|
||||
|
||||
// Block for the dependencies pane
|
||||
// --- Dependencies Pane (Right) ---
|
||||
let mut deps_pane_title_table_name = "N/A".to_string();
|
||||
let dependencies_to_display: Vec<String>;
|
||||
|
||||
if admin_state.current_focus == AdminFocus::InsideTablesList {
|
||||
// If navigating tables, show dependencies for the '>' highlighted table.
|
||||
// The profile context is `profile_to_display_tables_for_idx` (from Tables pane logic).
|
||||
if let Some(p_idx_for_current_tables) = profile_to_display_tables_for_idx {
|
||||
if let Some(current_profile_showing_tables) = app_state.profile_tree.profiles.get(p_idx_for_current_tables) {
|
||||
if let Some(table_nav_idx) = admin_state.table_list_state.selected() { // The '>' highlighted table
|
||||
if let Some(navigated_table) = current_profile_showing_tables.tables.get(table_nav_idx) {
|
||||
deps_pane_title_table_name = navigated_table.name.clone();
|
||||
dependencies_to_display = navigated_table.depends_on.clone();
|
||||
} else {
|
||||
dependencies_to_display = Vec::new(); // Navigated table index out of bounds
|
||||
}
|
||||
} else {
|
||||
dependencies_to_display = Vec::new(); // No table navigated with '>'
|
||||
}
|
||||
} else {
|
||||
dependencies_to_display = Vec::new(); // Profile for tables out of bounds
|
||||
}
|
||||
} else {
|
||||
dependencies_to_display = Vec::new(); // No profile active for table display
|
||||
}
|
||||
} else {
|
||||
// Otherwise, show dependencies for the '[*]' persistently selected table & profile.
|
||||
if let Some(p_idx) = admin_state.selected_profile_index { // Must be a persistently selected profile
|
||||
if let Some(selected_profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
if let Some(t_idx) = admin_state.selected_table_index { // Must be a persistently selected table
|
||||
if let Some(selected_table) = selected_profile.tables.get(t_idx) {
|
||||
deps_pane_title_table_name = selected_table.name.clone();
|
||||
dependencies_to_display = selected_table.depends_on.clone();
|
||||
} else { dependencies_to_display = Vec::new(); }
|
||||
} else { dependencies_to_display = Vec::new(); }
|
||||
} else { dependencies_to_display = Vec::new(); }
|
||||
} else { dependencies_to_display = Vec::new(); }
|
||||
}
|
||||
|
||||
let deps_block = Block::default()
|
||||
.title(format!(" Dependencies (Table: {}) ", selected_table_name))
|
||||
.title(format!(" Dependencies (Table: {}) ", deps_pane_title_table_name))
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.border)); // No focus highlight for deps pane
|
||||
let deps_inner_area = deps_block.inner(deps_pane); // Get inner area for content
|
||||
f.render_widget(deps_block, deps_pane); // Render the block itself
|
||||
.border_style(Style::default().fg(theme.border));
|
||||
let deps_inner_area = deps_block.inner(deps_pane);
|
||||
f.render_widget(deps_block, deps_pane);
|
||||
|
||||
// Prepare content for the dependencies paragraph
|
||||
let mut deps_content = Text::default();
|
||||
deps_content.lines.push(Line::from(Span::styled(
|
||||
"Depends On:",
|
||||
Style::default().fg(theme.accent), // Use accent color for the label
|
||||
Style::default().fg(theme.accent),
|
||||
)));
|
||||
|
||||
if !selected_table_deps.is_empty() {
|
||||
for dep in selected_table_deps {
|
||||
// List each dependency
|
||||
if !dependencies_to_display.is_empty() {
|
||||
for dep in dependencies_to_display {
|
||||
deps_content.lines.push(Line::from(Span::styled(format!("- {}", dep), theme.fg)));
|
||||
}
|
||||
} else {
|
||||
// Indicate if there are no dependencies
|
||||
deps_content.lines.push(Line::from(Span::styled(" None", theme.secondary)));
|
||||
}
|
||||
|
||||
// Build and render dependencies paragraph inside the block's inner area
|
||||
let deps_paragraph = Paragraph::new(deps_content);
|
||||
f.render_widget(deps_paragraph, deps_inner_area);
|
||||
|
||||
// --- Buttons Row ---
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(34),
|
||||
Constraint::Percentage(33),
|
||||
].as_ref())
|
||||
.split(buttons_area);
|
||||
|
||||
let button_chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Percentage(33), Constraint::Percentage(34), Constraint::Percentage(33)].as_ref()).split(buttons_area);
|
||||
let btn_base_style = Style::default().fg(theme.secondary);
|
||||
|
||||
// Define the helper closure to get style based on focus
|
||||
let get_btn_style = |button_focus: AdminFocus| {
|
||||
if admin_state.current_focus == button_focus {
|
||||
// Apply highlight style if this button is focused
|
||||
btn_base_style.add_modifier(ratatui::style::Modifier::REVERSED)
|
||||
} else {
|
||||
btn_base_style // Use base style otherwise
|
||||
}
|
||||
};
|
||||
let btn1 = Paragraph::new("Add Logic")
|
||||
.style(get_btn_style(AdminFocus::Button1))
|
||||
.alignment(Alignment::Center);
|
||||
let btn2 = Paragraph::new("Add Table")
|
||||
.style(get_btn_style(AdminFocus::Button2))
|
||||
.alignment(Alignment::Center);
|
||||
let btn3 = Paragraph::new("Change Table")
|
||||
.style(get_btn_style(AdminFocus::Button3))
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
let get_btn_style = |button_focus: AdminFocus| { if admin_state.current_focus == button_focus { btn_base_style.add_modifier(ratatui::style::Modifier::REVERSED) } else { btn_base_style } };
|
||||
let btn1 = Paragraph::new("Add Logic").style(get_btn_style(AdminFocus::Button1)).alignment(Alignment::Center);
|
||||
let btn2 = Paragraph::new("Add Table").style(get_btn_style(AdminFocus::Button2)).alignment(Alignment::Center);
|
||||
let btn3 = Paragraph::new("Change Table").style(get_btn_style(AdminFocus::Button3)).alignment(Alignment::Center);
|
||||
f.render_widget(btn1, button_chunks[0]);
|
||||
f.render_widget(btn2, button_chunks[1]);
|
||||
f.render_widget(btn3, button_chunks[2]);
|
||||
|
||||
@@ -142,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// src/components/common.rs
|
||||
pub mod command_line;
|
||||
pub mod status_line;
|
||||
pub mod text_editor;
|
||||
pub mod background;
|
||||
pub mod dialog;
|
||||
pub mod autocomplete;
|
||||
pub mod search_palette;
|
||||
pub mod find_file_palette;
|
||||
|
||||
pub use command_line::*;
|
||||
pub use status_line::*;
|
||||
pub use text_editor::*;
|
||||
pub use background::*;
|
||||
pub use dialog::*;
|
||||
pub use autocomplete::*;
|
||||
pub use search_palette::*;
|
||||
pub use find_file_palette::*;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// src/components/common/autocomplete.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::form::FormState;
|
||||
use common::proto::multieko2::search::search_response::Hit;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
@@ -9,7 +11,8 @@ use ratatui::{
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Renders an opaque dropdown list for autocomplete suggestions.
|
||||
/// Renders an opaque dropdown list for simple string-based suggestions.
|
||||
/// THIS IS THE RESTORED FUNCTION.
|
||||
pub fn render_autocomplete_dropdown(
|
||||
f: &mut Frame,
|
||||
input_rect: Rect,
|
||||
@@ -21,39 +24,32 @@ pub fn render_autocomplete_dropdown(
|
||||
if suggestions.is_empty() {
|
||||
return;
|
||||
}
|
||||
// --- Calculate Dropdown Size & Position ---
|
||||
let max_suggestion_width = suggestions.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
|
||||
let max_suggestion_width =
|
||||
suggestions.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
|
||||
let horizontal_padding: u16 = 2;
|
||||
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
|
||||
let dropdown_height = (suggestions.len() as u16).min(5);
|
||||
|
||||
let mut dropdown_area = Rect {
|
||||
x: input_rect.x, // Align horizontally with input
|
||||
y: input_rect.y + 1, // Position directly below input
|
||||
x: input_rect.x,
|
||||
y: input_rect.y + 1,
|
||||
width: dropdown_width,
|
||||
height: dropdown_height,
|
||||
};
|
||||
|
||||
// --- Clamping Logic (prevent rendering off-screen) ---
|
||||
// Clamp vertically (if it goes below the frame)
|
||||
if dropdown_area.bottom() > frame_area.height {
|
||||
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height); // Try rendering above
|
||||
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
|
||||
}
|
||||
// Clamp horizontally (if it goes past the right edge)
|
||||
if dropdown_area.right() > frame_area.width {
|
||||
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
|
||||
}
|
||||
// Ensure x is not negative (if clamping pushes it left)
|
||||
dropdown_area.x = dropdown_area.x.max(0);
|
||||
// Ensure y is not negative (if clamping pushes it up)
|
||||
dropdown_area.y = dropdown_area.y.max(0);
|
||||
// --- End Clamping ---
|
||||
|
||||
// Render a solid background block first to ensure opacity
|
||||
let background_block = Block::default().style(Style::default().bg(Color::DarkGray));
|
||||
let background_block =
|
||||
Block::default().style(Style::default().bg(Color::DarkGray));
|
||||
f.render_widget(background_block, dropdown_area);
|
||||
|
||||
// Create list items, ensuring each has a defined background
|
||||
let items: Vec<ListItem> = suggestions
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -61,30 +57,97 @@ pub fn render_autocomplete_dropdown(
|
||||
let is_selected = selected_index == Some(i);
|
||||
let s_width = s.width() as u16;
|
||||
let padding_needed = dropdown_width.saturating_sub(s_width);
|
||||
let padded_s = format!("{}{}", s, " ".repeat(padding_needed as usize));
|
||||
let padded_s =
|
||||
format!("{}{}", s, " ".repeat(padding_needed as usize));
|
||||
|
||||
ListItem::new(padded_s).style(if is_selected {
|
||||
Style::default()
|
||||
.fg(theme.bg) // Text color on highlight
|
||||
.bg(theme.highlight) // Highlight background
|
||||
.fg(theme.bg)
|
||||
.bg(theme.highlight)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
// Style for non-selected items (matching background block)
|
||||
Style::default()
|
||||
.fg(theme.fg) // Text color on gray
|
||||
.bg(Color::DarkGray) // Explicit gray background
|
||||
Style::default().fg(theme.fg).bg(Color::DarkGray)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create the list widget (without its own block)
|
||||
let list = List::new(items);
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(selected_index);
|
||||
|
||||
// State for managing selection highlight (still needed for logic)
|
||||
let mut profile_list_state = ListState::default();
|
||||
profile_list_state.select(selected_index);
|
||||
|
||||
// Render the list statefully *over* the background block
|
||||
f.render_stateful_widget(list, dropdown_area, &mut profile_list_state);
|
||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
||||
}
|
||||
|
||||
/// Renders an opaque dropdown list for rich `Hit`-based suggestions.
|
||||
/// RENAMED from render_rich_autocomplete_dropdown
|
||||
pub fn render_hit_autocomplete_dropdown(
|
||||
f: &mut Frame,
|
||||
input_rect: Rect,
|
||||
frame_area: Rect,
|
||||
theme: &Theme,
|
||||
suggestions: &[Hit],
|
||||
selected_index: Option<usize>,
|
||||
form_state: &FormState,
|
||||
) {
|
||||
if suggestions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let display_names: Vec<String> = suggestions
|
||||
.iter()
|
||||
.map(|hit| form_state.get_display_name_for_hit(hit))
|
||||
.collect();
|
||||
|
||||
let max_suggestion_width =
|
||||
display_names.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
|
||||
let horizontal_padding: u16 = 2;
|
||||
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
|
||||
let dropdown_height = (suggestions.len() as u16).min(5);
|
||||
|
||||
let mut dropdown_area = Rect {
|
||||
x: input_rect.x,
|
||||
y: input_rect.y + 1,
|
||||
width: dropdown_width,
|
||||
height: dropdown_height,
|
||||
};
|
||||
|
||||
if dropdown_area.bottom() > frame_area.height {
|
||||
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
|
||||
}
|
||||
if dropdown_area.right() > frame_area.width {
|
||||
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
|
||||
}
|
||||
dropdown_area.x = dropdown_area.x.max(0);
|
||||
dropdown_area.y = dropdown_area.y.max(0);
|
||||
|
||||
let background_block =
|
||||
Block::default().style(Style::default().bg(Color::DarkGray));
|
||||
f.render_widget(background_block, dropdown_area);
|
||||
|
||||
let items: Vec<ListItem> = display_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let is_selected = selected_index == Some(i);
|
||||
let s_width = s.width() as u16;
|
||||
let padding_needed = dropdown_width.saturating_sub(s_width);
|
||||
let padded_s =
|
||||
format!("{}{}", s, " ".repeat(padding_needed as usize));
|
||||
|
||||
ListItem::new(padded_s).style(if is_selected {
|
||||
Style::default()
|
||||
.fg(theme.bg)
|
||||
.bg(theme.highlight)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg).bg(Color::DarkGray)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items);
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(selected_index);
|
||||
|
||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// src/client/components/command_line.rs
|
||||
// src/components/common/command_line.rs
|
||||
|
||||
use ratatui::{
|
||||
widgets::{Block, Paragraph},
|
||||
style::Style,
|
||||
@@ -6,30 +7,63 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use unicode_width::UnicodeWidthStr; // Import for width calculation
|
||||
|
||||
pub fn render_command_line(f: &mut Frame, area: Rect, input: &str, active: bool, theme: &Theme, message: &str) {
|
||||
let prompt = if active {
|
||||
":"
|
||||
} else {
|
||||
""
|
||||
pub fn render_command_line(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
input: &str, // This is event_handler.command_input
|
||||
active: bool, // This is event_handler.command_mode
|
||||
theme: &Theme,
|
||||
message: &str, // This is event_handler.command_message
|
||||
) {
|
||||
// Original logic for determining display_text
|
||||
let display_text = if !active {
|
||||
// If not in normal command mode, but there's a message (e.g. from Find File palette closing)
|
||||
// Or if command mode is off and message is empty (render minimally)
|
||||
if message.is_empty() {
|
||||
"".to_string() // Render an empty string, background will cover
|
||||
} else {
|
||||
message.to_string()
|
||||
}
|
||||
} else { // active is true (normal command mode)
|
||||
let prompt = ":";
|
||||
if message.is_empty() || message == ":" {
|
||||
format!("{}{}", prompt, input)
|
||||
} else {
|
||||
if input.is_empty() { // If command was just executed, input is cleared, show message
|
||||
message.to_string()
|
||||
} else { // Show input and message
|
||||
format!("{}{} | {}", prompt, input, message)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Combine the prompt, input, and message
|
||||
let display_text = if message.is_empty() {
|
||||
format!("{}{}", prompt, input)
|
||||
let content_width = UnicodeWidthStr::width(display_text.as_str());
|
||||
let available_width = area.width as usize;
|
||||
let padding_needed = available_width.saturating_sub(content_width);
|
||||
|
||||
let display_text_padded = if padding_needed > 0 {
|
||||
format!("{}{}", display_text, " ".repeat(padding_needed))
|
||||
} else {
|
||||
format!("{}{} | {}", prompt, input, message)
|
||||
// If text is too long, ratatui's Paragraph will handle truncation.
|
||||
// We could also truncate here if specific behavior is needed:
|
||||
// display_text.chars().take(available_width).collect::<String>()
|
||||
display_text
|
||||
};
|
||||
|
||||
let style = if active {
|
||||
// Determine style based on active state, but apply to the whole paragraph
|
||||
let text_style = if active {
|
||||
Style::default().fg(theme.accent)
|
||||
} else {
|
||||
// If not active, but there's a message, use default foreground.
|
||||
// If message is also empty, this style won't matter much for empty text.
|
||||
Style::default().fg(theme.fg)
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(display_text)
|
||||
.block(Block::default().style(Style::default().bg(theme.bg)))
|
||||
.style(style);
|
||||
let paragraph = Paragraph::new(display_text_padded)
|
||||
.block(Block::default().style(Style::default().bg(theme.bg))) // Block ensures bg for whole area
|
||||
.style(text_style); // Style for the text itself
|
||||
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
@@ -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,27 +64,36 @@ pub fn render_dialog(
|
||||
vertical: 1, // Top/Bottom padding inside border
|
||||
});
|
||||
|
||||
// Layout for Message and Buttons based on actual message height
|
||||
let mut constraints = vec![
|
||||
// Allocate space for message, ensuring at least 1 line height
|
||||
Constraint::Length(message_height.max(1)), // Use actual calculated height
|
||||
];
|
||||
if button_row_height > 0 {
|
||||
constraints.push(Constraint::Length(button_row_height));
|
||||
}
|
||||
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) ---
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(inner_area);
|
||||
// Layout for Message and Buttons based on actual message height
|
||||
let mut constraints = vec![
|
||||
// Allocate space for message, ensuring at least 1 line height
|
||||
Constraint::Length(message_height.max(1)), // Use actual calculated height
|
||||
];
|
||||
if button_row_height > 0 {
|
||||
constraints.push(Constraint::Length(button_row_height));
|
||||
}
|
||||
|
||||
// Render Message
|
||||
let available_width = inner_area.width as usize;
|
||||
let ellipsis = "...";
|
||||
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(inner_area);
|
||||
|
||||
let processed_lines: Vec<Line> =
|
||||
message_lines
|
||||
// Render Message
|
||||
let available_width = inner_area.width as usize;
|
||||
let ellipsis = "...";
|
||||
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
|
||||
|
||||
let processed_lines: Vec<Line> = message_lines
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
let line_width = UnicodeWidthStr::width(line);
|
||||
@@ -91,81 +101,83 @@ 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)))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
.collect();
|
||||
|
||||
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]);
|
||||
let message_paragraph =
|
||||
Paragraph::new(Text::from(processed_lines)).alignment(Alignment::Center);
|
||||
f.render_widget(message_paragraph, chunks[0]); // Render message in the first chunk
|
||||
|
||||
// Render Buttons if they exist and there's a chunk for them
|
||||
if !dialog_buttons.is_empty() && chunks.len() > 1 {
|
||||
let button_area = chunks[1];
|
||||
let button_count = dialog_buttons.len();
|
||||
// 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,
|
||||
))
|
||||
.take(button_count)
|
||||
.collect::<Vec<_>>();
|
||||
let button_constraints = std::iter::repeat(Constraint::Ratio(
|
||||
1,
|
||||
button_count as u32,
|
||||
))
|
||||
.take(button_count)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(button_constraints)
|
||||
.horizontal_margin(1) // Add space between buttons
|
||||
.split(button_area);
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(button_constraints)
|
||||
.horizontal_margin(1) // Add space between buttons
|
||||
.split(button_area);
|
||||
|
||||
for (i, button_label) in dialog_buttons.iter().enumerate() {
|
||||
// Ensure we don't try to render into a non-existent chunk
|
||||
if i >= button_chunks.len() {
|
||||
break;
|
||||
}
|
||||
for (i, button_label) in dialog_buttons.iter().enumerate() {
|
||||
if i >= button_chunks.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let is_active = i == dialog_active_button_index;
|
||||
let (button_style, border_style) = if is_active {
|
||||
(
|
||||
Style::default()
|
||||
let is_active = i == dialog_active_button_index;
|
||||
let (button_style, border_style) = if is_active {
|
||||
(
|
||||
Style::default()
|
||||
.fg(theme.highlight)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.accent), // Highlight border
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Style::default().fg(theme.fg),
|
||||
Style::default().fg(theme.border), // Normal border
|
||||
)
|
||||
};
|
||||
Style::default().fg(theme.accent),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Style::default().fg(theme.fg),
|
||||
Style::default().fg(theme.border),
|
||||
)
|
||||
};
|
||||
|
||||
let button_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(border_style);
|
||||
let button_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(border_style);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(button_label.as_str())
|
||||
f.render_widget(
|
||||
Paragraph::new(button_label.as_str())
|
||||
.block(button_block)
|
||||
.style(button_style)
|
||||
.alignment(Alignment::Center),
|
||||
button_chunks[i],
|
||||
);
|
||||
button_chunks[i],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
142
client/src/components/common/find_file_palette.rs
Normal file
142
client/src/components/common/find_file_palette.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
// src/components/common/find_file_palette.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::modes::general::command_navigation::NavigationState; // Corrected path
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
widgets::{Block, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
const PALETTE_MAX_VISIBLE_OPTIONS: usize = 15;
|
||||
const PADDING_CHAR: &str = " ";
|
||||
|
||||
pub fn render_find_file_palette(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
navigation_state: &NavigationState,
|
||||
) {
|
||||
let palette_display_input = navigation_state.get_display_input(); // Use the new method
|
||||
|
||||
let num_total_filtered = navigation_state.filtered_options.len();
|
||||
let current_selected_list_idx = navigation_state.selected_index;
|
||||
|
||||
let mut display_start_offset = 0;
|
||||
if num_total_filtered > PALETTE_MAX_VISIBLE_OPTIONS {
|
||||
if let Some(sel_idx) = current_selected_list_idx {
|
||||
if sel_idx >= display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS {
|
||||
display_start_offset = sel_idx - PALETTE_MAX_VISIBLE_OPTIONS + 1;
|
||||
} else if sel_idx < display_start_offset {
|
||||
display_start_offset = sel_idx;
|
||||
}
|
||||
display_start_offset = display_start_offset
|
||||
.min(num_total_filtered.saturating_sub(PALETTE_MAX_VISIBLE_OPTIONS));
|
||||
}
|
||||
}
|
||||
display_start_offset = display_start_offset.max(0);
|
||||
|
||||
let display_end_offset = (display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS)
|
||||
.min(num_total_filtered);
|
||||
|
||||
// navigation_state.filtered_options is Vec<(usize, String)>
|
||||
// We only need the String part for display.
|
||||
let visible_options_slice: Vec<&String> = if num_total_filtered > 0 {
|
||||
navigation_state.filtered_options
|
||||
[display_start_offset..display_end_offset]
|
||||
.iter()
|
||||
.map(|(_, opt_str)| opt_str)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // For palette input line
|
||||
Constraint::Min(0), // For options list, take remaining space
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Ensure list_area height does not exceed PALETTE_MAX_VISIBLE_OPTIONS
|
||||
let list_area_height = std::cmp::min(chunks[1].height, PALETTE_MAX_VISIBLE_OPTIONS as u16);
|
||||
let final_list_area = Rect::new(chunks[1].x, chunks[1].y, chunks[1].width, list_area_height);
|
||||
|
||||
|
||||
let input_area = chunks[0];
|
||||
// let list_area = chunks[1]; // Use final_list_area
|
||||
|
||||
let prompt_prefix = match navigation_state.navigation_type {
|
||||
crate::modes::general::command_navigation::NavigationType::FindFile => "Find File: ",
|
||||
crate::modes::general::command_navigation::NavigationType::TableTree => "Table Path: ",
|
||||
};
|
||||
let base_prompt_text = format!("{}{}", prompt_prefix, palette_display_input);
|
||||
let prompt_text_width = UnicodeWidthStr::width(base_prompt_text.as_str());
|
||||
let input_area_width = input_area.width as usize;
|
||||
let input_padding_needed =
|
||||
input_area_width.saturating_sub(prompt_text_width);
|
||||
|
||||
let padded_prompt_text = if input_padding_needed > 0 {
|
||||
format!(
|
||||
"{}{}",
|
||||
base_prompt_text,
|
||||
PADDING_CHAR.repeat(input_padding_needed)
|
||||
)
|
||||
} else {
|
||||
base_prompt_text
|
||||
};
|
||||
|
||||
let input_paragraph = Paragraph::new(padded_prompt_text)
|
||||
.style(Style::default().fg(theme.accent).bg(theme.bg));
|
||||
f.render_widget(input_paragraph, input_area);
|
||||
|
||||
let mut display_list_items: Vec<ListItem> =
|
||||
Vec::with_capacity(PALETTE_MAX_VISIBLE_OPTIONS);
|
||||
|
||||
for (idx_in_visible_slice, opt_str) in
|
||||
visible_options_slice.iter().enumerate()
|
||||
{
|
||||
// The selected_index in navigation_state is relative to the full filtered_options list.
|
||||
// We need to check if the current item (from the visible slice) corresponds to the selected_index.
|
||||
let original_filtered_idx = display_start_offset + idx_in_visible_slice;
|
||||
let is_selected =
|
||||
current_selected_list_idx == Some(original_filtered_idx);
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default().fg(theme.bg).bg(theme.accent)
|
||||
} else {
|
||||
Style::default().fg(theme.fg).bg(theme.bg)
|
||||
};
|
||||
|
||||
let opt_width = opt_str.width() as u16;
|
||||
let list_item_width = final_list_area.width;
|
||||
let padding_amount = list_item_width.saturating_sub(opt_width);
|
||||
let padded_opt_str = format!(
|
||||
"{}{}",
|
||||
opt_str,
|
||||
PADDING_CHAR.repeat(padding_amount as usize)
|
||||
);
|
||||
display_list_items.push(ListItem::new(padded_opt_str).style(style));
|
||||
}
|
||||
|
||||
// Fill remaining lines in the list area to maintain fixed height appearance
|
||||
let num_rendered_options = display_list_items.len();
|
||||
if num_rendered_options < PALETTE_MAX_VISIBLE_OPTIONS && (final_list_area.height as usize) > num_rendered_options {
|
||||
for _ in num_rendered_options..(final_list_area.height as usize) {
|
||||
let empty_padded_str =
|
||||
PADDING_CHAR.repeat(final_list_area.width as usize);
|
||||
display_list_items.push(
|
||||
ListItem::new(empty_padded_str)
|
||||
.style(Style::default().fg(theme.bg).bg(theme.bg)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let options_list_widget = List::new(display_list_items)
|
||||
.block(Block::default().style(Style::default().bg(theme.bg)));
|
||||
f.render_widget(options_list_widget, final_list_area);
|
||||
}
|
||||
121
client/src/components/common/search_palette.rs
Normal file
121
client/src/components/common/search_palette.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
// src/components/common/search_palette.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::search::SearchState;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Renders the search palette dialog over the main UI.
|
||||
pub fn render_search_palette(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
state: &SearchState,
|
||||
) {
|
||||
// --- Dialog Area Calculation ---
|
||||
let height = (area.height as f32 * 0.7).min(30.0) as u16;
|
||||
let width = (area.width as f32 * 0.6).min(100.0) as u16;
|
||||
let dialog_area = Rect {
|
||||
x: area.x + (area.width - width) / 2,
|
||||
y: area.y + (area.height - height) / 4,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
f.render_widget(Clear, dialog_area); // Clear background
|
||||
|
||||
let block = Block::default()
|
||||
.title(format!(" Search in '{}' ", state.table_name))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.accent));
|
||||
f.render_widget(block.clone(), dialog_area);
|
||||
|
||||
// --- Inner Layout (Input + Results) ---
|
||||
let inner_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(1)
|
||||
.constraints([
|
||||
Constraint::Length(3), // For input box
|
||||
Constraint::Min(0), // For results list
|
||||
])
|
||||
.split(dialog_area);
|
||||
|
||||
// --- Render Input Box ---
|
||||
let input_block = Block::default()
|
||||
.title("Query")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.border));
|
||||
let input_text = Paragraph::new(state.input.as_str())
|
||||
.block(input_block)
|
||||
.style(Style::default().fg(theme.fg));
|
||||
f.render_widget(input_text, inner_chunks[0]);
|
||||
// Set cursor position
|
||||
f.set_cursor(
|
||||
inner_chunks[0].x + state.cursor_position as u16 + 1,
|
||||
inner_chunks[0].y + 1,
|
||||
);
|
||||
|
||||
// --- Render Results List ---
|
||||
if state.is_loading {
|
||||
let loading_p = Paragraph::new("Searching...")
|
||||
.style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC));
|
||||
f.render_widget(loading_p, inner_chunks[1]);
|
||||
} else {
|
||||
let list_items: Vec<ListItem> = state
|
||||
.results
|
||||
.iter()
|
||||
.map(|hit| {
|
||||
// Parse the JSON string to make it readable
|
||||
let content_summary = match serde_json::from_str::<
|
||||
serde_json::Value,
|
||||
>(&hit.content_json)
|
||||
{
|
||||
Ok(json) => {
|
||||
if let Some(obj) = json.as_object() {
|
||||
// Create a summary from the first few non-null string values
|
||||
obj.values()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ")
|
||||
} else {
|
||||
"Non-object JSON".to_string()
|
||||
}
|
||||
}
|
||||
Err(_) => "Invalid JSON content".to_string(),
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{:<4.2} ", hit.score),
|
||||
Style::default().fg(theme.accent),
|
||||
),
|
||||
Span::raw(content_summary),
|
||||
]);
|
||||
ListItem::new(line)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results_list = List::new(list_items)
|
||||
.block(Block::default().title("Results"))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(theme.highlight)
|
||||
.fg(theme.bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol(">> ");
|
||||
|
||||
// We need a mutable ListState to render the selection
|
||||
let mut list_state =
|
||||
ratatui::widgets::ListState::default().with_selected(Some(state.selected_index));
|
||||
|
||||
f.render_stateful_widget(results_list, inner_chunks[1], &mut list_state);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
use ratatui::{
|
||||
style::Style,
|
||||
layout::Rect,
|
||||
Frame,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
// client/src/components/common/status_line.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::state::AppState;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Paragraph, Wrap}, // Make sure Wrap is imported
|
||||
Frame,
|
||||
};
|
||||
use std::path::Path;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub fn render_status_line(
|
||||
f: &mut Frame,
|
||||
@@ -16,11 +18,41 @@ pub fn render_status_line(
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
current_fps: f64,
|
||||
app_state: &AppState,
|
||||
) {
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
if let Some(debug_state) = &app_state.debug_state {
|
||||
let paragraph = if debug_state.is_error {
|
||||
// --- THIS IS THE CRITICAL LOGIC FOR ERRORS ---
|
||||
// 1. Create a `Text` object, which can contain multiple lines.
|
||||
let error_text = Text::from(debug_state.displayed_message.clone());
|
||||
|
||||
// 2. Create a Paragraph from the Text and TELL IT TO WRAP.
|
||||
Paragraph::new(error_text)
|
||||
.wrap(Wrap { trim: true }) // This line makes the text break into new rows.
|
||||
.style(Style::default().bg(theme.highlight).fg(theme.bg))
|
||||
} else {
|
||||
// --- This is for normal, single-line info messages ---
|
||||
Paragraph::new(debug_state.displayed_message.as_str())
|
||||
.style(Style::default().fg(theme.accent).bg(theme.bg))
|
||||
};
|
||||
f.render_widget(paragraph, area);
|
||||
} else {
|
||||
// Fallback for when debug state is None
|
||||
let paragraph = Paragraph::new("").style(Style::default().bg(theme.bg));
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
return; // Stop here and don't render the normal status line.
|
||||
}
|
||||
|
||||
// --- The normal status line rendering logic (unchanged) ---
|
||||
let program_info = format!("multieko2 v{}", env!("CARGO_PKG_VERSION"));
|
||||
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
|
||||
|
||||
let home_dir = dirs::home_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let home_dir = dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let display_dir = if current_dir.starts_with(&home_dir) {
|
||||
current_dir.replacen(&home_dir, "~", 1)
|
||||
} else {
|
||||
@@ -35,16 +67,30 @@ pub fn render_status_line(
|
||||
let separator = " | ";
|
||||
let separator_width = UnicodeWidthStr::width(separator);
|
||||
|
||||
let fixed_width_with_fps = mode_width + separator_width + separator_width +
|
||||
program_info_width + separator_width + fps_width;
|
||||
let show_fps = fixed_width_with_fps < available_width;
|
||||
let fixed_width_with_fps = mode_width
|
||||
+ separator_width
|
||||
+ separator_width
|
||||
+ program_info_width
|
||||
+ separator_width
|
||||
+ fps_width;
|
||||
|
||||
let show_fps = fixed_width_with_fps <= available_width;
|
||||
|
||||
let remaining_width_for_dir = available_width.saturating_sub(
|
||||
mode_width + separator_width + separator_width + program_info_width +
|
||||
if show_fps { separator_width + fps_width } else { 0 }
|
||||
mode_width
|
||||
+ separator_width
|
||||
+ separator_width
|
||||
+ program_info_width
|
||||
+ (if show_fps {
|
||||
separator_width + fps_width
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
);
|
||||
|
||||
let dir_display_text = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir {
|
||||
let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str())
|
||||
<= remaining_width_for_dir
|
||||
{
|
||||
display_dir
|
||||
} else {
|
||||
let dir_name = Path::new(current_dir)
|
||||
@@ -54,25 +100,55 @@ pub fn render_status_line(
|
||||
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
|
||||
dir_name.to_string()
|
||||
} else {
|
||||
dir_name.chars().take(remaining_width_for_dir).collect()
|
||||
dir_name
|
||||
.chars()
|
||||
.take(remaining_width_for_dir)
|
||||
.collect::<String>()
|
||||
}
|
||||
};
|
||||
|
||||
let mut spans = vec![
|
||||
let mut current_content_width = mode_width
|
||||
+ separator_width
|
||||
+ UnicodeWidthStr::width(dir_display_text_str.as_str())
|
||||
+ separator_width
|
||||
+ program_info_width;
|
||||
if show_fps {
|
||||
current_content_width += separator_width + fps_width;
|
||||
}
|
||||
|
||||
let mut line_spans = vec![
|
||||
Span::styled(mode_text, Style::default().fg(theme.accent)),
|
||||
Span::styled(" | ", Style::default().fg(theme.border)),
|
||||
Span::styled(dir_display_text, Style::default().fg(theme.fg)),
|
||||
Span::styled(" | ", Style::default().fg(theme.border)),
|
||||
Span::styled(program_info, Style::default().fg(theme.secondary)),
|
||||
Span::styled(separator, Style::default().fg(theme.border)),
|
||||
Span::styled(
|
||||
dir_display_text_str.as_str(),
|
||||
Style::default().fg(theme.fg),
|
||||
),
|
||||
Span::styled(separator, Style::default().fg(theme.border)),
|
||||
Span::styled(
|
||||
program_info.as_str(),
|
||||
Style::default().fg(theme.secondary),
|
||||
),
|
||||
];
|
||||
|
||||
if show_fps {
|
||||
spans.push(Span::styled(" | ", Style::default().fg(theme.border)));
|
||||
spans.push(Span::styled(fps_text, Style::default().fg(theme.secondary)));
|
||||
line_spans
|
||||
.push(Span::styled(separator, Style::default().fg(theme.border)));
|
||||
line_spans.push(Span::styled(
|
||||
fps_text.as_str(),
|
||||
Style::default().fg(theme.secondary),
|
||||
));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(Line::from(spans))
|
||||
.style(Style::default().bg(theme.bg));
|
||||
let padding_needed = available_width.saturating_sub(current_content_width);
|
||||
if padding_needed > 0 {
|
||||
line_spans.push(Span::styled(
|
||||
" ".repeat(padding_needed),
|
||||
Style::default().bg(theme.bg),
|
||||
));
|
||||
}
|
||||
|
||||
let paragraph =
|
||||
Paragraph::new(Line::from(line_spans)).style(Style::default().bg(theme.bg));
|
||||
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
331
client/src/components/common/text_editor.rs
Normal file
331
client/src/components/common/text_editor.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
// src/components/common/text_editor.rs
|
||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
||||
use ratatui::style::{Color, Style, Modifier};
|
||||
use tui_textarea::{Input, Key, TextArea, CursorMove};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VimMode {
|
||||
Normal,
|
||||
Insert,
|
||||
Visual,
|
||||
Operator(char),
|
||||
}
|
||||
|
||||
impl VimMode {
|
||||
pub fn cursor_style(&self) -> Style {
|
||||
let color = match self {
|
||||
Self::Normal => Color::Reset,
|
||||
Self::Insert => Color::LightBlue,
|
||||
Self::Visual => Color::LightYellow,
|
||||
Self::Operator(_) => Color::LightGreen,
|
||||
};
|
||||
Style::default().fg(color).add_modifier(Modifier::REVERSED)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VimMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
|
||||
match self {
|
||||
Self::Normal => write!(f, "NORMAL"),
|
||||
Self::Insert => write!(f, "INSERT"),
|
||||
Self::Visual => write!(f, "VISUAL"),
|
||||
Self::Operator(c) => write!(f, "OPERATOR({})", c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Transition {
|
||||
Nop,
|
||||
Mode(VimMode),
|
||||
Pending(Input),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VimState {
|
||||
pub mode: VimMode,
|
||||
pub pending: Input,
|
||||
}
|
||||
|
||||
impl Default for VimState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: VimMode::Normal,
|
||||
pending: Input::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VimState {
|
||||
pub fn new(mode: VimMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
pending: Input::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_pending(self, pending: Input) -> Self {
|
||||
Self {
|
||||
mode: self.mode,
|
||||
pending,
|
||||
}
|
||||
}
|
||||
|
||||
fn transition(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition {
|
||||
if input.key == Key::Null {
|
||||
return Transition::Nop;
|
||||
}
|
||||
|
||||
match self.mode {
|
||||
VimMode::Normal | VimMode::Visual | VimMode::Operator(_) => {
|
||||
match input {
|
||||
Input { key: Key::Char('h'), .. } => textarea.move_cursor(CursorMove::Back),
|
||||
Input { key: Key::Char('j'), .. } => textarea.move_cursor(CursorMove::Down),
|
||||
Input { key: Key::Char('k'), .. } => textarea.move_cursor(CursorMove::Up),
|
||||
Input { key: Key::Char('l'), .. } => textarea.move_cursor(CursorMove::Forward),
|
||||
Input { key: Key::Char('w'), .. } => textarea.move_cursor(CursorMove::WordForward),
|
||||
Input { key: Key::Char('e'), ctrl: false, .. } => {
|
||||
textarea.move_cursor(CursorMove::WordEnd);
|
||||
if matches!(self.mode, VimMode::Operator(_)) {
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
}
|
||||
}
|
||||
Input { key: Key::Char('b'), ctrl: false, .. } => textarea.move_cursor(CursorMove::WordBack),
|
||||
Input { key: Key::Char('^'), .. } => textarea.move_cursor(CursorMove::Head),
|
||||
Input { key: Key::Char('$'), .. } => textarea.move_cursor(CursorMove::End),
|
||||
Input { key: Key::Char('0'), .. } => textarea.move_cursor(CursorMove::Head),
|
||||
Input { key: Key::Char('D'), .. } => {
|
||||
textarea.delete_line_by_end();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('C'), .. } => {
|
||||
textarea.delete_line_by_end();
|
||||
textarea.cancel_selection();
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('p'), .. } => {
|
||||
textarea.paste();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('u'), ctrl: false, .. } => {
|
||||
textarea.undo();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('r'), ctrl: true, .. } => {
|
||||
textarea.redo();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('x'), .. } => {
|
||||
textarea.delete_next_char();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('i'), .. } => {
|
||||
textarea.cancel_selection();
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('a'), .. } => {
|
||||
textarea.cancel_selection();
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('A'), .. } => {
|
||||
textarea.cancel_selection();
|
||||
textarea.move_cursor(CursorMove::End);
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('o'), .. } => {
|
||||
textarea.move_cursor(CursorMove::End);
|
||||
textarea.insert_newline();
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('O'), .. } => {
|
||||
textarea.move_cursor(CursorMove::Head);
|
||||
textarea.insert_newline();
|
||||
textarea.move_cursor(CursorMove::Up);
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('I'), .. } => {
|
||||
textarea.cancel_selection();
|
||||
textarea.move_cursor(CursorMove::Head);
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Normal => {
|
||||
textarea.start_selection();
|
||||
return Transition::Mode(VimMode::Visual);
|
||||
}
|
||||
Input { key: Key::Char('V'), ctrl: false, .. } if self.mode == VimMode::Normal => {
|
||||
textarea.move_cursor(CursorMove::Head);
|
||||
textarea.start_selection();
|
||||
textarea.move_cursor(CursorMove::End);
|
||||
return Transition::Mode(VimMode::Visual);
|
||||
}
|
||||
Input { key: Key::Esc, .. } | Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
||||
textarea.cancel_selection();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('g'), ctrl: false, .. } if matches!(
|
||||
self.pending,
|
||||
Input { key: Key::Char('g'), ctrl: false, .. }
|
||||
) => {
|
||||
textarea.move_cursor(CursorMove::Top)
|
||||
}
|
||||
Input { key: Key::Char('G'), ctrl: false, .. } => textarea.move_cursor(CursorMove::Bottom),
|
||||
Input { key: Key::Char(c), ctrl: false, .. } if self.mode == VimMode::Operator(c) => {
|
||||
textarea.move_cursor(CursorMove::Head);
|
||||
textarea.start_selection();
|
||||
let cursor = textarea.cursor();
|
||||
textarea.move_cursor(CursorMove::Down);
|
||||
if cursor == textarea.cursor() {
|
||||
textarea.move_cursor(CursorMove::End);
|
||||
}
|
||||
}
|
||||
Input { key: Key::Char(op @ ('y' | 'd' | 'c')), ctrl: false, .. } if self.mode == VimMode::Normal => {
|
||||
textarea.start_selection();
|
||||
return Transition::Mode(VimMode::Operator(op));
|
||||
}
|
||||
Input { key: Key::Char('y'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
textarea.copy();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('d'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
textarea.cut();
|
||||
return Transition::Mode(VimMode::Normal);
|
||||
}
|
||||
Input { key: Key::Char('c'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
||||
textarea.move_cursor(CursorMove::Forward);
|
||||
textarea.cut();
|
||||
return Transition::Mode(VimMode::Insert);
|
||||
}
|
||||
// Arrow keys work in normal mode
|
||||
Input { key: Key::Up, .. } => textarea.move_cursor(CursorMove::Up),
|
||||
Input { key: Key::Down, .. } => textarea.move_cursor(CursorMove::Down),
|
||||
Input { key: Key::Left, .. } => textarea.move_cursor(CursorMove::Back),
|
||||
Input { key: Key::Right, .. } => textarea.move_cursor(CursorMove::Forward),
|
||||
input => return Transition::Pending(input),
|
||||
}
|
||||
|
||||
// Handle the pending operator
|
||||
match self.mode {
|
||||
VimMode::Operator('y') => {
|
||||
textarea.copy();
|
||||
Transition::Mode(VimMode::Normal)
|
||||
}
|
||||
VimMode::Operator('d') => {
|
||||
textarea.cut();
|
||||
Transition::Mode(VimMode::Normal)
|
||||
}
|
||||
VimMode::Operator('c') => {
|
||||
textarea.cut();
|
||||
Transition::Mode(VimMode::Insert)
|
||||
}
|
||||
_ => Transition::Nop,
|
||||
}
|
||||
}
|
||||
VimMode::Insert => match input {
|
||||
Input { key: Key::Esc, .. } | Input { key: Key::Char('c'), ctrl: true, .. } => {
|
||||
Transition::Mode(VimMode::Normal)
|
||||
}
|
||||
input => {
|
||||
textarea.input(input);
|
||||
Transition::Mode(VimMode::Insert)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextEditor;
|
||||
|
||||
impl TextEditor {
|
||||
pub fn new_textarea(editor_config: &EditorConfig) -> TextArea<'static> {
|
||||
let mut textarea = TextArea::default();
|
||||
|
||||
if editor_config.show_line_numbers {
|
||||
textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
|
||||
}
|
||||
|
||||
textarea.set_tab_length(editor_config.tab_width);
|
||||
|
||||
textarea
|
||||
}
|
||||
|
||||
pub fn handle_input(
|
||||
textarea: &mut TextArea<'static>,
|
||||
key_event: KeyEvent,
|
||||
keybinding_mode: &EditorKeybindingMode,
|
||||
vim_state: &mut VimState,
|
||||
) -> bool {
|
||||
match keybinding_mode {
|
||||
EditorKeybindingMode::Vim => {
|
||||
Self::handle_vim_input(textarea, key_event, vim_state)
|
||||
}
|
||||
_ => {
|
||||
let tui_input: Input = key_event.into();
|
||||
textarea.input(tui_input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_vim_input(
|
||||
textarea: &mut TextArea<'static>,
|
||||
key_event: KeyEvent,
|
||||
vim_state: &mut VimState,
|
||||
) -> bool {
|
||||
let input = Self::convert_key_event_to_input(key_event);
|
||||
|
||||
*vim_state = match vim_state.transition(input, textarea) {
|
||||
Transition::Mode(mode) if vim_state.mode != mode => {
|
||||
// Update cursor style based on mode
|
||||
textarea.set_cursor_style(mode.cursor_style());
|
||||
VimState::new(mode)
|
||||
}
|
||||
Transition::Nop | Transition::Mode(_) => vim_state.clone(),
|
||||
Transition::Pending(input) => vim_state.clone().with_pending(input),
|
||||
};
|
||||
|
||||
true // Always consider input as handled in vim mode
|
||||
}
|
||||
|
||||
fn convert_key_event_to_input(key_event: KeyEvent) -> Input {
|
||||
let key = match key_event.code {
|
||||
KeyCode::Char(c) => Key::Char(c),
|
||||
KeyCode::Enter => Key::Enter,
|
||||
KeyCode::Left => Key::Left,
|
||||
KeyCode::Right => Key::Right,
|
||||
KeyCode::Up => Key::Up,
|
||||
KeyCode::Down => Key::Down,
|
||||
KeyCode::Backspace => Key::Backspace,
|
||||
KeyCode::Delete => Key::Delete,
|
||||
KeyCode::Home => Key::Home,
|
||||
KeyCode::End => Key::End,
|
||||
KeyCode::PageUp => Key::PageUp,
|
||||
KeyCode::PageDown => Key::PageDown,
|
||||
KeyCode::Tab => Key::Tab,
|
||||
KeyCode::Esc => Key::Esc,
|
||||
_ => Key::Null,
|
||||
};
|
||||
|
||||
Input {
|
||||
key,
|
||||
ctrl: key_event.modifiers.contains(KeyModifiers::CONTROL),
|
||||
alt: key_event.modifiers.contains(KeyModifiers::ALT),
|
||||
shift: key_event.modifiers.contains(KeyModifiers::SHIFT),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_vim_mode_status(vim_state: &VimState) -> String {
|
||||
vim_state.mode.to_string()
|
||||
}
|
||||
|
||||
pub fn is_vim_insert_mode(vim_state: &VimState) -> bool {
|
||||
matches!(vim_state.mode, VimMode::Insert)
|
||||
}
|
||||
|
||||
pub fn is_vim_normal_mode(vim_state: &VimState) -> bool {
|
||||
matches!(vim_state.mode, VimMode::Normal)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,115 @@
|
||||
// src/components/form/form.rs
|
||||
use crate::components::common::autocomplete;
|
||||
use crate::components::handlers::canvas::render_canvas;
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use ratatui::{
|
||||
widgets::{Paragraph, Block, Borders},
|
||||
layout::{Layout, Constraint, Direction, Rect, Margin, Alignment},
|
||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||
style::Style,
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::components::handlers::canvas::render_canvas;
|
||||
|
||||
pub fn render_form(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &impl CanvasState,
|
||||
form_state: &FormState, // <--- CHANGE THIS to the concrete type
|
||||
fields: &[&str],
|
||||
current_field: &usize,
|
||||
current_field_idx: &usize,
|
||||
inputs: &[&String],
|
||||
table_name: &str,
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
total_count: u64,
|
||||
current_position: u64,
|
||||
) {
|
||||
// Create Adresar card
|
||||
let card_title = format!(" {} ", table_name);
|
||||
|
||||
let adresar_card = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(" Adresar ")
|
||||
.title(card_title)
|
||||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||
|
||||
f.render_widget(adresar_card, area);
|
||||
|
||||
// Define inner area
|
||||
let inner_area = area.inner(Margin {
|
||||
horizontal: 1,
|
||||
vertical: 1,
|
||||
});
|
||||
|
||||
// Create main layout
|
||||
let main_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
||||
.split(inner_area);
|
||||
|
||||
// Render count/position
|
||||
let count_position_text = format!("Total: {} | Position: {}", total_count, current_position);
|
||||
let count_position_text = if total_count == 0 && current_position == 1 {
|
||||
"Total: 0 | New Entry".to_string()
|
||||
} else if current_position > total_count && total_count > 0 {
|
||||
format!("Total: {} | New Entry ({})", total_count, current_position)
|
||||
} else if total_count == 0 && current_position > 1 {
|
||||
format!("Total: 0 | New Entry ({})", current_position)
|
||||
} else {
|
||||
format!(
|
||||
"Total: {} | Position: {}/{}",
|
||||
total_count, current_position, total_count
|
||||
)
|
||||
};
|
||||
let count_para = Paragraph::new(count_position_text)
|
||||
.style(Style::default().fg(theme.fg))
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(count_para, main_layout[0]);
|
||||
|
||||
// Delegate input handling to canvas
|
||||
render_canvas(
|
||||
// Get the active field's rect from render_canvas
|
||||
let active_field_rect = render_canvas(
|
||||
f,
|
||||
main_layout[1],
|
||||
form_state,
|
||||
fields,
|
||||
current_field,
|
||||
current_field_idx,
|
||||
inputs,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
);
|
||||
|
||||
// --- NEW: RENDER AUTOCOMPLETE ---
|
||||
if form_state.autocomplete_active {
|
||||
if let Some(active_rect) = active_field_rect {
|
||||
let selected_index = form_state.get_selected_suggestion_index();
|
||||
|
||||
if let Some(rich_suggestions) = form_state.get_rich_suggestions() {
|
||||
if !rich_suggestions.is_empty() {
|
||||
// CHANGE THIS to call the renamed function
|
||||
autocomplete::render_hit_autocomplete_dropdown(
|
||||
f,
|
||||
active_rect,
|
||||
f.area(),
|
||||
theme,
|
||||
rich_suggestions,
|
||||
selected_index,
|
||||
form_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The fallback to simple suggestions is now correctly handled
|
||||
// because the original render_autocomplete_dropdown exists again.
|
||||
else if let Some(simple_suggestions) = form_state.get_suggestions() {
|
||||
if !simple_suggestions.is_empty() {
|
||||
autocomplete::render_autocomplete_dropdown(
|
||||
f,
|
||||
active_rect,
|
||||
f.area(),
|
||||
theme,
|
||||
simple_suggestions,
|
||||
selected_index,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::app::state::AppState; // Add this import
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Style, Stylize},
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
@@ -17,6 +18,7 @@ pub fn render_buffer_list(
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
buffer_state: &BufferState,
|
||||
app_state: &AppState,
|
||||
) {
|
||||
// --- Style Definitions ---
|
||||
let active_style = Style::default()
|
||||
@@ -37,6 +39,8 @@ pub fn render_buffer_list(
|
||||
let mut spans = Vec::new();
|
||||
let mut current_width = 0;
|
||||
|
||||
let current_table_name = app_state.current_view_table_name.as_deref();
|
||||
|
||||
for (original_index, view) in buffer_state.history.iter().enumerate() {
|
||||
// Filter: Only process views matching the active layer
|
||||
if get_view_layer(view) != active_layer {
|
||||
@@ -44,7 +48,7 @@ pub fn render_buffer_list(
|
||||
}
|
||||
|
||||
let is_active = original_index == buffer_state.active_index;
|
||||
let buffer_name = view.display_name();
|
||||
let buffer_name = view.display_name_with_context(current_table_name);
|
||||
let buffer_text = format!(" {} ", buffer_name);
|
||||
let text_width = UnicodeWidthStr::width(buffer_text.as_str());
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// src/components/handlers/canvas.rs
|
||||
|
||||
use ratatui::{
|
||||
widgets::{Paragraph, Block, Borders},
|
||||
layout::{Layout, Constraint, Direction, Rect},
|
||||
style::{Style, Modifier},
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
prelude::Alignment,
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::app::highlight::HighlightState; // Ensure correct import path
|
||||
use std::cmp::{min, max};
|
||||
use std::cmp::{max, min};
|
||||
|
||||
pub fn render_canvas(
|
||||
f: &mut Frame,
|
||||
@@ -21,9 +21,8 @@ pub fn render_canvas(
|
||||
inputs: &[&String],
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState, // Using the enum state
|
||||
highlight_state: &HighlightState,
|
||||
) -> Option<Rect> {
|
||||
// ... (setup code remains the same) ...
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||
@@ -58,46 +57,47 @@ pub fn render_canvas(
|
||||
|
||||
let mut active_field_input_rect = None;
|
||||
|
||||
// Render labels
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
let label = Paragraph::new(Line::from(Span::styled(
|
||||
format!("{}:", field),
|
||||
Style::default().fg(theme.fg)),
|
||||
));
|
||||
f.render_widget(label, Rect {
|
||||
x: columns[0].x,
|
||||
y: input_block.y + 1 + i as u16,
|
||||
width: columns[0].width,
|
||||
height: 1,
|
||||
});
|
||||
Style::default().fg(theme.fg),
|
||||
)));
|
||||
f.render_widget(
|
||||
label,
|
||||
Rect {
|
||||
x: columns[0].x,
|
||||
y: input_block.y + 1 + i as u16,
|
||||
width: columns[0].width,
|
||||
height: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Render inputs and cursor
|
||||
for (i, input) in inputs.iter().enumerate() {
|
||||
for (i, _input) in inputs.iter().enumerate() {
|
||||
let is_active = i == *current_field_idx;
|
||||
let current_cursor_pos = form_state.current_cursor_pos();
|
||||
let text = input.as_str();
|
||||
let text_len = text.chars().count();
|
||||
|
||||
// Use the trait method to get display value
|
||||
let text = form_state.get_display_value_for_field(i);
|
||||
let text_len = text.chars().count();
|
||||
let line: Line;
|
||||
|
||||
// --- Use match on the highlight_state enum ---
|
||||
match highlight_state {
|
||||
HighlightState::Off => {
|
||||
// Not in highlight mode, render normally
|
||||
line = Line::from(Span::styled(
|
||||
text,
|
||||
if is_active { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.fg) }
|
||||
if is_active {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.fg)
|
||||
},
|
||||
));
|
||||
}
|
||||
HighlightState::Characterwise { anchor } => {
|
||||
// --- Character-wise Highlight Logic ---
|
||||
let (anchor_field, anchor_char) = *anchor;
|
||||
let start_field = min(anchor_field, *current_field_idx);
|
||||
let end_field = max(anchor_field, *current_field_idx);
|
||||
|
||||
// Use start_char and end_char consistently
|
||||
let (start_char, end_char) = if anchor_field == *current_field_idx {
|
||||
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
|
||||
} else if anchor_field < *current_field_idx {
|
||||
@@ -111,24 +111,20 @@ pub fn render_canvas(
|
||||
let normal_style_outside = Style::default().fg(theme.fg);
|
||||
|
||||
if i >= start_field && i <= end_field {
|
||||
// This line is within the character-wise highlight range
|
||||
if start_field == end_field { // Case 1: Single Line Highlight
|
||||
// Use start_char and end_char here
|
||||
if start_field == end_field {
|
||||
let clamped_start = start_char.min(text_len);
|
||||
let clamped_end = end_char.min(text_len); // Use text_len for slicing logic
|
||||
let clamped_end = end_char.min(text_len);
|
||||
|
||||
let before: String = text.chars().take(clamped_start).collect();
|
||||
let highlighted: String = text.chars().skip(clamped_start).take(clamped_end.saturating_sub(clamped_start) + 1).collect();
|
||||
// Define 'after' here
|
||||
let after: String = text.chars().skip(clamped_end + 1).collect();
|
||||
|
||||
line = Line::from(vec![
|
||||
Span::styled(before, normal_style_in_highlight),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style_in_highlight), // Use defined 'after'
|
||||
Span::styled(after, normal_style_in_highlight),
|
||||
]);
|
||||
} else if i == start_field { // Case 2: Multi-Line Highlight - Start Line
|
||||
// Use start_char here
|
||||
} else if i == start_field {
|
||||
let safe_start = start_char.min(text_len);
|
||||
let before: String = text.chars().take(safe_start).collect();
|
||||
let highlighted: String = text.chars().skip(safe_start).collect();
|
||||
@@ -136,8 +132,7 @@ pub fn render_canvas(
|
||||
Span::styled(before, normal_style_in_highlight),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
]);
|
||||
} else if i == end_field { // Case 3: Multi-Line Highlight - End Line (Corrected index)
|
||||
// Use end_char here
|
||||
} else if i == end_field {
|
||||
let safe_end_inclusive = if text_len > 0 { end_char.min(text_len - 1) } else { 0 };
|
||||
let highlighted: String = text.chars().take(safe_end_inclusive + 1).collect();
|
||||
let after: String = text.chars().skip(safe_end_inclusive + 1).collect();
|
||||
@@ -145,19 +140,17 @@ pub fn render_canvas(
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style_in_highlight),
|
||||
]);
|
||||
} else { // Case 4: Multi-Line Highlight - Middle Line (Corrected index)
|
||||
line = Line::from(Span::styled(text, highlight_style)); // Highlight whole line
|
||||
} else {
|
||||
line = Line::from(Span::styled(text, highlight_style));
|
||||
}
|
||||
} else { // Case 5: Line Outside Character-wise Highlight Range
|
||||
} else {
|
||||
line = Line::from(Span::styled(
|
||||
text,
|
||||
// Use normal styling (active or inactive)
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
));
|
||||
}
|
||||
}
|
||||
HighlightState::Linewise { anchor_line } => {
|
||||
// --- Linewise Highlight Logic ---
|
||||
let start_field = min(*anchor_line, *current_field_idx);
|
||||
let end_field = max(*anchor_line, *current_field_idx);
|
||||
let highlight_style = Style::default().fg(theme.highlight).bg(theme.highlight_bg).add_modifier(Modifier::BOLD);
|
||||
@@ -165,25 +158,31 @@ pub fn render_canvas(
|
||||
let normal_style_outside = Style::default().fg(theme.fg);
|
||||
|
||||
if i >= start_field && i <= end_field {
|
||||
// Highlight the entire line
|
||||
line = Line::from(Span::styled(text, highlight_style));
|
||||
} 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 {
|
||||
active_field_input_rect = Some(input_rows[i]);
|
||||
let cursor_x = input_rows[i].x + form_state.current_cursor_pos() as u16;
|
||||
|
||||
// --- CORRECTED CURSOR POSITIONING LOGIC ---
|
||||
// Use the new generic trait method to check for an override.
|
||||
let cursor_x = if form_state.has_display_override(i) {
|
||||
// If an override exists, place the cursor at the end.
|
||||
input_rows[i].x + text.chars().count() as u16
|
||||
} else {
|
||||
// Otherwise, use the real cursor position.
|
||||
input_rows[i].x + form_state.current_cursor_pos() as u16
|
||||
};
|
||||
let cursor_y = input_rows[i].y;
|
||||
f.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
@@ -191,4 +190,3 @@ pub fn render_canvas(
|
||||
|
||||
active_field_input_rect
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,15 @@ 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 {
|
||||
let chunks = Layout::default()
|
||||
@@ -36,18 +41,54 @@ pub fn render_sidebar(
|
||||
) {
|
||||
let sidebar_block = Block::default().style(Style::default().bg(theme.bg));
|
||||
let mut items = Vec::new();
|
||||
let profile_name_available_width = (SIDEBAR_WIDTH as usize).saturating_sub(3);
|
||||
let table_name_available_width = (SIDEBAR_WIDTH as usize).saturating_sub(5);
|
||||
|
||||
if let Some(profile_name) = selected_profile {
|
||||
// Existing code for when a profile is selected...
|
||||
// Find the selected profile in the tree
|
||||
if let Some(profile) = profile_tree
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|p| &p.name == profile_name)
|
||||
{
|
||||
// Add profile name as header
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled(format!("{} ", ICON_PROFILE), Style::default().fg(theme.accent)),
|
||||
Span::styled(
|
||||
truncate_string(&profile.name, profile_name_available_width),
|
||||
Style::default().fg(theme.highlight)
|
||||
),
|
||||
])));
|
||||
|
||||
// List tables for the selected profile
|
||||
for table in &profile.tables {
|
||||
// Get table name without year prefix to save space
|
||||
let display_name = if table.name.starts_with("2025_") {
|
||||
&table.name[5..] // Skip "2025_" prefix
|
||||
} else {
|
||||
&table.name
|
||||
};
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::raw(" "), // Indentation
|
||||
Span::styled(format!("{} ", ICON_TABLE), Style::default().fg(theme.secondary)),
|
||||
Span::styled(
|
||||
truncate_string(display_name, table_name_available_width),
|
||||
theme.fg
|
||||
),
|
||||
])));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Show full profile tree when no profile is selected (compact version)
|
||||
for (profile_idx, profile) in profile_tree.profiles.iter().enumerate() {
|
||||
// Profile header - more compact
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled("◆", Style::default().fg(theme.accent)),
|
||||
Span::styled(&profile.name, Style::default().fg(theme.highlight)),
|
||||
Span::styled(format!("{} ", ICON_PROFILE), Style::default().fg(theme.accent)),
|
||||
Span::styled(
|
||||
&profile.name,
|
||||
Style::default().fg(theme.highlight)
|
||||
),
|
||||
])));
|
||||
|
||||
// Tables with compact prefixes
|
||||
for (table_idx, table) in profile.tables.iter().enumerate() {
|
||||
let is_last_table = table_idx == profile.tables.len() - 1;
|
||||
@@ -68,18 +109,18 @@ pub fn render_sidebar(
|
||||
&table.name
|
||||
};
|
||||
|
||||
let mut line = vec![
|
||||
Span::styled(prefix, Style::default().fg(theme.fg)),
|
||||
Span::styled(display_name, Style::default().fg(theme.fg)),
|
||||
];
|
||||
// Adjust available width if dependency arrow is shown
|
||||
let current_table_available_width = if !table.depends_on.is_empty() {
|
||||
table_name_available_width.saturating_sub(1)
|
||||
} else {
|
||||
table_name_available_width
|
||||
};
|
||||
|
||||
// Show a simple indicator for dependencies instead of listing them
|
||||
if !table.depends_on.is_empty() {
|
||||
line.push(Span::styled(
|
||||
"→",
|
||||
Style::default().fg(theme.secondary)
|
||||
));
|
||||
}
|
||||
let line = vec![
|
||||
Span::styled(prefix, Style::default().fg(theme.fg)),
|
||||
Span::styled(format!("{} ", ICON_TABLE), Style::default().fg(theme.secondary)),
|
||||
Span::styled(truncate_string(display_name, current_table_available_width), Style::default().fg(theme.fg)),
|
||||
];
|
||||
|
||||
items.push(ListItem::new(Line::from(line)));
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
4
client/src/components/utils.rs
Normal file
4
client/src/components/utils.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
// src/components/utils.rs
|
||||
pub mod text;
|
||||
|
||||
pub use text::*;
|
||||
29
client/src/components/utils/text.rs
Normal file
29
client/src/components/utils/text.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,57 @@
|
||||
// src/config/binds/config.rs
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize}; // Added Serialize for EditorKeybindingMode if needed elsewhere
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
// NEW: Editor Keybinding Mode Enum
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum EditorKeybindingMode {
|
||||
#[serde(rename = "default")]
|
||||
Default,
|
||||
#[serde(rename = "vim")]
|
||||
Vim,
|
||||
#[serde(rename = "emacs")]
|
||||
Emacs,
|
||||
}
|
||||
|
||||
impl Default for EditorKeybindingMode {
|
||||
fn default() -> Self {
|
||||
EditorKeybindingMode::Default
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: Editor Configuration Struct
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditorConfig {
|
||||
#[serde(default)]
|
||||
pub keybinding_mode: EditorKeybindingMode,
|
||||
#[serde(default = "default_show_line_numbers")]
|
||||
pub show_line_numbers: bool,
|
||||
#[serde(default = "default_tab_width")]
|
||||
pub tab_width: u8,
|
||||
}
|
||||
|
||||
fn default_show_line_numbers() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_tab_width() -> u8 {
|
||||
4
|
||||
}
|
||||
|
||||
impl Default for EditorConfig {
|
||||
fn default() -> Self {
|
||||
EditorConfig {
|
||||
keybinding_mode: EditorKeybindingMode::default(),
|
||||
show_line_numbers: default_show_line_numbers(),
|
||||
tab_width: default_tab_width(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ColorsConfig {
|
||||
#[serde(default = "default_theme")]
|
||||
@@ -21,9 +68,14 @@ pub struct Config {
|
||||
pub keybindings: ModeKeybindings,
|
||||
#[serde(default)]
|
||||
pub colors: ColorsConfig,
|
||||
// NEW: Add editor configuration
|
||||
#[serde(default)]
|
||||
pub editor: EditorConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
// ... (rest of your Config struct and impl Config remains the same)
|
||||
// Make sure ModeKeybindings is also deserializable if it's not already
|
||||
#[derive(Debug, Deserialize, Default)] // Added Default here if not present
|
||||
pub struct ModeKeybindings {
|
||||
#[serde(default)]
|
||||
pub general: HashMap<String, Vec<String>>,
|
||||
@@ -43,16 +95,16 @@ pub struct ModeKeybindings {
|
||||
|
||||
impl Config {
|
||||
/// Loads the configuration from "config.toml" in the client crate directory.
|
||||
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
pub fn load() -> Result<Self> {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let config_path = Path::new(manifest_dir).join("config.toml");
|
||||
let config_str = std::fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read config file at {:?}: {}", config_path, e))?;
|
||||
let config: Config = toml::from_str(&config_str)?;
|
||||
.with_context(|| format!("Failed to read config file at {:?}", config_path))?;
|
||||
let config: Config = toml::from_str(&config_str)
|
||||
.with_context(|| format!("Failed to parse config file: {}. Check for syntax errors or missing fields like an empty [editor] section if you added it.", config_str))?; // Enhanced error message
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
|
||||
pub fn get_general_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||
self.get_action_for_key_in_mode(&self.keybindings.general, key, modifiers)
|
||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
pub mod binds;
|
||||
pub mod colors;
|
||||
pub mod storage;
|
||||
|
||||
4
client/src/config/storage.rs
Normal file
4
client/src/config/storage.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
// src/config/storage.rs
|
||||
pub mod storage;
|
||||
|
||||
pub use storage::*;
|
||||
101
client/src/config/storage/storage.rs
Normal file
101
client/src/config/storage/storage.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
// src/config/storage/storage.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{error, info};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
pub const APP_NAME: &str = "multieko2_client";
|
||||
pub const TOKEN_FILE_NAME: &str = "auth.token";
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct StoredAuthData {
|
||||
pub access_token: String,
|
||||
pub user_id: String,
|
||||
pub role: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
pub fn get_token_storage_path() -> Result<PathBuf> {
|
||||
let state_dir = dirs::state_dir()
|
||||
.or_else(|| dirs::home_dir().map(|home| home.join(".local").join("state")))
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not determine state directory"))?;
|
||||
|
||||
let app_state_dir = state_dir.join(APP_NAME);
|
||||
fs::create_dir_all(&app_state_dir)
|
||||
.with_context(|| format!("Failed to create app state directory at {:?}", app_state_dir))?;
|
||||
|
||||
Ok(app_state_dir.join(TOKEN_FILE_NAME))
|
||||
}
|
||||
|
||||
pub fn save_auth_data(data: &StoredAuthData) -> Result<()> {
|
||||
let path = get_token_storage_path()?;
|
||||
|
||||
let json_data = serde_json::to_string(data)
|
||||
.context("Failed to serialize auth data")?;
|
||||
|
||||
let mut file = File::create(&path)
|
||||
.with_context(|| format!("Failed to create token file at {:?}", path))?;
|
||||
|
||||
file.write_all(json_data.as_bytes())
|
||||
.context("Failed to write token data to file")?;
|
||||
|
||||
// Set file permissions to 600 (owner read/write only) on Unix
|
||||
#[cfg(unix)]
|
||||
{
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.context("Failed to set token file permissions")?;
|
||||
}
|
||||
|
||||
info!("Auth data saved to {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_auth_data() -> Result<Option<StoredAuthData>> {
|
||||
let path = get_token_storage_path()?;
|
||||
|
||||
if !path.exists() {
|
||||
info!("Token file not found at {:?}", path);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let json_data = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read token file at {:?}", path))?;
|
||||
|
||||
if json_data.trim().is_empty() {
|
||||
info!("Token file is empty at {:?}", path);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match serde_json::from_str::<StoredAuthData>(&json_data) {
|
||||
Ok(data) => {
|
||||
info!("Auth data loaded from {:?}", path);
|
||||
Ok(Some(data))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to deserialize token data from {:?}: {}. Deleting corrupt file.", path, e);
|
||||
if let Err(del_e) = fs::remove_file(&path) {
|
||||
error!("Failed to delete corrupt token file: {}", del_e);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_auth_data() -> Result<()> {
|
||||
let path = get_token_storage_path()?;
|
||||
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("Failed to delete token file at {:?}", path))?;
|
||||
info!("Token file deleted from {:?}", path);
|
||||
} else {
|
||||
info!("Token file not found for deletion at {:?}", path);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,8 +6,8 @@ use crate::state::app::buffer::AppView;
|
||||
pub fn get_view_layer(view: &AppView) -> u8 {
|
||||
match view {
|
||||
AppView::Intro => 1,
|
||||
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable => 2,
|
||||
AppView::Form(_) | AppView::Scratch => 3,
|
||||
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
|
||||
AppView::Form | AppView::Scratch => 3,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
|
||||
pub mod form_e;
|
||||
pub mod auth_e;
|
||||
pub mod add_table_e;
|
||||
pub mod add_logic_e;
|
||||
|
||||
135
client/src/functions/modes/edit/add_logic_e.rs
Normal file
135
client/src/functions/modes/edit/add_logic_e.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
// src/functions/modes/edit/add_logic_e.rs
|
||||
use crate::state::pages::add_logic::AddLogicState;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
pub async fn execute_edit_action(
|
||||
action: &str,
|
||||
key: KeyEvent, // Keep key for insert_char
|
||||
state: &mut AddLogicState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
let mut message = String::new();
|
||||
|
||||
match action {
|
||||
"next_field" => {
|
||||
let current_field = state.current_field();
|
||||
let next_field = (current_field + 1) % AddLogicState::INPUT_FIELD_COUNT;
|
||||
state.set_current_field(next_field);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
message = format!("Focus on field {}", state.fields()[next_field]);
|
||||
}
|
||||
"prev_field" => {
|
||||
let current_field = state.current_field();
|
||||
let prev_field = if current_field == 0 {
|
||||
AddLogicState::INPUT_FIELD_COUNT - 1
|
||||
} else {
|
||||
current_field - 1
|
||||
};
|
||||
state.set_current_field(prev_field);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
message = format!("Focus on field {}", state.fields()[prev_field]);
|
||||
}
|
||||
"delete_char_forward" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let current_input_mut = state.get_current_input_mut();
|
||||
if current_pos < current_input_mut.len() {
|
||||
current_input_mut.remove(current_pos);
|
||||
state.set_has_unsaved_changes(true);
|
||||
if state.current_field() == 1 { state.update_target_column_suggestions(); }
|
||||
}
|
||||
}
|
||||
"delete_char_backward" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
if current_pos > 0 {
|
||||
let new_pos = current_pos - 1;
|
||||
state.get_current_input_mut().remove(new_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
state.set_has_unsaved_changes(true);
|
||||
if state.current_field() == 1 { state.update_target_column_suggestions(); }
|
||||
}
|
||||
}
|
||||
"move_left" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
if current_pos > 0 {
|
||||
let new_pos = current_pos - 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
}
|
||||
"move_right" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let input_len = state.get_current_input().len();
|
||||
if current_pos < input_len {
|
||||
let new_pos = current_pos + 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
}
|
||||
"insert_char" => {
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
state.get_current_input_mut().insert(current_pos, c);
|
||||
let new_pos = current_pos + 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
state.set_has_unsaved_changes(true);
|
||||
if state.current_field() == 1 {
|
||||
state.update_target_column_suggestions();
|
||||
}
|
||||
}
|
||||
}
|
||||
"suggestion_down" => {
|
||||
if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
|
||||
let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
|
||||
let next_selection = (current_selection + 1) % state.target_column_suggestions.len();
|
||||
state.selected_target_column_suggestion_index = Some(next_selection);
|
||||
}
|
||||
}
|
||||
"suggestion_up" => {
|
||||
if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
|
||||
let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
|
||||
let prev_selection = if current_selection == 0 {
|
||||
state.target_column_suggestions.len() - 1
|
||||
} else {
|
||||
current_selection - 1
|
||||
};
|
||||
state.selected_target_column_suggestion_index = Some(prev_selection);
|
||||
}
|
||||
}
|
||||
"select_suggestion" => {
|
||||
if state.in_target_column_suggestion_mode {
|
||||
let mut selected_suggestion_text: Option<String> = None;
|
||||
|
||||
if let Some(selected_idx) = state.selected_target_column_suggestion_index {
|
||||
if let Some(suggestion) = state.target_column_suggestions.get(selected_idx) {
|
||||
selected_suggestion_text = Some(suggestion.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(suggestion_text) = selected_suggestion_text {
|
||||
state.target_column_input = suggestion_text.clone();
|
||||
state.target_column_cursor_pos = state.target_column_input.len();
|
||||
*ideal_cursor_column = state.target_column_cursor_pos;
|
||||
state.set_has_unsaved_changes(true);
|
||||
message = format!("Selected column: '{}'", suggestion_text);
|
||||
}
|
||||
|
||||
state.in_target_column_suggestion_mode = false;
|
||||
state.show_target_column_suggestions = false;
|
||||
state.selected_target_column_suggestion_index = None;
|
||||
state.update_target_column_suggestions();
|
||||
} else {
|
||||
let current_field = state.current_field();
|
||||
let next_field = (current_field + 1) % AddLogicState::INPUT_FIELD_COUNT;
|
||||
state.set_current_field(next_field);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
message = format!("Focus on field {}", state.fields()[next_field]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
341
client/src/functions/modes/edit/add_table_e.rs
Normal file
341
client/src/functions/modes/edit/add_table_e.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,20 @@ use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::tui::functions::common::form::{revert, save};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use std::any::Any;
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn execute_common_action<S: CanvasState + Any>(
|
||||
action: &str,
|
||||
state: &mut S,
|
||||
grpc_client: &mut GrpcClient,
|
||||
app_state: &AppState,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
) -> Result<String> {
|
||||
match action {
|
||||
"save" | "revert" => {
|
||||
if !state.has_unsaved_changes() {
|
||||
@@ -26,10 +29,9 @@ pub async fn execute_common_action<S: CanvasState + Any>(
|
||||
match action {
|
||||
"save" => {
|
||||
let outcome = save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
)
|
||||
.await?;
|
||||
let message = format!("Save successful: {:?}", outcome); // Simple message for now
|
||||
@@ -39,8 +41,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
|
||||
revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -62,10 +62,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 +117,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 +132,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();
|
||||
|
||||
@@ -3,19 +3,20 @@
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::tui::functions::common::form::{revert, save};
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use std::any::Any;
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn execute_common_action<S: CanvasState + Any>(
|
||||
action: &str,
|
||||
state: &mut S,
|
||||
grpc_client: &mut GrpcClient,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
|
||||
app_state: &AppState,
|
||||
) -> Result<EventOutcome> {
|
||||
match action {
|
||||
"save" | "revert" => {
|
||||
if !state.has_unsaved_changes() {
|
||||
@@ -27,12 +28,11 @@ pub async fn execute_common_action<S: CanvasState + Any>(
|
||||
match action {
|
||||
"save" => {
|
||||
let save_result = save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await;
|
||||
|
||||
|
||||
match save_result {
|
||||
Ok(save_outcome) => {
|
||||
let message = match save_outcome {
|
||||
@@ -49,10 +49,8 @@ pub async fn execute_common_action<S: CanvasState + Any>(
|
||||
let revert_result = revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await;
|
||||
|
||||
|
||||
match revert_result {
|
||||
Ok(message) => Ok(EventOutcome::Ok(message)),
|
||||
Err(e) => Err(e),
|
||||
@@ -76,10 +74,7 @@ pub async fn execute_edit_action<S: CanvasState>(
|
||||
key: KeyEvent,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
grpc_client: &mut GrpcClient,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
) -> Result<String> {
|
||||
match action {
|
||||
"insert_char" => {
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
pub mod admin_nav;
|
||||
pub mod add_table_nav;
|
||||
pub mod add_logic_nav;
|
||||
|
||||
440
client/src/functions/modes/navigation/add_logic_nav.rs
Normal file
440
client/src/functions/modes/navigation/add_logic_nav.rs
Normal file
@@ -0,0 +1,440 @@
|
||||
// src/functions/modes/navigation/add_logic_nav.rs
|
||||
use crate::config::binds::config::{Config, EditorKeybindingMode};
|
||||
use crate::state::{
|
||||
app::state::AppState,
|
||||
pages::add_logic::{AddLogicFocus, AddLogicState},
|
||||
app::buffer::AppView,
|
||||
app::buffer::BufferState,
|
||||
};
|
||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
||||
use crate::services::GrpcClient;
|
||||
use tokio::sync::mpsc;
|
||||
use anyhow::Result;
|
||||
use crate::components::common::text_editor::TextEditor;
|
||||
use crate::services::ui_service::UiService;
|
||||
use tui_textarea::CursorMove; // Ensure this import is present
|
||||
|
||||
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
|
||||
|
||||
pub fn handle_add_logic_navigation(
|
||||
key_event: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
is_edit_mode: &mut bool,
|
||||
buffer_state: &mut BufferState,
|
||||
grpc_client: GrpcClient,
|
||||
_save_logic_sender: SaveLogicResultSender, // Marked as unused
|
||||
command_message: &mut String,
|
||||
) -> bool {
|
||||
// === FULLSCREEN SCRIPT EDITING - COMPLETE ISOLATION ===
|
||||
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
|
||||
// === AUTOCOMPLETE HANDLING ===
|
||||
if add_logic_state.script_editor_autocomplete_active {
|
||||
match key_event.code {
|
||||
// ... (Char, Backspace, Tab, Down, Up cases remain the same) ...
|
||||
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
|
||||
add_logic_state.script_editor_filter_text.push(c);
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
*command_message = format!("Filtering: @{}", add_logic_state.script_editor_filter_text);
|
||||
return true;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if !add_logic_state.script_editor_filter_text.is_empty() {
|
||||
add_logic_state.script_editor_filter_text.pop();
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
*command_message = if add_logic_state.script_editor_filter_text.is_empty() {
|
||||
"Autocomplete: @".to_string()
|
||||
} else {
|
||||
format!("Filtering: @{}", add_logic_state.script_editor_filter_text)
|
||||
};
|
||||
} else {
|
||||
let should_deactivate = if let Some((trigger_line, trigger_col)) = add_logic_state.script_editor_trigger_position {
|
||||
let current_cursor = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor()
|
||||
};
|
||||
current_cursor.0 == trigger_line && current_cursor.1 == trigger_col + 1
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if should_deactivate {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
}
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Down => {
|
||||
if !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
|
||||
let next = (current + 1) % add_logic_state.script_editor_suggestions.len();
|
||||
add_logic_state.script_editor_selected_suggestion_index = Some(next);
|
||||
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[next]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
|
||||
let prev = if current == 0 {
|
||||
add_logic_state.script_editor_suggestions.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
add_logic_state.script_editor_selected_suggestion_index = Some(prev);
|
||||
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[prev]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(selected_idx) = add_logic_state.script_editor_selected_suggestion_index {
|
||||
if let Some(suggestion) = add_logic_state.script_editor_suggestions.get(selected_idx).cloned() {
|
||||
let trigger_pos = add_logic_state.script_editor_trigger_position;
|
||||
let filter_len = add_logic_state.script_editor_filter_text.len();
|
||||
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
|
||||
if let Some(pos) = trigger_pos {
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
|
||||
if suggestion == "sql" {
|
||||
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, "sql");
|
||||
editor_borrow.insert_str("('')");
|
||||
// Move cursor back twice to be between the single quotes
|
||||
editor_borrow.move_cursor(CursorMove::Back); // Before ')'
|
||||
editor_borrow.move_cursor(CursorMove::Back); // Before ''' (inside '')
|
||||
*command_message = "Inserted: @sql('')".to_string();
|
||||
} else {
|
||||
let is_table_selection = add_logic_state.is_table_name_suggestion(&suggestion);
|
||||
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, &suggestion);
|
||||
|
||||
if is_table_selection {
|
||||
editor_borrow.insert_str(".");
|
||||
let new_cursor = editor_borrow.cursor();
|
||||
drop(editor_borrow); // Release borrow before calling add_logic_state methods
|
||||
|
||||
add_logic_state.script_editor_trigger_position = Some(new_cursor);
|
||||
add_logic_state.script_editor_autocomplete_active = true;
|
||||
add_logic_state.script_editor_filter_text.clear();
|
||||
add_logic_state.trigger_column_autocomplete_for_table(suggestion.clone());
|
||||
|
||||
let profile_name = add_logic_state.profile_name.clone();
|
||||
let table_name_for_fetch = suggestion.clone();
|
||||
let mut client_clone = grpc_client.clone();
|
||||
tokio::spawn(async move {
|
||||
match UiService::fetch_columns_for_table(&mut client_clone, &profile_name, &table_name_for_fetch).await {
|
||||
Ok(_columns) => {
|
||||
// Result handled by main UI loop
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name_for_fetch, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
*command_message = format!("Selected table '{}', fetching columns...", suggestion);
|
||||
} else {
|
||||
*command_message = format!("Inserted: {}", suggestion);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
}
|
||||
_ => {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if key_event.code == KeyCode::Char('@') && key_event.modifiers == KeyModifiers::NONE {
|
||||
let should_trigger = match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => *is_edit_mode,
|
||||
_ => true,
|
||||
};
|
||||
if should_trigger {
|
||||
let cursor_before = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor()
|
||||
};
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
add_logic_state.script_editor_trigger_position = Some(cursor_before);
|
||||
add_logic_state.script_editor_autocomplete_active = true;
|
||||
add_logic_state.script_editor_filter_text.clear();
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
*command_message = "Autocomplete: @ (Tab/↑↓ to navigate, Enter to select, Esc to cancel)".to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
|
||||
match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => {
|
||||
if *is_edit_mode {
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
if TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) {
|
||||
*is_edit_mode = false;
|
||||
*command_message = "VIM: Normal Mode. Esc again to exit script.".to_string();
|
||||
}
|
||||
} else {
|
||||
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script editing.".to_string();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if *is_edit_mode {
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script edit. Esc again to exit script.".to_string();
|
||||
} else {
|
||||
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script editing.".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let changed = {
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
)
|
||||
};
|
||||
if changed {
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
}
|
||||
if add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim {
|
||||
*is_edit_mode = !TextEditor::is_vim_normal_mode(&add_logic_state.vim_state);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let action = config.get_general_action(key_event.code, key_event.modifiers);
|
||||
let current_focus = add_logic_state.current_focus;
|
||||
let mut handled = true;
|
||||
let mut new_focus = current_focus;
|
||||
|
||||
match action.as_deref() {
|
||||
Some("exit_table_scroll") => {
|
||||
handled = false;
|
||||
}
|
||||
Some("move_up") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName => {}
|
||||
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputLogicName,
|
||||
AddLogicFocus::InputDescription => new_focus = AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("move_down") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName => new_focus = AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::InputDescription => {
|
||||
add_logic_state.last_canvas_field = 2;
|
||||
new_focus = AddLogicFocus::ScriptContentPreview;
|
||||
},
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => {}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_option") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
|
||||
{ new_focus = AddLogicFocus::ScriptContentPreview; }
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => { }
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("previous_option") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
|
||||
{ }
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddLogicFocus::InputLogicName => AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::InputDescription => AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::ScriptContentPreview => AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => AddLogicFocus::InputLogicName,
|
||||
_ => current_focus,
|
||||
};
|
||||
}
|
||||
Some("prev_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddLogicFocus::InputLogicName => AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputLogicName,
|
||||
AddLogicFocus::InputDescription => AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::ScriptContentPreview => AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => AddLogicFocus::SaveButton,
|
||||
_ => current_focus,
|
||||
};
|
||||
}
|
||||
Some("select") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::ScriptContentPreview => {
|
||||
new_focus = AddLogicFocus::InsideScriptContent;
|
||||
*is_edit_mode = false;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
let mode_hint = match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => "VIM mode - 'i'/'a'/'o' to edit",
|
||||
_ => "Enter/Ctrl+E to edit",
|
||||
};
|
||||
*command_message = format!("Fullscreen script editing. {} or Esc to exit.", mode_hint);
|
||||
}
|
||||
AddLogicFocus::SaveButton => {
|
||||
*command_message = "Save logic action".to_string();
|
||||
}
|
||||
AddLogicFocus::CancelButton => {
|
||||
buffer_state.update_history(AppView::Admin);
|
||||
app_state.ui.show_add_logic = false;
|
||||
*command_message = "Cancelled Add Logic".to_string();
|
||||
*is_edit_mode = false;
|
||||
}
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
|
||||
*is_edit_mode = !*is_edit_mode;
|
||||
*command_message = format!("Field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("toggle_edit_mode") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
|
||||
*is_edit_mode = !*is_edit_mode;
|
||||
*command_message = format!("Canvas field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
|
||||
}
|
||||
_ => {
|
||||
*command_message = "Cannot toggle edit mode here.".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
|
||||
if handled && current_focus != new_focus {
|
||||
add_logic_state.current_focus = new_focus;
|
||||
let new_is_canvas_input_focus = matches!(new_focus,
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription
|
||||
);
|
||||
if new_is_canvas_input_focus {
|
||||
*is_edit_mode = false;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
} else {
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
if matches!(new_focus, AddLogicFocus::ScriptContentPreview) {
|
||||
*is_edit_mode = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
handled
|
||||
}
|
||||
|
||||
fn replace_autocomplete_text(
|
||||
editor: &mut tui_textarea::TextArea,
|
||||
trigger_pos: (usize, usize),
|
||||
filter_len: usize,
|
||||
replacement: &str,
|
||||
) {
|
||||
// use tui_textarea::CursorMove; // Already imported at the top of the module
|
||||
let filter_start_pos = (trigger_pos.0, trigger_pos.1 + 1);
|
||||
editor.move_cursor(CursorMove::Jump(filter_start_pos.0 as u16, filter_start_pos.1 as u16));
|
||||
for _ in 0..filter_len {
|
||||
editor.delete_next_char();
|
||||
}
|
||||
editor.insert_str(replacement);
|
||||
}
|
||||
@@ -5,256 +5,201 @@ use crate::state::{
|
||||
pages::add_table::{AddTableFocus, AddTableState},
|
||||
};
|
||||
use crossterm::event::{KeyEvent};
|
||||
use ratatui::widgets::TableState; // Import TableState
|
||||
use ratatui::widgets::TableState;
|
||||
use crate::tui::functions::common::add_table::{handle_add_column_action, handle_save_table_action};
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::services::GrpcClient;
|
||||
use tokio::sync::mpsc;
|
||||
use anyhow::Result;
|
||||
|
||||
pub type SaveTableResultSender = mpsc::Sender<Result<String>>;
|
||||
|
||||
fn navigate_table_up(table_state: &mut TableState, item_count: usize) -> bool {
|
||||
if item_count == 0 { return false; }
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index > 0 { table_state.select(Some(index - 1)); true }
|
||||
else { false }
|
||||
}
|
||||
None => { table_state.select(Some(0)); true }
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate_table_down(table_state: &mut TableState, item_count: usize) -> bool {
|
||||
if item_count == 0 { return false; }
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index < item_count - 1 { table_state.select(Some(index + 1)); true }
|
||||
else { false }
|
||||
}
|
||||
None => { table_state.select(Some(0)); true }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: &AppState, // Keep for potential future use (e.g., checking permissions)
|
||||
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 handled = true;
|
||||
let mut new_focus = current_focus;
|
||||
|
||||
if matches!(current_focus, AddTableFocus::InsideColumnsTable | AddTableFocus::InsideIndexesTable | AddTableFocus::InsideLinksTable) {
|
||||
if matches!(action.as_deref(), Some("next_option") | Some("previous_option")) {
|
||||
*command_message = "Press Esc to exit table item navigation first.".to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
match action.as_deref() {
|
||||
// --- Vertical Navigation (Up/Down) ---
|
||||
Some("exit_table_scroll") => {
|
||||
match current_focus {
|
||||
AddTableFocus::InsideColumnsTable => {
|
||||
add_table_state.column_table_state.select(None);
|
||||
new_focus = AddTableFocus::ColumnsTable;
|
||||
// *command_message = "Exited Columns Table".to_string(); // Minimal change: remove message
|
||||
}
|
||||
AddTableFocus::InsideIndexesTable => {
|
||||
add_table_state.index_table_state.select(None);
|
||||
new_focus = AddTableFocus::IndexesTable;
|
||||
// *command_message = "Exited Indexes Table".to_string();
|
||||
}
|
||||
AddTableFocus::InsideLinksTable => {
|
||||
add_table_state.link_table_state.select(None);
|
||||
new_focus = AddTableFocus::LinksTable;
|
||||
// *command_message = "Exited Links Table".to_string();
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("move_up") => {
|
||||
let mut new_focus = current_focus; // Start with current focus
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName => new_focus = AddTableFocus::CancelButton, // Wrap top
|
||||
AddTableFocus::InputTableName => {
|
||||
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
|
||||
// *command_message = "At top of form.".to_string(); // Remove message
|
||||
}
|
||||
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputTableName,
|
||||
AddTableFocus::InputColumnType => new_focus = AddTableFocus::InputColumnName,
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable => {
|
||||
if !navigate_table_up(&mut add_table_state.column_table_state, add_table_state.columns.len()) {
|
||||
new_focus = AddTableFocus::AddColumnButton; // Move focus up if at table top
|
||||
}
|
||||
// Keep focus on table while navigating within it
|
||||
}
|
||||
AddTableFocus::IndexesTable => {
|
||||
if !navigate_table_up(&mut add_table_state.index_table_state, add_table_state.indexes.len()) {
|
||||
new_focus = AddTableFocus::ColumnsTable; // Move focus up
|
||||
}
|
||||
}
|
||||
AddTableFocus::LinksTable => {
|
||||
if !navigate_table_up(&mut add_table_state.link_table_state, add_table_state.links.len()) {
|
||||
new_focus = AddTableFocus::IndexesTable; // Move focus up
|
||||
}
|
||||
}
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::InsideColumnsTable => { navigate_table_up(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
|
||||
AddTableFocus::InsideIndexesTable => { navigate_table_up(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
|
||||
AddTableFocus::InsideLinksTable => { navigate_table_up(&mut add_table_state.link_table_state, add_table_state.links.len()); }
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
}
|
||||
add_table_state.current_focus = new_focus;
|
||||
*command_message = format!("Focus set to {:?}", add_table_state.current_focus);
|
||||
}
|
||||
Some("move_down") => {
|
||||
let mut new_focus = current_focus; // Start with current focus
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName => new_focus = AddTableFocus::InputColumnName,
|
||||
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::InputColumnType => new_focus = AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::InputColumnType => {
|
||||
add_table_state.last_canvas_field = 2;
|
||||
new_focus = AddTableFocus::AddColumnButton;
|
||||
},
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable => {
|
||||
if !navigate_table_down(&mut add_table_state.column_table_state, add_table_state.columns.len()) {
|
||||
new_focus = AddTableFocus::IndexesTable; // Move focus down if at table bottom
|
||||
}
|
||||
// Keep focus on table while navigating within it
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::InsideColumnsTable => { navigate_table_down(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
|
||||
AddTableFocus::InsideIndexesTable => { navigate_table_down(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
|
||||
AddTableFocus::InsideLinksTable => { navigate_table_down(&mut add_table_state.link_table_state, add_table_state.links.len()); }
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => {
|
||||
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
|
||||
// *command_message = "At bottom of form.".to_string(); // Remove message
|
||||
}
|
||||
AddTableFocus::IndexesTable => {
|
||||
if !navigate_table_down(&mut add_table_state.index_table_state, add_table_state.indexes.len()) {
|
||||
new_focus = AddTableFocus::LinksTable; // Move focus down
|
||||
}
|
||||
}
|
||||
AddTableFocus::LinksTable => {
|
||||
if !navigate_table_down(&mut add_table_state.link_table_state, add_table_state.links.len()) {
|
||||
new_focus = AddTableFocus::SaveButton; // Move focus down
|
||||
}
|
||||
}
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::InputTableName, // Wrap bottom
|
||||
}
|
||||
add_table_state.current_focus = new_focus;
|
||||
*command_message = format!("Focus set to {:?}", add_table_state.current_focus);
|
||||
}
|
||||
|
||||
// --- Horizontal Navigation (Left/Right) ---
|
||||
Some("next_option") => { // 'l' or Right
|
||||
add_table_state.current_focus = match current_focus {
|
||||
AddTableFocus::SaveButton => AddTableFocus::CancelButton,
|
||||
_ => current_focus, // No change for others yet
|
||||
Some("next_option") => { // This logic should already be non-wrapping
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
|
||||
{ new_focus = AddTableFocus::AddColumnButton; }
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => { /* *command_message = "At last focusable area.".to_string(); */ } // No change in focus
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("previous_option") => { // This logic should already be non-wrapping
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
|
||||
{ /* *command_message = "At first focusable area.".to_string(); */ } // No change in focus
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::InputColumnName, AddTableFocus::InputColumnName => AddTableFocus::InputColumnType, AddTableFocus::InputColumnType => AddTableFocus::AddColumnButton, AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::IndexesTable, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::LinksTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::SaveButton,
|
||||
AddTableFocus::SaveButton => AddTableFocus::DeleteSelectedButton, AddTableFocus::DeleteSelectedButton => AddTableFocus::CancelButton, AddTableFocus::CancelButton => AddTableFocus::InputTableName,
|
||||
};
|
||||
*command_message = format!("Focus set to {:?}", add_table_state.current_focus);
|
||||
}
|
||||
Some("previous_option") => { // 'h' or Left
|
||||
add_table_state.current_focus = match current_focus {
|
||||
AddTableFocus::CancelButton => AddTableFocus::SaveButton,
|
||||
_ => current_focus, // No change for others yet
|
||||
Some("prev_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::CancelButton, AddTableFocus::InputColumnName => AddTableFocus::InputTableName, AddTableFocus::InputColumnType => AddTableFocus::InputColumnName, AddTableFocus::AddColumnButton => AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::AddColumnButton, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::ColumnsTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::IndexesTable,
|
||||
AddTableFocus::SaveButton => AddTableFocus::LinksTable, AddTableFocus::DeleteSelectedButton => AddTableFocus::SaveButton, AddTableFocus::CancelButton => AddTableFocus::DeleteSelectedButton,
|
||||
};
|
||||
*command_message = format!("Focus set to {:?}", add_table_state.current_focus);
|
||||
}
|
||||
|
||||
// --- Tab / Shift+Tab Navigation ---
|
||||
Some("next_field") => { // Tab
|
||||
add_table_state.current_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::InputColumnName,
|
||||
AddTableFocus::InputColumnName => AddTableFocus::InputColumnType,
|
||||
AddTableFocus::InputColumnType => AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable => AddTableFocus::IndexesTable,
|
||||
AddTableFocus::IndexesTable => AddTableFocus::LinksTable,
|
||||
AddTableFocus::LinksTable => AddTableFocus::SaveButton,
|
||||
AddTableFocus::SaveButton => AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => AddTableFocus::InputTableName, // Wrap
|
||||
};
|
||||
*command_message = format!("Focus set to {:?}", add_table_state.current_focus);
|
||||
}
|
||||
Some("prev_field") => { // Shift+Tab
|
||||
add_table_state.current_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::CancelButton, // Wrap
|
||||
AddTableFocus::InputColumnName => AddTableFocus::InputTableName,
|
||||
AddTableFocus::InputColumnType => AddTableFocus::InputColumnName,
|
||||
AddTableFocus::AddColumnButton => AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable => AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::IndexesTable => AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::LinksTable => AddTableFocus::IndexesTable,
|
||||
AddTableFocus::SaveButton => AddTableFocus::LinksTable,
|
||||
AddTableFocus::CancelButton => AddTableFocus::SaveButton,
|
||||
};
|
||||
*command_message = format!("Focus set to {:?}", add_table_state.current_focus);
|
||||
}
|
||||
|
||||
// --- Selection ---
|
||||
Some("select") => {
|
||||
match current_focus {
|
||||
AddTableFocus::AddColumnButton => {
|
||||
*command_message = "Action: Add Column (Not Implemented)".to_string();
|
||||
// TODO: Implement logic to add column based on inputs
|
||||
// Clear input fields, add to columns list, mark unsaved changes
|
||||
// add_table_state.add_column(); // Example method call
|
||||
}
|
||||
AddTableFocus::SaveButton => {
|
||||
*command_message = "Action: Save Table (Not Implemented)".to_string();
|
||||
// TODO: Implement logic to save table (e.g., call API)
|
||||
// Mark changes as saved
|
||||
// add_table_state.save_table(); // Example method call
|
||||
}
|
||||
AddTableFocus::CancelButton => {
|
||||
*command_message = "Action: Cancel Add Table".to_string();
|
||||
// TODO: Implement logic to navigate back (e.g., update AppView history)
|
||||
// Maybe show a confirmation dialog if there are unsaved changes
|
||||
// buffer_state.go_back(); // Example call
|
||||
}
|
||||
// Selecting input fields usually means entering Edit mode (handled elsewhere)
|
||||
// Selecting tables might mean focusing on them for editing/deletion (TODO)
|
||||
AddTableFocus::ColumnsTable => {
|
||||
if let Some(index) = add_table_state.column_table_state.selected() {
|
||||
*command_message = format!("Selected column index {}", index);
|
||||
// TODO: Add logic for editing/deleting selected column
|
||||
} else {
|
||||
*command_message = "No column selected".to_string();
|
||||
}
|
||||
}
|
||||
AddTableFocus::IndexesTable => {
|
||||
if let Some(index) = add_table_state.index_table_state.selected() {
|
||||
*command_message = format!("Selected index index {}", index);
|
||||
// TODO: Add logic for editing/deleting selected index
|
||||
} else {
|
||||
*command_message = "No index selected".to_string();
|
||||
}
|
||||
}
|
||||
AddTableFocus::LinksTable => {
|
||||
if let Some(index) = add_table_state.link_table_state.selected() {
|
||||
*command_message = format!("Selected link index {}", index);
|
||||
// TODO: Add logic for editing/deleting selected link
|
||||
} else {
|
||||
*command_message = "No link selected".to_string();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// For InputTableName, InputColumnName, InputColumnType,
|
||||
// the main event loop should handle 'select' by potentially
|
||||
// switching to Edit mode if not already in it.
|
||||
// We don't need specific logic here for that.
|
||||
*command_message = format!("Select on {:?}", current_focus);
|
||||
handled = false; // Let main loop handle edit mode toggle maybe
|
||||
}
|
||||
AddTableFocus::ColumnsTable => { new_focus = AddTableFocus::InsideColumnsTable; if add_table_state.column_table_state.selected().is_none() && !add_table_state.columns.is_empty() { add_table_state.column_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::IndexesTable => { new_focus = AddTableFocus::InsideIndexesTable; if add_table_state.index_table_state.selected().is_none() && !add_table_state.indexes.is_empty() { add_table_state.index_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::LinksTable => { new_focus = AddTableFocus::InsideLinksTable; if add_table_state.link_table_state.selected().is_none() && !add_table_state.links.is_empty() { add_table_state.link_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::InsideColumnsTable => { if let Some(index) = add_table_state.column_table_state.selected() { if let Some(col) = add_table_state.columns.get_mut(index) { col.selected = !col.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::InsideIndexesTable => { if let Some(index) = add_table_state.index_table_state.selected() { if let Some(idx_def) = add_table_state.indexes.get_mut(index) { idx_def.selected = !idx_def.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::InsideLinksTable => { if let Some(index) = add_table_state.link_table_state.selected() { if let Some(link) = add_table_state.links.get_mut(index) { link.selected = !link.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::AddColumnButton => { if let Some(focus_after_add) = handle_add_column_action(add_table_state, command_message) { new_focus = focus_after_add; } else { /* Message already set by handle_add_column_action */ }}
|
||||
AddTableFocus::SaveButton => { if add_table_state.table_name.is_empty() { *command_message = "Cannot save: Table name is empty.".to_string(); } else if add_table_state.columns.is_empty() { *command_message = "Cannot save: No columns defined.".to_string(); } else { *command_message = "Saving table...".to_string(); app_state.show_loading_dialog("Saving", "Please wait..."); let mut client_clone = grpc_client.clone(); let state_clone = add_table_state.clone(); let sender_clone = save_result_sender.clone(); tokio::spawn(async move { let result = handle_save_table_action(&mut client_clone, &state_clone).await; let _ = sender_clone.send(result).await; }); }}
|
||||
AddTableFocus::DeleteSelectedButton => { let columns_to_delete: Vec<(usize, String, String)> = add_table_state.columns.iter().enumerate().filter(|(_, col)| col.selected).map(|(index, col)| (index, col.name.clone(), col.data_type.clone())).collect(); if columns_to_delete.is_empty() { *command_message = "No columns selected for deletion.".to_string(); } else { let column_details: String = columns_to_delete.iter().map(|(index, name, dtype)| format!("{}. {} ({})", index + 1, name, dtype)).collect::<Vec<String>>().join("\n"); let message = format!("Delete the following columns?\n\n{}", column_details); app_state.show_dialog("Confirm Deletion", &message, vec!["Confirm".to_string(), "Cancel".to_string()], DialogPurpose::ConfirmDeleteColumns); }}
|
||||
AddTableFocus::CancelButton => { *command_message = "Action: Cancel Add Table (Not Implemented)".to_string(); }
|
||||
_ => { handled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Other General Keys (Ignore for add_table nav) ---
|
||||
Some("toggle_sidebar") | Some("toggle_buffer_list") => {
|
||||
handled = false; // Let global handler manage these
|
||||
}
|
||||
|
||||
// --- No matching action ---
|
||||
_ => handled = false, // Event not handled by add_table navigation
|
||||
_ => handled = false,
|
||||
}
|
||||
|
||||
// If focus changed TO a table, select the first row if nothing is selected
|
||||
if handled && current_focus != add_table_state.current_focus {
|
||||
match add_table_state.current_focus {
|
||||
AddTableFocus::ColumnsTable if add_table_state.column_table_state.selected().is_none() && !add_table_state.columns.is_empty() => {
|
||||
add_table_state.column_table_state.select(Some(0));
|
||||
}
|
||||
AddTableFocus::IndexesTable if add_table_state.index_table_state.selected().is_none() && !add_table_state.indexes.is_empty() => {
|
||||
add_table_state.index_table_state.select(Some(0));
|
||||
}
|
||||
AddTableFocus::LinksTable if add_table_state.link_table_state.selected().is_none() && !add_table_state.links.is_empty() => {
|
||||
add_table_state.link_table_state.select(Some(0));
|
||||
}
|
||||
_ => {} // No action needed for other focus states
|
||||
if handled && current_focus != new_focus {
|
||||
add_table_state.current_focus = new_focus;
|
||||
// Minimal change: Command message update logic can be simplified or removed if not desired
|
||||
// For now, let's keep it minimal and only update if it was truly a focus change,
|
||||
// and not a boundary message.
|
||||
if !command_message.starts_with("At ") && current_focus != new_focus { // Avoid overwriting boundary messages
|
||||
// *command_message = format!("Focus: {:?}", add_table_state.current_focus); // Optional: restore if needed
|
||||
}
|
||||
|
||||
|
||||
let new_is_canvas_input_focus = matches!(new_focus,
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType
|
||||
);
|
||||
app_state.ui.focus_outside_canvas = !new_is_canvas_input_focus;
|
||||
}
|
||||
// If not handled, command_message remains as it was (e.g., from a deeper function call or previous event)
|
||||
// or can be cleared if that's the desired default. For minimal change, we leave it.
|
||||
|
||||
handled
|
||||
}
|
||||
|
||||
|
||||
// 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; } // Cannot navigate empty table
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index > 0 {
|
||||
table_state.select(Some(index - 1));
|
||||
true // Moved up within table
|
||||
} else {
|
||||
false // Was at the top
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// If nothing selected, moving up could select the last item
|
||||
table_state.select(Some(item_count - 1));
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for navigating down within a table state
|
||||
// Returns true if navigation happened within the table, false if it reached the bottom
|
||||
fn navigate_table_down(table_state: &mut TableState, item_count: usize) -> bool {
|
||||
if item_count == 0 { return false; } // Cannot navigate empty table
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index < item_count - 1 {
|
||||
table_state.select(Some(index + 1));
|
||||
true // Moved down within table
|
||||
} else {
|
||||
false // Was at the bottom
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// If nothing selected, moving down could select the first item
|
||||
table_state.select(Some(0));
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,183 +1,351 @@
|
||||
// src/functions/modes/navigation/admin_nav.rs
|
||||
|
||||
use crate::state::pages::admin::{AdminFocus, AdminState};
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::{
|
||||
app::state::AppState,
|
||||
pages::admin::{AdminFocus, AdminState},
|
||||
};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use crate::state::app::buffer::AppView;
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::app::buffer::{BufferState, AppView};
|
||||
use crate::state::pages::add_table::{AddTableState, LinkDefinition};
|
||||
use ratatui::widgets::ListState;
|
||||
use crate::state::pages::add_logic::{AddLogicState, AddLogicFocus}; // Added AddLogicFocus import
|
||||
|
||||
// Helper functions list_select_next and list_select_previous remain the same
|
||||
fn list_select_next(list_state: &mut ListState, item_count: usize) {
|
||||
if item_count == 0 {
|
||||
list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match list_state.selected() {
|
||||
Some(i) => if i >= item_count - 1 { 0 } else { i + 1 },
|
||||
None => 0,
|
||||
};
|
||||
list_state.select(Some(i));
|
||||
}
|
||||
|
||||
fn list_select_previous(list_state: &mut ListState, item_count: usize) {
|
||||
if item_count == 0 {
|
||||
list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match list_state.selected() {
|
||||
Some(i) => if i == 0 { item_count - 1 } else { i - 1 },
|
||||
None => if item_count > 0 { item_count - 1 } else { 0 },
|
||||
};
|
||||
list_state.select(Some(i));
|
||||
}
|
||||
|
||||
/// Handles navigation events specifically for the Admin Panel view.
|
||||
/// Returns true if the event was handled, false otherwise.
|
||||
pub fn handle_admin_navigation(
|
||||
key: KeyEvent,
|
||||
key: crossterm::event::KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &AppState,
|
||||
app_state: &mut AppState,
|
||||
admin_state: &mut AdminState,
|
||||
buffer_state: &mut BufferState,
|
||||
command_message: &mut String,
|
||||
) -> bool {
|
||||
let action = config.get_general_action(key.code, key.modifiers);
|
||||
let action = config.get_general_action(key.code, key.modifiers).map(String::from);
|
||||
let current_focus = admin_state.current_focus;
|
||||
let profile_count = app_state.profile_tree.profiles.len();
|
||||
let mut handled = false;
|
||||
|
||||
match action {
|
||||
// --- Vertical Navigation (Up/Down) ---
|
||||
Some("move_up") => {
|
||||
match current_focus {
|
||||
AdminFocus::Profiles => {
|
||||
if profile_count > 0 {
|
||||
// Updates navigation state, resets table state
|
||||
admin_state.previous_profile(profile_count);
|
||||
*command_message = "Navigated profiles".to_string();
|
||||
}
|
||||
}
|
||||
AdminFocus::Tables => {
|
||||
// Updates table navigation state
|
||||
if let Some(nav_profile_idx) = admin_state.profile_list_state.selected() {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(nav_profile_idx) {
|
||||
let table_count = profile.tables.len();
|
||||
if table_count > 0 {
|
||||
admin_state.previous_table(table_count);
|
||||
*command_message = "Navigated tables".to_string();
|
||||
}
|
||||
match current_focus {
|
||||
AdminFocus::ProfilesPane => {
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
admin_state.current_focus = AdminFocus::InsideProfilesList;
|
||||
if !app_state.profile_tree.profiles.is_empty() {
|
||||
if admin_state.profile_list_state.selected().is_none() {
|
||||
admin_state.profile_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
*command_message = "Navigating profiles. Use Up/Down. Esc to exit.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3 => {}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
admin_state.current_focus = AdminFocus::Tables;
|
||||
*command_message = "Focus: Tables Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
// No wrap-around: Stay on ProfilesPane if trying to go "before" it
|
||||
*command_message = "At first focusable pane.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
true // Event handled
|
||||
}
|
||||
Some("move_down") => {
|
||||
match current_focus {
|
||||
AdminFocus::Profiles => {
|
||||
|
||||
AdminFocus::InsideProfilesList => {
|
||||
match action.as_deref() {
|
||||
Some("move_up") => {
|
||||
if profile_count > 0 {
|
||||
// Updates navigation state, resets table state
|
||||
admin_state.next_profile(profile_count);
|
||||
*command_message = "Navigated profiles".to_string();
|
||||
list_select_previous(&mut admin_state.profile_list_state, profile_count);
|
||||
*command_message = "".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
AdminFocus::Tables => {
|
||||
if let Some(nav_profile_idx) = admin_state.profile_list_state.selected() {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(nav_profile_idx) {
|
||||
let table_count = profile.tables.len();
|
||||
if table_count > 0 {
|
||||
admin_state.next_table(table_count);
|
||||
*command_message = "Navigated tables".to_string();
|
||||
Some("move_down") => {
|
||||
if profile_count > 0 {
|
||||
list_select_next(&mut admin_state.profile_list_state, profile_count);
|
||||
*command_message = "".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Some("select") => {
|
||||
admin_state.selected_profile_index = admin_state.profile_list_state.selected();
|
||||
admin_state.selected_table_index = None; // Deselect table when profile changes
|
||||
if let Some(profile_idx) = admin_state.selected_profile_index {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
admin_state.table_list_state.select(Some(0)); // Auto-select first table for nav
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3 => {}
|
||||
}
|
||||
true // Event handled
|
||||
}
|
||||
|
||||
// --- Horizontal Navigation (Focus Change) ---
|
||||
Some("next_option") | Some("previous_option") => {
|
||||
let old_focus = admin_state.current_focus;
|
||||
let is_next = action == Some("next_option"); // Check if 'l' or 'h'
|
||||
|
||||
admin_state.current_focus = match old_focus {
|
||||
AdminFocus::Profiles => if is_next { AdminFocus::Tables } else { AdminFocus::Button3 }, // P -> T (l) or P -> B3 (h)
|
||||
AdminFocus::Tables => if is_next { AdminFocus::Button1 } else { AdminFocus::Profiles }, // T -> B1 (l) or T -> P (h)
|
||||
AdminFocus::Button1 => if is_next { AdminFocus::Button2 } else { AdminFocus::Tables }, // B1 -> B2 (l) or B1 -> T (h)
|
||||
AdminFocus::Button2 => if is_next { AdminFocus::Button3 } else { AdminFocus::Button1 }, // B2 -> B3 (l) or B2 -> B1 (h)
|
||||
AdminFocus::Button3 => if is_next { AdminFocus::Profiles } else { AdminFocus::Button2 }, // B3 -> P (l) or B3 -> B2 (h)
|
||||
};
|
||||
|
||||
let new_focus = admin_state.current_focus;
|
||||
*command_message = format!("Focus set to {:?}", new_focus);
|
||||
// Auto-select first item only when moving from Profiles to Tables via 'l'
|
||||
if old_focus == AdminFocus::Profiles && new_focus == AdminFocus::Tables && is_next {
|
||||
if let Some(profile_idx) = admin_state.profile_list_state.selected() {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
admin_state.table_list_state.select(Some(0));
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
}
|
||||
// Clear table nav selection if moving away from Tables
|
||||
if old_focus == AdminFocus::Tables && new_focus != AdminFocus::Tables {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
// Clear profile nav selection if moving away from Profiles
|
||||
if old_focus == AdminFocus::Profiles && new_focus != AdminFocus::Profiles {
|
||||
// Maybe keep profile nav highlight? Let's try clearing it.
|
||||
// admin_state.profile_list_state.select(None); // Optional: clear profile nav highlight
|
||||
}
|
||||
|
||||
true // Event handled
|
||||
}
|
||||
|
||||
// --- Selection ---
|
||||
Some("select") => {
|
||||
match current_focus {
|
||||
AdminFocus::Profiles => {
|
||||
// Set the persistent selection to the currently navigated item
|
||||
if let Some(nav_idx) = admin_state.profile_list_state.selected() {
|
||||
admin_state.selected_profile_index = Some(nav_idx); // Set persistent selection
|
||||
|
||||
// Move focus to Tables (like pressing 'l')
|
||||
admin_state.current_focus = AdminFocus::Tables;
|
||||
|
||||
// Select the first table for navigation highlight
|
||||
admin_state.table_list_state.select(None); // Clear table nav first
|
||||
admin_state.selected_table_index = None; // Clear persistent table selection
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(nav_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
// Set table nav highlight
|
||||
admin_state.table_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
*command_message = format!("Selected profile idx {}, focus on Tables", nav_idx);
|
||||
} else {
|
||||
*command_message = "No profile selected".to_string();
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
*command_message = format!(
|
||||
"Profile '{}' set as active.",
|
||||
admin_state.get_selected_profile_name().unwrap_or(&"N/A".to_string())
|
||||
);
|
||||
handled = true;
|
||||
}
|
||||
Some("exit_table_scroll") => {
|
||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
||||
*command_message = "Focus: Profiles Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
AdminFocus::Tables => {
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
admin_state.current_focus = AdminFocus::InsideTablesList;
|
||||
let current_profile_idx = admin_state.selected_profile_index
|
||||
.or_else(|| admin_state.profile_list_state.selected());
|
||||
if let Some(profile_idx) = current_profile_idx {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
if admin_state.table_list_state.selected().is_none() {
|
||||
admin_state.table_list_state.select(Some(0));
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
*command_message = "Select a profile first to view its tables.".to_string();
|
||||
}
|
||||
if admin_state.current_focus == AdminFocus::InsideTablesList && !admin_state.table_list_state.selected().is_none() {
|
||||
*command_message = "Navigating tables. Use Up/Down. Esc to exit.".to_string();
|
||||
} else if admin_state.table_list_state.selected().is_none() {
|
||||
if current_profile_idx.is_none() {
|
||||
*command_message = "No profile selected to view tables.".to_string();
|
||||
} else {
|
||||
*command_message = "No tables in selected profile.".to_string();
|
||||
}
|
||||
admin_state.current_focus = AdminFocus::Tables; // Stay in Tables pane if no tables to enter
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
||||
*command_message = "Focus: Profiles Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
admin_state.current_focus = AdminFocus::Button1;
|
||||
*command_message = "Focus: Add Logic Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
AdminFocus::InsideTablesList => {
|
||||
match action.as_deref() {
|
||||
Some("move_up") => {
|
||||
let current_profile_idx = admin_state.selected_profile_index
|
||||
.or_else(|| admin_state.profile_list_state.selected());
|
||||
if let Some(p_idx) = current_profile_idx {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
list_select_previous(&mut admin_state.table_list_state, profile.tables.len());
|
||||
*command_message = "".to_string();
|
||||
handled = true;
|
||||
} else {
|
||||
*command_message = "No tables to navigate.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
*command_message = "No active profile for tables.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
AdminFocus::Tables => {
|
||||
// Set the persistent selection to the currently navigated item
|
||||
if let Some(nav_idx) = admin_state.table_list_state.selected() {
|
||||
admin_state.selected_table_index = Some(nav_idx); // Set persistent selection
|
||||
*command_message = format!("Selected table index {}", nav_idx);
|
||||
} else {
|
||||
*command_message = "No table highlighted".to_string();
|
||||
}
|
||||
// We don't change focus here for now.
|
||||
Some("move_down") => {
|
||||
let current_profile_idx = admin_state.selected_profile_index
|
||||
.or_else(|| admin_state.profile_list_state.selected());
|
||||
if let Some(p_idx) = current_profile_idx {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
list_select_next(&mut admin_state.table_list_state, profile.tables.len());
|
||||
*command_message = "".to_string();
|
||||
handled = true;
|
||||
} else {
|
||||
*command_message = "No tables to navigate.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
*command_message = "No active profile for tables.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
AdminFocus::Button1 => {
|
||||
*command_message = "Action: Add Logic (Not Implemented)".to_string();
|
||||
// TODO: Trigger action for Button 1
|
||||
Some("select") => { // This is for persistently selecting a table with [*]
|
||||
admin_state.selected_table_index = admin_state.table_list_state.selected();
|
||||
let table_name = admin_state.selected_profile_index
|
||||
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
|
||||
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx)))
|
||||
.map_or("N/A", |t| t.name.as_str());
|
||||
*command_message = format!("Table '{}' set as active.", table_name);
|
||||
handled = true;
|
||||
}
|
||||
AdminFocus::Button2 => {
|
||||
buffer_state.update_history(AppView::AddTable);
|
||||
*command_message = "Navigating to Add Table page...".to_string();
|
||||
}
|
||||
AdminFocus::Button3 => {
|
||||
*command_message = "Action: Change Table (Not Implemented)".to_string();
|
||||
// TODO: Trigger action for Button 3
|
||||
Some("exit_table_scroll") => {
|
||||
admin_state.current_focus = AdminFocus::Tables;
|
||||
*command_message = "Focus: Tables Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
true // Event handled
|
||||
}
|
||||
|
||||
// --- Other General Keys (Ignore for admin nav) ---
|
||||
Some("toggle_sidebar") | Some("toggle_buffer_list") | Some("next_field") | Some("prev_field") => {
|
||||
// These are handled globally or not applicable here.
|
||||
false
|
||||
AdminFocus::Button1 => { // Add Logic Button
|
||||
match action.as_deref() {
|
||||
Some("select") => { // Typically "Enter" key
|
||||
if let Some(p_idx) = admin_state.selected_profile_index {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
if let Some(t_idx) = admin_state.selected_table_index {
|
||||
if let Some(table) = profile.tables.get(t_idx) {
|
||||
// Both profile and table are selected, proceed
|
||||
admin_state.add_logic_state = AddLogicState {
|
||||
profile_name: profile.name.clone(),
|
||||
selected_table_name: Some(table.name.clone()),
|
||||
selected_table_id: Some(table.id), // If you have table IDs
|
||||
editor_keybinding_mode: config.editor.keybinding_mode.clone(),
|
||||
current_focus: AddLogicFocus::default(),
|
||||
..AddLogicState::default()
|
||||
};
|
||||
|
||||
// Store table info for later fetching
|
||||
app_state.pending_table_structure_fetch = Some((
|
||||
profile.name.clone(),
|
||||
table.name.clone()
|
||||
));
|
||||
|
||||
buffer_state.update_history(AppView::AddLogic);
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
*command_message = format!(
|
||||
"Opening Add Logic for table '{}' in profile '{}'...",
|
||||
table.name, profile.name
|
||||
);
|
||||
} else {
|
||||
*command_message = "Error: Selected table data not found.".to_string();
|
||||
}
|
||||
} else {
|
||||
*command_message = "Select a table first!".to_string();
|
||||
}
|
||||
} else {
|
||||
*command_message = "Error: Selected profile data not found.".to_string();
|
||||
}
|
||||
} else {
|
||||
*command_message = "Select a profile first!".to_string();
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
admin_state.current_focus = AdminFocus::Tables;
|
||||
*command_message = "Focus: Tables Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
admin_state.current_focus = AdminFocus::Button2;
|
||||
*command_message = "Focus: Add Table Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- No matching action ---
|
||||
_ => false, // Event not handled by admin navigation
|
||||
AdminFocus::Button2 => { // Add Table Button
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
if let Some(p_idx) = admin_state.selected_profile_index {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
let selected_profile_name = profile.name.clone();
|
||||
// Prepare links from the selected profile's existing tables
|
||||
let available_links: Vec<LinkDefinition> = profile.tables.iter()
|
||||
.map(|table| LinkDefinition {
|
||||
linked_table_name: table.name.clone(),
|
||||
is_required: false, // Default, can be changed in AddTable screen
|
||||
selected: false,
|
||||
}).collect();
|
||||
|
||||
admin_state.add_table_state = AddTableState {
|
||||
profile_name: selected_profile_name,
|
||||
links: available_links,
|
||||
..AddTableState::default() // Reset other fields
|
||||
};
|
||||
buffer_state.update_history(AppView::AddTable);
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
*command_message = format!("Opening Add Table for profile '{}'...", admin_state.add_table_state.profile_name);
|
||||
handled = true;
|
||||
} else {
|
||||
*command_message = "Error: Selected profile index out of bounds.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
} else {
|
||||
*command_message = "Please select a profile ([*]) first to add a table.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
admin_state.current_focus = AdminFocus::Button1;
|
||||
*command_message = "Focus: Add Logic Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
admin_state.current_focus = AdminFocus::Button3;
|
||||
*command_message = "Focus: Change Table Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
|
||||
AdminFocus::Button3 => { // Change Table Button
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
// Future: Logic to load selected table into AddTableState for editing
|
||||
*command_message = "Action: Change Table (Not Implemented)".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
admin_state.current_focus = AdminFocus::Button2;
|
||||
*command_message = "Focus: Add Table Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
// No wrap-around: Stay on Button3 if trying to go "after" it
|
||||
*command_message = "At last focusable button.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
}
|
||||
handled
|
||||
}
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
|
||||
pub mod auth_ro;
|
||||
pub mod form_ro;
|
||||
pub mod add_table_ro;
|
||||
pub mod add_logic_ro;
|
||||
|
||||
235
client/src/functions/modes/read_only/add_logic_ro.rs
Normal file
235
client/src/functions/modes/read_only/add_logic_ro.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
// src/functions/modes/read_only/add_logic_ro.rs
|
||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||
use crate::state::pages::add_logic::AddLogicState; // Changed
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::app::state::AppState;
|
||||
use anyhow::Result;
|
||||
|
||||
// Word navigation helpers (get_char_type, find_next_word_start, etc.)
|
||||
// can be kept as they are generic.
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
Whitespace,
|
||||
Alphanumeric,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
fn get_char_type(c: char) -> CharType {
|
||||
if c.is_whitespace() { CharType::Whitespace }
|
||||
else if c.is_alphanumeric() { CharType::Alphanumeric }
|
||||
else { CharType::Punctuation }
|
||||
}
|
||||
|
||||
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 || current_pos >= len { return len; }
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == initial_type { pos += 1; }
|
||||
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace { pos += 1; }
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
if len == 0 { return 0; }
|
||||
let mut pos = current_pos.min(len - 1);
|
||||
if get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos = find_next_word_start(text, pos);
|
||||
}
|
||||
if pos >= len { return len.saturating_sub(1); }
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos < len && get_char_type(chars[pos]) == word_type { pos += 1; }
|
||||
pos.saturating_sub(1).min(len.saturating_sub(1))
|
||||
}
|
||||
|
||||
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() || current_pos == 0 { return 0; }
|
||||
let mut pos = current_pos.saturating_sub(1);
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { pos -= 1; }
|
||||
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace { return 0; }
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type { pos -= 1; }
|
||||
pos
|
||||
}
|
||||
|
||||
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
||||
let prev_start = find_prev_word_start(text, current_pos);
|
||||
if prev_start == 0 { return 0; }
|
||||
find_word_end(text, prev_start.saturating_sub(1))
|
||||
}
|
||||
|
||||
|
||||
/// Executes read-only actions for the AddLogic view canvas.
|
||||
pub async fn execute_action(
|
||||
action: &str,
|
||||
app_state: &mut AppState,
|
||||
state: &mut AddLogicState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
key_sequence_tracker: &mut KeySequenceTracker,
|
||||
command_message: &mut String,
|
||||
) -> Result<String> {
|
||||
match action {
|
||||
"move_up" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
|
||||
if num_fields == 0 { return Ok("No fields.".to_string()); }
|
||||
let current_field = state.current_field();
|
||||
|
||||
if current_field > 0 {
|
||||
let new_field = current_field - 1;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
} else {
|
||||
*command_message = "At top of form.".to_string();
|
||||
}
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_down" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
|
||||
if num_fields == 0 { return Ok("No fields.".to_string()); }
|
||||
let current_field = state.current_field();
|
||||
let last_field_index = num_fields - 1;
|
||||
|
||||
if current_field < last_field_index {
|
||||
let new_field = current_field + 1;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
} else {
|
||||
// Move focus outside canvas when moving down from the last field
|
||||
// FIX: Go to ScriptContentPreview instead of SaveButton
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
state.last_canvas_field = 2;
|
||||
state.current_focus = crate::state::pages::add_logic::AddLogicFocus::ScriptContentPreview; // FIXED!
|
||||
*command_message = "Focus moved to script preview".to_string();
|
||||
}
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
// ... (rest of the actions remain the same) ...
|
||||
"move_first_line" => {
|
||||
key_sequence_tracker.reset();
|
||||
if AddLogicState::INPUT_FIELD_COUNT > 0 {
|
||||
state.set_current_field(0);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_last_line" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
|
||||
if num_fields > 0 {
|
||||
let last_field_index = num_fields - 1;
|
||||
state.set_current_field(last_field_index);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_left" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = current_pos.saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_right" => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
if !current_input.is_empty() && current_pos < current_input.len().saturating_sub(1) {
|
||||
let new_pos = current_pos + 1;
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_next" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
|
||||
let final_pos = new_pos.min(current_input.len().saturating_sub(1));
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = find_word_end(current_input, current_pos);
|
||||
let final_pos = if new_pos == current_pos && current_pos < current_input.len().saturating_sub(1) {
|
||||
find_word_end(current_input, current_pos + 1)
|
||||
} else {
|
||||
new_pos
|
||||
};
|
||||
let max_valid_index = current_input.len().saturating_sub(1);
|
||||
let clamped_pos = final_pos.min(max_valid_index);
|
||||
state.set_current_cursor_pos(clamped_pos);
|
||||
*ideal_cursor_column = clamped_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_word_end_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_line_start" => {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
Ok("".to_string())
|
||||
}
|
||||
"move_line_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = current_input.len().saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
} else {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
}
|
||||
Ok("".to_string())
|
||||
}
|
||||
"enter_edit_mode_before" | "enter_edit_mode_after" | "enter_command_mode" | "exit_highlight_mode" => {
|
||||
key_sequence_tracker.reset();
|
||||
Ok("Mode change handled by main loop".to_string())
|
||||
}
|
||||
_ => {
|
||||
key_sequence_tracker.reset();
|
||||
command_message.clear();
|
||||
Ok(format!("Unknown read-only action: {}", action))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
267
client/src/functions/modes/read_only/add_table_ro.rs
Normal file
267
client/src/functions/modes/read_only/add_table_ro.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
// 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 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 {
|
||||
*command_message = "No fields.".to_string();
|
||||
return Ok(command_message.clone());
|
||||
}
|
||||
let current_field = state.current_field(); // Gets the index (0, 1, or 2)
|
||||
|
||||
if current_field > 0 {
|
||||
// This handles moving from field 2 -> 1, or 1 -> 0
|
||||
let new_field = current_field - 1;
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos = current_input.len(); // Allow cursor at end
|
||||
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos; // Update ideal column as cursor moved
|
||||
*command_message = "".to_string(); // Clear message for successful internal navigation
|
||||
} else {
|
||||
// current_field is 0 (InputTableName), and user pressed Up.
|
||||
// Forbid moving up. Do not change focus or cursor.
|
||||
*command_message = "At top of form.".to_string();
|
||||
}
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_down" => {
|
||||
key_sequence_tracker.reset();
|
||||
let num_fields = AddTableState::INPUT_FIELD_COUNT;
|
||||
if num_fields == 0 {
|
||||
*command_message = "No fields.".to_string();
|
||||
return Ok(command_message.clone());
|
||||
}
|
||||
let current_field = state.current_field();
|
||||
let last_field_index = num_fields - 1;
|
||||
|
||||
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);
|
||||
*ideal_cursor_column = new_pos; // Update ideal column
|
||||
*command_message = "".to_string();
|
||||
} else {
|
||||
// Move focus outside canvas when moving down from the last field
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
// Set focus to the first element outside canvas (AddColumnButton)
|
||||
state.current_focus =
|
||||
crate::state::pages::add_table::AddTableFocus::AddColumnButton;
|
||||
*command_message = "Focus moved below canvas".to_string();
|
||||
}
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
// ... (other actions like "move_first_line", "move_left", etc. remain the same) ...
|
||||
"move_first_line" => {
|
||||
key_sequence_tracker.reset();
|
||||
if AddTableState::INPUT_FIELD_COUNT > 0 {
|
||||
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
|
||||
}
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"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
|
||||
}
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_left" => {
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = current_pos.saturating_sub(1);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"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;
|
||||
}
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_word_next" => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = find_next_word_start(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
let final_pos = new_pos.min(current_input.len()); // Allow cursor at end
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_word_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = find_word_end(current_input, current_pos);
|
||||
// If find_word_end returns current_pos, try starting search from next char
|
||||
let final_pos =
|
||||
if new_pos == current_pos && current_pos < current_input.len() {
|
||||
find_word_end(current_input, current_pos + 1)
|
||||
} else {
|
||||
new_pos
|
||||
};
|
||||
let 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;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_word_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = find_prev_word_start(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_word_end_prev" => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = find_prev_word_end(
|
||||
current_input,
|
||||
state.current_cursor_pos(),
|
||||
);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_line_start" => {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
"move_line_end" => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = current_input.len(); // Allow cursor at end
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
*command_message = "".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
// Actions handled by main event loop (mode changes)
|
||||
"enter_edit_mode_before" | "enter_edit_mode_after"
|
||||
| "enter_command_mode" | "exit_highlight_mode" => {
|
||||
key_sequence_tracker.reset();
|
||||
// These actions are primarily mode changes handled by the main event loop.
|
||||
// The message here might be overridden by the main loop's message for mode change.
|
||||
*command_message = "Mode change initiated".to_string();
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
_ => {
|
||||
key_sequence_tracker.reset();
|
||||
*command_message =
|
||||
format!("Unknown read-only action: {}", action);
|
||||
Ok(command_message.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod components;
|
||||
pub mod modes;
|
||||
pub mod functions;
|
||||
pub mod services;
|
||||
pub mod utils;
|
||||
|
||||
pub use ui::run_ui;
|
||||
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
// client/src/main.rs
|
||||
use client::run_ui;
|
||||
#[cfg(feature = "ui-debug")]
|
||||
use client::utils::debug_logger::UiDebugWriter;
|
||||
use dotenvy::dotenv;
|
||||
use std::error::Error;
|
||||
use anyhow::Result;
|
||||
use tracing_subscriber;
|
||||
use std::env;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
async fn main() -> Result<()> {
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
// If ui-debug is on, set up our custom writer.
|
||||
let writer = UiDebugWriter::new();
|
||||
tracing_subscriber::fmt()
|
||||
.with_level(false) // Don't show INFO, ERROR, etc.
|
||||
.with_target(false) // Don't show the module path.
|
||||
.without_time() // This is the correct and simpler method.
|
||||
.with_writer(move || writer.clone())
|
||||
.init();
|
||||
}
|
||||
#[cfg(not(feature = "ui-debug"))]
|
||||
{
|
||||
if env::var("ENABLE_TRACING").is_ok() {
|
||||
tracing_subscriber::fmt::init();
|
||||
}
|
||||
}
|
||||
|
||||
dotenv().ok();
|
||||
run_ui().await
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ use crate::services::grpc_client::GrpcClient;
|
||||
use crate::services::auth::AuthClient;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use anyhow::{Context, Result};
|
||||
use crate::tui::functions::common::{
|
||||
form::{save as form_save, revert as form_revert},
|
||||
login::{save as login_save, revert as login_revert},
|
||||
register::{save as register_save, revert as register_revert},
|
||||
register::{revert as register_revert},
|
||||
};
|
||||
|
||||
pub async fn handle_core_action(
|
||||
@@ -23,24 +24,18 @@ pub async fn handle_core_action(
|
||||
auth_client: &mut AuthClient,
|
||||
terminal: &mut TerminalCore,
|
||||
app_state: &mut AppState,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
|
||||
) -> Result<EventOutcome> {
|
||||
match action {
|
||||
"save" => {
|
||||
if app_state.ui.show_login {
|
||||
let message = login_save(auth_state, login_state, auth_client, app_state).await?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else if app_state.ui.show_register {
|
||||
let message = register_save(register_state, auth_client, app_state).await?;
|
||||
let message = login_save(auth_state, login_state, auth_client, app_state).await.context("Login save action failed")?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else {
|
||||
let save_outcome = form_save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
).await.context("Register save action failed")?;
|
||||
let message = match save_outcome {
|
||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
|
||||
@@ -55,15 +50,12 @@ pub async fn handle_core_action(
|
||||
},
|
||||
"save_and_quit" => {
|
||||
let message = if app_state.ui.show_login {
|
||||
login_save(auth_state, login_state, auth_client, app_state).await?
|
||||
} else if app_state.ui.show_register {
|
||||
register_save(register_state, auth_client, app_state).await?
|
||||
login_save(auth_state, login_state, auth_client, app_state).await.context("Login save n quit action failed")?
|
||||
} else {
|
||||
let save_outcome = form_save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
match save_outcome {
|
||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||
@@ -85,9 +77,7 @@ pub async fn handle_core_action(
|
||||
let message = form_revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
).await.context("Form revert x action failed")?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,234 +1,336 @@
|
||||
// src/modes/canvas/edit.rs
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::functions::modes::edit::{
|
||||
add_logic_e, add_table_e, auth_e, form_e,
|
||||
};
|
||||
use crate::modes::handlers::event::EventHandler;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::{auth::{LoginState, RegisterState}, canvas_state::CanvasState};
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::functions::modes::edit::{auth_e, form_e};
|
||||
use crate::state::app::state::AppState;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
// Removed duplicate/unused imports
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::state::pages::{
|
||||
auth::{LoginState, RegisterState},
|
||||
canvas_state::CanvasState,
|
||||
form::FormState,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use common::proto::multieko2::search::search_response::Hit;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EditEventOutcome {
|
||||
Message(String), // Return a message, stay in Edit mode
|
||||
ExitEditMode, // Signal to exit Edit mode
|
||||
Message(String),
|
||||
ExitEditMode,
|
||||
}
|
||||
|
||||
/// Helper function to spawn a non-blocking search task for autocomplete.
|
||||
async fn trigger_form_autocomplete_search(
|
||||
form_state: &mut FormState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
sender: mpsc::UnboundedSender<Vec<Hit>>,
|
||||
) {
|
||||
if let Some(field_def) = form_state.fields.get(form_state.current_field) {
|
||||
if field_def.is_link {
|
||||
if let Some(target_table) = &field_def.link_target_table {
|
||||
// 1. Update state for immediate UI feedback
|
||||
form_state.autocomplete_loading = true;
|
||||
form_state.autocomplete_active = true;
|
||||
form_state.autocomplete_suggestions.clear();
|
||||
form_state.selected_suggestion_index = None;
|
||||
|
||||
// 2. Clone everything needed for the background task
|
||||
let query = form_state.get_current_input().to_string();
|
||||
let table_to_search = target_table.clone();
|
||||
let mut grpc_client_clone = grpc_client.clone();
|
||||
|
||||
info!(
|
||||
"[Autocomplete] Spawning search in '{}' for query: '{}'",
|
||||
table_to_search, query
|
||||
);
|
||||
|
||||
// 3. Spawn the non-blocking task
|
||||
tokio::spawn(async move {
|
||||
match grpc_client_clone
|
||||
.search_table(table_to_search, query)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
// Send results back through the channel
|
||||
let _ = sender.send(response.hits);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"[Autocomplete] Search failed: {:?}",
|
||||
e
|
||||
);
|
||||
// Send an empty vec on error so the UI can stop loading
|
||||
let _ = sender.send(vec![]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn handle_edit_event(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
ideal_cursor_column: &mut usize,
|
||||
// command_message: &mut String, // Removed as messages are returned
|
||||
admin_state: &mut AdminState,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
grpc_client: &mut GrpcClient,
|
||||
event_handler: &mut EventHandler,
|
||||
app_state: &AppState,
|
||||
) -> Result<EditEventOutcome, Box<dyn std::error::Error>> {
|
||||
|
||||
// Global command mode check
|
||||
if let Some("enter_command_mode") = config.get_action_for_key_in_mode(
|
||||
&config.keybindings.global,
|
||||
key.code,
|
||||
key.modifiers
|
||||
) {
|
||||
return Ok(EditEventOutcome::Message("Switching to Command Mode...".to_string()));
|
||||
}
|
||||
|
||||
// Common actions (save/revert)
|
||||
if let Some(action) = config.get_action_for_key_in_mode(
|
||||
&config.keybindings.common,
|
||||
key.code,
|
||||
key.modifiers
|
||||
) {
|
||||
if matches!(action, "save" | "revert") {
|
||||
// Ensure all branches result in Result<String, Error> before the final Ok(...) wrap
|
||||
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? // Results in String on success
|
||||
} else if app_state.ui.show_register {
|
||||
auth_e::execute_common_action(
|
||||
action,
|
||||
register_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await? // Results in String on success
|
||||
} else {
|
||||
let outcome = form_e::execute_common_action(
|
||||
action,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await?; // This returns EventOutcome on success
|
||||
|
||||
// Extract the message string from the EventOutcome
|
||||
match outcome {
|
||||
EventOutcome::Ok(msg) => msg,
|
||||
EventOutcome::DataSaved(_, msg) => msg,
|
||||
_ => format!("Unexpected outcome from common action: {:?}", outcome),
|
||||
) -> Result<EditEventOutcome> {
|
||||
// --- AUTOCOMPLETE-SPECIFIC KEY HANDLING ---
|
||||
if app_state.ui.show_form && form_state.autocomplete_active {
|
||||
if let Some(action) =
|
||||
config.get_edit_action_for_key(key.code, key.modifiers)
|
||||
{
|
||||
match action {
|
||||
"suggestion_down" => {
|
||||
if !form_state.autocomplete_suggestions.is_empty() {
|
||||
let current =
|
||||
form_state.selected_suggestion_index.unwrap_or(0);
|
||||
let next = (current + 1)
|
||||
% form_state.autocomplete_suggestions.len();
|
||||
form_state.selected_suggestion_index = Some(next);
|
||||
}
|
||||
return Ok(EditEventOutcome::Message(String::new()));
|
||||
}
|
||||
};
|
||||
// Wrap the resulting String message
|
||||
return Ok(EditEventOutcome::Message(message_string));
|
||||
"suggestion_up" => {
|
||||
if !form_state.autocomplete_suggestions.is_empty() {
|
||||
let current =
|
||||
form_state.selected_suggestion_index.unwrap_or(0);
|
||||
let prev = if current == 0 {
|
||||
form_state.autocomplete_suggestions.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
form_state.selected_suggestion_index = Some(prev);
|
||||
}
|
||||
return Ok(EditEventOutcome::Message(String::new()));
|
||||
}
|
||||
"exit" => {
|
||||
form_state.deactivate_autocomplete();
|
||||
return Ok(EditEventOutcome::Message(
|
||||
"Autocomplete cancelled".to_string(),
|
||||
));
|
||||
}
|
||||
"enter_decider" => {
|
||||
if let Some(selected_idx) =
|
||||
form_state.selected_suggestion_index
|
||||
{
|
||||
if let Some(selection) = form_state
|
||||
.autocomplete_suggestions
|
||||
.get(selected_idx)
|
||||
.cloned()
|
||||
{
|
||||
// --- THIS IS THE CORE LOGIC CHANGE ---
|
||||
|
||||
// 1. Get the friendly display name for the UI
|
||||
let display_name =
|
||||
form_state.get_display_name_for_hit(&selection);
|
||||
|
||||
// 2. Store the REAL ID in the form's values
|
||||
let current_input =
|
||||
form_state.get_current_input_mut();
|
||||
*current_input = selection.id.to_string();
|
||||
|
||||
// 3. Set the persistent display override in the map
|
||||
form_state.link_display_map.insert(
|
||||
form_state.current_field,
|
||||
display_name,
|
||||
);
|
||||
|
||||
// 4. Finalize state
|
||||
form_state.deactivate_autocomplete();
|
||||
form_state.set_has_unsaved_changes(true);
|
||||
return Ok(EditEventOutcome::Message(
|
||||
"Selection made".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
form_state.deactivate_autocomplete();
|
||||
// Fall through to default 'enter' behavior
|
||||
}
|
||||
_ => {} // Let other keys fall through to the live search logic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edit-specific actions
|
||||
if let Some(action) = config.get_edit_action_for_key(key.code, key.modifiers) {
|
||||
if action == "exit" {
|
||||
if app_state.ui.show_register && register_state.in_suggestion_mode {
|
||||
// Call the action, get Result<String, Error>
|
||||
let msg = auth_e::execute_edit_action(
|
||||
"exit_suggestion_mode",
|
||||
// --- LIVE AUTOCOMPLETE TRIGGER LOGIC ---
|
||||
let mut trigger_search = false;
|
||||
|
||||
if app_state.ui.show_form {
|
||||
// Manual trigger
|
||||
if let Some("trigger_autocomplete") =
|
||||
config.get_edit_action_for_key(key.code, key.modifiers)
|
||||
{
|
||||
if !form_state.autocomplete_active {
|
||||
trigger_search = true;
|
||||
}
|
||||
}
|
||||
// Live search trigger while typing
|
||||
else if form_state.autocomplete_active {
|
||||
if let KeyCode::Char(_) | KeyCode::Backspace = key.code {
|
||||
let action = if let KeyCode::Backspace = key.code {
|
||||
"delete_char_backward"
|
||||
} else {
|
||||
"insert_char"
|
||||
};
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
form_e::execute_edit_action(
|
||||
action,
|
||||
key,
|
||||
register_state,
|
||||
ideal_cursor_column,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?; // Results in String on success
|
||||
// Wrap the String message
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
} else {
|
||||
// Signal exit
|
||||
return Ok(EditEventOutcome::ExitEditMode);
|
||||
form_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?;
|
||||
trigger_search = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for role field suggestions
|
||||
if app_state.ui.show_register && register_state.current_field() == 4 {
|
||||
if !register_state.in_suggestion_mode && key.code == KeyCode::Tab && key.modifiers == KeyModifiers::NONE {
|
||||
register_state.update_role_suggestions();
|
||||
if !register_state.role_suggestions.is_empty() {
|
||||
register_state.in_suggestion_mode = true;
|
||||
register_state.selected_suggestion_index = Some(0);
|
||||
return Ok(EditEventOutcome::Message("Suggestions shown".to_string()));
|
||||
} else { // Added else here for clarity
|
||||
return Ok(EditEventOutcome::Message("No suggestions available".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute other edit actions
|
||||
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? // Results in String
|
||||
} 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? // Results in String
|
||||
} else {
|
||||
form_e::execute_edit_action(
|
||||
action,
|
||||
key,
|
||||
form_state,
|
||||
ideal_cursor_column,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await? // Results in String
|
||||
};
|
||||
// Wrap the resulting String message
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
}
|
||||
|
||||
// Character insertion
|
||||
if let KeyCode::Char(_) = key.code {
|
||||
if app_state.ui.show_register && register_state.in_suggestion_mode {
|
||||
register_state.in_suggestion_mode = false;
|
||||
register_state.show_role_suggestions = false;
|
||||
register_state.selected_suggestion_index = None;
|
||||
}
|
||||
|
||||
// Execute insert_char action
|
||||
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? // Results in String
|
||||
} 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? // Results in String
|
||||
} else {
|
||||
form_e::execute_edit_action(
|
||||
"insert_char",
|
||||
key,
|
||||
form_state,
|
||||
ideal_cursor_column,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await? // Results in String
|
||||
};
|
||||
// Wrap the resulting String message
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
if trigger_search {
|
||||
trigger_form_autocomplete_search(
|
||||
form_state,
|
||||
&mut event_handler.grpc_client,
|
||||
event_handler.autocomplete_result_sender.clone(),
|
||||
)
|
||||
.await;
|
||||
return Ok(EditEventOutcome::Message("Searching...".to_string()));
|
||||
}
|
||||
|
||||
// Handle Backspace/Delete
|
||||
if matches!(key.code, KeyCode::Backspace | KeyCode::Delete) {
|
||||
if app_state.ui.show_register && register_state.in_suggestion_mode {
|
||||
register_state.in_suggestion_mode = false;
|
||||
register_state.show_role_suggestions = false;
|
||||
register_state.selected_suggestion_index = None;
|
||||
// --- GENERAL EDIT MODE EVENT HANDLING (IF NOT AUTOCOMPLETE) ---
|
||||
|
||||
if let Some(action_str) =
|
||||
config.get_edit_action_for_key(key.code, key.modifiers)
|
||||
{
|
||||
// Handle Enter key (next field)
|
||||
if action_str == "enter_decider" {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
let msg = form_e::execute_edit_action(
|
||||
"next_field",
|
||||
key,
|
||||
form_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?;
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
}
|
||||
|
||||
let action_str = if key.code == KeyCode::Backspace { "backspace" } else { "delete_char" };
|
||||
// Ensure both branches result in a String *before* wrapping
|
||||
let result_msg: String = if app_state.ui.show_register {
|
||||
auth_e::execute_edit_action(
|
||||
// Handle exiting edit mode
|
||||
if action_str == "exit" {
|
||||
return Ok(EditEventOutcome::ExitEditMode);
|
||||
}
|
||||
|
||||
// Handle all other edit actions
|
||||
let msg = if app_state.ui.show_login {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
auth_e::execute_edit_action(
|
||||
action_str,
|
||||
key,
|
||||
login_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
add_table_e::execute_edit_action(
|
||||
action_str,
|
||||
key,
|
||||
&mut admin_state.add_table_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_logic {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
add_logic_e::execute_edit_action(
|
||||
action_str,
|
||||
key,
|
||||
&mut admin_state.add_logic_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_register {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
auth_e::execute_edit_action(
|
||||
action_str,
|
||||
key,
|
||||
register_state,
|
||||
ideal_cursor_column,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count
|
||||
).await? // Results in String
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
// Return String directly, not Ok(String)
|
||||
"Action not applicable here".to_string()
|
||||
}; // Semicolon here ends the if/else expression
|
||||
|
||||
// Wrap the resulting String message
|
||||
return Ok(EditEventOutcome::Message(result_msg));
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
form_e::execute_edit_action(
|
||||
action_str,
|
||||
key,
|
||||
form_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
}
|
||||
|
||||
// Default return if no other handler matched
|
||||
Ok(EditEventOutcome::Message("".to_string()))
|
||||
}
|
||||
// --- FALLBACK FOR CHARACTER INSERTION (IF NO OTHER BINDING MATCHED) ---
|
||||
if let KeyCode::Char(_) = key.code {
|
||||
let msg = if app_state.ui.show_login {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
auth_e::execute_edit_action(
|
||||
"insert_char",
|
||||
key,
|
||||
login_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
add_table_e::execute_edit_action(
|
||||
"insert_char",
|
||||
key,
|
||||
&mut admin_state.add_table_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_add_logic {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
add_logic_e::execute_edit_action(
|
||||
"insert_char",
|
||||
key,
|
||||
&mut admin_state.add_logic_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_register {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
auth_e::execute_edit_action(
|
||||
"insert_char",
|
||||
key,
|
||||
register_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
||||
form_e::execute_edit_action(
|
||||
"insert_char",
|
||||
key,
|
||||
form_state,
|
||||
&mut event_handler.ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
return Ok(EditEventOutcome::Message(msg));
|
||||
}
|
||||
|
||||
Ok(EditEventOutcome::Message(String::new())) // No action taken
|
||||
}
|
||||
|
||||
@@ -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,14 +20,14 @@ 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,
|
||||
grpc_client: &mut GrpcClient,
|
||||
command_message: &mut String,
|
||||
edit_mode_cooldown: &mut bool,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<(bool, String), Box<dyn std::error::Error>> {
|
||||
) -> Result<(bool, String)> {
|
||||
if config.is_enter_edit_mode_before(key.code, key.modifiers) {
|
||||
*edit_mode_cooldown = true;
|
||||
*command_message = "Entering Edit mode".to_string();
|
||||
@@ -32,34 +35,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,28 +61,40 @@ 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,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
||||
crate::tui::functions::login::handle_action(action).await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
add_table_ro::execute_action(
|
||||
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,20 +133,36 @@ 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,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
||||
crate::tui::functions::login::handle_action(action).await?
|
||||
} else if app_state.ui.show_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,
|
||||
@@ -156,7 +171,7 @@ pub async fn handle_read_only_event(
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
).await?
|
||||
} else if app_state.ui.show_login { // Handle login general actions
|
||||
} else if app_state.ui.show_login {
|
||||
auth_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
@@ -184,20 +199,36 @@ 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,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
||||
crate::tui::functions::login::handle_action(action).await?
|
||||
} else if app_state.ui.show_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,
|
||||
@@ -206,7 +237,7 @@ pub async fn handle_read_only_event(
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
).await?
|
||||
} else if app_state.ui.show_login { // Handle login general actions
|
||||
} else if app_state.ui.show_login {
|
||||
auth_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
|
||||
@@ -10,12 +10,12 @@ use crate::tui::terminal::core::TerminalCore;
|
||||
use crate::tui::functions::common::form::{save, revert};
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use std::error::Error;
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn handle_command_event(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &AppState,
|
||||
app_state: &mut AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
form_state: &mut FormState,
|
||||
@@ -26,7 +26,7 @@ pub async fn handle_command_event(
|
||||
terminal: &mut TerminalCore,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<EventOutcome, Box<dyn Error>> {
|
||||
) -> Result<EventOutcome> {
|
||||
// Exit command mode (via configurable keybinding)
|
||||
if config.is_exit_command_mode(key.code, key.modifiers) {
|
||||
command_input.clear();
|
||||
@@ -74,7 +74,7 @@ pub async fn handle_command_event(
|
||||
async fn process_command(
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
app_state: &AppState,
|
||||
app_state: &mut AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
command_input: &mut String,
|
||||
@@ -84,7 +84,7 @@ async fn process_command(
|
||||
terminal: &mut TerminalCore,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<EventOutcome, Box<dyn Error>> {
|
||||
) -> Result<EventOutcome> {
|
||||
// Clone the trimmed command to avoid borrow issues
|
||||
let command = command_input.trim().to_string();
|
||||
if command.is_empty() {
|
||||
@@ -117,10 +117,9 @@ async fn process_command(
|
||||
},
|
||||
"save" => {
|
||||
let outcome = save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
let message = match outcome {
|
||||
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
|
||||
@@ -134,8 +133,6 @@ async fn process_command(
|
||||
let message = revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
).await?;
|
||||
command_input.clear();
|
||||
Ok(EventOutcome::Ok(message))
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/client/modes/general.rs
|
||||
pub mod navigation;
|
||||
pub mod dialog;
|
||||
pub mod command_navigation;
|
||||
|
||||
396
client/src/modes/general/command_navigation.rs
Normal file
396
client/src/modes/general/command_navigation.rs
Normal file
@@ -0,0 +1,396 @@
|
||||
// src/modes/general/command_navigation.rs
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use anyhow::Result;
|
||||
use common::proto::multieko2::table_definition::ProfileTreeResponse;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum NavigationType {
|
||||
FindFile,
|
||||
TableTree,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TableDependencyGraph {
|
||||
all_tables: HashSet<String>,
|
||||
dependents_map: HashMap<String, Vec<String>>,
|
||||
root_tables: Vec<String>,
|
||||
}
|
||||
|
||||
impl TableDependencyGraph {
|
||||
pub fn from_profile_tree(profile_tree: &ProfileTreeResponse) -> Self {
|
||||
let mut dependents_map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut all_tables_set: HashSet<String> = HashSet::new();
|
||||
let mut table_dependencies: HashMap<String, Vec<String>> = HashMap::new();
|
||||
|
||||
for profile in &profile_tree.profiles {
|
||||
for table in &profile.tables {
|
||||
all_tables_set.insert(table.name.clone());
|
||||
table_dependencies.insert(table.name.clone(), table.depends_on.clone());
|
||||
|
||||
for dependency_name in &table.depends_on {
|
||||
dependents_map
|
||||
.entry(dependency_name.clone())
|
||||
.or_default()
|
||||
.push(table.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let root_tables: Vec<String> = all_tables_set
|
||||
.iter()
|
||||
.filter(|name| {
|
||||
table_dependencies
|
||||
.get(*name)
|
||||
.map_or(true, |deps| deps.is_empty())
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let mut sorted_root_tables = root_tables;
|
||||
sorted_root_tables.sort();
|
||||
|
||||
for dependents_list in dependents_map.values_mut() {
|
||||
dependents_list.sort();
|
||||
}
|
||||
|
||||
Self {
|
||||
all_tables: all_tables_set,
|
||||
dependents_map,
|
||||
root_tables: sorted_root_tables,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_dependent_children(&self, path: &str) -> Vec<String> {
|
||||
if path.is_empty() {
|
||||
return self.root_tables.clone();
|
||||
}
|
||||
|
||||
let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
if let Some(last_segment_name) = path_segments.last() {
|
||||
if self.all_tables.contains(*last_segment_name) {
|
||||
return self
|
||||
.dependents_map
|
||||
.get(*last_segment_name)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ... (NavigationState struct and its new(), activate_*, deactivate(), add_char(), remove_char(), move_*, autocomplete_selected(), get_display_input() methods are unchanged) ...
|
||||
pub struct NavigationState {
|
||||
pub active: bool,
|
||||
pub input: String,
|
||||
pub selected_index: Option<usize>,
|
||||
pub filtered_options: Vec<(usize, String)>,
|
||||
pub navigation_type: NavigationType,
|
||||
pub current_path: String,
|
||||
pub graph: Option<TableDependencyGraph>,
|
||||
pub all_options: Vec<String>,
|
||||
}
|
||||
|
||||
impl NavigationState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
input: String::new(),
|
||||
selected_index: None,
|
||||
filtered_options: Vec::new(),
|
||||
navigation_type: NavigationType::FindFile,
|
||||
current_path: String::new(),
|
||||
graph: None,
|
||||
all_options: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate_find_file(&mut self, options: Vec<String>) {
|
||||
self.active = true;
|
||||
self.navigation_type = NavigationType::FindFile;
|
||||
self.all_options = options;
|
||||
self.input.clear();
|
||||
self.current_path.clear();
|
||||
self.graph = None;
|
||||
self.update_filtered_options();
|
||||
}
|
||||
|
||||
pub fn activate_table_tree(&mut self, graph: TableDependencyGraph) {
|
||||
self.active = true;
|
||||
self.navigation_type = NavigationType::TableTree;
|
||||
self.graph = Some(graph);
|
||||
self.input.clear();
|
||||
self.current_path.clear();
|
||||
self.update_options_for_path();
|
||||
}
|
||||
|
||||
pub fn deactivate(&mut self) {
|
||||
self.active = false;
|
||||
self.input.clear();
|
||||
self.all_options.clear();
|
||||
self.filtered_options.clear();
|
||||
self.selected_index = None;
|
||||
self.current_path.clear();
|
||||
self.graph = None;
|
||||
}
|
||||
|
||||
pub fn add_char(&mut self, c: char) {
|
||||
match self.navigation_type {
|
||||
NavigationType::FindFile => {
|
||||
self.input.push(c);
|
||||
self.update_filtered_options();
|
||||
}
|
||||
NavigationType::TableTree => {
|
||||
if c == '/' {
|
||||
if !self.input.is_empty() {
|
||||
if self.current_path.is_empty() {
|
||||
self.current_path = self.input.clone();
|
||||
} else {
|
||||
self.current_path.push('/');
|
||||
self.current_path.push_str(&self.input);
|
||||
}
|
||||
self.input.clear();
|
||||
self.update_options_for_path();
|
||||
}
|
||||
} else {
|
||||
self.input.push(c);
|
||||
self.update_filtered_options();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_char(&mut self) {
|
||||
match self.navigation_type {
|
||||
NavigationType::FindFile => {
|
||||
self.input.pop();
|
||||
self.update_filtered_options();
|
||||
}
|
||||
NavigationType::TableTree => {
|
||||
if self.input.is_empty() {
|
||||
if !self.current_path.is_empty() {
|
||||
if let Some(last_slash_idx) = self.current_path.rfind('/') {
|
||||
self.input = self.current_path[last_slash_idx + 1..].to_string();
|
||||
self.current_path = self.current_path[..last_slash_idx].to_string();
|
||||
} else {
|
||||
self.input = self.current_path.clone();
|
||||
self.current_path.clear();
|
||||
}
|
||||
self.update_options_for_path();
|
||||
self.update_filtered_options();
|
||||
}
|
||||
} else {
|
||||
self.input.pop();
|
||||
self.update_filtered_options();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_up(&mut self) {
|
||||
if self.filtered_options.is_empty() {
|
||||
self.selected_index = None;
|
||||
return;
|
||||
}
|
||||
self.selected_index = match self.selected_index {
|
||||
Some(0) => Some(self.filtered_options.len() - 1),
|
||||
Some(current) => Some(current - 1),
|
||||
None => Some(self.filtered_options.len() - 1),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn move_down(&mut self) {
|
||||
if self.filtered_options.is_empty() {
|
||||
self.selected_index = None;
|
||||
return;
|
||||
}
|
||||
self.selected_index = match self.selected_index {
|
||||
Some(current) if current >= self.filtered_options.len() - 1 => Some(0),
|
||||
Some(current) => Some(current + 1),
|
||||
None => Some(0),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_selected_option_str(&self) -> Option<&str> {
|
||||
self.selected_index
|
||||
.and_then(|idx| self.filtered_options.get(idx))
|
||||
.map(|(_, option_str)| option_str.as_str())
|
||||
}
|
||||
|
||||
pub fn autocomplete_selected(&mut self) {
|
||||
if let Some(selected_option_str) = self.get_selected_option_str() {
|
||||
self.input = selected_option_str.to_string();
|
||||
self.update_filtered_options();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_display_input(&self) -> String {
|
||||
match self.navigation_type {
|
||||
NavigationType::FindFile => self.input.clone(),
|
||||
NavigationType::TableTree => {
|
||||
if self.current_path.is_empty() {
|
||||
self.input.clone()
|
||||
} else {
|
||||
format!("{}/{}", self.current_path, self.input)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- START FIX ---
|
||||
pub fn get_selected_value(&self) -> Option<String> {
|
||||
match self.navigation_type {
|
||||
NavigationType::FindFile => {
|
||||
// Return the highlighted option, not the raw input buffer.
|
||||
self.get_selected_option_str().map(|s| s.to_string())
|
||||
}
|
||||
NavigationType::TableTree => {
|
||||
self.get_selected_option_str().map(|selected_name| {
|
||||
if self.current_path.is_empty() {
|
||||
selected_name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.current_path, selected_name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
|
||||
fn update_options_for_path(&mut self) {
|
||||
if let NavigationType::TableTree = self.navigation_type {
|
||||
if let Some(graph) = &self.graph {
|
||||
self.all_options = graph.get_dependent_children(&self.current_path);
|
||||
} else {
|
||||
self.all_options.clear();
|
||||
}
|
||||
}
|
||||
self.update_filtered_options();
|
||||
}
|
||||
|
||||
fn update_filtered_options(&mut self) {
|
||||
let filter_text = match self.navigation_type {
|
||||
NavigationType::FindFile => &self.input,
|
||||
NavigationType::TableTree => &self.input,
|
||||
}
|
||||
.to_lowercase();
|
||||
|
||||
if filter_text.is_empty() {
|
||||
self.filtered_options = self
|
||||
.all_options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, opt)| (i, opt.clone()))
|
||||
.collect();
|
||||
} else {
|
||||
self.filtered_options = self
|
||||
.all_options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, opt)| opt.to_lowercase().contains(&filter_text))
|
||||
.map(|(i, opt)| (i, opt.clone()))
|
||||
.collect();
|
||||
}
|
||||
|
||||
if self.filtered_options.is_empty() {
|
||||
self.selected_index = None;
|
||||
} else {
|
||||
self.selected_index = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn handle_command_navigation_event(
|
||||
navigation_state: &mut NavigationState,
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
) -> Result<EventOutcome> {
|
||||
if !navigation_state.active {
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
navigation_state.deactivate();
|
||||
Ok(EventOutcome::Ok("Navigation cancelled".to_string()))
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
if let Some(selected_opt_str) = navigation_state.get_selected_option_str() {
|
||||
if navigation_state.input == selected_opt_str {
|
||||
if navigation_state.navigation_type == NavigationType::TableTree {
|
||||
let path_before_nav = navigation_state.current_path.clone();
|
||||
let input_before_nav = navigation_state.input.clone();
|
||||
navigation_state.add_char('/');
|
||||
if !(navigation_state.input.is_empty() &&
|
||||
(navigation_state.current_path != path_before_nav || !navigation_state.all_options.is_empty())) {
|
||||
if !navigation_state.input.is_empty() && navigation_state.input != input_before_nav {
|
||||
navigation_state.input = input_before_nav;
|
||||
if navigation_state.current_path != path_before_nav {
|
||||
navigation_state.current_path = path_before_nav;
|
||||
}
|
||||
navigation_state.update_options_for_path();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
navigation_state.autocomplete_selected();
|
||||
}
|
||||
}
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
navigation_state.remove_char();
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
navigation_state.add_char(c);
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
_ => {
|
||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
||||
match action {
|
||||
"move_up" => {
|
||||
navigation_state.move_up();
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
"move_down" => {
|
||||
navigation_state.move_down();
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
"select" => {
|
||||
if let Some(selected_value) = navigation_state.get_selected_value() {
|
||||
let outcome = match navigation_state.navigation_type {
|
||||
// --- START FIX ---
|
||||
NavigationType::FindFile => {
|
||||
// The purpose of this palette is to select a table.
|
||||
// Emit a TableSelected event instead of a generic Ok message.
|
||||
EventOutcome::TableSelected {
|
||||
path: selected_value,
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
NavigationType::TableTree => {
|
||||
EventOutcome::TableSelected {
|
||||
path: selected_value,
|
||||
}
|
||||
}
|
||||
};
|
||||
navigation_state.deactivate();
|
||||
Ok(outcome)
|
||||
} else {
|
||||
Ok(EventOutcome::Ok("No selection".to_string()))
|
||||
}
|
||||
}
|
||||
_ => Ok(EventOutcome::Ok(String::new())),
|
||||
}
|
||||
} else {
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::app::{state::AppState, buffer::AppView};
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::state::pages::auth::LoginState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
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),
|
||||
@@ -18,11 +19,11 @@ pub async fn handle_dialog_event(
|
||||
event: &Event,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
auth_state: &mut AuthState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
buffer_state: &mut BufferState,
|
||||
) -> Option<Result<EventOutcome, Box<dyn std::error::Error>>> {
|
||||
admin_state: &mut AdminState,
|
||||
) -> Option<Result<EventOutcome>> {
|
||||
if let Event::Key(key) = event {
|
||||
// Always allow Esc to dismiss
|
||||
if key.code == KeyCode::Esc {
|
||||
@@ -114,6 +115,40 @@ pub async fn handle_dialog_event(
|
||||
}
|
||||
}
|
||||
}
|
||||
DialogPurpose::ConfirmDeleteColumns => {
|
||||
match selected_index {
|
||||
0 => { // "Confirm" button selected
|
||||
let outcome_message = handle_delete_selected_columns(&mut admin_state.add_table_state);
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(outcome_message)));
|
||||
}
|
||||
1 => { // "Cancel" button selected
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Deletion cancelled.".to_string())));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
}
|
||||
}
|
||||
DialogPurpose::SaveTableSuccess => {
|
||||
match selected_index {
|
||||
0 => { // "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
buffer_state.update_history(AppView::Admin); // Navigate back
|
||||
return Some(Ok(EventOutcome::Ok("Save success dialog dismissed.".to_string())));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
}
|
||||
}
|
||||
DialogPurpose::SaveLogicSuccess => {
|
||||
match selected_index {
|
||||
0 => { // "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
buffer_state.update_history(AppView::Admin);
|
||||
return Some(Ok(EventOutcome::Ok("Save success dialog dismissed.".to_string())));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {} // Ignore other general actions when dialog is shown
|
||||
|
||||
@@ -11,6 +11,8 @@ use crate::state::pages::admin::AdminState;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::ui::handlers::context::UiContext;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState};
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn handle_navigation_event(
|
||||
key: KeyEvent,
|
||||
@@ -24,7 +26,13 @@ pub async fn handle_navigation_event(
|
||||
command_mode: &mut bool,
|
||||
command_input: &mut String,
|
||||
command_message: &mut String,
|
||||
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
|
||||
navigation_state: &mut NavigationState,
|
||||
) -> Result<EventOutcome> {
|
||||
// Handle command navigation first if active
|
||||
if navigation_state.active {
|
||||
return handle_command_navigation_event(navigation_state, key, config).await;
|
||||
}
|
||||
|
||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
||||
match action {
|
||||
"move_up" => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
// 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 {
|
||||
@@ -16,7 +18,15 @@ pub struct ModeManager;
|
||||
|
||||
impl ModeManager {
|
||||
// Determine current mode based on app state
|
||||
pub fn derive_mode(app_state: &AppState, event_handler: &EventHandler) -> AppMode {
|
||||
pub fn derive_mode(
|
||||
app_state: &AppState,
|
||||
event_handler: &EventHandler,
|
||||
admin_state: &AdminState,
|
||||
) -> AppMode {
|
||||
if event_handler.navigation_state.active {
|
||||
return AppMode::General;
|
||||
}
|
||||
|
||||
if event_handler.command_mode {
|
||||
return AppMode::Command;
|
||||
}
|
||||
@@ -25,21 +35,46 @@ impl ModeManager {
|
||||
return AppMode::Highlight;
|
||||
}
|
||||
|
||||
if app_state.ui.focus_outside_canvas || app_state.ui.show_add_table{
|
||||
return AppMode::General;
|
||||
}
|
||||
let is_canvas_view = app_state.ui.show_login
|
||||
|| app_state.ui.show_register
|
||||
|| app_state.ui.show_form
|
||||
|| app_state.ui.show_add_table
|
||||
|| app_state.ui.show_add_logic;
|
||||
|
||||
if app_state.ui.show_login || app_state.ui.show_register {
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
if app_state.ui.show_add_logic {
|
||||
// Specific logic for AddLogic view
|
||||
match admin_state.add_logic_state.current_focus {
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription => {
|
||||
// These are canvas inputs
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
}
|
||||
_ => AppMode::General,
|
||||
}
|
||||
} else if app_state.ui.show_form {
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else if app_state.ui.show_add_table {
|
||||
if app_state.ui.focus_outside_canvas {
|
||||
AppMode::General
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
}
|
||||
} else if is_canvas_view {
|
||||
if app_state.ui.focus_outside_canvas {
|
||||
AppMode::General
|
||||
} else {
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AppMode::General
|
||||
@@ -47,14 +82,14 @@ impl ModeManager {
|
||||
}
|
||||
|
||||
// Mode transition rules
|
||||
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
|
||||
!matches!(current_mode, AppMode::Edit) // Can't enter from Edit mode
|
||||
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
|
||||
!matches!(current_mode, AppMode::Edit)
|
||||
}
|
||||
|
||||
|
||||
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
|
||||
matches!(current_mode, AppMode::ReadOnly) // Only from ReadOnly
|
||||
matches!(current_mode, AppMode::ReadOnly)
|
||||
}
|
||||
|
||||
|
||||
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
|
||||
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::auth::{LoginState, RegisterState};
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::modes::read_only; // Import the ReadOnly handler
|
||||
use crate::modes::read_only;
|
||||
use crossterm::event::KeyEvent;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Handles events when in Highlight mode.
|
||||
/// Currently, it mostly delegates to the read_only handler for movement.
|
||||
@@ -21,6 +23,7 @@ pub async fn handle_highlight_event(
|
||||
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,
|
||||
@@ -28,7 +31,7 @@ pub async fn handle_highlight_event(
|
||||
command_message: &mut String,
|
||||
edit_mode_cooldown: &mut bool,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<EventOutcome, Box<dyn std::error::Error>> {
|
||||
) -> Result<EventOutcome> {
|
||||
// Delegate movement and other actions to the read_only handler
|
||||
// The rendering logic will use the highlight_anchor to draw the selection
|
||||
let (should_exit, message) = read_only::handle_read_only_event(
|
||||
@@ -38,9 +41,9 @@ pub async fn handle_highlight_event(
|
||||
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,
|
||||
|
||||
@@ -9,4 +9,3 @@ pub use handlers::*;
|
||||
pub use canvas::*;
|
||||
pub use general::*;
|
||||
pub use common::*;
|
||||
pub use highlight::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,69 +1,257 @@
|
||||
// src/services/grpc_client.rs
|
||||
|
||||
use tonic::transport::Channel;
|
||||
use common::proto::multieko2::adresar::adresar_client::AdresarClient;
|
||||
use common::proto::multieko2::adresar::{AdresarResponse, PostAdresarRequest, PutAdresarRequest};
|
||||
use common::proto::multieko2::common::{CountResponse, PositionRequest, Empty};
|
||||
use common::proto::multieko2::common::Empty;
|
||||
use common::proto::multieko2::table_structure::table_structure_service_client::TableStructureServiceClient;
|
||||
use common::proto::multieko2::table_structure::TableStructureResponse;
|
||||
use common::proto::multieko2::table_structure::{GetTableStructureRequest, TableStructureResponse};
|
||||
use common::proto::multieko2::table_definition::{
|
||||
table_definition_client::TableDefinitionClient,
|
||||
ProfileTreeResponse
|
||||
PostTableDefinitionRequest, ProfileTreeResponse, TableDefinitionResponse,
|
||||
};
|
||||
use common::proto::multieko2::table_script::{
|
||||
table_script_client::TableScriptClient,
|
||||
PostTableScriptRequest, TableScriptResponse,
|
||||
};
|
||||
use common::proto::multieko2::tables_data::{
|
||||
tables_data_client::TablesDataClient,
|
||||
GetTableDataByPositionRequest,
|
||||
GetTableDataRequest, // ADD THIS
|
||||
GetTableDataResponse,
|
||||
DeleteTableDataRequest, // ADD THIS
|
||||
DeleteTableDataResponse, // ADD THIS
|
||||
GetTableDataCountRequest,
|
||||
PostTableDataRequest, PostTableDataResponse, PutTableDataRequest,
|
||||
PutTableDataResponse,
|
||||
};
|
||||
use common::proto::multieko2::search::{
|
||||
searcher_client::SearcherClient, SearchRequest, SearchResponse,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use tonic::transport::Channel;
|
||||
use prost_types::Value;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GrpcClient {
|
||||
adresar_client: AdresarClient<Channel>,
|
||||
table_structure_client: TableStructureServiceClient<Channel>,
|
||||
table_definition_client: TableDefinitionClient<Channel>,
|
||||
table_script_client: TableScriptClient<Channel>,
|
||||
tables_data_client: TablesDataClient<Channel>,
|
||||
search_client: SearcherClient<Channel>,
|
||||
}
|
||||
|
||||
impl GrpcClient {
|
||||
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let adresar_client = AdresarClient::connect("http://[::1]:50051").await?;
|
||||
let table_structure_client = TableStructureServiceClient::connect("http://[::1]:50051").await?;
|
||||
let table_definition_client = TableDefinitionClient::connect("http://[::1]:50051").await?;
|
||||
pub async fn new() -> Result<Self> {
|
||||
let channel = Channel::from_static("http://[::1]:50051")
|
||||
.connect()
|
||||
.await
|
||||
.context("Failed to create gRPC channel")?;
|
||||
|
||||
let table_structure_client =
|
||||
TableStructureServiceClient::new(channel.clone());
|
||||
let table_definition_client =
|
||||
TableDefinitionClient::new(channel.clone());
|
||||
let table_script_client = TableScriptClient::new(channel.clone());
|
||||
let tables_data_client = TablesDataClient::new(channel.clone());
|
||||
let search_client = SearcherClient::new(channel.clone());
|
||||
|
||||
Ok(Self {
|
||||
adresar_client,
|
||||
table_structure_client,
|
||||
table_definition_client,
|
||||
table_script_client,
|
||||
tables_data_client,
|
||||
search_client,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_adresar_count(&mut self) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
let request = tonic::Request::new(Empty::default());
|
||||
let response: CountResponse = self.adresar_client.get_adresar_count(request).await?.into_inner();
|
||||
Ok(response.count as u64)
|
||||
}
|
||||
|
||||
pub async fn get_adresar_by_position(&mut self, position: u64) -> Result<AdresarResponse, Box<dyn std::error::Error>> {
|
||||
let request = tonic::Request::new(PositionRequest { position: position as i64 });
|
||||
let response: AdresarResponse = self.adresar_client.get_adresar_by_position(request).await?.into_inner();
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn post_adresar(&mut self, request: PostAdresarRequest) -> Result<tonic::Response<AdresarResponse>, Box<dyn std::error::Error>> {
|
||||
let request = tonic::Request::new(request);
|
||||
let response = self.adresar_client.post_adresar(request).await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn put_adresar(&mut self, request: PutAdresarRequest) -> Result<tonic::Response<AdresarResponse>, Box<dyn std::error::Error>> {
|
||||
let request = tonic::Request::new(request);
|
||||
let response = self.adresar_client.put_adresar(request).await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn get_table_structure(&mut self) -> Result<TableStructureResponse, Box<dyn std::error::Error>> {
|
||||
let request = tonic::Request::new(Empty::default());
|
||||
let response = self.table_structure_client.get_adresar_table_structure(request).await?;
|
||||
pub async fn get_table_structure(
|
||||
&mut self,
|
||||
profile_name: String,
|
||||
table_name: String,
|
||||
) -> Result<TableStructureResponse> {
|
||||
let grpc_request = GetTableStructureRequest {
|
||||
profile_name,
|
||||
table_name,
|
||||
};
|
||||
let request = tonic::Request::new(grpc_request);
|
||||
let response = self
|
||||
.table_structure_client
|
||||
.get_table_structure(request)
|
||||
.await
|
||||
.context("gRPC GetTableStructure call failed")?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_profile_tree(&mut self) -> Result<ProfileTreeResponse, Box<dyn std::error::Error>> {
|
||||
pub async fn get_profile_tree(
|
||||
&mut self,
|
||||
) -> Result<ProfileTreeResponse> {
|
||||
let request = tonic::Request::new(Empty::default());
|
||||
let response = self.table_definition_client.get_profile_tree(request).await?;
|
||||
let response = self
|
||||
.table_definition_client
|
||||
.get_profile_tree(request)
|
||||
.await
|
||||
.context("gRPC GetProfileTree call failed")?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn post_table_definition(
|
||||
&mut self,
|
||||
request: PostTableDefinitionRequest,
|
||||
) -> Result<TableDefinitionResponse> {
|
||||
let tonic_request = tonic::Request::new(request);
|
||||
let response = self
|
||||
.table_definition_client
|
||||
.post_table_definition(tonic_request)
|
||||
.await
|
||||
.context("gRPC PostTableDefinition call failed")?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn post_table_script(
|
||||
&mut self,
|
||||
request: PostTableScriptRequest,
|
||||
) -> Result<TableScriptResponse> {
|
||||
let tonic_request = tonic::Request::new(request);
|
||||
let response = self
|
||||
.table_script_client
|
||||
.post_table_script(tonic_request)
|
||||
.await
|
||||
.context("gRPC PostTableScript call failed")?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
// Existing TablesData methods
|
||||
pub async fn get_table_data_count(
|
||||
&mut self,
|
||||
profile_name: String,
|
||||
table_name: String,
|
||||
) -> Result<u64> {
|
||||
let grpc_request = GetTableDataCountRequest {
|
||||
profile_name,
|
||||
table_name,
|
||||
};
|
||||
let request = tonic::Request::new(grpc_request);
|
||||
let response = self
|
||||
.tables_data_client
|
||||
.get_table_data_count(request)
|
||||
.await
|
||||
.context("gRPC GetTableDataCount call failed")?;
|
||||
Ok(response.into_inner().count as u64)
|
||||
}
|
||||
|
||||
pub async fn get_table_data_by_position(
|
||||
&mut self,
|
||||
profile_name: String,
|
||||
table_name: String,
|
||||
position: i32,
|
||||
) -> Result<GetTableDataResponse> {
|
||||
let grpc_request = GetTableDataByPositionRequest {
|
||||
profile_name,
|
||||
table_name,
|
||||
position,
|
||||
};
|
||||
let request = tonic::Request::new(grpc_request);
|
||||
let response = self
|
||||
.tables_data_client
|
||||
.get_table_data_by_position(request)
|
||||
.await
|
||||
.context("gRPC GetTableDataByPosition call failed")?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
// ADD THIS: Missing get_table_data method
|
||||
pub async fn get_table_data(
|
||||
&mut self,
|
||||
profile_name: String,
|
||||
table_name: String,
|
||||
id: i64,
|
||||
) -> Result<GetTableDataResponse> {
|
||||
let grpc_request = GetTableDataRequest {
|
||||
profile_name,
|
||||
table_name,
|
||||
id,
|
||||
};
|
||||
let request = tonic::Request::new(grpc_request);
|
||||
let response = self
|
||||
.tables_data_client
|
||||
.get_table_data(request)
|
||||
.await
|
||||
.context("gRPC GetTableData call failed")?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
// ADD THIS: Missing delete_table_data method
|
||||
pub async fn delete_table_data(
|
||||
&mut self,
|
||||
profile_name: String,
|
||||
table_name: String,
|
||||
record_id: i64,
|
||||
) -> Result<DeleteTableDataResponse> {
|
||||
let grpc_request = DeleteTableDataRequest {
|
||||
profile_name,
|
||||
table_name,
|
||||
record_id,
|
||||
};
|
||||
let request = tonic::Request::new(grpc_request);
|
||||
let response = self
|
||||
.tables_data_client
|
||||
.delete_table_data(request)
|
||||
.await
|
||||
.context("gRPC DeleteTableData call failed")?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn post_table_data(
|
||||
&mut self,
|
||||
profile_name: String,
|
||||
table_name: String,
|
||||
data: HashMap<String, Value>,
|
||||
) -> Result<PostTableDataResponse> {
|
||||
let grpc_request = PostTableDataRequest {
|
||||
profile_name,
|
||||
table_name,
|
||||
data,
|
||||
};
|
||||
let request = tonic::Request::new(grpc_request);
|
||||
let response = self
|
||||
.tables_data_client
|
||||
.post_table_data(request)
|
||||
.await
|
||||
.context("gRPC PostTableData call failed")?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn put_table_data(
|
||||
&mut self,
|
||||
profile_name: String,
|
||||
table_name: String,
|
||||
id: i64,
|
||||
data: HashMap<String, Value>,
|
||||
) -> Result<PutTableDataResponse> {
|
||||
let grpc_request = PutTableDataRequest {
|
||||
profile_name,
|
||||
table_name,
|
||||
id,
|
||||
data,
|
||||
};
|
||||
let request = tonic::Request::new(grpc_request);
|
||||
let response = self
|
||||
.tables_data_client
|
||||
.put_table_data(request)
|
||||
.await
|
||||
.context("gRPC PutTableData call failed")?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn search_table(
|
||||
&mut self,
|
||||
table_name: String,
|
||||
query: String,
|
||||
) -> Result<SearchResponse> {
|
||||
let request = tonic::Request::new(SearchRequest { table_name, query });
|
||||
let response = self
|
||||
.search_client
|
||||
.search_table(request)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +1,319 @@
|
||||
// src/services/ui_service.rs
|
||||
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_logic::AddLogicState;
|
||||
use crate::state::pages::form::{FieldDefinition, FormState};
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use crate::utils::columns::filter_user_columns;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct UiService;
|
||||
|
||||
impl UiService {
|
||||
pub async fn initialize_app_state(
|
||||
pub async fn load_table_view(
|
||||
grpc_client: &mut GrpcClient,
|
||||
app_state: &mut AppState,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
// Fetch profile tree
|
||||
let profile_tree = grpc_client.get_profile_tree().await?;
|
||||
app_state.profile_tree = profile_tree;
|
||||
profile_name: &str,
|
||||
table_name: &str,
|
||||
) -> Result<FormState> {
|
||||
// 1. & 2. Fetch and Cache Schema - UNCHANGED
|
||||
let table_structure = grpc_client
|
||||
.get_table_structure(profile_name.to_string(), table_name.to_string())
|
||||
.await
|
||||
.context(format!(
|
||||
"Failed to get table structure for {}.{}",
|
||||
profile_name, table_name
|
||||
))?;
|
||||
let cache_key = format!("{}.{}", profile_name, table_name);
|
||||
app_state
|
||||
.schema_cache
|
||||
.insert(cache_key, Arc::new(table_structure.clone()));
|
||||
tracing::info!("Schema for '{}.{}' cached.", profile_name, table_name);
|
||||
|
||||
// Fetch table structure
|
||||
let table_structure = grpc_client.get_table_structure().await?;
|
||||
// --- START: FINAL, SIMPLIFIED, CORRECT LOGIC ---
|
||||
|
||||
// Extract the column names from the response
|
||||
let column_names: Vec<String> = table_structure
|
||||
// 3a. Create definitions for REGULAR fields first.
|
||||
let mut fields: Vec<FieldDefinition> = table_structure
|
||||
.columns
|
||||
.iter()
|
||||
.map(|col| col.name.clone())
|
||||
.filter(|col| {
|
||||
!col.is_primary_key
|
||||
&& col.name != "deleted"
|
||||
&& col.name != "created_at"
|
||||
&& !col.name.ends_with("_id") // Filter out ALL potential links
|
||||
})
|
||||
.map(|col| FieldDefinition {
|
||||
display_name: col.name.clone(),
|
||||
data_key: col.name.clone(),
|
||||
is_link: false,
|
||||
link_target_table: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(column_names)
|
||||
// 3b. Now, find and APPEND definitions for LINK fields based on the `_id` convention.
|
||||
let link_fields: Vec<FieldDefinition> = table_structure
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|col| col.name.ends_with("_id")) // Find all foreign key columns
|
||||
.map(|col| {
|
||||
// The table we link to is derived from the column name.
|
||||
// e.g., "test_diacritics_id" -> "test_diacritics"
|
||||
let target_table_base = col
|
||||
.name
|
||||
.strip_suffix("_id")
|
||||
.unwrap_or(&col.name);
|
||||
|
||||
// Find the full table name from the profile tree for display.
|
||||
// e.g., "test_diacritics" -> "2025_test_diacritics"
|
||||
let full_target_table_name = app_state
|
||||
.profile_tree
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.and_then(|p| p.tables.iter().find(|t| t.name.ends_with(target_table_base)))
|
||||
.map_or(target_table_base.to_string(), |t| t.name.clone());
|
||||
|
||||
FieldDefinition {
|
||||
display_name: full_target_table_name.clone(),
|
||||
data_key: col.name.clone(), // The actual FK column name
|
||||
is_link: true,
|
||||
link_target_table: Some(full_target_table_name),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
fields.extend(link_fields); // Append the link fields to the end
|
||||
|
||||
// --- END: FINAL, SIMPLIFIED, CORRECT LOGIC ---
|
||||
|
||||
Ok(FormState::new(
|
||||
profile_name.to_string(),
|
||||
table_name.to_string(),
|
||||
fields,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn initialize_adresar_count(
|
||||
pub async fn initialize_add_logic_table_data(
|
||||
grpc_client: &mut GrpcClient,
|
||||
app_state: &mut AppState,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let total_count = grpc_client.get_adresar_count().await?;
|
||||
app_state.update_total_count(total_count);
|
||||
app_state.update_current_position(total_count.saturating_add(1)); // Start in new entry mode
|
||||
Ok(())
|
||||
add_logic_state: &mut AddLogicState,
|
||||
profile_tree: &common::proto::multieko2::table_definition::ProfileTreeResponse,
|
||||
) -> Result<String> {
|
||||
let profile_name_clone_opt = Some(add_logic_state.profile_name.clone());
|
||||
let table_name_opt_clone = add_logic_state.selected_table_name.clone();
|
||||
|
||||
// Collect table names from SAME profile only
|
||||
let same_profile_table_names: Vec<String> = profile_tree.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.name == add_logic_state.profile_name)
|
||||
.map(|profile| profile.tables.iter().map(|table| table.name.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Set same profile table names for autocomplete
|
||||
add_logic_state.set_same_profile_table_names(same_profile_table_names.clone());
|
||||
|
||||
if let (Some(profile_name_clone), Some(table_name_clone)) = (profile_name_clone_opt, table_name_opt_clone) {
|
||||
match grpc_client.get_table_structure(profile_name_clone.clone(), table_name_clone.clone()).await {
|
||||
Ok(response) => {
|
||||
let column_names: Vec<String> = response.columns
|
||||
.into_iter()
|
||||
.map(|col| col.name)
|
||||
.collect();
|
||||
|
||||
add_logic_state.set_table_columns(column_names.clone());
|
||||
|
||||
Ok(format!(
|
||||
"Loaded {} columns for table '{}' and {} tables from profile '{}'",
|
||||
column_names.len(),
|
||||
table_name_clone,
|
||||
same_profile_table_names.len(),
|
||||
add_logic_state.profile_name
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to fetch table structure for {}.{}: {}",
|
||||
profile_name_clone,
|
||||
table_name_clone,
|
||||
e
|
||||
);
|
||||
Ok(format!(
|
||||
"Warning: Could not load table structure for '{}'. Autocomplete will use {} tables from profile '{}'.",
|
||||
table_name_clone,
|
||||
same_profile_table_names.len(),
|
||||
add_logic_state.profile_name
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(format!(
|
||||
"No table selected for Add Logic. Loaded {} tables from profile '{}' for autocomplete.",
|
||||
same_profile_table_names.len(),
|
||||
add_logic_state.profile_name
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_adresar_count(
|
||||
/// Fetches columns for a specific table (used for table.column autocomplete)
|
||||
pub async fn fetch_columns_for_table(
|
||||
grpc_client: &mut GrpcClient,
|
||||
app_state: &mut AppState,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let total_count = grpc_client.get_adresar_count().await?;
|
||||
app_state.update_total_count(total_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_adresar_by_position(
|
||||
grpc_client: &mut GrpcClient,
|
||||
app_state: &mut AppState,
|
||||
form_state: &mut FormState,
|
||||
position: u64,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
match grpc_client.get_adresar_by_position(position).await {
|
||||
profile_name: &str,
|
||||
table_name: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
match grpc_client.get_table_structure(profile_name.to_string(), table_name.to_string()).await {
|
||||
Ok(response) => {
|
||||
// Set the ID properly
|
||||
form_state.id = response.id;
|
||||
|
||||
// Update form values dynamically
|
||||
form_state.values = vec![
|
||||
response.firma,
|
||||
response.kz,
|
||||
response.drc,
|
||||
response.ulica,
|
||||
response.psc,
|
||||
response.mesto,
|
||||
response.stat,
|
||||
response.banka,
|
||||
response.ucet,
|
||||
response.skladm,
|
||||
response.ico,
|
||||
response.kontakt,
|
||||
response.telefon,
|
||||
response.skladu,
|
||||
response.fax,
|
||||
];
|
||||
|
||||
form_state.has_unsaved_changes = false;
|
||||
Ok(format!("Loaded entry {}", position))
|
||||
let column_names: Vec<String> = response.columns
|
||||
.into_iter()
|
||||
.map(|col| col.name)
|
||||
.collect();
|
||||
Ok(filter_user_columns(column_names))
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(format!("Error loading entry: {}", e))
|
||||
tracing::warn!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the consequences of a save operation, like updating counts.
|
||||
pub async fn handle_save_outcome(
|
||||
save_outcome: SaveOutcome,
|
||||
// REFACTOR THIS FUNCTION
|
||||
pub async fn initialize_app_state_and_form(
|
||||
grpc_client: &mut GrpcClient,
|
||||
app_state: &mut AppState,
|
||||
form_state: &mut FormState, // Needed to potentially update position/ID
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<(String, String, Vec<String>)> {
|
||||
let profile_tree = grpc_client
|
||||
.get_profile_tree()
|
||||
.await
|
||||
.context("Failed to get profile tree")?;
|
||||
app_state.profile_tree = profile_tree;
|
||||
|
||||
let initial_profile_name = app_state
|
||||
.profile_tree
|
||||
.profiles
|
||||
.first()
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
let initial_table_name = app_state
|
||||
.profile_tree
|
||||
.profiles
|
||||
.first()
|
||||
.and_then(|p| p.tables.first().map(|t| t.name.clone()))
|
||||
.unwrap_or_else(|| "2025_company_data1".to_string());
|
||||
|
||||
app_state.set_current_view_table(
|
||||
initial_profile_name.clone(),
|
||||
initial_table_name.clone(),
|
||||
);
|
||||
|
||||
// NOW, just call our new central function. This avoids code duplication.
|
||||
let form_state = Self::load_table_view(
|
||||
grpc_client,
|
||||
app_state,
|
||||
&initial_profile_name,
|
||||
&initial_table_name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The field names for the UI are derived from the new form_state
|
||||
let field_names = form_state.fields.iter().map(|f| f.display_name.clone()).collect();
|
||||
|
||||
Ok((initial_profile_name, initial_table_name, field_names))
|
||||
}
|
||||
|
||||
pub async fn fetch_and_set_table_count(
|
||||
grpc_client: &mut GrpcClient,
|
||||
form_state: &mut FormState,
|
||||
) -> Result<()> {
|
||||
let total_count = grpc_client
|
||||
.get_table_data_count(
|
||||
form_state.profile_name.clone(),
|
||||
form_state.table_name.clone(),
|
||||
)
|
||||
.await
|
||||
.context(format!(
|
||||
"Failed to get count for table {}.{}",
|
||||
form_state.profile_name, form_state.table_name
|
||||
))?;
|
||||
form_state.total_count = total_count;
|
||||
|
||||
if total_count > 0 {
|
||||
form_state.current_position = total_count;
|
||||
} else {
|
||||
form_state.current_position = 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_table_data_by_position(
|
||||
grpc_client: &mut GrpcClient,
|
||||
form_state: &mut FormState,
|
||||
) -> Result<String> {
|
||||
if form_state.current_position == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) {
|
||||
form_state.reset_to_empty();
|
||||
return Ok(format!(
|
||||
"New entry mode for table {}.{}",
|
||||
form_state.profile_name, form_state.table_name
|
||||
));
|
||||
}
|
||||
if form_state.total_count == 0 && form_state.current_position == 1 {
|
||||
form_state.reset_to_empty();
|
||||
return Ok(format!(
|
||||
"New entry mode for empty table {}.{}",
|
||||
form_state.profile_name, form_state.table_name
|
||||
));
|
||||
}
|
||||
|
||||
match grpc_client
|
||||
.get_table_data_by_position(
|
||||
form_state.profile_name.clone(),
|
||||
form_state.table_name.clone(),
|
||||
form_state.current_position as i32,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
// FIX: Pass the current position as the second argument
|
||||
form_state.update_from_response(&response.data, form_state.current_position);
|
||||
Ok(format!(
|
||||
"Loaded entry {}/{} for table {}.{}",
|
||||
form_state.current_position,
|
||||
form_state.total_count,
|
||||
form_state.profile_name,
|
||||
form_state.table_name
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Error loading entry {} for table {}.{}: {}",
|
||||
form_state.current_position,
|
||||
form_state.profile_name,
|
||||
form_state.table_name,
|
||||
e
|
||||
);
|
||||
Err(anyhow::anyhow!(
|
||||
"Error loading entry {}: {}",
|
||||
form_state.current_position,
|
||||
e
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_save_outcome(
|
||||
save_outcome: SaveOutcome,
|
||||
_grpc_client: &mut GrpcClient,
|
||||
_app_state: &mut AppState,
|
||||
form_state: &mut FormState,
|
||||
) -> Result<()> {
|
||||
match save_outcome {
|
||||
SaveOutcome::CreatedNew(new_id) => {
|
||||
// A new record was created, update the count!
|
||||
UiService::update_adresar_count(grpc_client, app_state).await?;
|
||||
// Navigate to the new record (now that count is updated)
|
||||
app_state.update_current_position(app_state.total_count);
|
||||
form_state.id = new_id; // Ensure ID is set (might be redundant if save already did it)
|
||||
form_state.id = new_id;
|
||||
}
|
||||
SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => {
|
||||
// No count update needed for these outcomes
|
||||
// No action needed
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
pub mod state;
|
||||
pub mod buffer;
|
||||
pub mod search;
|
||||
pub mod highlight;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// src/state/app/buffer.rs
|
||||
|
||||
/// Represents the distinct views or "buffers" the user can navigate.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppView {
|
||||
Intro,
|
||||
@@ -8,12 +7,14 @@ pub enum AppView {
|
||||
Register,
|
||||
Admin,
|
||||
AddTable,
|
||||
Form(String),
|
||||
AddLogic,
|
||||
Form,
|
||||
Scratch,
|
||||
}
|
||||
|
||||
impl AppView {
|
||||
/// Returns the display name for the view.
|
||||
/// For Form, pass the current table name to get dynamic naming.
|
||||
pub fn display_name(&self) -> &str {
|
||||
match self {
|
||||
AppView::Intro => "Intro",
|
||||
@@ -21,13 +22,25 @@ impl AppView {
|
||||
AppView::Register => "Register",
|
||||
AppView::Admin => "Admin_Panel",
|
||||
AppView::AddTable => "Add_Table",
|
||||
AppView::Form(name) => name.as_str(),
|
||||
AppView::AddLogic => "Add_Logic",
|
||||
AppView::Form => "Form",
|
||||
AppView::Scratch => "*scratch*",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the display name with dynamic context (for Form buffers)
|
||||
pub fn display_name_with_context(&self, current_table_name: Option<&str>) -> String {
|
||||
match self {
|
||||
AppView::Form => {
|
||||
current_table_name
|
||||
.unwrap_or("Data Form")
|
||||
.to_string()
|
||||
}
|
||||
_ => self.display_name().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the state related to buffer management (navigation history).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BufferState {
|
||||
pub history: Vec<AppView>,
|
||||
@@ -37,23 +50,17 @@ pub struct BufferState {
|
||||
impl Default for BufferState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
history: vec![AppView::Intro], // Start with Intro view
|
||||
history: vec![AppView::Intro],
|
||||
active_index: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BufferState {
|
||||
/// Updates the buffer history and active index.
|
||||
/// If the view already exists, it sets it as active.
|
||||
/// Otherwise, it adds the new view and makes it active.
|
||||
pub fn update_history(&mut self, view: AppView) {
|
||||
let existing_pos = self.history.iter().position(|v| v == &view);
|
||||
|
||||
match existing_pos {
|
||||
Some(pos) => {
|
||||
self.active_index = pos;
|
||||
}
|
||||
Some(pos) => self.active_index = pos,
|
||||
None => {
|
||||
self.history.push(view.clone());
|
||||
self.active_index = self.history.len() - 1;
|
||||
@@ -61,34 +68,52 @@ impl BufferState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the currently active view.
|
||||
pub fn get_active_view(&self) -> Option<&AppView> {
|
||||
self.history.get(self.active_index)
|
||||
}
|
||||
|
||||
/// Removes the currently active buffer from the history, unless it's the Intro buffer.
|
||||
/// Sets the new active buffer to the one preceding the closed one.
|
||||
/// # Returns
|
||||
/// * `true` if a non-Intro buffer was closed.
|
||||
/// * `false` if the active buffer was Intro or only Intro remained.
|
||||
pub fn close_active_buffer(&mut self) -> bool {
|
||||
let current_index = self.active_index;
|
||||
|
||||
// Rule 1: Cannot close Intro buffer.
|
||||
if matches!(self.history.get(current_index), Some(AppView::Intro)) {
|
||||
if self.history.is_empty() {
|
||||
self.history.push(AppView::Intro);
|
||||
self.active_index = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rule 2: Cannot close if only Intro would remain (or already remains).
|
||||
// This check implicitly covers the case where len <= 1.
|
||||
if self.history.len() <= 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let current_index = self.active_index;
|
||||
self.history.remove(current_index);
|
||||
self.active_index = current_index.saturating_sub(1).min(self.history.len() - 1);
|
||||
|
||||
if self.history.is_empty() {
|
||||
self.history.push(AppView::Intro);
|
||||
self.active_index = 0;
|
||||
} else if self.active_index >= self.history.len() {
|
||||
self.active_index = self.history.len() - 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_buffer_with_intro_fallback(&mut self, current_table_name: Option<&str>) -> String {
|
||||
let current_view_cloned = self.get_active_view().cloned();
|
||||
|
||||
if let Some(AppView::Intro) = current_view_cloned {
|
||||
if self.history.len() == 1 {
|
||||
self.close_active_buffer();
|
||||
return "Intro buffer reset".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
let closed_name = current_view_cloned
|
||||
.as_ref()
|
||||
.map(|v| v.display_name_with_context(current_table_name))
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
if self.close_active_buffer() {
|
||||
if self.history.len() == 1 && matches!(self.history.get(0), Some(AppView::Intro)) {
|
||||
format!("Closed '{}' - returned to Intro", closed_name)
|
||||
} else {
|
||||
format!("Closed '{}'", closed_name)
|
||||
}
|
||||
} else {
|
||||
format!("Buffer '{}' could not be closed", closed_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
56
client/src/state/app/search.rs
Normal file
56
client/src/state/app/search.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
// src/state/app/search.rs
|
||||
|
||||
use common::proto::multieko2::search::search_response::Hit;
|
||||
|
||||
/// Holds the complete state for the search palette.
|
||||
pub struct SearchState {
|
||||
/// The name of the table being searched.
|
||||
pub table_name: String,
|
||||
/// The current text entered by the user.
|
||||
pub input: String,
|
||||
/// The position of the cursor within the input text.
|
||||
pub cursor_position: usize,
|
||||
/// The search results returned from the server.
|
||||
pub results: Vec<Hit>,
|
||||
/// The index of the currently selected search result.
|
||||
pub selected_index: usize,
|
||||
/// A flag to indicate if a search is currently in progress.
|
||||
pub is_loading: bool,
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
/// Creates a new SearchState for a given table.
|
||||
pub fn new(table_name: String) -> Self {
|
||||
Self {
|
||||
table_name,
|
||||
input: String::new(),
|
||||
cursor_position: 0,
|
||||
results: Vec::new(),
|
||||
selected_index: 0,
|
||||
is_loading: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the selection to the next item, wrapping around if at the end.
|
||||
pub fn next_result(&mut self) {
|
||||
if !self.results.is_empty() {
|
||||
let next = self.selected_index + 1;
|
||||
self.selected_index = if next >= self.results.len() {
|
||||
0 // Wrap to the start
|
||||
} else {
|
||||
next
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the selection to the previous item, wrapping around if at the beginning.
|
||||
pub fn previous_result(&mut self) {
|
||||
if !self.results.is_empty() {
|
||||
self.selected_index = if self.selected_index == 0 {
|
||||
self.results.len() - 1 // Wrap to the end
|
||||
} else {
|
||||
self.selected_index - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
// src/state/state.rs
|
||||
// src/state/app/state.rs
|
||||
|
||||
use std::env;
|
||||
use anyhow::Result;
|
||||
use common::proto::multieko2::table_definition::ProfileTreeResponse;
|
||||
// NEW: Import the types we need for the cache
|
||||
use common::proto::multieko2::table_structure::TableStructureResponse;
|
||||
use crate::modes::handlers::mode_manager::AppMode;
|
||||
use crate::state::app::search::SearchState;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "ui-debug")]
|
||||
use std::time::Instant;
|
||||
|
||||
// --- DialogState and UiState are unchanged ---
|
||||
pub struct DialogState {
|
||||
pub dialog_show: bool,
|
||||
pub dialog_title: String,
|
||||
@@ -12,6 +21,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 {
|
||||
@@ -20,60 +30,79 @@ pub struct UiState {
|
||||
pub show_intro: bool,
|
||||
pub show_admin: bool,
|
||||
pub show_add_table: bool,
|
||||
pub show_add_logic: bool,
|
||||
pub show_form: bool,
|
||||
pub show_login: bool,
|
||||
pub show_register: bool,
|
||||
pub show_search_palette: bool,
|
||||
pub focus_outside_canvas: bool,
|
||||
pub dialog: DialogState,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui-debug")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DebugState {
|
||||
pub displayed_message: String,
|
||||
pub is_error: bool,
|
||||
pub display_start_time: Instant,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
// Core editor state
|
||||
pub current_dir: String,
|
||||
pub total_count: u64,
|
||||
pub current_position: u64,
|
||||
pub profile_tree: ProfileTreeResponse,
|
||||
pub selected_profile: Option<String>,
|
||||
pub current_mode: AppMode,
|
||||
pub current_view_profile_name: Option<String>,
|
||||
pub current_view_table_name: Option<String>,
|
||||
|
||||
// NEW: The "Rulebook" cache. We use Arc for efficient sharing.
|
||||
pub schema_cache: HashMap<String, Arc<TableStructureResponse>>,
|
||||
|
||||
pub focused_button_index: usize,
|
||||
pub pending_table_structure_fetch: Option<(String, String)>,
|
||||
|
||||
pub search_state: Option<SearchState>,
|
||||
|
||||
// UI preferences
|
||||
pub ui: UiState,
|
||||
|
||||
#[cfg(feature = "ui-debug")]
|
||||
pub debug_state: Option<DebugState>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let current_dir = env::current_dir()?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
pub fn new() -> Result<Self> {
|
||||
let current_dir = env::current_dir()?.to_string_lossy().to_string();
|
||||
Ok(AppState {
|
||||
current_dir,
|
||||
total_count: 0,
|
||||
current_position: 0,
|
||||
profile_tree: ProfileTreeResponse::default(),
|
||||
selected_profile: None,
|
||||
current_view_profile_name: None,
|
||||
current_view_table_name: None,
|
||||
current_mode: AppMode::General,
|
||||
schema_cache: HashMap::new(), // NEW: Initialize the cache
|
||||
focused_button_index: 0,
|
||||
pending_table_structure_fetch: None,
|
||||
search_state: None,
|
||||
ui: UiState::default(),
|
||||
|
||||
#[cfg(feature = "ui-debug")]
|
||||
debug_state: None,
|
||||
})
|
||||
}
|
||||
|
||||
// Existing methods remain unchanged
|
||||
pub fn update_total_count(&mut self, total_count: u64) {
|
||||
self.total_count = total_count;
|
||||
}
|
||||
|
||||
pub fn update_current_position(&mut self, current_position: u64) {
|
||||
self.current_position = current_position;
|
||||
}
|
||||
// --- ALL YOUR EXISTING METHODS ARE UNTOUCHED ---
|
||||
|
||||
pub fn update_mode(&mut self, mode: AppMode) {
|
||||
self.current_mode = mode;
|
||||
}
|
||||
|
||||
// Add dialog helper methods
|
||||
/// Shows a dialog with the given title, message, and buttons.
|
||||
/// The first button (index 0) is active by default.
|
||||
pub fn set_current_view_table(&mut self, profile_name: String, table_name: String) {
|
||||
self.current_view_profile_name = Some(profile_name);
|
||||
self.current_view_table_name = Some(table_name);
|
||||
}
|
||||
|
||||
pub fn show_dialog(
|
||||
&mut self,
|
||||
title: &str,
|
||||
@@ -86,11 +115,37 @@ 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;
|
||||
}
|
||||
|
||||
/// Hides the dialog and clears its content.
|
||||
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();
|
||||
self.ui.dialog.dialog_active_button_index = 0;
|
||||
self.ui.dialog.purpose = None;
|
||||
self.ui.dialog.is_loading = true;
|
||||
self.ui.dialog.dialog_show = true;
|
||||
self.ui.focus_outside_canvas = true;
|
||||
}
|
||||
|
||||
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;
|
||||
self.ui.dialog.purpose = Some(purpose);
|
||||
self.ui.dialog.is_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hide_dialog(&mut self) {
|
||||
self.ui.dialog.dialog_show = false;
|
||||
self.ui.dialog.dialog_title.clear();
|
||||
@@ -99,32 +154,30 @@ impl AppState {
|
||||
self.ui.dialog.dialog_active_button_index = 0;
|
||||
self.ui.dialog.purpose = None;
|
||||
self.ui.focus_outside_canvas = false;
|
||||
self.ui.dialog.is_loading = false;
|
||||
}
|
||||
|
||||
/// Sets the active button index, wrapping around if necessary.
|
||||
pub fn next_dialog_button(&mut self) {
|
||||
if !self.ui.dialog.dialog_buttons.is_empty() {
|
||||
let next_index = (self.ui.dialog.dialog_active_button_index + 1)
|
||||
% self.ui.dialog.dialog_buttons.len();
|
||||
self.ui.dialog.dialog_active_button_index = next_index; // Use new name
|
||||
self.ui.dialog.dialog_active_button_index = next_index;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the active button index, wrapping around if necessary.
|
||||
pub fn previous_dialog_button(&mut self) {
|
||||
if !self.ui.dialog.dialog_buttons.is_empty() {
|
||||
let len = self.ui.dialog.dialog_buttons.len();
|
||||
let prev_index =
|
||||
(self.ui.dialog.dialog_active_button_index + len - 1) % len;
|
||||
self.ui.dialog.dialog_active_button_index = prev_index; // Use new name
|
||||
self.ui.dialog.dialog_active_button_index = prev_index;
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the label of the currently active button, if any.
|
||||
pub fn get_active_dialog_button_label(&self) -> Option<&str> {
|
||||
self.ui.dialog
|
||||
.dialog_buttons // Use new name
|
||||
.get(self.ui.dialog.dialog_active_button_index) // Use new name
|
||||
.dialog_buttons
|
||||
.get(self.ui.dialog.dialog_active_button_index)
|
||||
.map(|s| s.as_str())
|
||||
}
|
||||
}
|
||||
@@ -136,17 +189,18 @@ impl Default for UiState {
|
||||
show_intro: true,
|
||||
show_admin: false,
|
||||
show_add_table: false,
|
||||
show_add_logic: false,
|
||||
show_form: false,
|
||||
show_login: false,
|
||||
show_register: false,
|
||||
show_buffer_list: true,
|
||||
show_search_palette: false, // ADDED
|
||||
focus_outside_canvas: false,
|
||||
dialog: DialogState::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the Default implementation for DialogState itself
|
||||
impl Default for DialogState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -156,6 +210,7 @@ impl Default for DialogState {
|
||||
dialog_buttons: Vec::new(),
|
||||
dialog_active_button_index: 0,
|
||||
purpose: None,
|
||||
is_loading: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ pub mod auth;
|
||||
pub mod admin;
|
||||
pub mod intro;
|
||||
pub mod add_table;
|
||||
pub mod add_logic;
|
||||
pub mod canvas_state;
|
||||
|
||||
346
client/src/state/pages/add_logic.rs
Normal file
346
client/src/state/pages/add_logic.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
// src/state/pages/add_logic.rs
|
||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::components::common::text_editor::{TextEditor, VimState};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use tui_textarea::TextArea;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AddLogicFocus {
|
||||
#[default]
|
||||
InputLogicName,
|
||||
InputTargetColumn,
|
||||
InputDescription,
|
||||
ScriptContentPreview,
|
||||
InsideScriptContent,
|
||||
SaveButton,
|
||||
CancelButton,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AddLogicState {
|
||||
pub profile_name: String,
|
||||
pub selected_table_id: Option<i64>,
|
||||
pub selected_table_name: Option<String>,
|
||||
pub logic_name_input: String,
|
||||
pub target_column_input: String,
|
||||
pub script_content_editor: Rc<RefCell<TextArea<'static>>>,
|
||||
pub description_input: String,
|
||||
pub current_focus: AddLogicFocus,
|
||||
pub last_canvas_field: usize,
|
||||
pub logic_name_cursor_pos: usize,
|
||||
pub target_column_cursor_pos: usize,
|
||||
pub description_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
pub editor_keybinding_mode: EditorKeybindingMode,
|
||||
pub vim_state: VimState,
|
||||
|
||||
// New fields for Target Column Autocomplete
|
||||
pub table_columns_for_suggestions: Vec<String>, // All columns for the table
|
||||
pub target_column_suggestions: Vec<String>, // Filtered suggestions
|
||||
pub show_target_column_suggestions: bool,
|
||||
pub selected_target_column_suggestion_index: Option<usize>,
|
||||
pub in_target_column_suggestion_mode: bool,
|
||||
|
||||
// Script Editor Autocomplete
|
||||
pub script_editor_autocomplete_active: bool,
|
||||
pub script_editor_suggestions: Vec<String>,
|
||||
pub script_editor_selected_suggestion_index: Option<usize>,
|
||||
pub script_editor_trigger_position: Option<(usize, usize)>, // (line, column)
|
||||
pub all_table_names: Vec<String>,
|
||||
pub script_editor_filter_text: String,
|
||||
|
||||
// New fields for same-profile table names and column autocomplete
|
||||
pub same_profile_table_names: Vec<String>, // Tables from same profile only
|
||||
pub script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
|
||||
}
|
||||
|
||||
impl AddLogicState {
|
||||
pub fn new(editor_config: &EditorConfig) -> Self {
|
||||
let editor = TextEditor::new_textarea(editor_config);
|
||||
AddLogicState {
|
||||
profile_name: "default".to_string(),
|
||||
selected_table_id: None,
|
||||
selected_table_name: None,
|
||||
logic_name_input: String::new(),
|
||||
target_column_input: String::new(),
|
||||
script_content_editor: Rc::new(RefCell::new(editor)),
|
||||
description_input: String::new(),
|
||||
current_focus: AddLogicFocus::InputLogicName,
|
||||
last_canvas_field: 2,
|
||||
logic_name_cursor_pos: 0,
|
||||
target_column_cursor_pos: 0,
|
||||
description_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
editor_keybinding_mode: editor_config.keybinding_mode.clone(),
|
||||
vim_state: VimState::default(),
|
||||
|
||||
table_columns_for_suggestions: Vec::new(),
|
||||
target_column_suggestions: Vec::new(),
|
||||
show_target_column_suggestions: false,
|
||||
selected_target_column_suggestion_index: None,
|
||||
in_target_column_suggestion_mode: false,
|
||||
|
||||
script_editor_autocomplete_active: false,
|
||||
script_editor_suggestions: Vec::new(),
|
||||
script_editor_selected_suggestion_index: None,
|
||||
script_editor_trigger_position: None,
|
||||
all_table_names: Vec::new(),
|
||||
script_editor_filter_text: String::new(),
|
||||
|
||||
same_profile_table_names: Vec::new(),
|
||||
script_editor_awaiting_column_autocomplete: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const INPUT_FIELD_COUNT: usize = 3;
|
||||
|
||||
/// Updates the target_column_suggestions based on current input.
|
||||
pub fn update_target_column_suggestions(&mut self) {
|
||||
let current_input = self.target_column_input.to_lowercase();
|
||||
if self.table_columns_for_suggestions.is_empty() {
|
||||
self.target_column_suggestions.clear();
|
||||
self.show_target_column_suggestions = false;
|
||||
self.selected_target_column_suggestion_index = None;
|
||||
return;
|
||||
}
|
||||
|
||||
if current_input.is_empty() {
|
||||
self.target_column_suggestions = self.table_columns_for_suggestions.clone();
|
||||
} else {
|
||||
self.target_column_suggestions = self
|
||||
.table_columns_for_suggestions
|
||||
.iter()
|
||||
.filter(|name| name.to_lowercase().contains(¤t_input))
|
||||
.cloned()
|
||||
.collect();
|
||||
}
|
||||
|
||||
self.show_target_column_suggestions = !self.target_column_suggestions.is_empty();
|
||||
if self.show_target_column_suggestions {
|
||||
if let Some(selected_idx) = self.selected_target_column_suggestion_index {
|
||||
if selected_idx >= self.target_column_suggestions.len() {
|
||||
self.selected_target_column_suggestion_index = Some(0);
|
||||
}
|
||||
} else {
|
||||
self.selected_target_column_suggestion_index = Some(0);
|
||||
}
|
||||
} else {
|
||||
self.selected_target_column_suggestion_index = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates script editor suggestions based on current filter text
|
||||
pub fn update_script_editor_suggestions(&mut self) {
|
||||
let mut suggestions = vec!["sql".to_string()];
|
||||
|
||||
if self.selected_table_name.is_some() {
|
||||
suggestions.extend(self.table_columns_for_suggestions.clone());
|
||||
}
|
||||
|
||||
let current_selected_table_name = self.selected_table_name.as_deref();
|
||||
suggestions.extend(
|
||||
self.same_profile_table_names
|
||||
.iter()
|
||||
.filter(|tn| Some(tn.as_str()) != current_selected_table_name)
|
||||
.cloned()
|
||||
);
|
||||
|
||||
if self.script_editor_filter_text.is_empty() {
|
||||
self.script_editor_suggestions = suggestions;
|
||||
} else {
|
||||
let filter_lower = self.script_editor_filter_text.to_lowercase();
|
||||
self.script_editor_suggestions = suggestions
|
||||
.into_iter()
|
||||
.filter(|suggestion| suggestion.to_lowercase().contains(&filter_lower))
|
||||
.collect();
|
||||
}
|
||||
|
||||
// Update selection index
|
||||
if self.script_editor_suggestions.is_empty() {
|
||||
self.script_editor_selected_suggestion_index = None;
|
||||
self.script_editor_autocomplete_active = false;
|
||||
} else if let Some(selected_idx) = self.script_editor_selected_suggestion_index {
|
||||
if selected_idx >= self.script_editor_suggestions.len() {
|
||||
self.script_editor_selected_suggestion_index = Some(0);
|
||||
}
|
||||
} else {
|
||||
self.script_editor_selected_suggestion_index = Some(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a suggestion is a table name (for triggering column autocomplete)
|
||||
pub fn is_table_name_suggestion(&self, suggestion: &str) -> bool {
|
||||
// Not "sql"
|
||||
if suggestion == "sql" {
|
||||
return false;
|
||||
}
|
||||
if self.table_columns_for_suggestions.contains(&suggestion.to_string()) {
|
||||
return false;
|
||||
}
|
||||
self.same_profile_table_names.contains(&suggestion.to_string())
|
||||
}
|
||||
|
||||
/// Sets table columns for autocomplete suggestions
|
||||
pub fn set_table_columns(&mut self, columns: Vec<String>) {
|
||||
self.table_columns_for_suggestions = columns.clone();
|
||||
if !columns.is_empty() {
|
||||
self.update_target_column_suggestions();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets all available table names for autocomplete suggestions
|
||||
pub fn set_all_table_names(&mut self, table_names: Vec<String>) {
|
||||
self.all_table_names = table_names;
|
||||
}
|
||||
|
||||
/// Sets table names from the same profile for autocomplete suggestions
|
||||
pub fn set_same_profile_table_names(&mut self, table_names: Vec<String>) {
|
||||
self.same_profile_table_names = table_names;
|
||||
}
|
||||
|
||||
/// Triggers waiting for column autocomplete for a specific table
|
||||
pub fn trigger_column_autocomplete_for_table(&mut self, table_name: String) {
|
||||
self.script_editor_awaiting_column_autocomplete = Some(table_name);
|
||||
}
|
||||
|
||||
/// Updates autocomplete with columns for a specific table
|
||||
pub fn set_columns_for_table_autocomplete(&mut self, columns: Vec<String>) {
|
||||
self.script_editor_suggestions = columns;
|
||||
self.script_editor_selected_suggestion_index = if self.script_editor_suggestions.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(0)
|
||||
};
|
||||
self.script_editor_autocomplete_active = !self.script_editor_suggestions.is_empty();
|
||||
self.script_editor_awaiting_column_autocomplete = None;
|
||||
}
|
||||
|
||||
/// Deactivates script editor autocomplete and clears related state
|
||||
pub fn deactivate_script_editor_autocomplete(&mut self) {
|
||||
self.script_editor_autocomplete_active = false;
|
||||
self.script_editor_suggestions.clear();
|
||||
self.script_editor_selected_suggestion_index = None;
|
||||
self.script_editor_trigger_position = None;
|
||||
self.script_editor_filter_text.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AddLogicState {
|
||||
fn default() -> Self {
|
||||
Self::new(&EditorConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for AddLogicState {
|
||||
fn current_field(&self) -> usize {
|
||||
match self.current_focus {
|
||||
AddLogicFocus::InputLogicName => 0,
|
||||
AddLogicFocus::InputTargetColumn => 1,
|
||||
AddLogicFocus::InputDescription => 2,
|
||||
_ => self.last_canvas_field,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_cursor_pos(&self) -> usize {
|
||||
match self.current_focus {
|
||||
AddLogicFocus::InputLogicName => self.logic_name_cursor_pos,
|
||||
AddLogicFocus::InputTargetColumn => self.target_column_cursor_pos,
|
||||
AddLogicFocus::InputDescription => self.description_cursor_pos,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![
|
||||
&self.logic_name_input,
|
||||
&self.target_column_input,
|
||||
&self.description_input,
|
||||
]
|
||||
}
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
match self.current_focus {
|
||||
AddLogicFocus::InputLogicName => &self.logic_name_input,
|
||||
AddLogicFocus::InputTargetColumn => &self.target_column_input,
|
||||
AddLogicFocus::InputDescription => &self.description_input,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_focus {
|
||||
AddLogicFocus::InputLogicName => &mut self.logic_name_input,
|
||||
AddLogicFocus::InputTargetColumn => &mut self.target_column_input,
|
||||
AddLogicFocus::InputDescription => &mut self.description_input,
|
||||
_ => &mut self.logic_name_input,
|
||||
}
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
vec!["Logic Name", "Target Column", "Description"]
|
||||
}
|
||||
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
let new_focus = match index {
|
||||
0 => AddLogicFocus::InputLogicName,
|
||||
1 => AddLogicFocus::InputTargetColumn,
|
||||
2 => AddLogicFocus::InputDescription,
|
||||
_ => return,
|
||||
};
|
||||
if self.current_focus != new_focus {
|
||||
if self.current_focus == AddLogicFocus::InputTargetColumn {
|
||||
self.in_target_column_suggestion_mode = false;
|
||||
self.show_target_column_suggestions = false;
|
||||
}
|
||||
self.current_focus = new_focus;
|
||||
self.last_canvas_field = index;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
match self.current_focus {
|
||||
AddLogicFocus::InputLogicName => {
|
||||
self.logic_name_cursor_pos = pos.min(self.logic_name_input.len());
|
||||
}
|
||||
AddLogicFocus::InputTargetColumn => {
|
||||
self.target_column_cursor_pos = pos.min(self.target_column_input.len());
|
||||
}
|
||||
AddLogicFocus::InputDescription => {
|
||||
self.description_cursor_pos = pos.min(self.description_input.len());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.has_unsaved_changes = changed;
|
||||
}
|
||||
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
if self.current_field() == 1
|
||||
&& self.in_target_column_suggestion_mode
|
||||
&& self.show_target_column_suggestions
|
||||
{
|
||||
Some(&self.target_column_suggestions)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||
if self.current_field() == 1
|
||||
&& self.in_target_column_suggestion_mode
|
||||
&& self.show_target_column_suggestions
|
||||
{
|
||||
self.selected_target_column_suggestion_index
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
// 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)]
|
||||
@@ -25,8 +34,13 @@ pub enum AddTableFocus {
|
||||
ColumnsTable,
|
||||
IndexesTable,
|
||||
LinksTable,
|
||||
// Inside Tables (Scrolling Focus)
|
||||
InsideColumnsTable,
|
||||
InsideIndexesTable,
|
||||
InsideLinksTable,
|
||||
// Buttons
|
||||
SaveButton,
|
||||
DeleteSelectedButton,
|
||||
CancelButton,
|
||||
}
|
||||
|
||||
@@ -34,12 +48,14 @@ pub enum AddTableFocus {
|
||||
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<String>,
|
||||
pub indexes: Vec<IndexDefinition>,
|
||||
pub links: Vec<LinkDefinition>,
|
||||
pub current_focus: AddTableFocus,
|
||||
pub last_canvas_field: usize,
|
||||
pub column_table_state: TableState,
|
||||
pub index_table_state: TableState,
|
||||
pub link_table_state: TableState,
|
||||
@@ -53,23 +69,19 @@ impl Default for AddTableState {
|
||||
fn default() -> Self {
|
||||
// Initialize with some dummy data for demonstration
|
||||
AddTableState {
|
||||
profile_name: "default".to_string(), // Should be set dynamically
|
||||
table_name: String::new(), // Start empty
|
||||
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![
|
||||
ColumnDefinition { name: "id".to_string(), data_type: "INTEGER".to_string() },
|
||||
ColumnDefinition { name: "name".to_string(), data_type: "TEXT".to_string() },
|
||||
],
|
||||
indexes: vec!["id".to_string()],
|
||||
links: vec![
|
||||
LinkDefinition { linked_table_name: "related_table".to_string(), is_required: true },
|
||||
LinkDefinition { linked_table_name: "another_table".to_string(), is_required: false },
|
||||
],
|
||||
columns: Vec::new(),
|
||||
indexes: Vec::new(),
|
||||
links: Vec::new(),
|
||||
current_focus: AddTableFocus::InputTableName,
|
||||
column_table_state: TableState::default().with_selected(0),
|
||||
index_table_state: TableState::default().with_selected(0),
|
||||
link_table_state: TableState::default().with_selected(0),
|
||||
last_canvas_field: 2,
|
||||
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,
|
||||
@@ -79,7 +91,7 @@ impl Default for AddTableState {
|
||||
}
|
||||
|
||||
impl AddTableState {
|
||||
const INPUT_FIELD_COUNT: usize = 3;
|
||||
pub const INPUT_FIELD_COUNT: usize = 3;
|
||||
}
|
||||
|
||||
// Implement CanvasState for the input fields
|
||||
@@ -90,7 +102,7 @@ impl CanvasState for AddTableState {
|
||||
AddTableFocus::InputColumnName => 1,
|
||||
AddTableFocus::InputColumnType => 2,
|
||||
// If focus is elsewhere, default to the first field for canvas rendering logic
|
||||
_ => 0,
|
||||
_ => self.last_canvas_field,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,12 +120,12 @@ impl CanvasState for AddTableState {
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![&self.table_name, &self.column_name_input, &self.column_type_input]
|
||||
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,
|
||||
AddTableFocus::InputTableName => &self.table_name_input,
|
||||
AddTableFocus::InputColumnName => &self.column_name_input,
|
||||
AddTableFocus::InputColumnType => &self.column_type_input,
|
||||
_ => "", // Should not happen if called correctly
|
||||
@@ -122,14 +134,10 @@ impl CanvasState for AddTableState {
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_focus {
|
||||
AddTableFocus::InputTableName => &mut self.table_name,
|
||||
AddTableFocus::InputTableName => &mut self.table_name_input,
|
||||
AddTableFocus::InputColumnName => &mut self.column_name_input,
|
||||
AddTableFocus::InputColumnType => &mut self.column_type_input,
|
||||
// This case needs careful handling. If focus isn't on an input,
|
||||
// which mutable string should we return? Returning the first one
|
||||
// might be unexpected. Consider panicking or returning Option if this state is invalid.
|
||||
// For now, returning the first field to avoid panics during rendering.
|
||||
_ => &mut self.table_name,
|
||||
_ => &mut self.table_name_input,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,10 +147,20 @@ impl CanvasState for AddTableState {
|
||||
}
|
||||
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
// Update both current focus and last canvas field
|
||||
self.current_focus = match index {
|
||||
0 => AddTableFocus::InputTableName,
|
||||
1 => AddTableFocus::InputColumnName,
|
||||
2 => AddTableFocus::InputColumnType,
|
||||
0 => {
|
||||
self.last_canvas_field = 0;
|
||||
AddTableFocus::InputTableName
|
||||
},
|
||||
1 => {
|
||||
self.last_canvas_field = 1;
|
||||
AddTableFocus::InputColumnName
|
||||
},
|
||||
2 => {
|
||||
self.last_canvas_field = 2;
|
||||
AddTableFocus::InputColumnType
|
||||
},
|
||||
_ => self.current_focus, // Stay on current focus if index is out of bounds
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
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,
|
||||
ProfilesPane,
|
||||
InsideProfilesList,
|
||||
Tables,
|
||||
InsideTablesList,
|
||||
Button1,
|
||||
Button2,
|
||||
Button3,
|
||||
@@ -23,6 +26,7 @@ pub struct AdminState {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// src/state/canvas_state.rs
|
||||
// src/state/pages/canvas_state.rs
|
||||
|
||||
use common::proto::multieko2::search::search_response::Hit;
|
||||
|
||||
pub trait CanvasState {
|
||||
// --- Existing methods (unchanged) ---
|
||||
fn current_field(&self) -> usize;
|
||||
fn current_cursor_pos(&self) -> usize;
|
||||
fn has_unsaved_changes(&self) -> bool;
|
||||
@@ -9,12 +11,22 @@ pub trait CanvasState {
|
||||
fn get_current_input(&self) -> &str;
|
||||
fn get_current_input_mut(&mut self) -> &mut String;
|
||||
fn fields(&self) -> Vec<&str>;
|
||||
|
||||
fn set_current_field(&mut self, index: usize);
|
||||
fn set_current_cursor_pos(&mut self, pos: usize);
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool);
|
||||
|
||||
// --- Autocomplete Support ---
|
||||
fn get_suggestions(&self) -> Option<&[String]>;
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize>;
|
||||
fn get_rich_suggestions(&self) -> Option<&[Hit]> {
|
||||
None
|
||||
}
|
||||
|
||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||
self.inputs()
|
||||
.get(index)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
}
|
||||
fn has_display_override(&self, _index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,109 @@
|
||||
// src/state/pages/form.rs
|
||||
|
||||
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;
|
||||
use common::proto::multieko2::search::search_response::Hit;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::Frame;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn json_value_to_string(value: &serde_json::Value) -> String {
|
||||
match value {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FieldDefinition {
|
||||
pub display_name: String,
|
||||
pub data_key: String,
|
||||
pub is_link: bool,
|
||||
pub link_target_table: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FormState {
|
||||
pub id: i64,
|
||||
pub fields: Vec<String>,
|
||||
pub profile_name: String,
|
||||
pub table_name: String,
|
||||
pub total_count: u64,
|
||||
pub current_position: u64,
|
||||
pub fields: Vec<FieldDefinition>,
|
||||
pub values: Vec<String>,
|
||||
pub current_field: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
pub current_cursor_pos: usize,
|
||||
pub autocomplete_active: bool,
|
||||
pub autocomplete_suggestions: Vec<Hit>,
|
||||
pub selected_suggestion_index: Option<usize>,
|
||||
pub autocomplete_loading: bool,
|
||||
pub link_display_map: HashMap<usize, String>,
|
||||
}
|
||||
|
||||
impl FormState {
|
||||
/// Create a new FormState with dynamic fields.
|
||||
pub fn new(fields: Vec<String>) -> Self {
|
||||
let values = vec![String::new(); fields.len()]; // Initialize values for each field
|
||||
pub fn new(
|
||||
profile_name: String,
|
||||
table_name: String,
|
||||
fields: Vec<FieldDefinition>,
|
||||
) -> Self {
|
||||
let values = vec![String::new(); fields.len()];
|
||||
FormState {
|
||||
id: 0,
|
||||
profile_name,
|
||||
table_name,
|
||||
total_count: 0,
|
||||
current_position: 1,
|
||||
fields,
|
||||
values,
|
||||
current_field: 0,
|
||||
has_unsaved_changes: false,
|
||||
current_cursor_pos: 0,
|
||||
autocomplete_active: false,
|
||||
autocomplete_suggestions: Vec::new(),
|
||||
selected_suggestion_index: None,
|
||||
autocomplete_loading: false,
|
||||
link_display_map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_display_name_for_hit(&self, hit: &Hit) -> String {
|
||||
if let Ok(content_map) =
|
||||
serde_json::from_str::<HashMap<String, serde_json::Value>>(
|
||||
&hit.content_json,
|
||||
)
|
||||
{
|
||||
const IGNORED_KEYS: &[&str] = &["id", "deleted", "created_at"];
|
||||
let mut keys: Vec<_> = content_map
|
||||
.keys()
|
||||
.filter(|k| !IGNORED_KEYS.contains(&k.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
keys.sort();
|
||||
|
||||
let values: Vec<_> = keys
|
||||
.iter()
|
||||
.map(|key| {
|
||||
content_map
|
||||
.get(key)
|
||||
.map(json_value_to_string)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
.take(1)
|
||||
.collect();
|
||||
|
||||
let display_part = values.first().cloned().unwrap_or_default();
|
||||
if display_part.is_empty() {
|
||||
format!("ID: {}", hit.id)
|
||||
} else {
|
||||
format!("{} | ID: {}", display_part, hit.id)
|
||||
}
|
||||
} else {
|
||||
format!("ID: {} (parse error)", hit.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,31 +114,40 @@ impl FormState {
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
total_count: u64,
|
||||
current_position: u64,
|
||||
) {
|
||||
let fields: Vec<&str> = self.fields.iter().map(|s| s.as_str()).collect();
|
||||
let values: Vec<&String> = self.values.iter().collect();
|
||||
let fields_str_slice: Vec<&str> =
|
||||
self.fields().iter().map(|s| *s).collect();
|
||||
let values_str_slice: Vec<&String> = self.values.iter().collect();
|
||||
|
||||
crate::components::form::form::render_form(
|
||||
f,
|
||||
area,
|
||||
self,
|
||||
&fields,
|
||||
&fields_str_slice,
|
||||
&self.current_field,
|
||||
&values,
|
||||
&values_str_slice,
|
||||
&self.table_name,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
total_count,
|
||||
current_position,
|
||||
self.total_count,
|
||||
self.current_position,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn reset_to_empty(&mut self) {
|
||||
self.id = 0; // Reset ID to 0 for new entries
|
||||
self.values.iter_mut().for_each(|v| v.clear()); // Clear all values
|
||||
self.id = 0;
|
||||
self.values.iter_mut().for_each(|v| v.clear());
|
||||
self.current_field = 0;
|
||||
self.current_cursor_pos = 0;
|
||||
self.has_unsaved_changes = false;
|
||||
if self.total_count > 0 {
|
||||
self.current_position = self.total_count + 1;
|
||||
} else {
|
||||
self.current_position = 1;
|
||||
}
|
||||
self.deactivate_autocomplete();
|
||||
self.link_display_map.clear();
|
||||
}
|
||||
|
||||
pub fn get_current_input(&self) -> &str {
|
||||
@@ -70,20 +158,62 @@ impl FormState {
|
||||
}
|
||||
|
||||
pub fn get_current_input_mut(&mut self) -> &mut String {
|
||||
self.link_display_map.remove(&self.current_field);
|
||||
self.values
|
||||
.get_mut(self.current_field)
|
||||
.expect("Invalid current_field index")
|
||||
}
|
||||
|
||||
pub fn update_from_response(&mut self, response: common::proto::multieko2::adresar::AdresarResponse) {
|
||||
self.id = response.id;
|
||||
self.values = vec![
|
||||
response.firma, response.kz, response.drc,
|
||||
response.ulica, response.psc, response.mesto,
|
||||
response.stat, response.banka, response.ucet,
|
||||
response.skladm, response.ico, response.kontakt,
|
||||
response.telefon, response.skladu, response.fax,
|
||||
];
|
||||
pub fn update_from_response(
|
||||
&mut self,
|
||||
response_data: &HashMap<String, String>,
|
||||
new_position: u64,
|
||||
) {
|
||||
self.values = self
|
||||
.fields
|
||||
.iter()
|
||||
.map(|field_def| {
|
||||
response_data
|
||||
.get(&field_def.data_key)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let id_str_opt = response_data
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("id"))
|
||||
.map(|(_, v)| v);
|
||||
|
||||
if let Some(id_str) = id_str_opt {
|
||||
if let Ok(parsed_id) = id_str.parse::<i64>() {
|
||||
self.id = parsed_id;
|
||||
} else {
|
||||
tracing::error!(
|
||||
"Failed to parse 'id' field '{}' for table {}.{}",
|
||||
id_str,
|
||||
self.profile_name,
|
||||
self.table_name
|
||||
);
|
||||
self.id = 0;
|
||||
}
|
||||
} else {
|
||||
self.id = 0;
|
||||
}
|
||||
|
||||
self.current_position = new_position;
|
||||
self.has_unsaved_changes = false;
|
||||
self.current_field = 0;
|
||||
self.current_cursor_pos = 0;
|
||||
self.deactivate_autocomplete();
|
||||
self.link_display_map.clear();
|
||||
}
|
||||
|
||||
pub fn deactivate_autocomplete(&mut self) {
|
||||
self.autocomplete_active = false;
|
||||
self.autocomplete_suggestions.clear();
|
||||
self.selected_suggestion_index = None;
|
||||
self.autocomplete_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,58 +221,69 @@ impl CanvasState for FormState {
|
||||
fn current_field(&self) -> usize {
|
||||
self.current_field
|
||||
}
|
||||
|
||||
fn current_cursor_pos(&self) -> usize {
|
||||
self.current_cursor_pos
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
self.values.iter().collect()
|
||||
}
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
self.values
|
||||
.get(self.current_field)
|
||||
FormState::get_current_input(self)
|
||||
}
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
FormState::get_current_input_mut(self)
|
||||
}
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
self.fields
|
||||
.iter()
|
||||
.map(|f| f.display_name.as_str())
|
||||
.collect()
|
||||
}
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
if index < self.fields.len() {
|
||||
self.current_field = index;
|
||||
}
|
||||
self.deactivate_autocomplete();
|
||||
}
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
self.current_cursor_pos = pos;
|
||||
}
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.has_unsaved_changes = changed;
|
||||
}
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
None
|
||||
}
|
||||
fn get_rich_suggestions(&self) -> Option<&[Hit]> {
|
||||
if self.autocomplete_active {
|
||||
Some(&self.autocomplete_suggestions)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||
if self.autocomplete_active {
|
||||
self.selected_suggestion_index
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||
if let Some(display_text) = self.link_display_map.get(&index) {
|
||||
return display_text.as_str();
|
||||
}
|
||||
self.inputs()
|
||||
.get(index)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
self.values
|
||||
.get_mut(self.current_field)
|
||||
.expect("Invalid current_field index")
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
self.fields.iter().map(|s| s.as_str()).collect()
|
||||
}
|
||||
|
||||
// --- Implement the setter methods ---
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
if index < self.fields.len() { // Basic bounds check
|
||||
self.current_field = index;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
// Optional: Add validation based on current input length if needed
|
||||
self.current_cursor_pos = pos;
|
||||
}
|
||||
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.has_unsaved_changes = changed;
|
||||
}
|
||||
|
||||
// --- Autocomplete Support (Not Used for FormState) ---
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
None // FormState doesn't provide suggestions
|
||||
}
|
||||
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||
None // FormState doesn't have selected suggestions
|
||||
// --- IMPLEMENT THE NEW TRAIT METHOD ---
|
||||
fn has_display_override(&self, index: usize) -> bool {
|
||||
self.link_display_map.contains_key(&index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
|
||||
pub mod form;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod register;
|
||||
pub mod add_table;
|
||||
|
||||
198
client/src/tui/functions/common/add_table.rs
Normal file
198
client/src/tui/functions/common/add_table.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
// src/tui/functions/common/add_table.rs
|
||||
use crate::state::pages::add_table::{
|
||||
AddTableFocus, AddTableState, ColumnDefinition, IndexDefinition,
|
||||
};
|
||||
use crate::services::GrpcClient;
|
||||
use anyhow::{anyhow, Result};
|
||||
use common::proto::multieko2::table_definition::{
|
||||
PostTableDefinitionRequest,
|
||||
ColumnDefinition as ProtoColumnDefinition,
|
||||
TableLink as ProtoTableLink,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
/// Handles the logic for adding a column when the "Add" button is activated.
|
||||
///
|
||||
/// 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)),
|
||||
}
|
||||
}
|
||||
@@ -1,114 +1,156 @@
|
||||
// src/tui/functions/common/form.rs
|
||||
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::app::state::AppState; // NEW: Import AppState
|
||||
use crate::state::pages::form::FormState;
|
||||
use common::proto::multieko2::adresar::{PostAdresarRequest, PutAdresarRequest};
|
||||
use crate::utils::data_converter; // NEW: Import our translator
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SaveOutcome {
|
||||
NoChange, // Nothing needed saving
|
||||
UpdatedExisting, // An existing record was updated
|
||||
CreatedNew(i64), // A new record was created (include its new ID)
|
||||
NoChange,
|
||||
UpdatedExisting,
|
||||
CreatedNew(i64),
|
||||
}
|
||||
|
||||
/// Shared logic for saving the current form state
|
||||
// MODIFIED save function signature and logic
|
||||
pub async fn save(
|
||||
app_state: &AppState, // NEW: Pass in AppState
|
||||
form_state: &mut FormState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<SaveOutcome, Box<dyn std::error::Error>> { // <-- Return SaveOutcome
|
||||
) -> Result<SaveOutcome> {
|
||||
if !form_state.has_unsaved_changes {
|
||||
return Ok(SaveOutcome::NoChange); // Early exit if no changes
|
||||
return Ok(SaveOutcome::NoChange);
|
||||
}
|
||||
let is_new = *current_position == total_count + 1;
|
||||
|
||||
let outcome = if is_new {
|
||||
let post_request = PostAdresarRequest {
|
||||
firma: form_state.values[0].clone(),
|
||||
kz: form_state.values[1].clone(),
|
||||
drc: form_state.values[2].clone(),
|
||||
ulica: form_state.values[3].clone(),
|
||||
psc: form_state.values[4].clone(),
|
||||
mesto: form_state.values[5].clone(),
|
||||
stat: form_state.values[6].clone(),
|
||||
banka: form_state.values[7].clone(),
|
||||
ucet: form_state.values[8].clone(),
|
||||
skladm: form_state.values[9].clone(),
|
||||
ico: form_state.values[10].clone(),
|
||||
kontakt: form_state.values[11].clone(),
|
||||
telefon: form_state.values[12].clone(),
|
||||
skladu: form_state.values[13].clone(),
|
||||
fax: form_state.values[14].clone(),
|
||||
};
|
||||
let response = grpc_client.post_adresar(post_request).await?;
|
||||
let new_id = response.into_inner().id;
|
||||
form_state.id = new_id;
|
||||
SaveOutcome::CreatedNew(new_id) // <-- Return CreatedNew with ID
|
||||
} else {
|
||||
let put_request = PutAdresarRequest {
|
||||
id: form_state.id,
|
||||
firma: form_state.values[0].clone(),
|
||||
kz: form_state.values[1].clone(),
|
||||
drc: form_state.values[2].clone(),
|
||||
ulica: form_state.values[3].clone(),
|
||||
psc: form_state.values[4].clone(),
|
||||
mesto: form_state.values[5].clone(),
|
||||
stat: form_state.values[6].clone(),
|
||||
banka: form_state.values[7].clone(),
|
||||
ucet: form_state.values[8].clone(),
|
||||
skladm: form_state.values[9].clone(),
|
||||
ico: form_state.values[10].clone(),
|
||||
kontakt: form_state.values[11].clone(),
|
||||
telefon: form_state.values[12].clone(),
|
||||
skladu: form_state.values[13].clone(),
|
||||
fax: form_state.values[14].clone(),
|
||||
};
|
||||
let _ = grpc_client.put_adresar(put_request).await?;
|
||||
SaveOutcome::UpdatedExisting
|
||||
// --- NEW: VALIDATION & CONVERSION STEP ---
|
||||
let cache_key =
|
||||
format!("{}.{}", form_state.profile_name, form_state.table_name);
|
||||
let schema = match app_state.schema_cache.get(&cache_key) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(anyhow!(
|
||||
"Schema for table '{}' not found in cache. Cannot save.",
|
||||
form_state.table_name
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let data_map: HashMap<String, String> = form_state
|
||||
.fields
|
||||
.iter()
|
||||
.zip(form_state.values.iter())
|
||||
.map(|(field_def, value)| (field_def.data_key.clone(), value.clone()))
|
||||
.collect();
|
||||
|
||||
// Use our new translator. It returns a user-friendly error on failure.
|
||||
let converted_data =
|
||||
match data_converter::convert_and_validate_data(&data_map, schema) {
|
||||
Ok(data) => data,
|
||||
Err(user_error) => return Err(anyhow!(user_error)),
|
||||
};
|
||||
// --- END OF NEW STEP ---
|
||||
|
||||
let outcome: SaveOutcome;
|
||||
let is_new_entry = form_state.id == 0
|
||||
|| (form_state.total_count > 0
|
||||
&& form_state.current_position > form_state.total_count)
|
||||
|| (form_state.total_count == 0 && form_state.current_position == 1);
|
||||
|
||||
if is_new_entry {
|
||||
let response = grpc_client
|
||||
.post_table_data(
|
||||
form_state.profile_name.clone(),
|
||||
form_state.table_name.clone(),
|
||||
converted_data, // Use the validated & converted data
|
||||
)
|
||||
.await
|
||||
.context("Failed to post new table data")?;
|
||||
|
||||
if response.success {
|
||||
form_state.id = response.inserted_id;
|
||||
form_state.total_count += 1;
|
||||
form_state.current_position = form_state.total_count;
|
||||
outcome = SaveOutcome::CreatedNew(response.inserted_id);
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"Server failed to insert data: {}",
|
||||
response.message
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if form_state.id == 0 {
|
||||
return Err(anyhow!(
|
||||
"Cannot update record: ID is 0, but not classified as new entry."
|
||||
));
|
||||
}
|
||||
let response = grpc_client
|
||||
.put_table_data(
|
||||
form_state.profile_name.clone(),
|
||||
form_state.table_name.clone(),
|
||||
form_state.id,
|
||||
converted_data, // Use the validated & converted data
|
||||
)
|
||||
.await
|
||||
.context("Failed to put (update) table data")?;
|
||||
|
||||
if response.success {
|
||||
outcome = SaveOutcome::UpdatedExisting;
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"Server failed to update data: {}",
|
||||
response.message
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
form_state.has_unsaved_changes = false;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Discard changes since last save
|
||||
pub async fn revert(
|
||||
form_state: &mut FormState,
|
||||
form_state: &mut FormState, // Takes &mut FormState to update it
|
||||
grpc_client: &mut GrpcClient,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let is_new = *current_position == total_count + 1;
|
||||
|
||||
if is_new {
|
||||
// Clear all fields for new entries
|
||||
form_state.values.iter_mut().for_each(|v| *v = String::new());
|
||||
form_state.has_unsaved_changes = false;
|
||||
) -> Result<String> {
|
||||
if form_state.id == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) || (form_state.total_count == 0 && form_state.current_position == 1) {
|
||||
let old_total_count = form_state.total_count; // Preserve for correct new position
|
||||
form_state.reset_to_empty(); // reset_to_empty will clear values and set id=0
|
||||
form_state.total_count = old_total_count; // Restore total_count
|
||||
if form_state.total_count > 0 { // Correctly set current_position for new
|
||||
form_state.current_position = form_state.total_count + 1;
|
||||
} else {
|
||||
form_state.current_position = 1;
|
||||
}
|
||||
return Ok("New entry cleared".to_string());
|
||||
}
|
||||
|
||||
let data = grpc_client.get_adresar_by_position(*current_position).await?;
|
||||
if form_state.current_position == 0 || form_state.current_position > form_state.total_count {
|
||||
if form_state.total_count > 0 {
|
||||
form_state.current_position = 1;
|
||||
} else {
|
||||
// No records to revert to, effectively a new entry state.
|
||||
form_state.reset_to_empty();
|
||||
return Ok("No saved data to revert to; form cleared.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Update form fields with saved values
|
||||
form_state.values = vec![
|
||||
data.firma,
|
||||
data.kz,
|
||||
data.drc,
|
||||
data.ulica,
|
||||
data.psc,
|
||||
data.mesto,
|
||||
data.stat,
|
||||
data.banka,
|
||||
data.ucet,
|
||||
data.skladm,
|
||||
data.ico,
|
||||
data.kontakt,
|
||||
data.telefon,
|
||||
data.skladu,
|
||||
data.fax,
|
||||
];
|
||||
let response = grpc_client
|
||||
.get_table_data_by_position(
|
||||
form_state.profile_name.clone(),
|
||||
form_state.table_name.clone(),
|
||||
form_state.current_position as i32,
|
||||
)
|
||||
.await
|
||||
.context(format!(
|
||||
"Failed to get table data by position {} for table {}.{}",
|
||||
form_state.current_position,
|
||||
form_state.profile_name,
|
||||
form_state.table_name
|
||||
))?;
|
||||
|
||||
form_state.has_unsaved_changes = false;
|
||||
// FIX: Pass the current position as the second argument
|
||||
form_state.update_from_response(&response.data, form_state.current_position);
|
||||
Ok("Changes discarded, reloaded last saved version".to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,21 @@ 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::config::storage::storage::{StoredAuthData, save_auth_data};
|
||||
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.
|
||||
@@ -15,30 +28,47 @@ 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\
|
||||
User ID: {}\n\
|
||||
Role: {}",
|
||||
Username: {}\n\
|
||||
User ID: {}\n\
|
||||
Role: {}",
|
||||
response.username,
|
||||
response.user_id,
|
||||
response.role
|
||||
@@ -50,23 +80,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,13 +105,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()
|
||||
}
|
||||
@@ -95,9 +127,10 @@ pub async fn back_to_main(
|
||||
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
|
||||
buffer_state.close_active_buffer();
|
||||
@@ -109,3 +142,99 @@ 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());
|
||||
|
||||
// --- NEW: Save auth data to file ---
|
||||
let data_to_store = StoredAuthData {
|
||||
access_token: response.access_token.clone(),
|
||||
user_id: response.user_id.clone(),
|
||||
role: response.role.clone(),
|
||||
username: response.username.clone(),
|
||||
};
|
||||
|
||||
if let Err(e) = save_auth_data(&data_to_store) {
|
||||
error!("Failed to save auth data to file: {}", e);
|
||||
// Continue anyway - user is still logged in for this session
|
||||
}
|
||||
// --- END NEW ---
|
||||
|
||||
let success_message = format!(
|
||||
"Login Successful!\n\nUsername: {}\nUser ID: {}\nRole: {}",
|
||||
response.username, response.user_id, response.role
|
||||
);
|
||||
app_state.update_dialog_content(
|
||||
&success_message,
|
||||
vec!["Menu".to_string(), "Exit".to_string()],
|
||||
DialogPurpose::LoginSuccess,
|
||||
);
|
||||
info!(message = %success_message, "Login successful");
|
||||
}
|
||||
LoginResult::Failure(err_msg) | LoginResult::ConnectionError(err_msg) => {
|
||||
app_state.update_dialog_content(&err_msg, vec!["OK".to_string()], DialogPurpose::LoginFailed);
|
||||
login_state.error_message = Some(err_msg.clone());
|
||||
error!(error = %err_msg, "Login failed/connection error");
|
||||
}
|
||||
}
|
||||
login_state.username.clear();
|
||||
login_state.password.clear();
|
||||
login_state.set_has_unsaved_changes(false);
|
||||
login_state.current_cursor_pos = 0;
|
||||
true // Request redraw as dialog content changed
|
||||
}
|
||||
|
||||
47
client/src/tui/functions/common/logout.rs
Normal file
47
client/src/tui/functions/common/logout.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
// src/tui/functions/common/logout.rs
|
||||
use crate::config::storage::delete_auth_data;
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::app::buffer::{AppView, BufferState};
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub fn logout(
|
||||
auth_state: &mut AuthState,
|
||||
app_state: &mut AppState,
|
||||
buffer_state: &mut BufferState,
|
||||
) -> String {
|
||||
// Clear auth state in memory
|
||||
auth_state.auth_token = None;
|
||||
auth_state.user_id = None;
|
||||
auth_state.role = None;
|
||||
auth_state.decoded_username = None;
|
||||
|
||||
// Delete stored auth data
|
||||
if let Err(e) = delete_auth_data() {
|
||||
error!("Failed to delete stored auth data: {}", e);
|
||||
// Continue anyway - user is logged out in memory
|
||||
}
|
||||
|
||||
// Navigate to intro screen
|
||||
buffer_state.history = vec![AppView::Intro];
|
||||
buffer_state.active_index = 0;
|
||||
|
||||
// Reset UI state
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
app_state.focused_button_index = 0;
|
||||
|
||||
// Hide any open dialogs
|
||||
app_state.hide_dialog();
|
||||
|
||||
// Show logout confirmation dialog
|
||||
app_state.show_dialog(
|
||||
"Logged Out",
|
||||
"You have been successfully logged out.",
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginSuccess, // Reuse or create a new purpose
|
||||
);
|
||||
|
||||
info!("User logged out successfully.");
|
||||
"Logged out successfully".to_string()
|
||||
}
|
||||
@@ -8,108 +8,17 @@ use crate::state::{
|
||||
};
|
||||
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.
|
||||
@@ -152,3 +61,83 @@ pub async fn back_to_login(
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
// src/tui/functions/form.rs
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
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,
|
||||
form_state: &mut FormState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
_grpc_client: &mut GrpcClient,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// TODO store unsaved changes without deleting form state values
|
||||
// First check for unsaved changes in both cases
|
||||
) -> Result<String> {
|
||||
if form_state.has_unsaved_changes() {
|
||||
return Ok(
|
||||
"Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating."
|
||||
@@ -20,70 +17,29 @@ pub async fn handle_action(
|
||||
);
|
||||
}
|
||||
|
||||
let total_count = form_state.total_count;
|
||||
|
||||
match action {
|
||||
"previous_entry" => {
|
||||
let new_position = current_position.saturating_sub(1);
|
||||
if new_position >= 1 {
|
||||
*current_position = new_position;
|
||||
let response = grpc_client.get_adresar_by_position(*current_position).await?;
|
||||
|
||||
// Direct field assignments
|
||||
form_state.id = response.id;
|
||||
form_state.values = vec![
|
||||
response.firma, response.kz, response.drc,
|
||||
response.ulica, response.psc, response.mesto,
|
||||
response.stat, response.banka, response.ucet,
|
||||
response.skladm, response.ico, response.kontakt,
|
||||
response.telefon, response.skladu, response.fax,
|
||||
];
|
||||
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1
|
||||
} else { 0 };
|
||||
form_state.current_cursor_pos = std::cmp::min(*ideal_cursor_column, max_cursor_pos);
|
||||
form_state.has_unsaved_changes = false;
|
||||
|
||||
Ok(format!("Loaded form entry {}", *current_position))
|
||||
} else {
|
||||
Ok("Already at first form entry".into())
|
||||
// Only decrement if the current position is greater than the first record.
|
||||
// This prevents wrapping from 1 to total_count.
|
||||
// It also correctly handles moving from "New Entry" (total_count + 1) to the last record.
|
||||
if form_state.current_position > 1 {
|
||||
form_state.current_position -= 1;
|
||||
*ideal_cursor_column = 0;
|
||||
}
|
||||
}
|
||||
"next_entry" => {
|
||||
if *current_position <= total_count {
|
||||
*current_position += 1;
|
||||
if *current_position <= total_count {
|
||||
let response = grpc_client.get_adresar_by_position(*current_position).await?;
|
||||
|
||||
// Direct field assignments
|
||||
form_state.id = response.id;
|
||||
form_state.values = vec![
|
||||
response.firma, response.kz, response.drc,
|
||||
response.ulica, response.psc, response.mesto,
|
||||
response.stat, response.banka, response.ucet,
|
||||
response.skladm, response.ico, response.kontakt,
|
||||
response.telefon, response.skladu, response.fax,
|
||||
];
|
||||
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1
|
||||
} else { 0 };
|
||||
form_state.current_cursor_pos = std::cmp::min(*ideal_cursor_column, max_cursor_pos);
|
||||
form_state.has_unsaved_changes = false;
|
||||
|
||||
Ok(format!("Loaded form entry {}", *current_position))
|
||||
} else {
|
||||
form_state.reset_to_empty();
|
||||
form_state.current_field = 0;
|
||||
form_state.current_cursor_pos = 0;
|
||||
*ideal_cursor_column = 0;
|
||||
Ok("New form entry mode".into())
|
||||
}
|
||||
} else {
|
||||
Ok("Already at last entry".into())
|
||||
// Only increment if the current position is not yet at the "New Entry" stage.
|
||||
// The "New Entry" position is total_count + 1.
|
||||
// This allows moving from the last record to "New Entry", but stops there.
|
||||
if form_state.current_position <= total_count {
|
||||
form_state.current_position += 1;
|
||||
*ideal_cursor_column = 0;
|
||||
}
|
||||
}
|
||||
_ => Err("Unknown form action".into())
|
||||
_ => return Err(anyhow!("Unknown form action: {}", action)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
@@ -13,10 +13,7 @@ pub fn handle_intro_selection(
|
||||
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)
|
||||
}
|
||||
0 => AppView::Form,
|
||||
1 => AppView::Admin,
|
||||
2 => AppView::Login,
|
||||
3 => AppView::Register,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()?)
|
||||
}
|
||||
}
|
||||
|
||||
34
client/src/ui/docs/ui_redraws.md
Normal file
34
client/src/ui/docs/ui_redraws.md
Normal 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.
|
||||
|
||||
@@ -15,7 +15,9 @@ pub enum DialogPurpose {
|
||||
LoginFailed,
|
||||
RegisterSuccess,
|
||||
RegisterFailed,
|
||||
ConfirmDeleteColumns,
|
||||
SaveTableSuccess,
|
||||
SaveLogicSuccess,
|
||||
// TODO in the future:
|
||||
// ConfirmQuit,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/ui/handlers/rat_state.rs
|
||||
// client/src/ui/handlers/rat_state.rs
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::app::state::UiState;
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
// src/ui/handlers/render.rs
|
||||
|
||||
use crate::components::{
|
||||
admin::add_logic::render_add_logic,
|
||||
admin::render_add_table,
|
||||
auth::{login::render_login, register::render_register},
|
||||
common::dialog::render_dialog,
|
||||
common::find_file_palette,
|
||||
common::search_palette::render_search_palette,
|
||||
form::form::render_form,
|
||||
handlers::sidebar::{self, calculate_sidebar_layout},
|
||||
intro::intro::render_intro,
|
||||
render_background,
|
||||
render_buffer_list,
|
||||
render_command_line,
|
||||
render_status_line,
|
||||
intro::intro::render_intro,
|
||||
handlers::sidebar::{self, calculate_sidebar_layout},
|
||||
form::form::render_form,
|
||||
admin::render_add_table,
|
||||
auth::{login::render_login, register::render_register},
|
||||
};
|
||||
use crate::config::colors::themes::Theme;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::Frame;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::modes::general::command_navigation::NavigationState;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::state::pages::auth::LoginState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::pages::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;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
Frame,
|
||||
};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_ui(
|
||||
f: &mut Frame,
|
||||
form_state: &mut FormState,
|
||||
@@ -34,56 +43,81 @@ pub fn render_ui(
|
||||
admin_state: &mut AdminState,
|
||||
buffer_state: &BufferState,
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
is_event_handler_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
total_count: u64,
|
||||
current_position: u64,
|
||||
event_handler_command_input: &str,
|
||||
event_handler_command_mode_active: bool,
|
||||
event_handler_command_message: &str,
|
||||
navigation_state: &NavigationState,
|
||||
current_dir: &str,
|
||||
command_input: &str,
|
||||
command_mode: bool,
|
||||
command_message: &str,
|
||||
current_fps: f64,
|
||||
app_state: &AppState,
|
||||
) {
|
||||
render_background(f, f.area(), theme);
|
||||
|
||||
// Adjust layout based on whether buffer list is shown
|
||||
let constraints = if app_state.ui.show_buffer_list {
|
||||
vec![
|
||||
Constraint::Length(1), // Buffer list
|
||||
Constraint::Min(1), // Main content
|
||||
Constraint::Length(1), // Status line
|
||||
Constraint::Length(1), // Command line
|
||||
]
|
||||
// --- START DYNAMIC LAYOUT LOGIC ---
|
||||
let mut status_line_height = 1;
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
if let Some(debug_state) = &app_state.debug_state {
|
||||
if debug_state.is_error {
|
||||
status_line_height = 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END DYNAMIC LAYOUT LOGIC ---
|
||||
|
||||
const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15;
|
||||
|
||||
|
||||
let mut bottom_area_constraints: Vec<Constraint> = vec![Constraint::Length(status_line_height)];
|
||||
let command_palette_area_height = if navigation_state.active {
|
||||
1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT
|
||||
} else if event_handler_command_mode_active {
|
||||
1
|
||||
} else {
|
||||
vec![
|
||||
Constraint::Min(1), // Main content
|
||||
Constraint::Length(1), // Status line (no buffer list)
|
||||
Constraint::Length(1), // Command line
|
||||
]
|
||||
0
|
||||
};
|
||||
|
||||
let root = Layout::default()
|
||||
if command_palette_area_height > 0 {
|
||||
bottom_area_constraints.push(Constraint::Length(command_palette_area_height));
|
||||
}
|
||||
|
||||
let mut main_layout_constraints = vec![Constraint::Min(1)];
|
||||
if app_state.ui.show_buffer_list {
|
||||
main_layout_constraints.insert(0, Constraint::Length(1));
|
||||
}
|
||||
main_layout_constraints.extend(bottom_area_constraints);
|
||||
|
||||
let root_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.constraints(main_layout_constraints)
|
||||
.split(f.area());
|
||||
|
||||
let mut buffer_list_area = None;
|
||||
let main_content_area;
|
||||
let status_line_area;
|
||||
let command_line_area;
|
||||
|
||||
// Assign areas based on layout
|
||||
if app_state.ui.show_buffer_list {
|
||||
buffer_list_area = Some(root[0]);
|
||||
main_content_area = root[1];
|
||||
status_line_area = root[2];
|
||||
command_line_area = root[3];
|
||||
let mut chunk_idx = 0;
|
||||
let buffer_list_area = if app_state.ui.show_buffer_list {
|
||||
let area = Some(root_chunks[chunk_idx]);
|
||||
chunk_idx += 1;
|
||||
area
|
||||
} else {
|
||||
main_content_area = root[0];
|
||||
status_line_area = root[1];
|
||||
command_line_area = root[2];
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let main_content_area = root_chunks[chunk_idx];
|
||||
chunk_idx += 1;
|
||||
|
||||
let status_line_area = root_chunks[chunk_idx];
|
||||
chunk_idx += 1;
|
||||
|
||||
let command_render_area = if command_palette_area_height > 0 {
|
||||
if root_chunks.len() > chunk_idx {
|
||||
Some(root_chunks[chunk_idx])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if app_state.ui.show_intro {
|
||||
render_intro(f, intro_state, main_content_area, theme);
|
||||
@@ -94,7 +128,7 @@ 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 {
|
||||
@@ -104,7 +138,17 @@ pub fn render_ui(
|
||||
theme,
|
||||
app_state,
|
||||
&mut admin_state.add_table_state,
|
||||
login_state.current_field < 3,
|
||||
is_event_handler_edit_mode,
|
||||
highlight_state,
|
||||
);
|
||||
} else if app_state.ui.show_add_logic {
|
||||
render_add_logic(
|
||||
f,
|
||||
main_content_area,
|
||||
theme,
|
||||
app_state,
|
||||
&mut admin_state.add_logic_state,
|
||||
is_event_handler_edit_mode,
|
||||
highlight_state,
|
||||
);
|
||||
} else if app_state.ui.show_login {
|
||||
@@ -114,7 +158,7 @@ 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 {
|
||||
@@ -129,70 +173,92 @@ pub fn render_ui(
|
||||
&app_state.selected_profile,
|
||||
);
|
||||
} else if app_state.ui.show_form {
|
||||
let (sidebar_area, form_area) = calculate_sidebar_layout(
|
||||
app_state.ui.show_sidebar,
|
||||
main_content_area
|
||||
);
|
||||
|
||||
let (sidebar_area, form_actual_area) =
|
||||
calculate_sidebar_layout(app_state.ui.show_sidebar, main_content_area);
|
||||
if let Some(sidebar_rect) = sidebar_area {
|
||||
sidebar::render_sidebar(
|
||||
f,
|
||||
sidebar_rect,
|
||||
theme,
|
||||
&app_state.profile_tree,
|
||||
&app_state.selected_profile
|
||||
&app_state.selected_profile,
|
||||
);
|
||||
}
|
||||
|
||||
// This change makes the form stay stationary when toggling sidebar
|
||||
let available_width = form_area.width;
|
||||
let form_constraint = if available_width >= 80 {
|
||||
// Use main_content_area for centering when enough space
|
||||
let available_width = form_actual_area.width;
|
||||
let form_render_area = if available_width >= 80 {
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(80),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(main_content_area)[1]
|
||||
.constraints([Constraint::Min(0), Constraint::Length(80), Constraint::Min(0)])
|
||||
.split(form_actual_area)[1]
|
||||
} else {
|
||||
// Use form_area (post sidebar) when limited space
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(80.min(available_width)),
|
||||
Constraint::Length(available_width),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(form_area)[1]
|
||||
.split(form_actual_area)[1]
|
||||
};
|
||||
|
||||
// Convert fields to &[&str] and values to &[&String]
|
||||
let fields: Vec<&str> = form_state.fields.iter().map(|s| s.as_str()).collect();
|
||||
let values: Vec<&String> = form_state.values.iter().collect();
|
||||
|
||||
render_form(
|
||||
|
||||
form_state.render(
|
||||
f,
|
||||
form_constraint,
|
||||
form_state,
|
||||
&fields,
|
||||
&form_state.current_field,
|
||||
&values,
|
||||
form_render_area,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
is_event_handler_edit_mode,
|
||||
highlight_state,
|
||||
total_count,
|
||||
current_position,
|
||||
);
|
||||
}
|
||||
|
||||
// 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, buffer_state);
|
||||
render_buffer_list(f, area, theme, buffer_state, app_state);
|
||||
}
|
||||
|
||||
render_status_line(
|
||||
f,
|
||||
status_line_area,
|
||||
current_dir,
|
||||
theme,
|
||||
is_event_handler_edit_mode,
|
||||
current_fps,
|
||||
app_state,
|
||||
);
|
||||
|
||||
if let Some(palette_or_command_area) = command_render_area {
|
||||
if navigation_state.active {
|
||||
find_file_palette::render_find_file_palette(
|
||||
f,
|
||||
palette_or_command_area,
|
||||
theme,
|
||||
navigation_state,
|
||||
);
|
||||
} else if event_handler_command_mode_active {
|
||||
render_command_line(
|
||||
f,
|
||||
palette_or_command_area,
|
||||
event_handler_command_input,
|
||||
true,
|
||||
theme,
|
||||
event_handler_command_message,
|
||||
);
|
||||
}
|
||||
}
|
||||
render_status_line(f, status_line_area, current_dir, theme, is_edit_mode, current_fps);
|
||||
render_command_line(f, command_line_area, command_input, command_mode, theme, command_message);
|
||||
|
||||
// This block now correctly handles drawing popups over any view.
|
||||
if app_state.ui.show_search_palette {
|
||||
if let Some(search_state) = &app_state.search_state {
|
||||
render_search_palette(f, f.area(), theme, search_state);
|
||||
}
|
||||
} else if app_state.ui.dialog.dialog_show {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,33 +4,61 @@ use crate::config::binds::config::Config;
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::services::ui_service::UiService;
|
||||
use crate::config::storage::storage::load_auth_data;
|
||||
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::form::{FormState, FieldDefinition}; // Import FieldDefinition
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::state::pages::auth::LoginState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::state::pages::admin::AdminFocus;
|
||||
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 std::time::Instant;
|
||||
use crate::tui::functions::common::login::LoginResult;
|
||||
use crate::tui::functions::common::register::RegisterResult;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::tui::functions::common::login;
|
||||
use crate::tui::functions::common::register;
|
||||
use crate::utils::columns::filter_user_columns;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use crossterm::event as crossterm_event;
|
||||
use tracing::{error, info, warn};
|
||||
use tokio::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
#[cfg(feature = "ui-debug")]
|
||||
use crate::state::app::state::DebugState;
|
||||
#[cfg(feature = "ui-debug")]
|
||||
use crate::utils::debug_logger::pop_next_debug_message;
|
||||
|
||||
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 grpc_client = GrpcClient::new().await?;
|
||||
let mut terminal = TerminalCore::new().context("Failed to initialize terminal")?;
|
||||
let mut grpc_client = GrpcClient::new().await.context("Failed to create GrpcClient")?;
|
||||
let mut command_handler = CommandHandler::new();
|
||||
|
||||
let mut event_handler = EventHandler::new().await?;
|
||||
let (login_result_sender, mut login_result_receiver) = mpsc::channel::<LoginResult>(1);
|
||||
let (register_result_sender, mut register_result_receiver) = mpsc::channel::<RegisterResult>(1);
|
||||
let (save_table_result_sender, mut save_table_result_receiver) = mpsc::channel::<Result<String>>(1);
|
||||
let (save_logic_result_sender, _save_logic_result_receiver) = mpsc::channel::<Result<String>>(1);
|
||||
|
||||
let mut event_handler = EventHandler::new(
|
||||
login_result_sender.clone(),
|
||||
register_result_sender.clone(),
|
||||
save_table_result_sender.clone(),
|
||||
save_logic_result_sender.clone(),
|
||||
grpc_client.clone(),
|
||||
)
|
||||
.await
|
||||
.context("Failed to create event handler")?;
|
||||
let event_reader = EventReader::new();
|
||||
|
||||
let mut auth_state = AuthState::default();
|
||||
@@ -39,124 +67,130 @@ pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut intro_state = IntroState::default();
|
||||
let mut admin_state = AdminState::default();
|
||||
let mut buffer_state = BufferState::default();
|
||||
let mut app_state = AppState::new()?;
|
||||
let mut app_state = AppState::new().context("Failed to create initial app state")?;
|
||||
|
||||
// Initialize app state with profile tree and table structure
|
||||
let column_names =
|
||||
UiService::initialize_app_state(&mut grpc_client, &mut app_state)
|
||||
.await?;
|
||||
let mut form_state = FormState::new(column_names);
|
||||
let mut auto_logged_in = false;
|
||||
match load_auth_data() {
|
||||
Ok(Some(stored_data)) => {
|
||||
auth_state.auth_token = Some(stored_data.access_token);
|
||||
auth_state.user_id = Some(stored_data.user_id);
|
||||
auth_state.role = Some(stored_data.role);
|
||||
auth_state.decoded_username = Some(stored_data.username);
|
||||
auto_logged_in = true;
|
||||
info!("Auth data loaded from file. User is auto-logged in.");
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("No stored auth data found. User will see intro/login.");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to load auth data: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the total count of Adresar entries
|
||||
UiService::initialize_adresar_count(&mut grpc_client, &mut app_state)
|
||||
.await?;
|
||||
form_state.reset_to_empty();
|
||||
let (initial_profile, initial_table, initial_columns_from_service) =
|
||||
UiService::initialize_app_state_and_form(&mut grpc_client, &mut app_state)
|
||||
.await
|
||||
.context("Failed to initialize app state and form")?;
|
||||
|
||||
let initial_field_defs: Vec<FieldDefinition> = filter_user_columns(initial_columns_from_service)
|
||||
.into_iter()
|
||||
.map(|col_name| FieldDefinition {
|
||||
display_name: col_name.clone(),
|
||||
data_key: col_name,
|
||||
is_link: false,
|
||||
link_target_table: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut form_state = FormState::new(
|
||||
initial_profile.clone(),
|
||||
initial_table.clone(),
|
||||
initial_field_defs,
|
||||
);
|
||||
|
||||
UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state)
|
||||
.await
|
||||
.context(format!(
|
||||
"Failed to fetch initial count for table {}.{}",
|
||||
initial_profile, initial_table
|
||||
))?;
|
||||
|
||||
if form_state.total_count > 0 {
|
||||
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
|
||||
event_handler.command_message = format!("Error loading initial data: {}", e);
|
||||
}
|
||||
} else {
|
||||
form_state.reset_to_empty();
|
||||
}
|
||||
|
||||
if auto_logged_in {
|
||||
buffer_state.history = vec![AppView::Form];
|
||||
buffer_state.active_index = 0;
|
||||
info!("Initial view set to Form due to auto-login.");
|
||||
}
|
||||
|
||||
// --- FPS Calculation State ---
|
||||
let mut last_frame_time = Instant::now();
|
||||
let mut current_fps = 0.0;
|
||||
let mut needs_redraw = true;
|
||||
let mut prev_view_profile_name = app_state.current_view_profile_name.clone();
|
||||
let mut prev_view_table_name = app_state.current_view_table_name.clone();
|
||||
let mut table_just_switched = false;
|
||||
|
||||
loop {
|
||||
// Determine edit mode based on EventHandler state
|
||||
let is_edit_mode = event_handler.is_edit_mode;
|
||||
let position_before_event = form_state.current_position;
|
||||
let mut event_processed = false;
|
||||
|
||||
// --- 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_form = false;
|
||||
match active_view {
|
||||
AppView::Intro => app_state.ui.show_intro = true,
|
||||
AppView::Login => app_state.ui.show_login = true,
|
||||
AppView::Register => app_state.ui.show_register = true,
|
||||
AppView::Admin => {
|
||||
app_state.ui.show_admin = true;
|
||||
let profile_names = app_state.profile_tree.profiles.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect();
|
||||
admin_state.set_profiles(profile_names);
|
||||
// --- CHANNEL RECEIVERS ---
|
||||
|
||||
// For main search palette
|
||||
match event_handler.search_result_receiver.try_recv() {
|
||||
Ok(hits) => {
|
||||
info!("--- 4. Main loop received message from channel. ---");
|
||||
if let Some(search_state) = app_state.search_state.as_mut() {
|
||||
search_state.results = hits;
|
||||
search_state.is_loading = false;
|
||||
}
|
||||
AppView::AddTable => app_state.ui.show_add_table = true,
|
||||
AppView::Form(_) => app_state.ui.show_form = true,
|
||||
AppView::Scratch => {} // Or show a scratchpad component
|
||||
needs_redraw = true;
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Empty) => {
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
error!("Search result channel disconnected!");
|
||||
}
|
||||
}
|
||||
// --- End Synchronization ---
|
||||
|
||||
terminal.draw(|f| {
|
||||
render_ui(
|
||||
f,
|
||||
&mut form_state,
|
||||
&mut auth_state,
|
||||
&login_state,
|
||||
®ister_state,
|
||||
&intro_state,
|
||||
&mut admin_state,
|
||||
&buffer_state,
|
||||
&theme,
|
||||
is_edit_mode,
|
||||
&event_handler.highlight_state,
|
||||
app_state.total_count,
|
||||
app_state.current_position,
|
||||
&app_state.current_dir,
|
||||
&event_handler.command_input,
|
||||
event_handler.command_mode,
|
||||
&event_handler.command_message,
|
||||
current_fps,
|
||||
&app_state,
|
||||
);
|
||||
})?;
|
||||
|
||||
// --- Cursor Visibility Logic ---
|
||||
let current_mode = ModeManager::derive_mode(&app_state, &event_handler);
|
||||
match current_mode {
|
||||
AppMode::Edit => {
|
||||
terminal.show_cursor()?;
|
||||
}
|
||||
AppMode::Highlight => {
|
||||
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
||||
terminal.show_cursor()?;
|
||||
}
|
||||
AppMode::ReadOnly => {
|
||||
if !app_state.ui.focus_outside_canvas {
|
||||
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
||||
} else {
|
||||
terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?;
|
||||
// --- ADDED: For live form autocomplete ---
|
||||
match event_handler.autocomplete_result_receiver.try_recv() {
|
||||
Ok(hits) => {
|
||||
if form_state.autocomplete_active {
|
||||
form_state.autocomplete_suggestions = hits;
|
||||
form_state.autocomplete_loading = false;
|
||||
if !form_state.autocomplete_suggestions.is_empty() {
|
||||
form_state.selected_suggestion_index = Some(0);
|
||||
} else {
|
||||
form_state.selected_suggestion_index = None;
|
||||
}
|
||||
event_handler.command_message = format!("Found {} suggestions.", form_state.autocomplete_suggestions.len());
|
||||
}
|
||||
terminal.show_cursor()?;
|
||||
needs_redraw = true;
|
||||
}
|
||||
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()?;
|
||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
error!("Autocomplete result channel disconnected!");
|
||||
}
|
||||
}
|
||||
// --- End Cursor Visibility Logic ---
|
||||
|
||||
let total_count = app_state.total_count; // Keep track for save logic
|
||||
let mut current_position = app_state.current_position;
|
||||
let position_before_event = current_position;
|
||||
|
||||
let event = event_reader.read_event()?;
|
||||
|
||||
// Get the outcome from the event handler
|
||||
let event_outcome_result = event_handler
|
||||
.handle_event(
|
||||
if app_state.ui.show_search_palette {
|
||||
needs_redraw = true;
|
||||
}
|
||||
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;
|
||||
let event_outcome_result = event_handler.handle_event(
|
||||
event,
|
||||
&config,
|
||||
&mut terminal,
|
||||
&mut grpc_client,
|
||||
&mut command_handler,
|
||||
&mut form_state,
|
||||
&mut auth_state,
|
||||
@@ -166,169 +200,435 @@ pub async fn run_ui() -> Result<(), Box<dyn std::error::Error>> {
|
||||
&mut admin_state,
|
||||
&mut buffer_state,
|
||||
&mut app_state,
|
||||
total_count,
|
||||
&mut current_position,
|
||||
)
|
||||
.await;
|
||||
).await;
|
||||
|
||||
// Update position based on handler's modification
|
||||
app_state.current_position = current_position;
|
||||
|
||||
// --- 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() {
|
||||
let mut should_exit = false;
|
||||
match event_outcome_result {
|
||||
Ok(outcome) => match outcome {
|
||||
EventOutcome::Ok(message) => {
|
||||
if !message.is_empty() {
|
||||
event_handler.command_message = message;
|
||||
}
|
||||
}
|
||||
EventOutcome::Exit(message) => {
|
||||
event_handler.command_message = message;
|
||||
should_exit = true;
|
||||
}
|
||||
}
|
||||
EventOutcome::Exit(message) => {
|
||||
event_handler.command_message = message;
|
||||
should_exit = true;
|
||||
}
|
||||
EventOutcome::DataSaved(save_outcome, message) => {
|
||||
event_handler.command_message = message; // Show save status
|
||||
EventOutcome::DataSaved(save_outcome, message) => {
|
||||
event_handler.command_message = message;
|
||||
if let Err(e) = UiService::handle_save_outcome(
|
||||
save_outcome,
|
||||
&mut grpc_client,
|
||||
&mut app_state,
|
||||
&mut form_state,
|
||||
).await {
|
||||
event_handler.command_message =
|
||||
format!("Error handling save outcome: {}", e);
|
||||
}
|
||||
}
|
||||
EventOutcome::ButtonSelected { .. } => {}
|
||||
EventOutcome::TableSelected { path } => {
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
if parts.len() == 2 {
|
||||
let profile_name = parts[0].to_string();
|
||||
let table_name = parts[1].to_string();
|
||||
|
||||
// *** Delegate outcome handling to UiService ***
|
||||
if let Err(e) = UiService::handle_save_outcome(
|
||||
save_outcome,
|
||||
&mut grpc_client,
|
||||
&mut app_state,
|
||||
&mut form_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Handle potential errors from the outcome handler itself
|
||||
event_handler.command_message =
|
||||
format!("Error handling save outcome: {}", e);
|
||||
app_state.set_current_view_table(profile_name, table_name);
|
||||
buffer_state.update_history(AppView::Form);
|
||||
event_handler.command_message = format!("Loading table: {}", path);
|
||||
} else {
|
||||
event_handler.command_message = format!("Invalid table path: {}", path);
|
||||
}
|
||||
}
|
||||
// No count update needed for UpdatedExisting or NoChange
|
||||
},
|
||||
Err(e) => {
|
||||
event_handler.command_message = format!("Error: {}", e);
|
||||
}
|
||||
EventOutcome::ButtonSelected { context, index } => {
|
||||
event_handler.command_message = "Internal error: Unexpected button state".to_string();
|
||||
}
|
||||
},
|
||||
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;
|
||||
}
|
||||
if should_exit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
let current_total_count = app_state.total_count;
|
||||
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) => {}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
error!("Login result channel disconnected unexpectedly.");
|
||||
}
|
||||
}
|
||||
|
||||
match register_result_receiver.try_recv() {
|
||||
Ok(result) => {
|
||||
if register::handle_registration_result(result, &mut app_state, &mut register_state) {
|
||||
needs_redraw = true;
|
||||
}
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
error!("Register result channel disconnected unexpectedly.");
|
||||
}
|
||||
}
|
||||
|
||||
match save_table_result_receiver.try_recv() {
|
||||
Ok(result) => {
|
||||
app_state.hide_dialog();
|
||||
match result {
|
||||
Ok(ref success_message) => {
|
||||
app_state.show_dialog(
|
||||
"Save Successful",
|
||||
success_message,
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::SaveTableSuccess,
|
||||
);
|
||||
admin_state.add_table_state.has_unsaved_changes = false;
|
||||
}
|
||||
Err(e) => {
|
||||
event_handler.command_message = format!("Save failed: {}", e);
|
||||
}
|
||||
}
|
||||
needs_redraw = true;
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
error!("Save table result channel disconnected unexpectedly.");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(active_view) = buffer_state.get_active_view() {
|
||||
app_state.ui.show_intro = false;
|
||||
app_state.ui.show_login = false;
|
||||
app_state.ui.show_register = false;
|
||||
app_state.ui.show_admin = false;
|
||||
app_state.ui.show_add_table = false;
|
||||
app_state.ui.show_add_logic = false;
|
||||
app_state.ui.show_form = false;
|
||||
match active_view {
|
||||
AppView::Intro => app_state.ui.show_intro = true,
|
||||
AppView::Login => app_state.ui.show_login = true,
|
||||
AppView::Register => app_state.ui.show_register = true,
|
||||
AppView::Admin => {
|
||||
info!("Active view is Admin, refreshing profile tree...");
|
||||
match grpc_client.get_profile_tree().await {
|
||||
Ok(refreshed_tree) => {
|
||||
app_state.profile_tree = refreshed_tree;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to refresh profile tree for Admin panel: {}", e);
|
||||
event_handler.command_message = format!("Error refreshing admin data: {}", e);
|
||||
}
|
||||
}
|
||||
app_state.ui.show_admin = true;
|
||||
let profile_names = app_state.profile_tree.profiles.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect();
|
||||
admin_state.set_profiles(profile_names);
|
||||
|
||||
if admin_state.current_focus == AdminFocus::default() ||
|
||||
!matches!(admin_state.current_focus,
|
||||
AdminFocus::InsideProfilesList |
|
||||
AdminFocus::Tables | AdminFocus::InsideTablesList |
|
||||
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3) {
|
||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
||||
}
|
||||
if admin_state.profile_list_state.selected().is_none() && !app_state.profile_tree.profiles.is_empty() {
|
||||
admin_state.profile_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
AppView::AddTable => app_state.ui.show_add_table = true,
|
||||
AppView::AddLogic => app_state.ui.show_add_logic = true,
|
||||
AppView::Form => app_state.ui.show_form = true,
|
||||
AppView::Scratch => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle position changes and update form state (Only when form is shown)
|
||||
if app_state.ui.show_form {
|
||||
if position_changed && !event_handler.is_edit_mode {
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1 // Limit to last character in readonly mode
|
||||
} else {
|
||||
0
|
||||
};
|
||||
form_state.current_cursor_pos =
|
||||
event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
let current_view_profile = app_state.current_view_profile_name.clone();
|
||||
let current_view_table = app_state.current_view_table_name.clone();
|
||||
|
||||
// Ensure position never exceeds total_count + 1
|
||||
if app_state.current_position > current_total_count + 1 {
|
||||
app_state.current_position = current_total_count + 1;
|
||||
}
|
||||
if app_state.current_position > current_total_count {
|
||||
// New entry - reset form
|
||||
form_state.reset_to_empty();
|
||||
form_state.current_field = 0;
|
||||
} else if app_state.current_position >= 1
|
||||
&& app_state.current_position <= current_total_count
|
||||
// This condition correctly detects a table switch.
|
||||
if prev_view_profile_name != current_view_profile
|
||||
|| prev_view_table_name != current_view_table
|
||||
{
|
||||
if let (Some(prof_name), Some(tbl_name)) =
|
||||
(current_view_profile.as_ref(), current_view_table.as_ref())
|
||||
{
|
||||
// Existing entry - load data
|
||||
let current_position_to_load = app_state.current_position; // Use a copy
|
||||
let load_message = UiService::load_adresar_by_position(
|
||||
&mut grpc_client,
|
||||
&mut app_state, // Pass app_state mutably if needed by the service
|
||||
&mut form_state,
|
||||
current_position_to_load,
|
||||
)
|
||||
.await?;
|
||||
// --- START OF REFACTORED LOGIC ---
|
||||
app_state.show_loading_dialog(
|
||||
"Loading Table",
|
||||
&format!("Fetching data for {}.{}...", prof_name, tbl_name),
|
||||
);
|
||||
needs_redraw = true;
|
||||
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = if !event_handler.is_edit_mode
|
||||
&& !current_input.is_empty()
|
||||
// 1. Call our new, central function. It handles fetching AND caching.
|
||||
match UiService::load_table_view(
|
||||
&mut grpc_client,
|
||||
&mut app_state,
|
||||
prof_name,
|
||||
tbl_name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
current_input.len() - 1 // In readonly mode, limit to last character
|
||||
} else {
|
||||
current_input.len()
|
||||
};
|
||||
form_state.current_cursor_pos = event_handler
|
||||
.ideal_cursor_column
|
||||
.min(max_cursor_pos);
|
||||
// Don't overwrite message from handle_event if load_message is simple success
|
||||
if !load_message.starts_with("Loaded entry")
|
||||
|| event_handler.command_message.is_empty()
|
||||
{
|
||||
event_handler.command_message = load_message;
|
||||
Ok(mut new_form_state) => {
|
||||
// 2. The function succeeded, we have a new FormState.
|
||||
// Now, fetch its data.
|
||||
if let Err(e) = UiService::fetch_and_set_table_count(
|
||||
&mut grpc_client,
|
||||
&mut new_form_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Handle count fetching error
|
||||
app_state.update_dialog_content(
|
||||
&format!("Error fetching count: {}", e),
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed, // Or a more appropriate purpose
|
||||
);
|
||||
} else if new_form_state.total_count > 0 {
|
||||
// If there are records, load the first/last one
|
||||
if let Err(e) = UiService::load_table_data_by_position(
|
||||
&mut grpc_client,
|
||||
&mut new_form_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Handle data loading error
|
||||
app_state.update_dialog_content(
|
||||
&format!("Error loading data: {}", e),
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed, // Or a more appropriate purpose
|
||||
);
|
||||
} else {
|
||||
// Success! Hide the loading dialog.
|
||||
app_state.hide_dialog();
|
||||
}
|
||||
} else {
|
||||
// No records, so just reset to an empty form.
|
||||
new_form_state.reset_to_empty();
|
||||
app_state.hide_dialog();
|
||||
}
|
||||
|
||||
// 3. CRITICAL: Replace the old form_state with the new one.
|
||||
form_state = new_form_state;
|
||||
|
||||
// 4. Update our tracking variables.
|
||||
prev_view_profile_name = current_view_profile;
|
||||
prev_view_table_name = current_view_table;
|
||||
table_just_switched = true;
|
||||
}
|
||||
Err(e) => {
|
||||
// This handles errors from load_table_view (e.g., schema fetch failed)
|
||||
app_state.update_dialog_content(
|
||||
&format!("Error loading table: {}", e),
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed, // Or a more appropriate purpose
|
||||
);
|
||||
// Revert the view change in app_state to avoid a loop
|
||||
app_state.current_view_profile_name =
|
||||
prev_view_profile_name.clone();
|
||||
app_state.current_view_table_name =
|
||||
prev_view_table_name.clone();
|
||||
}
|
||||
}
|
||||
// --- END OF REFACTORED LOGIC ---
|
||||
}
|
||||
needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
|
||||
if app_state.ui.show_add_logic {
|
||||
if admin_state.add_logic_state.profile_name == profile_name &&
|
||||
admin_state.add_logic_state.selected_table_name.as_deref() == Some(table_name.as_str()) {
|
||||
info!("Fetching table structure for {}.{}", profile_name, table_name);
|
||||
let fetch_message = UiService::initialize_add_logic_table_data(
|
||||
&mut grpc_client,
|
||||
&mut admin_state.add_logic_state,
|
||||
&app_state.profile_tree,
|
||||
).await.unwrap_or_else(|e| {
|
||||
error!("Error initializing add_logic_table_data: {}", e);
|
||||
format!("Error fetching table structure: {}", e)
|
||||
});
|
||||
|
||||
if !fetch_message.contains("Error") && !fetch_message.contains("Warning") {
|
||||
info!("{}", fetch_message);
|
||||
} else {
|
||||
event_handler.command_message = fetch_message;
|
||||
}
|
||||
needs_redraw = true;
|
||||
} else {
|
||||
// Invalid position (e.g., 0) - reset to first entry or new entry mode
|
||||
app_state.current_position =
|
||||
1.min(current_total_count + 1); // Go to 1 or new entry if empty
|
||||
if app_state.current_position > total_count {
|
||||
form_state.reset_to_empty();
|
||||
form_state.current_field = 0;
|
||||
error!(
|
||||
"Mismatch in pending_table_structure_fetch: app_state wants {}.{}, but add_logic_state is for {}.{:?}",
|
||||
profile_name, table_name,
|
||||
admin_state.add_logic_state.profile_name,
|
||||
admin_state.add_logic_state.selected_table_name
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"Pending table structure fetch for {}.{} but AddLogic view is not active. Fetch ignored.",
|
||||
profile_name, table_name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(table_name) = admin_state.add_logic_state.script_editor_awaiting_column_autocomplete.clone() {
|
||||
if app_state.ui.show_add_logic {
|
||||
let profile_name = admin_state.add_logic_state.profile_name.clone();
|
||||
|
||||
info!("Fetching columns for table selection: {}.{}", profile_name, table_name);
|
||||
match UiService::fetch_columns_for_table(&mut grpc_client, &profile_name, &table_name).await {
|
||||
Ok(columns) => {
|
||||
admin_state.add_logic_state.set_columns_for_table_autocomplete(columns.clone());
|
||||
info!("Loaded {} columns for table '{}'", columns.len(), table_name);
|
||||
event_handler.command_message = format!("Columns for '{}' loaded. Select a column.", table_name);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
|
||||
admin_state.add_logic_state.script_editor_awaiting_column_autocomplete = None;
|
||||
admin_state.add_logic_state.deactivate_script_editor_autocomplete();
|
||||
event_handler.command_message = format!("Error loading columns for '{}': {}", table_name, e);
|
||||
}
|
||||
}
|
||||
} else if !position_changed && !event_handler.is_edit_mode {
|
||||
// If position didn't change but we are in read-only, just adjust cursor
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1
|
||||
needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
let position_changed = form_state.current_position != position_before_event;
|
||||
let mut position_logic_needs_redraw = false;
|
||||
|
||||
if app_state.ui.show_form && !table_just_switched {
|
||||
if position_changed && !event_handler.is_edit_mode {
|
||||
position_logic_needs_redraw = true;
|
||||
|
||||
if form_state.current_position > form_state.total_count {
|
||||
form_state.reset_to_empty();
|
||||
event_handler.command_message = format!("New entry for {}.{}", form_state.profile_name, form_state.table_name);
|
||||
} else {
|
||||
match UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
|
||||
Ok(load_message) => {
|
||||
if event_handler.command_message.is_empty() || !load_message.starts_with("Error") {
|
||||
event_handler.command_message = load_message;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
event_handler.command_message = format!("Error loading data: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let current_input_after_load_str = form_state.get_current_input();
|
||||
let current_input_len_after_load = current_input_after_load_str.chars().count();
|
||||
let max_cursor_pos = if current_input_len_after_load > 0 {
|
||||
current_input_len_after_load.saturating_sub(1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
form_state.current_cursor_pos =
|
||||
event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
|
||||
} else if !position_changed && !event_handler.is_edit_mode {
|
||||
let current_input_str = form_state.get_current_input();
|
||||
let current_input_len = current_input_str.chars().count();
|
||||
let max_cursor_pos = if current_input_len > 0 {
|
||||
current_input_len.saturating_sub(1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
}
|
||||
} else if app_state.ui.show_register {
|
||||
if !event_handler.is_edit_mode {
|
||||
let current_input = register_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
|
||||
register_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
}
|
||||
} else if app_state.ui.show_login {
|
||||
if !event_handler.is_edit_mode {
|
||||
let current_input = login_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
|
||||
login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
}
|
||||
}
|
||||
|
||||
// Check exit condition *after* processing outcome
|
||||
if should_exit {
|
||||
// terminal.cleanup()?; // Optional: Drop handles this
|
||||
return Ok(());
|
||||
if position_logic_needs_redraw {
|
||||
needs_redraw = true;
|
||||
}
|
||||
|
||||
if app_state.ui.dialog.is_loading {
|
||||
needs_redraw = true;
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
let can_display_next = match &app_state.debug_state {
|
||||
Some(current) => current.display_start_time.elapsed() >= Duration::from_secs(2),
|
||||
None => true,
|
||||
};
|
||||
|
||||
if can_display_next {
|
||||
if let Some((new_message, is_error)) = pop_next_debug_message() {
|
||||
app_state.debug_state = Some(DebugState {
|
||||
displayed_message: new_message,
|
||||
is_error,
|
||||
display_start_time: Instant::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if event_processed || needs_redraw || position_changed {
|
||||
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
|
||||
match current_mode {
|
||||
AppMode::Edit => { terminal.show_cursor()?; }
|
||||
AppMode::Highlight => { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.show_cursor()?; }
|
||||
AppMode::ReadOnly => {
|
||||
if !app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; }
|
||||
else { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; }
|
||||
terminal.show_cursor().context("Failed to show cursor in ReadOnly mode")?;
|
||||
}
|
||||
AppMode::General => {
|
||||
if app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor()?; }
|
||||
else { terminal.hide_cursor()?; }
|
||||
}
|
||||
AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; }
|
||||
}
|
||||
|
||||
terminal.draw(|f| {
|
||||
render_ui(
|
||||
f,
|
||||
&mut form_state,
|
||||
&mut auth_state,
|
||||
&login_state,
|
||||
®ister_state,
|
||||
&intro_state,
|
||||
&mut admin_state,
|
||||
&buffer_state,
|
||||
&theme,
|
||||
event_handler.is_edit_mode,
|
||||
&event_handler.highlight_state,
|
||||
&event_handler.command_input,
|
||||
event_handler.command_mode,
|
||||
&event_handler.command_message,
|
||||
&event_handler.navigation_state,
|
||||
&app_state.current_dir,
|
||||
current_fps,
|
||||
&app_state,
|
||||
);
|
||||
}).context("Terminal draw call failed")?;
|
||||
needs_redraw = false;
|
||||
}
|
||||
|
||||
// --- FPS Calculation ---
|
||||
let now = Instant::now();
|
||||
let 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();
|
||||
}
|
||||
|
||||
table_just_switched = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
client/src/utils/columns.rs
Normal file
14
client/src/utils/columns.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
// src/utils/columns.rs
|
||||
pub fn is_system_column(column_name: &str) -> bool {
|
||||
match column_name {
|
||||
"id" | "deleted" | "created_at" => true,
|
||||
name if name.ends_with("_id") => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filter_user_columns(all_columns: Vec<String>) -> Vec<String> {
|
||||
all_columns.into_iter()
|
||||
.filter(|col| !is_system_column(col))
|
||||
.collect()
|
||||
}
|
||||
50
client/src/utils/data_converter.rs
Normal file
50
client/src/utils/data_converter.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
// src/utils/data_converter.rs
|
||||
|
||||
use common::proto::multieko2::table_structure::TableStructureResponse;
|
||||
use prost_types::{value::Kind, NullValue, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub fn convert_and_validate_data(
|
||||
data: &HashMap<String, String>,
|
||||
schema: &TableStructureResponse,
|
||||
) -> Result<HashMap<String, Value>, String> {
|
||||
let type_map: HashMap<_, _> = schema
|
||||
.columns
|
||||
.iter()
|
||||
.map(|col| (col.name.as_str(), col.data_type.as_str()))
|
||||
.collect();
|
||||
|
||||
data.iter()
|
||||
.map(|(key, str_value)| {
|
||||
let expected_type = type_map.get(key.as_str()).unwrap_or(&"TEXT");
|
||||
|
||||
let kind = if str_value.is_empty() {
|
||||
// TODO: Use the correct enum variant
|
||||
Kind::NullValue(NullValue::NullValue.into())
|
||||
} else {
|
||||
// Attempt to parse the string based on the expected type
|
||||
match *expected_type {
|
||||
"BOOL" => match str_value.to_lowercase().parse::<bool>() {
|
||||
Ok(v) => Kind::BoolValue(v),
|
||||
Err(_) => return Err(format!("Invalid boolean for '{}': must be 'true' or 'false'", key)),
|
||||
},
|
||||
"INT8" | "INT4" | "INT2" | "SERIAL" | "BIGSERIAL" => {
|
||||
match str_value.parse::<f64>() {
|
||||
Ok(v) => Kind::NumberValue(v),
|
||||
Err(_) => return Err(format!("Invalid number for '{}': must be a whole number", key)),
|
||||
}
|
||||
}
|
||||
"NUMERIC" | "FLOAT4" | "FLOAT8" => match str_value.parse::<f64>() {
|
||||
Ok(v) => Kind::NumberValue(v),
|
||||
Err(_) => return Err(format!("Invalid decimal for '{}': must be a number", key)),
|
||||
},
|
||||
"TIMESTAMPTZ" | "DATE" | "TIME" | "TEXT" | "VARCHAR" | "UUID" => {
|
||||
Kind::StringValue(str_value.clone())
|
||||
}
|
||||
_ => Kind::StringValue(str_value.clone()),
|
||||
}
|
||||
};
|
||||
Ok((key.clone(), Value { kind: Some(kind) }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
46
client/src/utils/debug_logger.rs
Normal file
46
client/src/utils/debug_logger.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
// client/src/utils/debug_logger.rs
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::VecDeque; // <-- FIX: Import VecDeque
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex}; // <-- FIX: Import Mutex
|
||||
|
||||
lazy_static! {
|
||||
static ref UI_DEBUG_BUFFER: Arc<Mutex<VecDeque<(String, bool)>>> =
|
||||
Arc::new(Mutex::new(VecDeque::from([(String::from("Logger initialized..."), false)])));
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UiDebugWriter;
|
||||
|
||||
impl Default for UiDebugWriter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl UiDebugWriter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Write for UiDebugWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let mut buffer = UI_DEBUG_BUFFER.lock().unwrap();
|
||||
let message = String::from_utf8_lossy(buf);
|
||||
let trimmed_message = message.trim().to_string();
|
||||
let is_error = trimmed_message.starts_with("ERROR");
|
||||
// Add the new message to the back of the queue
|
||||
buffer.push_back((trimmed_message, is_error));
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// A public function to pop the next message from the front of the queue.
|
||||
pub fn pop_next_debug_message() -> Option<(String, bool)> {
|
||||
UI_DEBUG_BUFFER.lock().unwrap().pop_front()
|
||||
}
|
||||
9
client/src/utils/mod.rs
Normal file
9
client/src/utils/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// src/utils/mod.rs
|
||||
|
||||
pub mod columns;
|
||||
pub mod debug_logger;
|
||||
pub mod data_converter;
|
||||
|
||||
pub use columns::*;
|
||||
pub use debug_logger::*;
|
||||
pub use data_converter::*;
|
||||
262
client/tests/form/gui/form_tests.rs
Normal file
262
client/tests/form/gui/form_tests.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
// client/tests/form_tests.rs
|
||||
use rstest::{fixture, rstest};
|
||||
use std::collections::HashMap;
|
||||
use client::state::pages::form::{FormState, FieldDefinition};
|
||||
use client::state::pages::canvas_state::CanvasState;
|
||||
|
||||
#[fixture]
|
||||
fn test_form_state() -> FormState {
|
||||
let fields = vec![
|
||||
FieldDefinition {
|
||||
display_name: "Company".to_string(),
|
||||
data_key: "firma".to_string(),
|
||||
is_link: false,
|
||||
link_target_table: None,
|
||||
},
|
||||
FieldDefinition {
|
||||
display_name: "Phone".to_string(),
|
||||
data_key: "telefon".to_string(),
|
||||
is_link: false,
|
||||
link_target_table: None,
|
||||
},
|
||||
FieldDefinition {
|
||||
display_name: "Email".to_string(),
|
||||
data_key: "email".to_string(),
|
||||
is_link: false,
|
||||
link_target_table: None,
|
||||
},
|
||||
];
|
||||
|
||||
FormState::new("test_profile".to_string(), "test_table".to_string(), fields)
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn test_form_data() -> HashMap<String, String> {
|
||||
let mut data = HashMap::new();
|
||||
data.insert("firma".to_string(), "Test Company".to_string());
|
||||
data.insert("telefon".to_string(), "+421123456789".to_string());
|
||||
data.insert("email".to_string(), "test@example.com".to_string());
|
||||
data
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_form_state_creation(test_form_state: FormState) {
|
||||
assert_eq!(test_form_state.profile_name, "test_profile");
|
||||
assert_eq!(test_form_state.table_name, "test_table");
|
||||
assert_eq!(test_form_state.fields.len(), 3);
|
||||
assert_eq!(test_form_state.current_field(), 0);
|
||||
assert!(!test_form_state.has_unsaved_changes());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_form_field_navigation(mut test_form_state: FormState) {
|
||||
// Test initial field
|
||||
assert_eq!(test_form_state.current_field(), 0);
|
||||
|
||||
// Test navigation to next field
|
||||
test_form_state.set_current_field(1);
|
||||
assert_eq!(test_form_state.current_field(), 1);
|
||||
|
||||
// Test navigation to last field
|
||||
test_form_state.set_current_field(2);
|
||||
assert_eq!(test_form_state.current_field(), 2);
|
||||
|
||||
// Test invalid field (should not crash)
|
||||
test_form_state.set_current_field(999);
|
||||
assert_eq!(test_form_state.current_field(), 2); // Should stay at valid field
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_form_data_entry(mut test_form_state: FormState) {
|
||||
// Test entering data in first field
|
||||
*test_form_state.get_current_input_mut() = "Test Company".to_string();
|
||||
test_form_state.set_has_unsaved_changes(true);
|
||||
|
||||
assert_eq!(test_form_state.get_current_input(), "Test Company");
|
||||
assert!(test_form_state.has_unsaved_changes());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_form_field_switching_with_data(mut test_form_state: FormState) {
|
||||
// Enter data in first field
|
||||
*test_form_state.get_current_input_mut() = "Company Name".to_string();
|
||||
|
||||
// Switch to second field
|
||||
test_form_state.set_current_field(1);
|
||||
*test_form_state.get_current_input_mut() = "+421123456789".to_string();
|
||||
|
||||
// Switch back to first field
|
||||
test_form_state.set_current_field(0);
|
||||
assert_eq!(test_form_state.get_current_input(), "Company Name");
|
||||
|
||||
// Switch to second field again
|
||||
test_form_state.set_current_field(1);
|
||||
assert_eq!(test_form_state.get_current_input(), "+421123456789");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_form_reset_functionality(mut test_form_state: FormState) {
|
||||
// Add some data
|
||||
test_form_state.set_current_field(0);
|
||||
*test_form_state.get_current_input_mut() = "Test Company".to_string();
|
||||
test_form_state.set_current_field(1);
|
||||
*test_form_state.get_current_input_mut() = "+421123456789".to_string();
|
||||
test_form_state.set_has_unsaved_changes(true);
|
||||
test_form_state.id = 123;
|
||||
test_form_state.current_position = 5;
|
||||
|
||||
// Reset the form
|
||||
test_form_state.reset_to_empty();
|
||||
|
||||
// Verify reset
|
||||
assert_eq!(test_form_state.id, 0);
|
||||
assert!(!test_form_state.has_unsaved_changes());
|
||||
assert_eq!(test_form_state.current_field(), 0);
|
||||
|
||||
// Check all fields are empty
|
||||
for i in 0..test_form_state.fields.len() {
|
||||
test_form_state.set_current_field(i);
|
||||
assert!(test_form_state.get_current_input().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_form_update_from_response(mut test_form_state: FormState, test_form_data: HashMap<String, String>) {
|
||||
let position = 3;
|
||||
|
||||
// Update form with response data
|
||||
test_form_state.update_from_response(&test_form_data, position);
|
||||
|
||||
// Verify data was loaded
|
||||
assert_eq!(test_form_state.current_position, position);
|
||||
assert!(!test_form_state.has_unsaved_changes());
|
||||
assert_eq!(test_form_state.current_field(), 0);
|
||||
|
||||
// Check field values
|
||||
test_form_state.set_current_field(0);
|
||||
assert_eq!(test_form_state.get_current_input(), "Test Company");
|
||||
|
||||
test_form_state.set_current_field(1);
|
||||
assert_eq!(test_form_state.get_current_input(), "+421123456789");
|
||||
|
||||
test_form_state.set_current_field(2);
|
||||
assert_eq!(test_form_state.get_current_input(), "test@example.com");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_form_cursor_position(mut test_form_state: FormState) {
|
||||
// Test initial cursor position
|
||||
assert_eq!(test_form_state.current_cursor_pos(), 0);
|
||||
|
||||
// Add some text
|
||||
*test_form_state.get_current_input_mut() = "Test Company".to_string();
|
||||
|
||||
// Test cursor positioning
|
||||
test_form_state.set_current_cursor_pos(5);
|
||||
assert_eq!(test_form_state.current_cursor_pos(), 5);
|
||||
|
||||
// Test cursor bounds
|
||||
test_form_state.set_current_cursor_pos(999);
|
||||
// Should be clamped to text length
|
||||
assert!(test_form_state.current_cursor_pos() <= "Test Company".len());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_form_field_display_names(test_form_state: FormState) {
|
||||
let field_names = test_form_state.fields();
|
||||
|
||||
assert_eq!(field_names.len(), 3);
|
||||
assert_eq!(field_names[0], "Company");
|
||||
assert_eq!(field_names[1], "Phone");
|
||||
assert_eq!(field_names[2], "Email");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_form_inputs_vector(mut test_form_state: FormState) {
|
||||
// Add data to fields
|
||||
test_form_state.set_current_field(0);
|
||||
*test_form_state.get_current_input_mut() = "Company A".to_string();
|
||||
|
||||
test_form_state.set_current_field(1);
|
||||
*test_form_state.get_current_input_mut() = "123456789".to_string();
|
||||
|
||||
test_form_state.set_current_field(2);
|
||||
*test_form_state.get_current_input_mut() = "test@test.com".to_string();
|
||||
|
||||
// Get inputs vector
|
||||
let inputs = test_form_state.inputs();
|
||||
|
||||
assert_eq!(inputs.len(), 3);
|
||||
assert_eq!(inputs[0], "Company A");
|
||||
assert_eq!(inputs[1], "123456789");
|
||||
assert_eq!(inputs[2], "test@test.com");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_form_position_management(mut test_form_state: FormState) {
|
||||
// Test initial position
|
||||
assert_eq!(test_form_state.current_position, 1);
|
||||
assert_eq!(test_form_state.total_count, 0);
|
||||
|
||||
// Set some values
|
||||
test_form_state.total_count = 10;
|
||||
test_form_state.current_position = 5;
|
||||
|
||||
assert_eq!(test_form_state.current_position, 5);
|
||||
assert_eq!(test_form_state.total_count, 10);
|
||||
|
||||
// Test reset affects position
|
||||
test_form_state.reset_to_empty();
|
||||
assert_eq!(test_form_state.current_position, 11); // total_count + 1
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_form_autocomplete_state(mut test_form_state: FormState) {
|
||||
// Test initial autocomplete state
|
||||
assert!(!test_form_state.autocomplete_active);
|
||||
assert!(test_form_state.autocomplete_suggestions.is_empty());
|
||||
assert!(test_form_state.selected_suggestion_index.is_none());
|
||||
|
||||
// Test deactivating autocomplete
|
||||
test_form_state.autocomplete_active = true;
|
||||
test_form_state.deactivate_autocomplete();
|
||||
|
||||
assert!(!test_form_state.autocomplete_active);
|
||||
assert!(test_form_state.autocomplete_suggestions.is_empty());
|
||||
assert!(test_form_state.selected_suggestion_index.is_none());
|
||||
assert!(!test_form_state.autocomplete_loading);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_form_empty_data_handling(mut test_form_state: FormState) {
|
||||
let empty_data = HashMap::new();
|
||||
|
||||
// Update with empty data
|
||||
test_form_state.update_from_response(&empty_data, 1);
|
||||
|
||||
// All fields should be empty
|
||||
for i in 0..test_form_state.fields.len() {
|
||||
test_form_state.set_current_field(i);
|
||||
assert!(test_form_state.get_current_input().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_form_partial_data_handling(mut test_form_state: FormState) {
|
||||
let mut partial_data = HashMap::new();
|
||||
partial_data.insert("firma".to_string(), "Partial Company".to_string());
|
||||
// Intentionally missing telefon and email
|
||||
|
||||
test_form_state.update_from_response(&partial_data, 1);
|
||||
|
||||
// First field should have data
|
||||
test_form_state.set_current_field(0);
|
||||
assert_eq!(test_form_state.get_current_input(), "Partial Company");
|
||||
|
||||
// Other fields should be empty
|
||||
test_form_state.set_current_field(1);
|
||||
assert!(test_form_state.get_current_input().is_empty());
|
||||
|
||||
test_form_state.set_current_field(2);
|
||||
assert!(test_form_state.get_current_input().is_empty());
|
||||
}
|
||||
1
client/tests/form/gui/mod.rs
Normal file
1
client/tests/form/gui/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod form_tests;
|
||||
2
client/tests/form/mod.rs
Normal file
2
client/tests/form/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod gui;
|
||||
pub mod requests;
|
||||
1019
client/tests/form/requests/form_request_tests.rs
Normal file
1019
client/tests/form/requests/form_request_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user