Compare commits

..

26 Commits

Author SHA1 Message Date
Priec
a604d62d44 inputs from keyboard are now decoupled 2025-09-10 22:12:22 +02:00
Priec
2cbbfd21aa revert works on login, now do the same for other pages as well 2025-09-08 22:11:53 +02:00
Priec
1c17d07497 space and revert working properly well, also shift 2025-09-08 20:05:39 +02:00
Priec
ad15becd7a doing key sequencing via space 2025-09-08 12:56:03 +02:00
Priec
c2a6272413 buttons in add_logic and add_table works properly well now 2025-09-04 18:56:21 +02:00
Priec
c51af13fb1 intro buttons fixed 2025-09-04 17:46:32 +02:00
Priec
d9d8562539 moving the state from general to each page owning its own state of button or canvas focus 2025-09-04 17:36:13 +02:00
Priec
6891631b8d validation of exact strings 2025-09-02 13:52:36 +02:00
Priec
738d58b5f1 moving add_table to add_logic modern architecture3 2025-09-02 11:46:35 +02:00
Priec
3081125716 moving add_table to add_logic modern architecture2 2025-09-02 00:36:49 +02:00
Priec
6073c7ab43 moving add_table to add_logic modern architecture 2025-09-02 00:23:50 +02:00
filipriec
8157dc7a60 add_table working properly well 2025-09-01 16:37:43 +02:00
filipriec
3b130e9208 add_table fixing 2025-09-01 16:30:57 +02:00
Priec
ab81434c4e add table3 2025-09-01 07:41:13 +02:00
Priec
62c54dc1eb moving add table to the same way as add logic 2025-08-31 23:07:57 +02:00
Priec
347802b2a4 working suggestions in add_logic 2025-08-31 21:48:54 +02:00
Priec
a5a8d98984 add Logic fully decoupled 2025-08-31 21:37:18 +02:00
filipriec
5b42da8290 separate page 2025-08-31 21:25:43 +02:00
filipriec
4e041f36ce move out of canvas properly fixed, now working everyhing properly well 2025-08-31 10:02:48 +02:00
filipriec
22926b7266 add logic now using general movement 2025-08-30 22:38:48 +02:00
filipriec
0a7f032028 add_logic cursor when refocus is proper again 2025-08-30 21:26:20 +02:00
filipriec
4edec5e72d add logic is now using canvas library now 2025-08-30 21:10:10 +02:00
filipriec
c7d524c76a add table and add logic removal from ui.rs and event.rs 2025-08-30 19:47:26 +02:00
filipriec
9ed558562b moved admin now 2025-08-30 19:26:12 +02:00
filipriec
43f5c1a764 login and register are now havving own handlers and loaders, moving logic out of event.rs and ui.rs 2025-08-30 19:13:12 +02:00
filipriec
46149c09db event.rs and ui.rs refactor for the forms page(moved logic to the forms page dir and just calling it now) 2025-08-30 16:42:04 +02:00
53 changed files with 2741 additions and 1902 deletions

View File

@@ -6,6 +6,49 @@ use crate::validation::{CharacterLimits, PatternFilters, DisplayMask};
use crate::validation::{CustomFormatter, FormattingResult, PositionMapper}; use crate::validation::{CustomFormatter, FormattingResult, PositionMapper};
use std::sync::Arc; use std::sync::Arc;
/// Whitelist of allowed exact values for a field.
/// If configured, the field is valid when it is empty (by default) or when the
/// content exactly matches one of the allowed values. This does not block field
/// switching (unlike minimum length in CharacterLimits).
#[derive(Clone, Debug)]
pub struct AllowedValues {
allowed: Vec<String>,
allow_empty: bool,
case_insensitive: bool,
}
impl AllowedValues {
pub fn new(allowed: Vec<String>) -> Self {
Self {
allowed,
allow_empty: true,
case_insensitive: false,
}
}
/// Allow or disallow empty value to be considered valid (default: true).
pub fn allow_empty(mut self, allow: bool) -> Self {
self.allow_empty = allow;
self
}
/// Enable/disable ASCII case-insensitive matching (default: false).
pub fn case_insensitive(mut self, ci: bool) -> Self {
self.case_insensitive = ci;
self
}
fn matches(&self, text: &str) -> bool {
if self.case_insensitive {
self.allowed
.iter()
.any(|s| s.eq_ignore_ascii_case(text))
} else {
self.allowed.iter().any(|s| s == text)
}
}
}
/// Main validation configuration for a field /// Main validation configuration for a field
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct ValidationConfig { pub struct ValidationConfig {
@@ -22,6 +65,9 @@ pub struct ValidationConfig {
#[cfg(feature = "validation")] #[cfg(feature = "validation")]
pub custom_formatter: Option<Arc<dyn CustomFormatter + Send + Sync>>, pub custom_formatter: Option<Arc<dyn CustomFormatter + Send + Sync>>,
/// Optional: restrict the field to one of exact allowed values (or empty)
pub allowed_values: Option<AllowedValues>,
/// Enable external validation indicator UI (feature 5) /// Enable external validation indicator UI (feature 5)
pub external_validation_enabled: bool, pub external_validation_enabled: bool,
@@ -50,6 +96,7 @@ impl std::fmt::Debug for ValidationConfig {
} }
}, },
) )
.field("allowed_values", &self.allowed_values)
.field("external_validation_enabled", &self.external_validation_enabled) .field("external_validation_enabled", &self.external_validation_enabled)
.field("external_validation", &self.external_validation) .field("external_validation", &self.external_validation)
.finish() .finish()
@@ -167,6 +214,18 @@ impl ValidationConfig {
} }
} }
// Allowed values (whitelist) validation
if let Some(ref allowed) = self.allowed_values {
// Empty value is allowed (default) or required (if allow_empty is false)
if text.is_empty() {
if !allowed.allow_empty {
return ValidationResult::warning("Value required");
}
} else if !allowed.matches(text) {
return ValidationResult::error("Value must be one of the allowed options");
}
}
// Future: Add other validation types here // Future: Add other validation types here
ValidationResult::Valid ValidationResult::Valid
@@ -183,6 +242,12 @@ impl ValidationConfig {
#[cfg(not(feature = "validation"))] #[cfg(not(feature = "validation"))]
{ false } { false }
} }
|| self.allowed_values.is_some()
}
/// Check if whitelist is configured
pub fn has_allowed_values(&self) -> bool {
self.allowed_values.is_some()
} }
pub fn allows_field_switch(&self, text: &str) -> bool { pub fn allows_field_switch(&self, text: &str) -> bool {
@@ -289,6 +354,41 @@ impl ValidationConfigBuilder {
self self
} }
/// Restrict content to one of the provided exact values (or empty).
/// - Empty is considered valid by default.
/// - Matching is case-sensitive by default.
pub fn with_allowed_values<S>(mut self, values: Vec<S>) -> Self
where
S: Into<String>,
{
let vals: Vec<String> = values.into_iter().map(Into::into).collect();
self.config.allowed_values = Some(AllowedValues::new(vals));
self
}
/// Same as with_allowed_values, but case-insensitive (ASCII).
pub fn with_allowed_values_ci<S>(mut self, values: Vec<S>) -> Self
where
S: Into<String>,
{
let vals: Vec<String> = values.into_iter().map(Into::into).collect();
self.config.allowed_values = Some(AllowedValues::new(vals).case_insensitive(true));
self
}
/// Configure whether empty value should be allowed when using AllowedValues.
pub fn with_allowed_values_allow_empty(mut self, allow_empty: bool) -> Self {
if let Some(av) = self.config.allowed_values.take() {
self.config.allowed_values = Some(AllowedValues {
allow_empty,
..av
});
} else {
self.config.allowed_values = Some(AllowedValues::new(vec![]).allow_empty(allow_empty));
}
self
}
/// Enable or disable external validation indicator UI (feature 5) /// Enable or disable external validation indicator UI (feature 5)
pub fn with_external_validation_enabled(mut self, enabled: bool) -> Self { pub fn with_external_validation_enabled(mut self, enabled: bool) -> Self {
self.config.external_validation_enabled = enabled; self.config.external_validation_enabled = enabled;
@@ -391,6 +491,47 @@ mod tests {
assert!(config.display_mask.is_some()); assert!(config.display_mask.is_some());
} }
#[test]
fn test_allowed_values() {
let config = ValidationConfigBuilder::new()
.with_allowed_values(vec!["alpha", "beta", "gamma", "delta", "epsilon"])
.build();
// Empty should be valid by default
let result = config.validate_content("");
assert!(result.is_acceptable());
// Exact allowed values are valid
assert!(config.validate_content("alpha").is_acceptable());
assert!(config.validate_content("beta").is_acceptable());
// Anything else is an error
let res = config.validate_content("alph");
assert!(res.is_error());
let res = config.validate_content("ALPHA");
assert!(res.is_error()); // case-sensitive by default
}
#[test]
fn test_allowed_values_case_insensitive_and_required() {
let config = ValidationConfigBuilder::new()
.with_allowed_values_ci(vec!["Yes", "No"])
.with_allowed_values_allow_empty(false)
.build();
// Empty is not allowed now (warning so it's still acceptable for typing)
let res = config.validate_content("");
assert!(res.is_acceptable());
// Case-insensitive matches
assert!(config.validate_content("yes").is_acceptable());
assert!(config.validate_content("NO").is_acceptable());
// Random text is an error
let res = config.validate_content("maybe");
assert!(res.is_error());
}
#[test] #[test]
fn test_validation_result() { fn test_validation_result() {
let valid = ValidationResult::Valid; let valid = ValidationResult::Valid;

View File

@@ -2,9 +2,14 @@
[keybindings] [keybindings]
enter_command_mode = [":", "ctrl+;"] enter_command_mode = [":", "ctrl+;"]
next_buffer = ["space+b+n"] next_buffer = ["ctrl+b+n"]
previous_buffer = ["space+b+p"] previous_buffer = ["ctrl+b+p"]
close_buffer = ["space+b+d"] close_buffer = ["ctrl+b+d"]
# SPACE NOT WORKING, NEEDS REDESIGN
# next_buffer = ["space+b+n"]
# previous_buffer = ["space+b+p"]
# close_buffer = ["space+b+d"]
# revert = ["space+b+r"]
[keybindings.general] [keybindings.general]
up = ["k", "Up"] up = ["k", "Up"]
@@ -27,7 +32,6 @@ move_up = ["Up"]
move_down = ["Down"] move_down = ["Down"]
toggle_sidebar = ["ctrl+t"] toggle_sidebar = ["ctrl+t"]
toggle_buffer_list = ["ctrl+b"] toggle_buffer_list = ["ctrl+b"]
revert = ["space+b+r"]
# MODE SPECIFIC # MODE SPECIFIC
# READ ONLY MODE # READ ONLY MODE
@@ -60,7 +64,7 @@ prev_field = ["Shift+Tab"]
[keybindings.highlight] [keybindings.highlight]
exit_highlight_mode = ["esc"] exit_highlight_mode = ["esc"]
enter_highlight_mode_linewise = ["ctrl+v"] enter_highlight_mode_linewise = ["shift+v"]
### AUTOGENERATED CANVAS CONFIG ### AUTOGENERATED CANVAS CONFIG
# Required # Required

View File

@@ -250,28 +250,44 @@ impl Config {
key: KeyCode, key: KeyCode,
modifiers: KeyModifiers, modifiers: KeyModifiers,
) -> bool { ) -> bool {
// Special handling for shift+character combinations
if binding.to_lowercase().starts_with("shift+") { // Normalize binding once
let binding_lc = binding.to_lowercase();
// Robust handling for Shift+Tab
// Accept either BackTab (with or without SHIFT flagged) or Tab+SHIFT
if binding_lc == "shift+tab" || binding_lc == "backtab" {
return match key {
KeyCode::BackTab => true,
KeyCode::Tab => modifiers.contains(KeyModifiers::SHIFT),
_ => false,
};
}
// Robust handling for shift+<char> (letters)
// Many terminals send uppercase Char without SHIFT bit.
if binding_lc.starts_with("shift+") {
let parts: Vec<&str> = binding.split('+').collect(); let parts: Vec<&str> = binding.split('+').collect();
if parts.len() == 2 && parts[1].len() == 1 { if parts.len() == 2 && parts[1].chars().count() == 1 {
let expected_lowercase = parts[1].chars().next().unwrap().to_lowercase().next().unwrap(); let base = parts[1].chars().next().unwrap();
let expected_uppercase = expected_lowercase.to_uppercase().next().unwrap(); let upper = base.to_ascii_uppercase();
if let KeyCode::Char(actual_char) = key { let lower = base.to_ascii_lowercase();
if actual_char == expected_uppercase && modifiers.contains(KeyModifiers::SHIFT) { if let KeyCode::Char(actual) = key {
// Accept uppercase char regardless of SHIFT bit
if actual == upper {
return true;
}
// Also accept lowercase char with SHIFT flagged (some terms do this)
if actual == lower && modifiers.contains(KeyModifiers::SHIFT) {
return true; return true;
} }
} }
} }
} }
// Handle Shift+Tab -> BackTab
if binding.to_lowercase() == "shift+tab" && key == KeyCode::BackTab && modifiers.is_empty() {
return true;
}
// Handle multi-character bindings (all standard keys without modifiers) // Handle multi-character bindings (all standard keys without modifiers)
if binding.len() > 1 && !binding.contains('+') { if binding.len() > 1 && !binding.contains('+') {
return match binding.to_lowercase().as_str() { return match binding_lc.as_str() {
// Navigation keys // Navigation keys
"left" => key == KeyCode::Left, "left" => key == KeyCode::Left,
"right" => key == KeyCode::Right, "right" => key == KeyCode::Right,
@@ -371,6 +387,7 @@ impl Config {
let mut expected_key = None; let mut expected_key = None;
for part in parts { for part in parts {
let part_lc = part.to_lowercase();
match part.to_lowercase().as_str() { match part.to_lowercase().as_str() {
// Modifiers // Modifiers
"ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL, "ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
@@ -789,12 +806,43 @@ impl Config {
None None
} }
// Normalize bindings for canvas consumption:
// - "shift+<char>" -> also add "<CHAR>"
// - "shift+tab" -> also add "backtab"
// This keeps your config human-friendly while making the canvas happy.
fn normalize_for_canvas(
map: &HashMap<String, Vec<String>>,
) -> HashMap<String, Vec<String>> {
let mut out: HashMap<String, Vec<String>> = HashMap::new();
for (action, bindings) in map {
let mut new_list: Vec<String> = Vec::new();
for b in bindings {
new_list.push(b.clone());
let blc = b.to_lowercase();
if blc.starts_with("shift+") {
let parts: Vec<&str> = b.split('+').collect();
if parts.len() == 2 && parts[1].chars().count() == 1 {
let ch = parts[1].chars().next().unwrap();
new_list.push(ch.to_ascii_uppercase().to_string());
}
if blc == "shift+tab" {
new_list.push("backtab".to_string());
}
}
if blc == "shift+tab" {
new_list.push("backtab".to_string());
}
}
out.insert(action.clone(), new_list);
}
out
}
pub fn build_canvas_keymap(&self) -> CanvasKeyMap { pub fn build_canvas_keymap(&self) -> CanvasKeyMap {
CanvasKeyMap::from_mode_maps( let ro = Self::normalize_for_canvas(&self.keybindings.read_only);
&self.keybindings.read_only, let ed = Self::normalize_for_canvas(&self.keybindings.edit);
&self.keybindings.edit, let hl = Self::normalize_for_canvas(&self.keybindings.highlight);
&self.keybindings.highlight, CanvasKeyMap::from_mode_maps(&ro, &ed, &hl)
)
} }
} }

View File

@@ -67,6 +67,7 @@ impl KeySequenceTracker {
// Helper function to convert any KeyCode to a string representation // Helper function to convert any KeyCode to a string representation
pub fn key_to_string(key: &KeyCode) -> String { pub fn key_to_string(key: &KeyCode) -> String {
match key { match key {
KeyCode::Char(' ') => "space".to_string(),
KeyCode::Char(c) => c.to_string(), KeyCode::Char(c) => c.to_string(),
KeyCode::Left => "left".to_string(), KeyCode::Left => "left".to_string(),
KeyCode::Right => "right".to_string(), KeyCode::Right => "right".to_string(),
@@ -90,6 +91,7 @@ pub fn key_to_string(key: &KeyCode) -> String {
// Helper function to convert a string to a KeyCode // Helper function to convert a string to a KeyCode
pub fn string_to_keycode(s: &str) -> Option<KeyCode> { pub fn string_to_keycode(s: &str) -> Option<KeyCode> {
match s.to_lowercase().as_str() { match s.to_lowercase().as_str() {
"space" => Some(KeyCode::Char(' ')),
"left" => Some(KeyCode::Left), "left" => Some(KeyCode::Left),
"right" => Some(KeyCode::Right), "right" => Some(KeyCode::Right),
"up" => Some(KeyCode::Up), "up" => Some(KeyCode::Up),
@@ -140,7 +142,7 @@ fn is_compound_key(part: &str) -> bool {
matches!(part.to_lowercase().as_str(), matches!(part.to_lowercase().as_str(),
"esc" | "up" | "down" | "left" | "right" | "enter" | "esc" | "up" | "down" | "left" | "right" | "enter" |
"backspace" | "delete" | "tab" | "backtab" | "home" | "backspace" | "delete" | "tab" | "backtab" | "home" |
"end" | "pageup" | "pagedown" | "insert" "end" | "pageup" | "pagedown" | "insert" | "space"
) )
} }

View File

@@ -19,7 +19,6 @@ impl AppState {
self.ui.dialog.purpose = Some(purpose); self.ui.dialog.purpose = Some(purpose);
self.ui.dialog.is_loading = false; self.ui.dialog.is_loading = false;
self.ui.dialog.dialog_show = true; self.ui.dialog.dialog_show = true;
self.ui.focus_outside_canvas = true;
} }
pub fn show_loading_dialog(&mut self, title: &str, message: &str) { pub fn show_loading_dialog(&mut self, title: &str, message: &str) {
@@ -30,7 +29,6 @@ impl AppState {
self.ui.dialog.purpose = None; self.ui.dialog.purpose = None;
self.ui.dialog.is_loading = true; self.ui.dialog.is_loading = true;
self.ui.dialog.dialog_show = true; self.ui.dialog.dialog_show = true;
self.ui.focus_outside_canvas = true;
} }
pub fn update_dialog_content( pub fn update_dialog_content(
@@ -55,7 +53,6 @@ impl AppState {
self.ui.dialog.dialog_buttons.clear(); self.ui.dialog.dialog_buttons.clear();
self.ui.dialog.dialog_active_button_index = 0; self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = None; self.ui.dialog.purpose = None;
self.ui.focus_outside_canvas = false;
self.ui.dialog.is_loading = false; self.ui.dialog.is_loading = false;
} }

View File

@@ -154,15 +154,14 @@ pub async fn handle_dialog_event(
DialogPurpose::ConfirmDeleteColumns => match selected_index { DialogPurpose::ConfirmDeleteColumns => match selected_index {
0 => { 0 => {
// "Confirm" button selected // "Confirm" button selected
if let Page::Admin(state) = &mut router.current { if let Page::AddTable(page) = &mut router.current {
let outcome_message = let outcome_message = handle_delete_selected_columns(&mut page.state);
handle_delete_selected_columns(&mut state.add_table_state);
app_state.hide_dialog(); app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok(outcome_message))); return Some(Ok(EventOutcome::Ok(outcome_message)));
} }
return Some(Ok(EventOutcome::Ok( return Some(Ok(EventOutcome::Ok(
"Admin state not active".to_string(), "AddTable page not active".to_string(),
))); )));
} }
1 => { 1 => {
// "Cancel" button selected // "Cancel" button selected

View File

@@ -0,0 +1,41 @@
// src/input/action.rs
use crate::movement::MovementAction;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BufferAction {
Next,
Previous,
Close,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoreAction {
Save,
ForceQuit,
SaveAndQuit,
Revert,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppAction {
// Global/UI
ToggleSidebar,
ToggleBufferList,
OpenSearch,
FindFilePaletteToggle,
// Buffers
Buffer(BufferAction),
// Command mode
EnterCommandMode,
ExitCommandMode,
CommandExecute,
CommandBackspace,
// Navigation across UI
Navigate(MovementAction),
// Core actions
Core(CoreAction),
}

176
client/src/input/engine.rs Normal file
View File

@@ -0,0 +1,176 @@
// src/input/engine.rs
use crate::config::binds::config::Config;
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::input::action::{AppAction, BufferAction, CoreAction};
use crate::movement::MovementAction;
use crate::modes::handlers::mode_manager::AppMode;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[derive(Debug, Clone, Copy)]
pub struct InputContext {
pub app_mode: AppMode,
pub overlay_active: bool,
pub allow_navigation_capture: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputOutcome {
Action(AppAction),
Pending, // sequence in progress
PassThrough, // let page/canvas handle it
}
pub struct InputEngine {
seq: KeySequenceTracker,
}
impl InputEngine {
pub fn new(timeout_ms: u64) -> Self {
Self {
seq: KeySequenceTracker::new(timeout_ms),
}
}
pub fn reset_sequence(&mut self) {
self.seq.reset();
}
pub fn process_key(
&mut self,
key_event: KeyEvent,
ctx: &InputContext,
config: &Config,
) -> InputOutcome {
// Command mode keys are special (exit/execute/backspace) and typed chars
if ctx.app_mode == AppMode::Command {
if config.is_exit_command_mode(key_event.code, key_event.modifiers) {
self.seq.reset();
return InputOutcome::Action(AppAction::ExitCommandMode);
}
if config.is_command_execute(key_event.code, key_event.modifiers) {
self.seq.reset();
return InputOutcome::Action(AppAction::CommandExecute);
}
if config.is_command_backspace(key_event.code, key_event.modifiers) {
self.seq.reset();
return InputOutcome::Action(AppAction::CommandBackspace);
}
// Let command-line collect characters and other keys pass through
self.seq.reset();
return InputOutcome::PassThrough;
}
// If overlays are active, do not intercept (palette, navigation, etc.)
if ctx.overlay_active {
self.seq.reset();
return InputOutcome::PassThrough;
}
// Space-led multi-key sequences (leader = space)
if ctx.allow_navigation_capture {
let space = KeyCode::Char(' ');
let seq_active = !self.seq.current_sequence.is_empty()
&& self.seq.current_sequence[0] == space;
if seq_active {
self.seq.add_key(key_event.code);
let sequence = self.seq.get_sequence();
if let Some(action_str) = config.matches_key_sequence_generalized(&sequence) {
if let Some(app_action) = map_action_string(action_str, ctx) {
self.seq.reset();
return InputOutcome::Action(app_action);
}
// A non-app action sequence (canvas stuff) → pass-through
self.seq.reset();
return InputOutcome::PassThrough;
}
if config.is_key_sequence_prefix(&sequence) {
return InputOutcome::Pending;
}
// Not matched and not a prefix → reset and continue to single key
self.seq.reset();
} else if key_event.code == space && config.is_key_sequence_prefix(&[space]) {
self.seq.reset();
self.seq.add_key(space);
return InputOutcome::Pending;
}
}
// Single-key mapping: try general binds first (arrows, open_search, enter_command_mode)
if let Some(action_str) =
config.get_general_action(key_event.code, key_event.modifiers)
{
if let Some(app_action) = map_action_string(action_str, ctx) {
return InputOutcome::Action(app_action);
}
// Unknown to app layer (likely canvas movement etc.) → pass
return InputOutcome::PassThrough;
}
// Then app-level common/read-only/edit/highlight for UI toggles or core actions
if let Some(action_str) = config.get_app_action(key_event.code, key_event.modifiers) {
if let Some(app_action) = map_action_string(action_str, ctx) {
return InputOutcome::Action(app_action);
}
}
InputOutcome::PassThrough
}
/// Check if a key sequence is currently active
pub fn has_active_sequence(&self) -> bool {
!self.seq.current_sequence.is_empty()
}
}
fn str_to_movement(s: &str) -> Option<MovementAction> {
match s {
"up" => Some(MovementAction::Up),
"down" => Some(MovementAction::Down),
"left" => Some(MovementAction::Left),
"right" => Some(MovementAction::Right),
"next" => Some(MovementAction::Next),
"previous" => Some(MovementAction::Previous),
"select" => Some(MovementAction::Select),
"esc" => Some(MovementAction::Esc),
_ => None,
}
}
fn map_action_string(action: &str, ctx: &InputContext) -> Option<AppAction> {
match action {
// Global/UI
"toggle_sidebar" => Some(AppAction::ToggleSidebar),
"toggle_buffer_list" => Some(AppAction::ToggleBufferList),
"open_search" => Some(AppAction::OpenSearch),
"find_file_palette_toggle" => Some(AppAction::FindFilePaletteToggle),
// Buffers
"next_buffer" => Some(AppAction::Buffer(BufferAction::Next)),
"previous_buffer" => Some(AppAction::Buffer(BufferAction::Previous)),
"close_buffer" => Some(AppAction::Buffer(BufferAction::Close)),
// Command mode
"enter_command_mode" => Some(AppAction::EnterCommandMode),
"exit_command_mode" => Some(AppAction::ExitCommandMode),
"command_execute" => Some(AppAction::CommandExecute),
"command_backspace" => Some(AppAction::CommandBackspace),
// Navigation across UI (only if allowed)
s if str_to_movement(s).is_some() && ctx.allow_navigation_capture => {
Some(AppAction::Navigate(str_to_movement(s).unwrap()))
}
// Core actions
"save" => Some(AppAction::Core(CoreAction::Save)),
"force_quit" => Some(AppAction::Core(CoreAction::ForceQuit)),
"save_and_quit" => Some(AppAction::Core(CoreAction::SaveAndQuit)),
"revert" => Some(AppAction::Core(CoreAction::Revert)),
// Unknown to app layer: ignore (canvas-specific actions, etc.)
_ => None,
}
}

3
client/src/input/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
// src/input/mod.rs
pub mod action;
pub mod engine;

View File

@@ -14,6 +14,7 @@ pub mod search;
pub mod bottom_panel; pub mod bottom_panel;
pub mod pages; pub mod pages;
pub mod movement; pub mod movement;
pub mod input;
pub use ui::run_ui; pub use ui::run_ui;

View File

@@ -82,8 +82,6 @@ impl TableDependencyGraph {
} }
} }
// ... (NavigationState struct and its new(), activate_*, deactivate(), add_char(), remove_char(), move_*, autocomplete_selected(), get_display_input() methods are unchanged) ...
pub struct NavigationState { pub struct NavigationState {
pub active: bool, pub active: bool,
pub input: String, pub input: String,

View File

@@ -66,12 +66,12 @@ pub async fn handle_navigation_event(
} }
"select" => { "select" => {
let (context, index) = match &router.current { let (context, index) = match &router.current {
Page::Intro(state) => (UiContext::Intro, state.selected_option), Page::Intro(state) => (UiContext::Intro, state.focused_button_index),
Page::Login(_) if app_state.ui.focus_outside_canvas => { Page::Login(state) if state.focus_outside_canvas => {
(UiContext::Login, app_state.focused_button_index) (UiContext::Login, state.focused_button_index)
} }
Page::Register(_) if app_state.ui.focus_outside_canvas => { Page::Register(state) if state.focus_outside_canvas => {
(UiContext::Register, app_state.focused_button_index) (UiContext::Register, state.focused_button_index)
} }
Page::Admin(state) => { Page::Admin(state) => {
(UiContext::Admin, state.get_selected_index().unwrap_or(0)) (UiContext::Admin, state.get_selected_index().unwrap_or(0))
@@ -91,24 +91,24 @@ pub async fn handle_navigation_event(
pub fn up(app_state: &mut AppState, router: &mut Router) { pub fn up(app_state: &mut AppState, router: &mut Router) {
match &mut router.current { match &mut router.current {
Page::Login(page) if app_state.ui.focus_outside_canvas => { Page::Login(page) if page.focus_outside_canvas => {
if app_state.focused_button_index == 0 { if page.focused_button_index == 0 {
app_state.ui.focus_outside_canvas = false; page.focus_outside_canvas = false;
let last_field_index = page.state.field_count().saturating_sub(1); let last_field_index = page.state.field_count().saturating_sub(1);
page.state.set_current_field(last_field_index); page.state.set_current_field(last_field_index);
} else { } else {
app_state.focused_button_index = page.focused_button_index =
app_state.focused_button_index.saturating_sub(1); page.focused_button_index.saturating_sub(1);
} }
} }
Page::Register(state) if app_state.ui.focus_outside_canvas => { Page::Register(state) if state.focus_outside_canvas => {
if app_state.focused_button_index == 0 { if state.focused_button_index == 0 {
app_state.ui.focus_outside_canvas = false; state.focus_outside_canvas = false;
let last_field_index = state.state.field_count().saturating_sub(1); let last_field_index = state.state.field_count().saturating_sub(1);
state.set_current_field(last_field_index); state.set_current_field(last_field_index);
} else { } else {
app_state.focused_button_index = state.focused_button_index =
app_state.focused_button_index.saturating_sub(1); state.focused_button_index.saturating_sub(1);
} }
} }
Page::Intro(state) => state.previous_option(), Page::Intro(state) => state.previous_option(),
@@ -119,10 +119,16 @@ pub fn up(app_state: &mut AppState, router: &mut Router) {
pub fn down(app_state: &mut AppState, router: &mut Router) { pub fn down(app_state: &mut AppState, router: &mut Router) {
match &mut router.current { match &mut router.current {
Page::Login(_) | Page::Register(_) if app_state.ui.focus_outside_canvas => { Page::Login(state) if state.focus_outside_canvas => {
let num_general_elements = 2; let num_general_elements = 2;
if app_state.focused_button_index < num_general_elements - 1 { if state.focused_button_index < num_general_elements - 1 {
app_state.focused_button_index += 1; state.focused_button_index += 1;
}
}
Page::Register(state) if state.focus_outside_canvas => {
let num_general_elements = 2;
if state.focused_button_index < num_general_elements - 1 {
state.focused_button_index += 1;
} }
} }
Page::Intro(state) => state.next_option(), Page::Intro(state) => state.next_option(),
@@ -134,11 +140,11 @@ pub fn down(app_state: &mut AppState, router: &mut Router) {
pub fn next_option(app_state: &mut AppState, router: &mut Router) { pub fn next_option(app_state: &mut AppState, router: &mut Router) {
match &mut router.current { match &mut router.current {
Page::Intro(state) => state.next_option(), Page::Intro(state) => state.next_option(),
Page::Admin(_) => { Page::Admin(state) => {
let option_count = app_state.profile_tree.profiles.len(); let option_count = app_state.profile_tree.profiles.len();
if option_count > 0 { if option_count > 0 {
app_state.focused_button_index = state.focused_button_index =
(app_state.focused_button_index + 1) % option_count; (state.focused_button_index + 1) % option_count;
} }
} }
_ => {} _ => {}
@@ -148,13 +154,13 @@ pub fn next_option(app_state: &mut AppState, router: &mut Router) {
pub fn previous_option(app_state: &mut AppState, router: &mut Router) { pub fn previous_option(app_state: &mut AppState, router: &mut Router) {
match &mut router.current { match &mut router.current {
Page::Intro(state) => state.previous_option(), Page::Intro(state) => state.previous_option(),
Page::Admin(_) => { Page::Admin(state) => {
let option_count = app_state.profile_tree.profiles.len(); let option_count = app_state.profile_tree.profiles.len();
if option_count > 0 { if option_count > 0 {
app_state.focused_button_index = if app_state.focused_button_index == 0 { state.focused_button_index = if state.focused_button_index == 0 {
option_count.saturating_sub(1) option_count.saturating_sub(1)
} else { } else {
app_state.focused_button_index - 1 state.focused_button_index - 1
}; };
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -40,14 +40,11 @@ impl ModeManager {
// If focus is inside a canvas, we don't duplicate canvas modes here. // If focus is inside a canvas, we don't duplicate canvas modes here.
// Canvas crate owns ReadOnly/Edit/Highlight internally. // Canvas crate owns ReadOnly/Edit/Highlight internally.
match &router.current { match &router.current {
Page::Form(_) Page::Form(_) => AppMode::General, // Form always has its own canvas
| Page::Login(_) Page::Login(state) if !state.focus_outside_canvas => AppMode::General,
| Page::Register(_) Page::Register(state) if !state.focus_outside_canvas => AppMode::General,
| Page::AddTable(_) Page::AddTable(state) if !state.focus_outside_canvas => AppMode::General,
| Page::AddLogic(_) if !app_state.ui.focus_outside_canvas => { Page::AddLogic(state) if !state.focus_outside_canvas => AppMode::General,
// Canvas active → let canvas handle its own AppMode
AppMode::General
}
_ => AppMode::General, _ => AppMode::General,
} }
} }

View File

@@ -0,0 +1,65 @@
// src/pages/admin/admin/event.rs
use anyhow::Result;
use crossterm::event::KeyEvent;
use crate::buffer::state::BufferState;
use crate::config::binds::config::Config;
use crate::pages::admin::main::logic::handle_admin_navigation;
use crate::state::app::state::AppState;
use crate::pages::routing::{Router, Page};
/// Handle all Admin page-specific key events (movement + actions).
/// Returns true if the key was handled (so the caller should stop propagation).
pub fn handle_admin_event(
key_event: KeyEvent,
config: &Config,
app_state: &mut AppState,
buffer_state: &mut BufferState,
router: &mut Router,
command_message: &mut String,
) -> Result<bool> {
if let Page::Admin(admin_state) = &mut router.current {
// 1) Map general action to MovementAction (same mapping used in event.rs)
let movement_action = if let Some(act) =
config.get_general_action(key_event.code, key_event.modifiers)
{
use crate::movement::MovementAction;
match act {
"up" => Some(MovementAction::Up),
"down" => Some(MovementAction::Down),
"left" => Some(MovementAction::Left),
"right" => Some(MovementAction::Right),
"next" => Some(MovementAction::Next),
"previous" => Some(MovementAction::Previous),
"select" => Some(MovementAction::Select),
"esc" => Some(MovementAction::Esc),
_ => None,
}
} else {
None
};
if let Some(ma) = movement_action {
if admin_state.handle_movement(app_state, ma) {
return Ok(true);
}
}
// 2) Rich Admin navigation (buttons, selections, etc.)
if handle_admin_navigation(
key_event,
config,
app_state,
buffer_state,
router,
command_message,
) {
return Ok(true);
}
// If we reached here, nothing was handled
return Ok(false);
}
Ok(false)
}

View File

@@ -0,0 +1,54 @@
// src/pages/admin/admin/loader.rs
use anyhow::{Context, Result};
use crate::pages::admin::{AdminFocus, AdminState};
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
/// Refresh admin data and ensure focus and selections are valid.
pub async fn refresh_admin_state(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
admin_state: &mut AdminState,
) -> Result<()> {
// Fetch latest profile tree
let refreshed_tree = grpc_client
.get_profile_tree()
.await
.context("Failed to refresh profile tree for Admin panel")?;
app_state.profile_tree = refreshed_tree;
// Populate profile names for AdminState's list
let profile_names = app_state
.profile_tree
.profiles
.iter()
.map(|p| p.name.clone())
.collect::<Vec<_>>();
admin_state.set_profiles(profile_names);
// Ensure a sane focus
if admin_state.current_focus == AdminFocus::default()
|| !matches!(
admin_state.current_focus,
AdminFocus::InsideProfilesList
| AdminFocus::Tables
| AdminFocus::InsideTablesList
| AdminFocus::Button1
| AdminFocus::Button2
| AdminFocus::Button3
| AdminFocus::ProfilesPane
)
{
admin_state.current_focus = AdminFocus::ProfilesPane;
}
// Ensure a selection exists when profiles are present
if admin_state.profile_list_state.selected().is_none()
&& !app_state.profile_tree.profiles.is_empty()
{
admin_state.profile_list_state.select(Some(0));
}
Ok(())
}

View File

@@ -3,5 +3,7 @@
pub mod state; pub mod state;
pub mod ui; pub mod ui;
pub mod tui; pub mod tui;
pub mod event;
pub mod loader;
pub use state::{AdminState, AdminFocus}; pub use state::{AdminState, AdminFocus};

View File

@@ -1,7 +1,5 @@
// src/pages/admin/admin/state.rs // src/pages/admin/admin/state.rs
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use crate::pages::admin_panel::add_table::state::AddTableState;
use crate::pages::admin_panel::add_logic::state::AddLogicState;
use crate::movement::{move_focus, MovementAction}; use crate::movement::{move_focus, MovementAction};
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
@@ -27,8 +25,8 @@ pub struct AdminState {
pub selected_profile_index: Option<usize>, pub selected_profile_index: Option<usize>,
pub selected_table_index: Option<usize>, pub selected_table_index: Option<usize>,
pub current_focus: AdminFocus, pub current_focus: AdminFocus,
pub add_table_state: AddTableState, pub focus_outside_canvas: bool,
pub add_logic_state: AddLogicState, pub focused_button_index: usize,
} }
impl AdminState { impl AdminState {

View File

@@ -3,9 +3,10 @@ use crate::pages::admin::{AdminFocus, AdminState};
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::config::binds::config::Config; use crate::config::binds::config::Config;
use crate::buffer::state::{BufferState, AppView}; use crate::buffer::state::{BufferState, AppView};
use crate::pages::admin_panel::add_table::state::{AddTableState, LinkDefinition};
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use crate::pages::admin_panel::add_logic::state::{AddLogicState, AddLogicFocus}; use crate::pages::admin_panel::add_table::state::{AddTableFormState, LinkDefinition};
use crate::pages::admin_panel::add_logic::state::{AddLogicState, AddLogicFocus, AddLogicFormState};
use crate::pages::routing::{Page, Router};
// Helper functions list_select_next and list_select_previous remain the same // Helper functions list_select_next and list_select_previous remain the same
fn list_select_next(list_state: &mut ListState, item_count: usize) { fn list_select_next(list_state: &mut ListState, item_count: usize) {
@@ -36,17 +37,35 @@ pub fn handle_admin_navigation(
key: crossterm::event::KeyEvent, key: crossterm::event::KeyEvent,
config: &Config, config: &Config,
app_state: &mut AppState, app_state: &mut AppState,
admin_state: &mut AdminState,
buffer_state: &mut BufferState, buffer_state: &mut BufferState,
router: &mut Router,
command_message: &mut String, command_message: &mut String,
) -> bool { ) -> bool {
let action = config.get_general_action(key.code, key.modifiers).map(String::from); let action = config.get_general_action(key.code, key.modifiers).map(String::from);
let current_focus = admin_state.current_focus;
// Check if we're in admin page, but don't borrow mutably yet
let is_admin = matches!(&router.current, Page::Admin(_));
if !is_admin {
return false;
}
// Get the current focus without borrowing mutably
let current_focus = if let Page::Admin(admin_state) = &router.current {
admin_state.current_focus
} else {
return false;
};
let profile_count = app_state.profile_tree.profiles.len(); let profile_count = app_state.profile_tree.profiles.len();
let mut handled = false; let mut handled = false;
match current_focus { match current_focus {
AdminFocus::ProfilesPane => { AdminFocus::ProfilesPane => {
// Now we can borrow mutably since we're not reassigning router.current
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
match action.as_deref() { match action.as_deref() {
Some("select") => { Some("select") => {
admin_state.current_focus = AdminFocus::InsideProfilesList; admin_state.current_focus = AdminFocus::InsideProfilesList;
@@ -64,7 +83,6 @@ pub fn handle_admin_navigation(
handled = true; handled = true;
} }
Some("previous_option") | Some("move_up") => { 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(); *command_message = "At first focusable pane.".to_string();
handled = true; handled = true;
} }
@@ -73,6 +91,10 @@ pub fn handle_admin_navigation(
} }
AdminFocus::InsideProfilesList => { AdminFocus::InsideProfilesList => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
match action.as_deref() { match action.as_deref() {
Some("move_up") => { Some("move_up") => {
if profile_count > 0 { if profile_count > 0 {
@@ -90,11 +112,11 @@ pub fn handle_admin_navigation(
} }
Some("select") => { Some("select") => {
admin_state.selected_profile_index = admin_state.profile_list_state.selected(); admin_state.selected_profile_index = admin_state.profile_list_state.selected();
admin_state.selected_table_index = None; // Deselect table when profile changes admin_state.selected_table_index = None;
if let Some(profile_idx) = admin_state.selected_profile_index { if let Some(profile_idx) = admin_state.selected_profile_index {
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) { if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
if !profile.tables.is_empty() { if !profile.tables.is_empty() {
admin_state.table_list_state.select(Some(0)); // Auto-select first table for nav admin_state.table_list_state.select(Some(0));
} else { } else {
admin_state.table_list_state.select(None); admin_state.table_list_state.select(None);
} }
@@ -118,6 +140,10 @@ pub fn handle_admin_navigation(
} }
AdminFocus::Tables => { AdminFocus::Tables => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
match action.as_deref() { match action.as_deref() {
Some("select") => { Some("select") => {
admin_state.current_focus = AdminFocus::InsideTablesList; admin_state.current_focus = AdminFocus::InsideTablesList;
@@ -147,7 +173,7 @@ pub fn handle_admin_navigation(
} else { } else {
*command_message = "No tables in selected profile.".to_string(); *command_message = "No tables in selected profile.".to_string();
} }
admin_state.current_focus = AdminFocus::Tables; // Stay in Tables pane if no tables to enter admin_state.current_focus = AdminFocus::Tables;
} }
handled = true; handled = true;
} }
@@ -166,6 +192,10 @@ pub fn handle_admin_navigation(
} }
AdminFocus::InsideTablesList => { AdminFocus::InsideTablesList => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
match action.as_deref() { match action.as_deref() {
Some("move_up") => { Some("move_up") => {
let current_profile_idx = admin_state.selected_profile_index let current_profile_idx = admin_state.selected_profile_index
@@ -205,7 +235,7 @@ pub fn handle_admin_navigation(
handled = true; handled = true;
} }
} }
Some("select") => { // This is for persistently selecting a table with [*] Some("select") => {
admin_state.selected_table_index = admin_state.table_list_state.selected(); admin_state.selected_table_index = admin_state.table_list_state.selected();
let table_name = admin_state.selected_profile_index let table_name = admin_state.selected_profile_index
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx)) .and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
@@ -225,29 +255,36 @@ pub fn handle_admin_navigation(
AdminFocus::Button1 => { // Add Logic Button AdminFocus::Button1 => { // Add Logic Button
match action.as_deref() { match action.as_deref() {
Some("select") => { // Typically "Enter" key Some("select") => {
if let Some(p_idx) = admin_state.selected_profile_index { // Extract needed data first, before any router reassignment
let (selected_profile_idx, selected_table_idx) = if let Page::Admin(admin_state) = &router.current {
(admin_state.selected_profile_index, admin_state.selected_table_index)
} else {
return false;
};
if let Some(p_idx) = selected_profile_idx {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) { 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(t_idx) = selected_table_idx {
if let Some(table) = profile.tables.get(t_idx) { if let Some(table) = profile.tables.get(t_idx) {
// Both profile and table are selected, proceed // Create AddLogic page with selected profile & table
admin_state.add_logic_state = AddLogicState { let add_logic_form = AddLogicFormState::new_with_table(
profile_name: profile.name.clone(), &config.editor,
selected_table_name: Some(table.name.clone()), profile.name.clone(),
selected_table_id: Some(table.id), // If you have table IDs Some(table.id),
editor_keybinding_mode: config.editor.keybinding_mode.clone(), table.name.clone(),
current_focus: AddLogicFocus::default(), );
..AddLogicState::default()
};
// Store table info for later fetching // Store table info for later fetching
app_state.pending_table_structure_fetch = Some(( app_state.pending_table_structure_fetch = Some((
profile.name.clone(), profile.name.clone(),
table.name.clone() table.name.clone(),
)); ));
// Now it's safe to reassign router.current
router.current = Page::AddLogic(add_logic_form);
buffer_state.update_history(AppView::AddLogic); buffer_state.update_history(AppView::AddLogic);
app_state.ui.focus_outside_canvas = false;
*command_message = format!( *command_message = format!(
"Opening Add Logic for table '{}' in profile '{}'...", "Opening Add Logic for table '{}' in profile '{}'...",
table.name, profile.name table.name, profile.name
@@ -267,11 +304,17 @@ pub fn handle_admin_navigation(
handled = true; handled = true;
} }
Some("previous_option") | Some("move_up") => { Some("previous_option") | Some("move_up") => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
admin_state.current_focus = AdminFocus::Tables; admin_state.current_focus = AdminFocus::Tables;
*command_message = "Focus: Tables Pane".to_string(); *command_message = "Focus: Tables Pane".to_string();
handled = true; handled = true;
} }
Some("next_option") | Some("move_down") => { Some("next_option") | Some("move_down") => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
admin_state.current_focus = AdminFocus::Button2; admin_state.current_focus = AdminFocus::Button2;
*command_message = "Focus: Add Table Button".to_string(); *command_message = "Focus: Add Table Button".to_string();
handled = true; handled = true;
@@ -283,25 +326,36 @@ pub fn handle_admin_navigation(
AdminFocus::Button2 => { // Add Table Button AdminFocus::Button2 => { // Add Table Button
match action.as_deref() { match action.as_deref() {
Some("select") => { Some("select") => {
if let Some(p_idx) = admin_state.selected_profile_index { // Extract needed data first
let selected_profile_idx = if let Page::Admin(admin_state) = &router.current {
admin_state.selected_profile_index
} else {
return false;
};
if let Some(p_idx) = selected_profile_idx {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) { if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
let selected_profile_name = profile.name.clone(); let selected_profile_name = profile.name.clone();
// Prepare links from the selected profile's existing tables // Prepare links from the selected profile's existing tables
let available_links: Vec<LinkDefinition> = profile.tables.iter() let available_links: Vec<LinkDefinition> = profile.tables.iter()
.map(|table| LinkDefinition { .map(|table| LinkDefinition {
linked_table_name: table.name.clone(), linked_table_name: table.name.clone(),
is_required: false, // Default, can be changed in AddTable screen is_required: false,
selected: false, selected: false,
}).collect(); }).collect();
admin_state.add_table_state = AddTableState { // Build decoupled AddTable page and route into it
profile_name: selected_profile_name, let mut page = AddTableFormState::new(selected_profile_name.clone());
links: available_links, page.state.links = available_links;
..AddTableState::default() // Reset other fields
}; // Now safe to reassign router.current
router.current = Page::AddTable(page);
buffer_state.update_history(AppView::AddTable); 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); *command_message = format!(
"Opening Add Table for profile '{}'...",
selected_profile_name
);
handled = true; handled = true;
} else { } else {
*command_message = "Error: Selected profile index out of bounds.".to_string(); *command_message = "Error: Selected profile index out of bounds.".to_string();
@@ -313,11 +367,17 @@ pub fn handle_admin_navigation(
} }
} }
Some("previous_option") | Some("move_up") => { Some("previous_option") | Some("move_up") => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
admin_state.current_focus = AdminFocus::Button1; admin_state.current_focus = AdminFocus::Button1;
*command_message = "Focus: Add Logic Button".to_string(); *command_message = "Focus: Add Logic Button".to_string();
handled = true; handled = true;
} }
Some("next_option") | Some("move_down") => { Some("next_option") | Some("move_down") => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
admin_state.current_focus = AdminFocus::Button3; admin_state.current_focus = AdminFocus::Button3;
*command_message = "Focus: Change Table Button".to_string(); *command_message = "Focus: Change Table Button".to_string();
handled = true; handled = true;
@@ -329,17 +389,18 @@ pub fn handle_admin_navigation(
AdminFocus::Button3 => { // Change Table Button AdminFocus::Button3 => { // Change Table Button
match action.as_deref() { match action.as_deref() {
Some("select") => { Some("select") => {
// Future: Logic to load selected table into AddTableState for editing
*command_message = "Action: Change Table (Not Implemented)".to_string(); *command_message = "Action: Change Table (Not Implemented)".to_string();
handled = true; handled = true;
} }
Some("previous_option") | Some("move_up") => { Some("previous_option") | Some("move_up") => {
let Page::Admin(admin_state) = &mut router.current else {
return false;
};
admin_state.current_focus = AdminFocus::Button2; admin_state.current_focus = AdminFocus::Button2;
*command_message = "Focus: Add Table Button".to_string(); *command_message = "Focus: Add Table Button".to_string();
handled = true; handled = true;
} }
Some("next_option") | Some("move_down") => { 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(); *command_message = "At last focusable button.".to_string();
handled = true; handled = true;
} }

View File

@@ -0,0 +1,159 @@
// src/pages/admin_panel/add_logic/event.rs
use anyhow::Result;
use crate::config::binds::config::Config;
use crate::movement::{move_focus, MovementAction};
use crate::pages::admin_panel::add_logic::nav::SaveLogicResultSender;
use crate::pages::admin_panel::add_logic::state::{AddLogicFocus, AddLogicFormState};
use crate::components::common::text_editor::TextEditor;
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
use crate::modes::handlers::event::EventOutcome;
use canvas::{AppMode as CanvasMode, DataProvider};
use crossterm::event::KeyEvent;
/// Focus traversal order for non-canvas navigation
const ADD_LOGIC_FOCUS_ORDER: [AddLogicFocus; 6] = [
AddLogicFocus::InputLogicName,
AddLogicFocus::InputTargetColumn,
AddLogicFocus::InputDescription,
AddLogicFocus::ScriptContentPreview,
AddLogicFocus::SaveButton,
AddLogicFocus::CancelButton,
];
/// Handles all AddLogic page-specific events.
/// Return a non-empty Ok(message) only when the page actually consumed the key,
/// otherwise return Ok("") to let global handling proceed.
pub fn handle_add_logic_event(
key_event: KeyEvent,
movement: Option<MovementAction>,
config: &Config,
app_state: &mut AppState,
add_logic_page: &mut AddLogicFormState,
grpc_client: GrpcClient,
save_logic_sender: SaveLogicResultSender,
) -> Result<EventOutcome> {
// 1) Script editor fullscreen mode
if add_logic_page.state.current_focus == AddLogicFocus::InsideScriptContent {
match key_event.code {
crossterm::event::KeyCode::Esc => {
add_logic_page.state.current_focus = AddLogicFocus::ScriptContentPreview;
add_logic_page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok("Exited script editing.".to_string()));
}
_ => {
let changed = {
let mut editor_borrow =
add_logic_page.state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_page.state.editor_keybinding_mode,
&mut add_logic_page.state.vim_state,
)
};
if changed {
add_logic_page.state.has_unsaved_changes = true;
return Ok(EventOutcome::Ok("Script updated".to_string()));
}
return Ok(EventOutcome::Ok(String::new()));
}
}
}
// 2) Inside canvas: forward to FormEditor
let inside_canvas_inputs = matches!(
add_logic_page.state.current_focus,
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription
);
if inside_canvas_inputs {
// Only allow leaving the canvas with Down/Next when the form editor
// is in ReadOnly mode. In Edit mode, keep focus inside the canvas.
let in_edit_mode = add_logic_page.editor.mode() == CanvasMode::Edit;
if !in_edit_mode {
if let Some(ma) = movement {
let last_idx = add_logic_page
.editor
.data_provider()
.field_count()
.saturating_sub(1);
let at_last = add_logic_page.editor.current_field() >= last_idx;
if at_last && matches!(ma, MovementAction::Down | MovementAction::Next) {
add_logic_page.state.last_canvas_field = last_idx;
add_logic_page.state.current_focus = AddLogicFocus::ScriptContentPreview;
add_logic_page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok("Moved to Script Preview".to_string()));
}
}
}
match add_logic_page.handle_key_event(key_event) {
canvas::keymap::KeyEventOutcome::Consumed(Some(msg)) => {
add_logic_page.sync_from_editor();
return Ok(EventOutcome::Ok(msg));
}
canvas::keymap::KeyEventOutcome::Consumed(None) => {
add_logic_page.sync_from_editor();
return Ok(EventOutcome::Ok("Input updated".into()));
}
canvas::keymap::KeyEventOutcome::Pending => {
return Ok(EventOutcome::Ok(String::new()));
}
canvas::keymap::KeyEventOutcome::NotMatched => {
// fall through
}
}
}
// 3) Outside canvas
if let Some(ma) = movement {
let mut current = add_logic_page.state.current_focus;
if move_focus(&ADD_LOGIC_FOCUS_ORDER, &mut current, ma) {
add_logic_page.state.current_focus = current;
add_logic_page.focus_outside_canvas = !matches!(
add_logic_page.state.current_focus,
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription
);
return Ok(EventOutcome::Ok(String::new()));
}
match ma {
MovementAction::Select => match add_logic_page.state.current_focus {
AddLogicFocus::ScriptContentPreview => {
add_logic_page.state.current_focus = AddLogicFocus::InsideScriptContent;
add_logic_page.focus_outside_canvas = false;
return Ok(EventOutcome::Ok(
"Fullscreen script editing. Esc to exit.".to_string(),
));
}
AddLogicFocus::SaveButton => {
if let Some(msg) = add_logic_page.state.save_logic() {
return Ok(EventOutcome::Ok(msg));
} else {
return Ok(EventOutcome::Ok("Saved (no changes)".to_string()));
}
}
AddLogicFocus::CancelButton => {
return Ok(EventOutcome::Ok("Cancelled Add Logic".to_string()));
}
_ => {}
},
MovementAction::Esc => {
if add_logic_page.state.current_focus == AddLogicFocus::ScriptContentPreview {
add_logic_page.state.current_focus = AddLogicFocus::InputDescription;
add_logic_page.focus_outside_canvas = false;
return Ok(EventOutcome::Ok("Back to Description".to_string()));
}
}
_ => {}
}
}
Ok(EventOutcome::Ok(String::new()))
}

View File

@@ -0,0 +1,115 @@
// src/pages/admin_panel/add_logic/loader.rs
use anyhow::{Context, Result};
use tracing::{error, info, warn};
use crate::pages::admin_panel::add_logic::state::AddLogicFormState;
use crate::pages::routing::{Page, Router};
use crate::services::grpc_client::GrpcClient;
use crate::services::ui_service::UiService;
use crate::state::app::state::AppState;
/// Process pending table structure fetch for AddLogic page.
/// Returns true if UI needs a redraw.
pub async fn process_pending_table_structure_fetch(
app_state: &mut AppState,
router: &mut Router,
grpc_client: &mut GrpcClient,
command_message: &mut String,
) -> Result<bool> {
let mut needs_redraw = false;
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
if let Page::AddLogic(page) = &mut router.current {
if page.profile_name() == profile_name
&& page.selected_table_name().map(|s| s.as_str()) == Some(table_name.as_str())
{
info!(
"Fetching table structure for {}.{}",
profile_name, table_name
);
let fetch_message = UiService::initialize_add_logic_table_data(
grpc_client,
&mut page.state, // keep state here, UiService expects AddLogicState
&app_state.profile_tree,
)
.await
.unwrap_or_else(|e| {
error!(
"Error initializing add_logic_table_data for {}.{}: {}",
profile_name, table_name, e
);
format!("Error fetching table structure: {}", e)
});
if !fetch_message.contains("Error") && !fetch_message.contains("Warning") {
info!("{}", fetch_message);
} else {
*command_message = fetch_message;
}
// 🔑 Rebuild FormEditor with updated state (so suggestions work)
page.editor = canvas::FormEditor::new(page.state.clone());
needs_redraw = true;
} else {
error!(
"Mismatch in pending_table_structure_fetch: app_state wants {}.{}, \
but AddLogic state is for {}.{:?}",
profile_name,
table_name,
page.profile_name(),
page.selected_table_name()
);
}
} else {
warn!(
"Pending table structure fetch for {}.{} but AddLogic view is not active. Ignored.",
profile_name, table_name
);
}
}
Ok(needs_redraw)
}
/// If the AddLogic page is awaiting columns for a selected table in the script editor,
/// fetch them and update the state. Returns true if UI needs a redraw.
pub async fn maybe_fetch_columns_for_awaiting_table(
grpc_client: &mut GrpcClient,
page: &mut AddLogicFormState,
command_message: &mut String,
) -> Result<bool> {
if let Some(table_name) = page
.state
.script_editor_awaiting_column_autocomplete
.clone()
{
let profile_name = page.state.profile_name.clone();
info!(
"Fetching columns for table selection: {}.{}",
profile_name, table_name
);
match UiService::fetch_columns_for_table(grpc_client, &profile_name, &table_name).await {
Ok(columns) => {
page.state.set_columns_for_table_autocomplete(columns.clone());
info!("Loaded {} columns for table '{}'", columns.len(), table_name);
*command_message =
format!("Columns for '{}' loaded. Select a column.", table_name);
}
Err(e) => {
error!(
"Failed to fetch columns for {}.{}: {}",
profile_name, table_name, e
);
page.state.script_editor_awaiting_column_autocomplete = None;
page.state.deactivate_script_editor_autocomplete();
*command_message = format!("Error loading columns for '{}': {}", table_name, e);
}
}
return Ok(true);
}
Ok(false)
}

View File

@@ -3,3 +3,5 @@
pub mod ui; pub mod ui;
pub mod nav; pub mod nav;
pub mod state; pub mod state;
pub mod loader;
pub mod event;

View File

@@ -1,531 +1,6 @@
// src/pages/admin_panel/add_logic/nav.rs // src/pages/admin_panel/add_logic/nav.rs
use crate::config::binds::config::{Config, EditorKeybindingMode};
use crate::state::app::state::AppState;
use crate::pages::admin_panel::add_logic::state::{AddLogicFocus, AddLogicState};
use crate::buffer::{AppView, BufferState};
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
use crate::services::GrpcClient;
use tokio::sync::mpsc;
use anyhow::Result; use anyhow::Result;
use crate::components::common::text_editor::TextEditor; use tokio::sync::mpsc;
use crate::services::ui_service::UiService;
use tui_textarea::CursorMove;
use crate::pages::admin::AdminState;
use crate::pages::routing::{Router, Page};
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>; pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
pub fn handle_add_logic_navigation(
key_event: KeyEvent,
config: &Config,
app_state: &mut AppState,
buffer_state: &mut BufferState,
grpc_client: GrpcClient,
save_logic_sender: SaveLogicResultSender,
command_message: &mut String,
router: &mut Router,
) -> bool {
if let Page::AddLogic(add_logic_state) = &mut router.current {
// === FULLSCREEN SCRIPT EDITING ===
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
// === AUTOCOMPLETE HANDLING ===
if add_logic_state.script_editor_autocomplete_active {
match key_event.code {
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("('')");
editor_borrow.move_cursor(CursorMove::Back);
editor_borrow.move_cursor(CursorMove::Back);
*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);
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 {
if let Err(e) = UiService::fetch_columns_for_table(
&mut client_clone,
&profile_name,
&table_name_for_fetch,
)
.await
{
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;
}
}
}
// Trigger autocomplete with '@'
if key_event.code == KeyCode::Char('@') && key_event.modifiers == KeyModifiers::NONE {
let should_trigger = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
TextEditor::is_vim_insert_mode(&add_logic_state.vim_state)
}
_ => 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;
}
}
// Esc handling
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
let was_insert =
TextEditor::is_vim_insert_mode(&add_logic_state.vim_state);
{
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 was_insert {
*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;
*command_message = "Exited script editing.".to_string();
}
}
_ => {
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
app_state.ui.focus_outside_canvas = true;
*command_message = "Exited script editing.".to_string();
}
}
return true;
}
// Normal text input
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;
}
return true;
}
// === NON-FULLSCREEN NAVIGATION ===
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;
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
);
handled = true;
}
AddLogicFocus::SaveButton => {
*command_message = "Save logic action".to_string();
handled = true;
}
AddLogicFocus::CancelButton => {
buffer_state.update_history(AppView::Admin);
*command_message = "Cancelled Add Logic".to_string();
handled = true;
}
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription => {
// Focus canvas inputs; let canvas keymap handle editing
app_state.ui.focus_outside_canvas = false;
handled = false; // forward to canvas
}
_ => handled = false,
}
}
Some("toggle_edit_mode") => {
match current_focus {
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription => {
app_state.ui.focus_outside_canvas = false;
*command_message =
"Focus moved to input. Use i/a (Vim) or type to edit.".to_string();
handled = true;
}
_ => {
*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 {
app_state.ui.focus_outside_canvas = false;
} else {
app_state.ui.focus_outside_canvas = true;
}
}
handled
} else {
false
}
}
fn replace_autocomplete_text(
editor: &mut tui_textarea::TextArea,
trigger_pos: (usize, usize),
filter_len: usize,
replacement: &str,
) {
let filter_start_pos = (trigger_pos.0, trigger_pos.1 + 1);
editor.move_cursor(CursorMove::Jump(filter_start_pos.0 as u16, filter_start_pos.1 as u16));
for _ in 0..filter_len {
editor.delete_next_char();
}
editor.insert_str(replacement);
}

View File

@@ -1,7 +1,8 @@
// src/pages/admin_panel/add_logic/state.rs // src/pages/admin_panel/add_logic/state.rs
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode}; use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
use crate::components::common::text_editor::{TextEditor, VimState}; use crate::components::common::text_editor::{TextEditor, VimState};
use canvas::{DataProvider, AppMode}; use canvas::{DataProvider, AppMode, FormEditor, SuggestionItem};
use crossterm::event::KeyCode;
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use tui_textarea::TextArea; use tui_textarea::TextArea;
@@ -98,6 +99,19 @@ impl AddLogicState {
pub const INPUT_FIELD_COUNT: usize = 3; pub const INPUT_FIELD_COUNT: usize = 3;
/// Build canvas SuggestionItem list for target column
pub fn column_suggestions_sync(&self, query: &str) -> Vec<SuggestionItem> {
let q = query.to_lowercase();
self.table_columns_for_suggestions
.iter()
.filter(|c| q.is_empty() || c.to_lowercase().contains(&q))
.map(|c| SuggestionItem {
display_text: c.clone(),
value_to_store: c.clone(),
})
.collect()
}
/// Updates the target_column_suggestions based on current input. /// Updates the target_column_suggestions based on current input.
pub fn update_target_column_suggestions(&mut self) { pub fn update_target_column_suggestions(&mut self) {
let current_input = self.target_column_input.to_lowercase(); let current_input = self.target_column_input.to_lowercase();
@@ -315,3 +329,242 @@ impl DataProvider for AddLogicState {
field_index == 1 field_index == 1
} }
} }
// Wrapper that owns both the raw state and its FormEditor (like LoginFormState)
pub struct AddLogicFormState {
pub state: AddLogicState,
pub editor: FormEditor<AddLogicState>,
pub focus_outside_canvas: bool,
pub focused_button_index: usize,
}
// manual Debug because FormEditor may not implement Debug
impl std::fmt::Debug for AddLogicFormState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AddLogicFormState")
.field("state", &self.state)
.field("focus_outside_canvas", &self.focus_outside_canvas)
.field("focused_button_index", &self.focused_button_index)
.finish()
}
}
impl AddLogicFormState {
pub fn new(editor_config: &EditorConfig) -> Self {
let state = AddLogicState::new(editor_config);
let editor = FormEditor::new(state.clone());
Self {
state,
editor,
focus_outside_canvas: false,
focused_button_index: 0,
}
}
pub fn new_with_table(
editor_config: &EditorConfig,
profile_name: String,
table_id: Option<i64>,
table_name: String,
) -> Self {
let mut state = AddLogicState::new(editor_config);
state.profile_name = profile_name;
state.selected_table_id = table_id;
state.selected_table_name = Some(table_name);
let editor = FormEditor::new(state.clone());
Self {
state,
editor,
focus_outside_canvas: false,
focused_button_index: 0,
}
}
pub fn from_state(state: AddLogicState) -> Self {
let editor = FormEditor::new(state.clone());
Self {
state,
editor,
focus_outside_canvas: false,
focused_button_index: 0,
}
}
/// Sync state from editor's data provider snapshot
pub fn sync_from_editor(&mut self) {
self.state = self.editor.data_provider().clone();
}
// === Delegates to AddLogicState fields ===
pub fn current_focus(&self) -> AddLogicFocus {
self.state.current_focus
}
pub fn set_current_focus(&mut self, focus: AddLogicFocus) {
self.state.current_focus = focus;
}
pub fn has_unsaved_changes(&self) -> bool {
self.state.has_unsaved_changes
}
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
self.state.has_unsaved_changes = changed;
}
pub fn profile_name(&self) -> &str {
&self.state.profile_name
}
pub fn selected_table_name(&self) -> Option<&String> {
self.state.selected_table_name.as_ref()
}
pub fn selected_table_id(&self) -> Option<i64> {
self.state.selected_table_id
}
pub fn script_content_editor(&self) -> &Rc<RefCell<TextArea<'static>>> {
&self.state.script_content_editor
}
pub fn script_content_editor_mut(&mut self) -> &mut Rc<RefCell<TextArea<'static>>> {
&mut self.state.script_content_editor
}
pub fn vim_state(&self) -> &VimState {
&self.state.vim_state
}
pub fn vim_state_mut(&mut self) -> &mut VimState {
&mut self.state.vim_state
}
pub fn editor_keybinding_mode(&self) -> &EditorKeybindingMode {
&self.state.editor_keybinding_mode
}
pub fn script_editor_autocomplete_active(&self) -> bool {
self.state.script_editor_autocomplete_active
}
pub fn script_editor_suggestions(&self) -> &Vec<String> {
&self.state.script_editor_suggestions
}
pub fn script_editor_selected_suggestion_index(&self) -> Option<usize> {
self.state.script_editor_selected_suggestion_index
}
pub fn target_column_suggestions(&self) -> &Vec<String> {
&self.state.target_column_suggestions
}
pub fn selected_target_column_suggestion_index(&self) -> Option<usize> {
self.state.selected_target_column_suggestion_index
}
pub fn in_target_column_suggestion_mode(&self) -> bool {
self.state.in_target_column_suggestion_mode
}
pub fn show_target_column_suggestions(&self) -> bool {
self.state.show_target_column_suggestions
}
// === Delegates to FormEditor ===
pub fn mode(&self) -> AppMode {
self.editor.mode()
}
pub fn cursor_position(&self) -> usize {
self.editor.cursor_position()
}
pub fn handle_key_event(
&mut self,
key_event: crossterm::event::KeyEvent,
) -> canvas::keymap::KeyEventOutcome {
// Customize behavior for Target Column (field index 1) in Edit mode,
// mirroring how Register page does suggestions for Role.
let in_target_col_field = self.editor.current_field() == 1;
let in_edit_mode = self.editor.mode() == canvas::AppMode::Edit;
if in_target_col_field && in_edit_mode {
match key_event.code {
// Tab: open suggestions if inactive; otherwise cycle next
KeyCode::Tab => {
if !self.editor.is_suggestions_active() {
if let Some(query) = self.editor.start_suggestions(1) {
let items = self.state.column_suggestions_sync(&query);
let applied =
self.editor.apply_suggestions_result(1, &query, items);
if applied {
self.editor.update_inline_completion();
}
}
} else {
self.editor.suggestions_next();
}
return canvas::keymap::KeyEventOutcome::Consumed(None);
}
// Shift+Tab: cycle suggestions too (fallback to next)
KeyCode::BackTab => {
if self.editor.is_suggestions_active() {
self.editor.suggestions_next();
return canvas::keymap::KeyEventOutcome::Consumed(None);
}
}
// Enter: apply selected suggestion (if active)
KeyCode::Enter => {
if self.editor.is_suggestions_active() {
let _ = self.editor.apply_suggestion();
return canvas::keymap::KeyEventOutcome::Consumed(None);
}
}
// Esc: close suggestions if active
KeyCode::Esc => {
if self.editor.is_suggestions_active() {
self.editor.close_suggestions();
return canvas::keymap::KeyEventOutcome::Consumed(None);
}
}
// Character input: mutate then refresh suggestions if active
KeyCode::Char(_) => {
let outcome = self.editor.handle_key_event(key_event);
if self.editor.is_suggestions_active() {
if let Some(query) = self.editor.start_suggestions(1) {
let items = self.state.column_suggestions_sync(&query);
let applied =
self.editor.apply_suggestions_result(1, &query, items);
if applied {
self.editor.update_inline_completion();
}
}
}
return outcome;
}
// Backspace/Delete: mutate then refresh suggestions if active
KeyCode::Backspace | KeyCode::Delete => {
let outcome = self.editor.handle_key_event(key_event);
if self.editor.is_suggestions_active() {
if let Some(query) = self.editor.start_suggestions(1) {
let items = self.state.column_suggestions_sync(&query);
let applied =
self.editor.apply_suggestions_result(1, &query, items);
if applied {
self.editor.update_inline_completion();
}
}
}
return outcome;
}
_ => { /* fall through */ }
}
}
// Default: let canvas handle it
self.editor.handle_key_event(key_event)
}
}

View File

@@ -1,8 +1,8 @@
// src/pages/admin_panel/add_logic/ui.rs // src/pages/admin_panel/add_logic/ui.rs
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::pages::admin_panel::add_logic::state::{AddLogicFocus, AddLogicState}; use crate::pages::admin_panel::add_logic::state::{AddLogicFocus, AddLogicState, AddLogicFormState};
use canvas::{render_canvas, FormEditor}; use canvas::{render_canvas, render_suggestions_dropdown, DefaultCanvasTheme, FormEditor};
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style}, style::{Modifier, Style},
@@ -19,7 +19,7 @@ pub fn render_add_logic(
area: Rect, area: Rect,
theme: &Theme, theme: &Theme,
app_state: &AppState, app_state: &AppState,
add_logic_state: &mut AddLogicState, add_logic_state: &mut AddLogicFormState,
) { ) {
let main_block = Block::default() let main_block = Block::default()
.title(" Add New Logic Script ") .title(" Add New Logic Script ")
@@ -32,9 +32,13 @@ pub fn render_add_logic(
f.render_widget(main_block, area); f.render_widget(main_block, area);
// Handle full-screen script editing // Handle full-screen script editing
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent { if add_logic_state.current_focus() == AddLogicFocus::InsideScriptContent {
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut(); let mut editor_ref = add_logic_state
let border_style_color = if crate::components::common::text_editor::TextEditor::is_vim_insert_mode(&add_logic_state.vim_state) { .state
.script_content_editor
.borrow_mut();
let border_style_color = if crate::components::common::text_editor::TextEditor::is_vim_insert_mode(add_logic_state.vim_state()) {
theme.highlight theme.highlight
} else { } else {
theme.secondary theme.secondary
@@ -44,13 +48,13 @@ pub fn render_add_logic(
editor_ref.set_cursor_line_style(Style::default()); editor_ref.set_cursor_line_style(Style::default());
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
let script_title_hint = match add_logic_state.editor_keybinding_mode { let script_title_hint = match add_logic_state.editor_keybinding_mode() {
EditorKeybindingMode::Vim => { EditorKeybindingMode::Vim => {
let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(&add_logic_state.vim_state); let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(add_logic_state.vim_state());
format!("Script {}", vim_mode_status) format!("Script {}", vim_mode_status)
} }
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => { EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
if crate::components::common::text_editor::TextEditor::is_vim_insert_mode(&add_logic_state.vim_state) { if crate::components::common::text_editor::TextEditor::is_vim_insert_mode(add_logic_state.vim_state()) {
"Script (Editing)".to_string() "Script (Editing)".to_string()
} else { } else {
"Script".to_string() "Script".to_string()
@@ -72,10 +76,10 @@ pub fn render_add_logic(
drop(editor_ref); drop(editor_ref);
// === SCRIPT EDITOR AUTOCOMPLETE RENDERING === // === SCRIPT EDITOR AUTOCOMPLETE RENDERING ===
if add_logic_state.script_editor_autocomplete_active && !add_logic_state.script_editor_suggestions.is_empty() { if add_logic_state.script_editor_autocomplete_active() && !add_logic_state.script_editor_suggestions().is_empty() {
// Get the current cursor position from textarea // Get the current cursor position from textarea
let current_cursor = { let current_cursor = {
let editor_borrow = add_logic_state.script_content_editor.borrow(); let editor_borrow = add_logic_state.script_content_editor().borrow();
editor_borrow.cursor() // Returns (row, col) as (usize, usize) editor_borrow.cursor() // Returns (row, col) as (usize, usize)
}; };
@@ -103,8 +107,8 @@ pub fn render_add_logic(
input_rect, input_rect,
f.area(), // Full frame area for clamping f.area(), // Full frame area for clamping
theme, theme,
&add_logic_state.script_editor_suggestions, add_logic_state.script_editor_suggestions(),
add_logic_state.script_editor_selected_suggestion_index, add_logic_state.script_editor_selected_suggestion_index(),
); );
} }
@@ -128,66 +132,61 @@ pub fn render_add_logic(
let buttons_area = main_chunks[3]; let buttons_area = main_chunks[3];
// Top info // Top info
let table_label = if let Some(name) = add_logic_state.selected_table_name() {
name.clone()
} else if let Some(id) = add_logic_state.selected_table_id() {
format!("ID {}", id)
} else {
"Global (Not Selected)".to_string()
};
let profile_text = Paragraph::new(vec![ let profile_text = Paragraph::new(vec![
Line::from(Span::styled( Line::from(Span::styled(
format!("Profile: {}", add_logic_state.profile_name), format!("Profile: {}", add_logic_state.profile_name()),
Style::default().fg(theme.fg), Style::default().fg(theme.fg),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!( format!("Table: {}", table_label),
"Table: {}", Style::default().fg(theme.fg),
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(
Block::default() Block::default()
.borders(Borders::BOTTOM) .borders(Borders::BOTTOM)
.border_style(Style::default().fg(theme.secondary)), .border_style(Style::default().fg(theme.secondary)),
); );
f.render_widget(profile_text, top_info_area); f.render_widget(profile_text, top_info_area);
// Canvas - USING CANVAS LIBRARY // Canvas - USING CANVAS LIBRARY
let focus_on_canvas_inputs = matches!( let focus_on_canvas_inputs = matches!(
add_logic_state.current_focus, add_logic_state.current_focus(),
AddLogicFocus::InputLogicName AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn | AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription | AddLogicFocus::InputDescription
); );
let editor = FormEditor::new(add_logic_state.clone()); let editor = &add_logic_state.editor;
let active_field_rect = render_canvas(f, canvas_area, &editor, theme); let active_field_rect = render_canvas(f, canvas_area, editor, theme);
// --- Render Autocomplete for Target Column --- // --- Canvas suggestions dropdown (Target Column, etc.) ---
if editor.mode() == canvas::AppMode::Edit && editor.current_field() == 1 { // Target Column field if editor.mode() == canvas::AppMode::Edit {
if add_logic_state.in_target_column_suggestion_mode && add_logic_state.show_target_column_suggestions { if let Some(input_rect) = active_field_rect {
if !add_logic_state.target_column_suggestions.is_empty() { render_suggestions_dropdown(
if let Some(input_rect) = active_field_rect { f,
autocomplete::render_autocomplete_dropdown( f.area(),
f, input_rect,
input_rect, &DefaultCanvasTheme,
f.area(), // Full frame area for clamping editor,
theme, );
&add_logic_state.target_column_suggestions,
add_logic_state.selected_target_column_suggestion_index,
);
}
}
} }
} }
// Script content preview // Script content preview
{ {
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut(); let mut editor_ref = add_logic_state.script_content_editor().borrow_mut();
editor_ref.set_cursor_line_style(Style::default()); editor_ref.set_cursor_line_style(Style::default());
let is_script_preview_focused = add_logic_state.current_focus == AddLogicFocus::ScriptContentPreview; let is_script_preview_focused = add_logic_state.current_focus() == AddLogicFocus::ScriptContentPreview;
if is_script_preview_focused { if is_script_preview_focused {
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
@@ -256,7 +255,7 @@ pub fn render_add_logic(
let save_button = Paragraph::new(" Save Logic ") let save_button = Paragraph::new(" Save Logic ")
.style(get_button_style( .style(get_button_style(
AddLogicFocus::SaveButton, AddLogicFocus::SaveButton,
add_logic_state.current_focus, add_logic_state.current_focus(),
)) ))
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block( .block(
@@ -264,7 +263,7 @@ pub fn render_add_logic(
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(get_button_border_style( .border_style(get_button_border_style(
add_logic_state.current_focus == AddLogicFocus::SaveButton, add_logic_state.current_focus() == AddLogicFocus::SaveButton,
theme, theme,
)), )),
); );
@@ -273,7 +272,7 @@ pub fn render_add_logic(
let cancel_button = Paragraph::new(" Cancel ") let cancel_button = Paragraph::new(" Cancel ")
.style(get_button_style( .style(get_button_style(
AddLogicFocus::CancelButton, AddLogicFocus::CancelButton,
add_logic_state.current_focus, add_logic_state.current_focus(),
)) ))
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block( .block(
@@ -281,7 +280,7 @@ pub fn render_add_logic(
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(get_button_border_style( .border_style(get_button_border_style(
add_logic_state.current_focus == AddLogicFocus::CancelButton, add_logic_state.current_focus() == AddLogicFocus::CancelButton,
theme, theme,
)), )),
); );

View File

@@ -0,0 +1,287 @@
// src/pages/admin_panel/add_table/event.rs
use anyhow::Result;
use crate::config::binds::config::Config;
use crate::movement::{move_focus, MovementAction};
use crate::pages::admin_panel::add_table::logic::{
handle_add_column_action, handle_delete_selected_columns,
};
use crate::pages::admin_panel::add_table::loader::handle_save_table_action;
use crate::pages::admin_panel::add_table::nav::SaveTableResultSender;
use crate::pages::admin_panel::add_table::state::{AddTableFocus, AddTableFormState};
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
use crate::modes::handlers::event::EventOutcome;
use canvas::{AppMode as CanvasMode, DataProvider};
use crossterm::event::KeyEvent;
/// Focus traversal order for AddTable (outside canvas)
const ADD_TABLE_FOCUS_ORDER: [AddTableFocus; 10] = [
AddTableFocus::InputTableName,
AddTableFocus::InputColumnName,
AddTableFocus::InputColumnType,
AddTableFocus::AddColumnButton,
AddTableFocus::ColumnsTable,
AddTableFocus::IndexesTable,
AddTableFocus::LinksTable,
AddTableFocus::SaveButton,
AddTableFocus::DeleteSelectedButton,
AddTableFocus::CancelButton,
];
/// Handles all AddTable page-specific events.
/// Return a non-empty Ok(message) only when the page actually consumed the key,
/// otherwise return Ok("") to let global handling proceed.
pub fn handle_add_table_event(
key_event: KeyEvent,
movement: Option<MovementAction>,
config: &Config,
app_state: &mut AppState,
page: &mut AddTableFormState,
mut grpc_client: GrpcClient,
save_result_sender: SaveTableResultSender,
) -> Result<EventOutcome> {
// 1) Inside canvas (FormEditor)
let inside_canvas_inputs = matches!(
page.current_focus(),
AddTableFocus::InputTableName
| AddTableFocus::InputColumnName
| AddTableFocus::InputColumnType
);
if inside_canvas_inputs {
// Disable global shortcuts while typing
page.focus_outside_canvas = false;
// Only allow leaving the canvas with Down/Next when in ReadOnly mode
let in_edit_mode = page.editor.mode() == CanvasMode::Edit;
if !in_edit_mode {
if let Some(ma) = movement {
let last_idx = page.editor.data_provider().field_count().saturating_sub(1);
let at_last = page.editor.current_field() >= last_idx;
if at_last && matches!(ma, MovementAction::Down | MovementAction::Next) {
page.state.last_canvas_field = last_idx;
page.set_current_focus(AddTableFocus::AddColumnButton);
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok("Moved to Add button".to_string()));
}
}
}
// Let the FormEditor handle typing
match page.editor.handle_key_event(key_event) {
canvas::keymap::KeyEventOutcome::Consumed(Some(msg)) => {
page.sync_from_editor();
return Ok(EventOutcome::Ok(msg));
}
canvas::keymap::KeyEventOutcome::Consumed(None) => {
page.sync_from_editor();
return Ok(EventOutcome::Ok("Input updated".into()));
}
canvas::keymap::KeyEventOutcome::Pending => {
return Ok(EventOutcome::Ok(String::new()));
}
canvas::keymap::KeyEventOutcome::NotMatched => {
// fall through
}
}
}
// 2) Outside canvas
if let Some(ma) = movement {
// Block outer moves when "inside" any table and handle locally
match page.current_focus() {
AddTableFocus::InsideColumnsTable => {
match ma {
MovementAction::Up => {
if let Some(i) = page.state.column_table_state.selected() {
let next = i.saturating_sub(1);
page.state.column_table_state.select(Some(next));
} else if !page.state.columns.is_empty() {
page.state.column_table_state.select(Some(0));
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Down => {
if let Some(i) = page.state.column_table_state.selected() {
let last = page.state.columns.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
page.state.column_table_state.select(Some(next));
} else if !page.state.columns.is_empty() {
page.state.column_table_state.select(Some(0));
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Select => {
if let Some(i) = page.state.column_table_state.selected() {
if let Some(col) = page.state.columns.get_mut(i) {
col.selected = !col.selected;
page.state.has_unsaved_changes = true;
}
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Esc => {
page.state.column_table_state.select(None);
page.set_current_focus(AddTableFocus::ColumnsTable);
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Next | MovementAction::Previous => {
// Block outer movement while inside
return Ok(EventOutcome::Ok(String::new()));
}
_ => {}
}
}
AddTableFocus::InsideIndexesTable => {
match ma {
MovementAction::Up => {
if let Some(i) = page.state.index_table_state.selected() {
let next = i.saturating_sub(1);
page.state.index_table_state.select(Some(next));
} else if !page.state.indexes.is_empty() {
page.state.index_table_state.select(Some(0));
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Down => {
if let Some(i) = page.state.index_table_state.selected() {
let last = page.state.indexes.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
page.state.index_table_state.select(Some(next));
} else if !page.state.indexes.is_empty() {
page.state.index_table_state.select(Some(0));
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Select => {
if let Some(i) = page.state.index_table_state.selected() {
if let Some(ix) = page.state.indexes.get_mut(i) {
ix.selected = !ix.selected;
page.state.has_unsaved_changes = true;
}
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Esc => {
page.state.index_table_state.select(None);
page.set_current_focus(AddTableFocus::IndexesTable);
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Next | MovementAction::Previous => {
return Ok(EventOutcome::Ok(String::new()));
}
_ => {}
}
}
AddTableFocus::InsideLinksTable => {
match ma {
MovementAction::Up => {
if let Some(i) = page.state.link_table_state.selected() {
let next = i.saturating_sub(1);
page.state.link_table_state.select(Some(next));
} else if !page.state.links.is_empty() {
page.state.link_table_state.select(Some(0));
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Down => {
if let Some(i) = page.state.link_table_state.selected() {
let last = page.state.links.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
page.state.link_table_state.select(Some(next));
} else if !page.state.links.is_empty() {
page.state.link_table_state.select(Some(0));
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Select => {
if let Some(i) = page.state.link_table_state.selected() {
if let Some(link) = page.state.links.get_mut(i) {
link.selected = !link.selected;
page.state.has_unsaved_changes = true;
}
}
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Esc => {
page.state.link_table_state.select(None);
page.set_current_focus(AddTableFocus::LinksTable);
page.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
MovementAction::Next | MovementAction::Previous => {
return Ok(EventOutcome::Ok(String::new()));
}
_ => {}
}
}
_ => {}
}
let mut current = page.current_focus();
if move_focus(&ADD_TABLE_FOCUS_ORDER, &mut current, ma) {
page.set_current_focus(current);
page.focus_outside_canvas = !matches!(
page.current_focus(),
AddTableFocus::InputTableName
| AddTableFocus::InputColumnName
| AddTableFocus::InputColumnType
);
return Ok(EventOutcome::Ok(String::new()));
}
// 3) Rich actions
match ma {
MovementAction::Select => match page.current_focus() {
AddTableFocus::AddColumnButton => {
if let Some(msg) = page.state.add_column_from_inputs() {
// Focus is set by the state method; just bubble message
return Ok(EventOutcome::Ok(msg));
}
}
AddTableFocus::SaveButton => {
if page.state.table_name.is_empty() {
return Ok(EventOutcome::Ok("Cannot save: Table name is empty".into()));
}
if page.state.columns.is_empty() {
return Ok(EventOutcome::Ok("Cannot save: No columns defined".into()));
}
app_state.show_loading_dialog("Saving", "Please wait...");
let state_clone = page.state.clone();
let sender_clone = save_result_sender.clone();
tokio::spawn(async move {
let result = handle_save_table_action(&mut grpc_client, &state_clone).await;
let _ = sender_clone.send(result).await;
});
return Ok(EventOutcome::Ok("Saving table...".into()));
}
AddTableFocus::DeleteSelectedButton => {
let msg = page
.state
.delete_selected_items()
.unwrap_or_else(|| "No items selected for deletion".to_string());
return Ok(EventOutcome::Ok(msg));
}
AddTableFocus::CancelButton => {
return Ok(EventOutcome::Ok("Cancelled Add Table".to_string()));
}
_ => {}
},
_ => {}
}
}
Ok(EventOutcome::Ok(String::new()))
}

View File

@@ -0,0 +1,78 @@
// src/pages/admin_panel/add_table/loader.rs
use anyhow::{anyhow, Result};
use tracing::debug;
use crate::pages::admin_panel::add_table::state::AddTableState;
use crate::services::grpc_client::GrpcClient;
use common::proto::komp_ac::table_definition::{
ColumnDefinition as ProtoColumnDefinition, PostTableDefinitionRequest, TableLink as ProtoTableLink,
};
/// 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> {
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."));
}
let proto_columns: Vec<ProtoColumnDefinition> = add_table_state
.columns
.iter()
.map(|col| ProtoColumnDefinition {
name: col.name.clone(),
field_type: col.data_type.clone(),
})
.collect();
let proto_indexes: Vec<String> = add_table_state
.indexes
.iter()
.filter(|idx| idx.selected)
.map(|idx| idx.name.clone())
.collect();
let proto_links: Vec<ProtoTableLink> = add_table_state
.links
.iter()
.filter(|link| link.selected)
.map(|link| ProtoTableLink {
linked_table_name: link.linked_table_name.clone(),
required: false,
})
.collect();
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);
match grpc_client.post_table_definition(request).await {
Ok(response) => {
if response.success {
Ok(format!(
"Table '{}' saved successfully.",
add_table_state.table_name
))
} else {
let error_message = if !response.sql.is_empty() {
format!("Server failed to save table: {}", response.sql)
} else {
"Server failed to save table (unknown reason).".to_string()
};
Err(anyhow!(error_message))
}
}
Err(e) => Err(anyhow!("gRPC call failed: {}", e)),
}
}

View File

@@ -1,197 +1,24 @@
// src/pages/admin_panel/add_table/logic.rs // src/pages/admin_panel/add_table/logic.rs
use crate::pages::admin_panel::add_table::state;
use crate::pages::admin_panel::add_table::state::{AddTableState, AddTableFocus, IndexDefinition, ColumnDefinition};
use crate::services::GrpcClient;
use anyhow::{anyhow, Result};
use common::proto::komp_ac::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. use crate::pages::admin_panel::add_table::state::{AddTableState, AddTableFocus};
///
/// Takes the mutable state and command message string. /// Thin wrapper around AddTableState::add_column_from_inputs
/// Returns `Some(AddTableFocus)` indicating the desired focus state after a successful add, /// Returns Some(AddTableFocus) for compatibility with old call sites.
/// or `None` if the action failed (e.g., validation error).
pub fn handle_add_column_action( pub fn handle_add_column_action(
add_table_state: &mut AddTableState, add_table_state: &mut AddTableState,
command_message: &mut String, command_message: &mut String,
) -> Option<AddTableFocus> { ) -> Option<AddTableFocus> {
if let Some(msg) = add_table_state.add_column_from_inputs() {
// Trim and create owned Strings from inputs *command_message = msg;
let table_name_in = add_table_state.table_name_input.trim(); // State sets focus internally; return it explicitly for old call sites
let column_name_in = add_table_state.column_name_input.trim(); return Some(add_table_state.current_focus);
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
}
} }
None
} }
/// Handles deleting columns marked as selected in the AddTableState. /// Thin wrapper around AddTableState::delete_selected_items
pub fn handle_delete_selected_columns( pub fn handle_delete_selected_columns(add_table_state: &mut AddTableState) -> String {
add_table_state: &mut AddTableState, add_table_state
) -> String { .delete_selected_items()
let initial_count = add_table_state.columns.len(); .unwrap_or_else(|| "No items selected for deletion".to_string())
// Keep only the columns that are NOT selected
let initial_selected_indices: std::collections::HashSet<String> = add_table_state
.columns
.iter()
.filter(|col| col.selected)
.map(|col| col.name.clone())
.collect();
add_table_state.columns.retain(|col| !col.selected);
let deleted_count = initial_count - add_table_state.columns.len();
if deleted_count > 0 {
add_table_state.indexes.retain(|index| !initial_selected_indices.contains(&index.name));
add_table_state.has_unsaved_changes = true;
// Reset selection highlight as indices have changed
add_table_state.column_table_state.select(None);
// Optionally, select the first item if the list is not empty
// if !add_table_state.columns.is_empty() {
// add_table_state.column_table_state.select(Some(0));
// }
add_table_state.index_table_state.select(None);
format!("Deleted {} selected column(s).", deleted_count)
} else {
"No columns marked for deletion.".to_string()
}
}
/// Prepares and sends the request to save the new table definition via gRPC.
pub async fn handle_save_table_action(
grpc_client: &mut GrpcClient,
add_table_state: &AddTableState,
) -> Result<String> {
// --- Basic Validation ---
if add_table_state.table_name.is_empty() {
return Err(anyhow!("Table name cannot be empty."));
}
if add_table_state.columns.is_empty() {
return Err(anyhow!("Table must have at least one column."));
}
// --- Prepare Proto Data ---
let proto_columns: Vec<ProtoColumnDefinition> = add_table_state
.columns
.iter()
.map(|col| ProtoColumnDefinition {
name: col.name.clone(),
field_type: col.data_type.clone(), // Assuming data_type maps directly
})
.collect();
let proto_indexes: Vec<String> = add_table_state
.indexes
.iter()
.filter(|idx| idx.selected) // Only include selected indexes
.map(|idx| idx.name.clone())
.collect();
let proto_links: Vec<ProtoTableLink> = add_table_state
.links
.iter()
.filter(|link| link.selected) // Only include selected links
.map(|link| ProtoTableLink {
linked_table_name: link.linked_table_name.clone(),
// Assuming 'required' maps directly, adjust if needed
// For now, the proto only seems to use linked_table_name based on example
// If your proto evolves, map link.is_required here.
required: false, // Set based on your proto definition/needs
})
.collect();
// --- Create Request ---
let request = PostTableDefinitionRequest {
table_name: add_table_state.table_name.clone(),
columns: proto_columns,
indexes: proto_indexes,
links: proto_links,
profile_name: add_table_state.profile_name.clone(),
};
debug!("Sending PostTableDefinitionRequest: {:?}", request);
// --- Call gRPC Service ---
match grpc_client.post_table_definition(request).await {
Ok(response) => {
if response.success {
Ok(format!(
"Table '{}' saved successfully.",
add_table_state.table_name
))
} else {
// Use the SQL message from the response if available, otherwise generic error
let error_message = if !response.sql.is_empty() {
format!("Server failed to save table: {}", response.sql)
} else {
"Server failed to save table (unknown reason).".to_string()
};
Err(anyhow!(error_message))
}
}
Err(e) => Err(anyhow!("gRPC call failed: {}", e)),
}
} }

View File

@@ -4,3 +4,5 @@ pub mod ui;
pub mod nav; pub mod nav;
pub mod state; pub mod state;
pub mod logic; pub mod logic;
pub mod event;
pub mod loader;

View File

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

View File

@@ -1,9 +1,9 @@
// src/pages/admin_panel/add_table/state.rs // src/pages/admin_panel/add_table/state.rs
use canvas::{DataProvider, AppMode}; use canvas::{DataProvider, AppMode};
use canvas::FormEditor;
use ratatui::widgets::TableState; use ratatui::widgets::TableState;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::movement::{move_focus, MovementAction};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnDefinition { pub struct ColumnDefinition {
@@ -98,23 +98,48 @@ impl AddTableState {
/// Helper method to add a column from current inputs /// Helper method to add a column from current inputs
pub fn add_column_from_inputs(&mut self) -> Option<String> { pub fn add_column_from_inputs(&mut self) -> Option<String> {
if self.column_name_input.trim().is_empty() || self.column_type_input.trim().is_empty() { let table_name_in = self.table_name_input.trim().to_string();
return Some("Both column name and type are required".to_string()); let column_name_in = self.column_name_input.trim().to_string();
let column_type_in = self.column_type_input.trim().to_string();
// Case: "only table name" provided → set it and stay on TableName
if !table_name_in.is_empty() && column_name_in.is_empty() && column_type_in.is_empty() {
self.table_name = table_name_in;
self.table_name_input.clear();
self.table_name_cursor_pos = 0;
self.current_focus = AddTableFocus::InputTableName;
self.has_unsaved_changes = true;
return Some(format!("Table name set to '{}'.", self.table_name));
} }
// Check for duplicate column names // Column validation
if self.columns.iter().any(|col| col.name == self.column_name_input.trim()) { if column_name_in.is_empty() || column_type_in.is_empty() {
return Some("Both column name and type are required".to_string());
}
if self.columns.iter().any(|col| col.name == column_name_in) {
return Some("Column name already exists".to_string()); return Some("Column name already exists".to_string());
} }
// If table_name input present while adding first column, apply it too
if !table_name_in.is_empty() {
self.table_name = table_name_in;
self.table_name_input.clear();
self.table_name_cursor_pos = 0;
}
// Add the column // Add the column
self.columns.push(ColumnDefinition { self.columns.push(ColumnDefinition {
name: self.column_name_input.trim().to_string(), name: column_name_in.clone(),
data_type: self.column_type_input.trim().to_string(), data_type: column_type_in.clone(),
selected: false,
});
// Add a corresponding (unselected) index with the same name
self.indexes.push(IndexDefinition {
name: column_name_in.clone(),
selected: false, selected: false,
}); });
// Clear inputs and reset focus to column name for next entry // Clear column inputs and set focus for next entry
self.column_name_input.clear(); self.column_name_input.clear();
self.column_type_input.clear(); self.column_type_input.clear();
self.column_name_cursor_pos = 0; self.column_name_cursor_pos = 0;
@@ -123,23 +148,33 @@ impl AddTableState {
self.last_canvas_field = 1; self.last_canvas_field = 1;
self.has_unsaved_changes = true; self.has_unsaved_changes = true;
Some(format!("Column '{}' added successfully", self.columns.last().unwrap().name)) Some(format!("Column '{}' added successfully", column_name_in))
} }
/// Helper method to delete selected items /// Helper method to delete selected items
pub fn delete_selected_items(&mut self) -> Option<String> { pub fn delete_selected_items(&mut self) -> Option<String> {
let mut deleted_items = Vec::new(); let mut deleted_items: Vec<String> = Vec::new();
// Remove selected columns // Remove selected columns
let initial_column_count = self.columns.len(); let selected_col_names: std::collections::HashSet<String> = self
self.columns.retain(|col| { .columns
if col.selected { .iter()
deleted_items.push(format!("column '{}'", col.name)); .filter(|c| c.selected)
false .map(|c| c.name.clone())
} else { .collect();
true if !selected_col_names.is_empty() {
} self.columns.retain(|col| {
}); if selected_col_names.contains(&col.name) {
deleted_items.push(format!("column '{}'", col.name));
false
} else {
true
}
});
// Also purge indexes for deleted columns
self.indexes
.retain(|idx| !selected_col_names.contains(&idx.name));
}
// Remove selected indexes // Remove selected indexes
let initial_index_count = self.indexes.len(); let initial_index_count = self.indexes.len();
@@ -167,6 +202,8 @@ impl AddTableState {
Some("No items selected for deletion".to_string()) Some("No items selected for deletion".to_string())
} else { } else {
self.has_unsaved_changes = true; self.has_unsaved_changes = true;
self.column_table_state.select(None);
self.index_table_state.select(None);
Some(format!("Deleted: {}", deleted_items.join(", "))) Some(format!("Deleted: {}", deleted_items.join(", ")))
} }
} }
@@ -210,174 +247,86 @@ impl DataProvider for AddTableState {
} }
} }
pub struct AddTableFormState {
pub state: AddTableState,
pub editor: FormEditor<AddTableState>,
pub focus_outside_canvas: bool,
pub focused_button_index: usize,
}
impl AddTableState { impl std::fmt::Debug for AddTableFormState {
pub fn handle_movement(&mut self, action: MovementAction) -> bool { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use AddTableFocus::*; f.debug_struct("AddTableFormState")
.field("state", &self.state)
// Linear outer focus order .field("focus_outside_canvas", &self.focus_outside_canvas)
const ORDER: [AddTableFocus; 10] = [ .field("focused_button_index", &self.focused_button_index)
InputTableName, .finish()
InputColumnName, }
InputColumnType, }
AddColumnButton,
ColumnsTable, impl AddTableFormState {
IndexesTable, pub fn new(profile_name: String) -> Self {
LinksTable, let mut state = AddTableState::default();
SaveButton, state.profile_name = profile_name;
DeleteSelectedButton, let editor = FormEditor::new(state.clone());
CancelButton, Self {
]; state,
editor,
// Enter "inside" on Select from outer panes focus_outside_canvas: false,
match (self.current_focus, action) { focused_button_index: 0,
(ColumnsTable, MovementAction::Select) => { }
if !self.columns.is_empty() && self.column_table_state.selected().is_none() { }
self.column_table_state.select(Some(0));
} pub fn from_state(state: AddTableState) -> Self {
self.current_focus = InsideColumnsTable; let editor = FormEditor::new(state.clone());
return true; Self {
} state,
(IndexesTable, MovementAction::Select) => { editor,
if !self.indexes.is_empty() && self.index_table_state.selected().is_none() { focus_outside_canvas: false,
self.index_table_state.select(Some(0)); focused_button_index: 0,
} }
self.current_focus = InsideIndexesTable; }
return true;
} /// Sync state from editors snapshot
(LinksTable, MovementAction::Select) => { pub fn sync_from_editor(&mut self) {
if !self.links.is_empty() && self.link_table_state.selected().is_none() { self.state = self.editor.data_provider().clone();
self.link_table_state.select(Some(0)); }
}
self.current_focus = InsideLinksTable; // === Delegates to AddTableState fields ===
return true; pub fn current_focus(&self) -> AddTableFocus {
} self.state.current_focus
_ => {} }
} pub fn set_current_focus(&mut self, focus: AddTableFocus) {
self.state.current_focus = focus;
// Handle "inside" states: Up/Down/Select/Esc; block outer movement keys }
match self.current_focus { pub fn profile_name(&self) -> &str {
InsideColumnsTable => { &self.state.profile_name
match action { }
MovementAction::Up => { pub fn table_name(&self) -> &str {
if let Some(i) = self.column_table_state.selected() { &self.state.table_name
let next = i.saturating_sub(1); }
self.column_table_state.select(Some(next)); pub fn columns(&self) -> &Vec<ColumnDefinition> {
} else if !self.columns.is_empty() { &self.state.columns
self.column_table_state.select(Some(0)); }
} pub fn indexes(&self) -> &Vec<IndexDefinition> {
return true; &self.state.indexes
} }
MovementAction::Down => { pub fn links(&self) -> &Vec<LinkDefinition> {
if let Some(i) = self.column_table_state.selected() { &self.state.links
let last = self.columns.len().saturating_sub(1); }
let next = if i < last { i + 1 } else { i }; pub fn column_table_state(&mut self) -> &mut TableState {
self.column_table_state.select(Some(next)); &mut self.state.column_table_state
} else if !self.columns.is_empty() { }
self.column_table_state.select(Some(0)); pub fn index_table_state(&mut self) -> &mut TableState {
} &mut self.state.index_table_state
return true; }
} pub fn link_table_state(&mut self) -> &mut TableState {
MovementAction::Select => { &mut self.state.link_table_state
if let Some(i) = self.column_table_state.selected() { }
if let Some(col) = self.columns.get_mut(i) { pub fn set_focused_button(&mut self, index: usize) {
col.selected = !col.selected; self.focused_button_index = index;
self.has_unsaved_changes = true; }
} pub fn focused_button(&self) -> usize {
} self.focused_button_index
return true;
}
MovementAction::Esc => {
self.column_table_state.select(None);
self.current_focus = ColumnsTable;
return true;
}
MovementAction::Next | MovementAction::Previous => return true, // block outer moves
_ => {}
}
}
InsideIndexesTable => {
match action {
MovementAction::Up => {
if let Some(i) = self.index_table_state.selected() {
let next = i.saturating_sub(1);
self.index_table_state.select(Some(next));
} else if !self.indexes.is_empty() {
self.index_table_state.select(Some(0));
}
return true;
}
MovementAction::Down => {
if let Some(i) = self.index_table_state.selected() {
let last = self.indexes.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
self.index_table_state.select(Some(next));
} else if !self.indexes.is_empty() {
self.index_table_state.select(Some(0));
}
return true;
}
MovementAction::Select => {
if let Some(i) = self.index_table_state.selected() {
if let Some(ix) = self.indexes.get_mut(i) {
ix.selected = !ix.selected;
self.has_unsaved_changes = true;
}
}
return true;
}
MovementAction::Esc => {
self.index_table_state.select(None);
self.current_focus = IndexesTable;
return true;
}
MovementAction::Next | MovementAction::Previous => return true, // block outer moves
_ => {}
}
}
InsideLinksTable => {
match action {
MovementAction::Up => {
if let Some(i) = self.link_table_state.selected() {
let next = i.saturating_sub(1);
self.link_table_state.select(Some(next));
} else if !self.links.is_empty() {
self.link_table_state.select(Some(0));
}
return true;
}
MovementAction::Down => {
if let Some(i) = self.link_table_state.selected() {
let last = self.links.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
self.link_table_state.select(Some(next));
} else if !self.links.is_empty() {
self.link_table_state.select(Some(0));
}
return true;
}
MovementAction::Select => {
if let Some(i) = self.link_table_state.selected() {
if let Some(link) = self.links.get_mut(i) {
link.selected = !link.selected;
self.has_unsaved_changes = true;
}
}
return true;
}
MovementAction::Esc => {
self.link_table_state.select(None);
self.current_focus = LinksTable;
return true;
}
MovementAction::Next | MovementAction::Previous => return true, // block outer moves
_ => {}
}
}
_ => {}
}
// Default: outer navigation via helper
move_focus(&ORDER, &mut self.current_focus, action)
} }
} }

View File

@@ -1,8 +1,8 @@
// src/pages/admin_panel/add_table/ui.rs // src/pages/admin_panel/add_table/ui.rs
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::pages::admin_panel::add_table::state::{AddTableFocus, AddTableState}; use crate::pages::admin_panel::add_table::state::{AddTableFocus, AddTableFormState};
use canvas::{render_canvas, FormEditor}; use canvas::render_canvas;
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style}, style::{Modifier, Style},
@@ -19,7 +19,7 @@ pub fn render_add_table(
area: Rect, area: Rect,
theme: &Theme, theme: &Theme,
app_state: &AppState, app_state: &AppState,
add_table_state: &mut AddTableState, add_table_state: &mut AddTableFormState,
) { ) {
// --- Configuration --- // --- Configuration ---
// Threshold width to switch between wide and narrow layouts // Threshold width to switch between wide and narrow layouts
@@ -27,7 +27,7 @@ pub fn render_add_table(
// --- State Checks --- // --- State Checks ---
let focus_on_canvas_inputs = matches!( let focus_on_canvas_inputs = matches!(
add_table_state.current_focus, add_table_state.current_focus(),
AddTableFocus::InputTableName AddTableFocus::InputTableName
| AddTableFocus::InputColumnName | AddTableFocus::InputColumnName
| AddTableFocus::InputColumnType | AddTableFocus::InputColumnType
@@ -45,11 +45,11 @@ pub fn render_add_table(
f.render_widget(main_block, area); f.render_widget(main_block, area);
// --- Fullscreen Columns Table Check (Narrow Screens Only) --- // --- Fullscreen Columns Table Check (Narrow Screens Only) ---
if area.width < NARROW_LAYOUT_THRESHOLD && add_table_state.current_focus == AddTableFocus::InsideColumnsTable { if area.width < NARROW_LAYOUT_THRESHOLD && add_table_state.current_focus() == AddTableFocus::InsideColumnsTable {
// Render ONLY the columns table taking the full inner area // Render ONLY the columns table taking the full inner area
let columns_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen let columns_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
let column_rows: Vec<Row<'_>> = add_table_state let column_rows: Vec<Row<'_>> = add_table_state
.columns .columns()
.iter() .iter()
.map(|col_def| { .map(|col_def| {
Row::new(vec![ Row::new(vec![
@@ -80,16 +80,16 @@ pub fn render_add_table(
.fg(theme.highlight), .fg(theme.highlight),
) )
.highlight_symbol(" > "); // Use the inside symbol .highlight_symbol(" > "); // Use the inside symbol
f.render_stateful_widget(columns_table, inner_area, &mut add_table_state.column_table_state); f.render_stateful_widget(columns_table, inner_area, add_table_state.column_table_state());
return; // IMPORTANT: Stop rendering here for fullscreen mode return; // IMPORTANT: Stop rendering here for fullscreen mode
} }
// --- Fullscreen Indexes Table Check --- // --- Fullscreen Indexes Table Check ---
if add_table_state.current_focus == AddTableFocus::InsideIndexesTable { // Remove width check if add_table_state.current_focus() == AddTableFocus::InsideIndexesTable { // Remove width check
// Render ONLY the indexes table taking the full inner area // Render ONLY the indexes table taking the full inner area
let indexes_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen let indexes_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
let index_rows: Vec<Row<'_>> = add_table_state let index_rows: Vec<Row<'_>> = add_table_state
.indexes .indexes()
.iter() .iter()
.map(|index_def| { .map(|index_def| {
Row::new(vec![ Row::new(vec![
@@ -115,16 +115,16 @@ pub fn render_add_table(
) )
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight)) .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
.highlight_symbol(" > "); // Use the inside symbol .highlight_symbol(" > "); // Use the inside symbol
f.render_stateful_widget(indexes_table, inner_area, &mut add_table_state.index_table_state); f.render_stateful_widget(indexes_table, inner_area, &mut add_table_state.index_table_state());
return; // IMPORTANT: Stop rendering here for fullscreen mode return; // IMPORTANT: Stop rendering here for fullscreen mode
} }
// --- Fullscreen Links Table Check --- // --- Fullscreen Links Table Check ---
if add_table_state.current_focus == AddTableFocus::InsideLinksTable { if add_table_state.current_focus() == AddTableFocus::InsideLinksTable {
// Render ONLY the links table taking the full inner area // Render ONLY the links table taking the full inner area
let links_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen let links_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
let link_rows: Vec<Row<'_>> = add_table_state let link_rows: Vec<Row<'_>> = add_table_state
.links .links()
.iter() .iter()
.map(|link_def| { .map(|link_def| {
Row::new(vec![ Row::new(vec![
@@ -151,7 +151,7 @@ pub fn render_add_table(
) )
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight)) .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
.highlight_symbol(" > "); // Use the inside symbol .highlight_symbol(" > "); // Use the inside symbol
f.render_stateful_widget(links_table, inner_area, &mut add_table_state.link_table_state); f.render_stateful_widget(links_table, inner_area, &mut add_table_state.link_table_state());
return; // IMPORTANT: Stop rendering here for fullscreen mode return; // IMPORTANT: Stop rendering here for fullscreen mode
} }
@@ -220,11 +220,11 @@ pub fn render_add_table(
// --- Top Info Rendering (Wide - 2 lines) --- // --- Top Info Rendering (Wide - 2 lines) ---
let profile_text = Paragraph::new(vec![ let profile_text = Paragraph::new(vec![
Line::from(Span::styled( Line::from(Span::styled(
format!("Profile: {}", add_table_state.profile_name), format!("Profile: {}", add_table_state.profile_name()),
theme.fg, theme.fg,
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!("Table name: {}", add_table_state.table_name), format!("Table name: {}", add_table_state.table_name()),
theme.fg, theme.fg,
)), )),
]) ])
@@ -276,14 +276,14 @@ pub fn render_add_table(
.split(top_info_area); .split(top_info_area);
let profile_text = Paragraph::new(Span::styled( let profile_text = Paragraph::new(Span::styled(
format!("Profile: {}", add_table_state.profile_name), format!("Profile: {}", add_table_state.profile_name()),
theme.fg, theme.fg,
)) ))
.alignment(Alignment::Left); .alignment(Alignment::Left);
f.render_widget(profile_text, top_info_chunks[0]); f.render_widget(profile_text, top_info_chunks[0]);
let table_name_text = Paragraph::new(Span::styled( let table_name_text = Paragraph::new(Span::styled(
format!("Table: {}", add_table_state.table_name), format!("Table: {}", add_table_state.table_name()),
theme.fg, theme.fg,
)) ))
.alignment(Alignment::Left); .alignment(Alignment::Left);
@@ -293,14 +293,14 @@ pub fn render_add_table(
// --- Common Widget Rendering (Uses calculated areas) --- // --- Common Widget Rendering (Uses calculated areas) ---
// --- Columns Table Rendering --- // --- Columns Table Rendering ---
let columns_focused = matches!(add_table_state.current_focus, AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable); let columns_focused = matches!(add_table_state.current_focus(), AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable);
let columns_border_style = if columns_focused { let columns_border_style = if columns_focused {
Style::default().fg(theme.highlight) Style::default().fg(theme.highlight)
} else { } else {
Style::default().fg(theme.secondary) Style::default().fg(theme.secondary)
}; };
let column_rows: Vec<Row<'_>> = add_table_state let column_rows: Vec<Row<'_>> = add_table_state
.columns .columns()
.iter() .iter()
.map(|col_def| { .map(|col_def| {
Row::new(vec![ Row::new(vec![
@@ -341,12 +341,11 @@ pub fn render_add_table(
f.render_stateful_widget( f.render_stateful_widget(
columns_table, columns_table,
columns_area, columns_area,
&mut add_table_state.column_table_state, &mut add_table_state.column_table_state(),
); );
// --- Canvas Rendering (Column Definition Input) - USING CANVAS LIBRARY --- // --- Canvas Rendering (Column Definition Input) - USING CANVAS LIBRARY ---
let editor = FormEditor::new(add_table_state.clone()); let _active_field_rect = render_canvas(f, canvas_area, &add_table_state.editor, theme);
let _active_field_rect = render_canvas(f, canvas_area, &editor, theme);
// --- Button Style Helpers --- // --- Button Style Helpers ---
let get_button_style = |button_focus: AddTableFocus, current_focus| { let get_button_style = |button_focus: AddTableFocus, current_focus| {
@@ -374,11 +373,11 @@ pub fn render_add_table(
// --- Add Button Rendering --- // --- Add Button Rendering ---
// Determine if the add button is focused // Determine if the add button is focused
let is_add_button_focused = add_table_state.current_focus == AddTableFocus::AddColumnButton; let is_add_button_focused = add_table_state.current_focus() == AddTableFocus::AddColumnButton;
// Create the Add button Paragraph widget // Create the Add button Paragraph widget
let add_button = Paragraph::new(" Add ") let add_button = Paragraph::new(" Add ")
.style(get_button_style(AddTableFocus::AddColumnButton, add_table_state.current_focus)) // Use existing closure .style(get_button_style(AddTableFocus::AddColumnButton, add_table_state.current_focus())) // Use existing closure
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block( .block(
Block::default() Block::default()
@@ -391,14 +390,14 @@ pub fn render_add_table(
f.render_widget(add_button, add_button_area); f.render_widget(add_button, add_button_area);
// --- Indexes Table Rendering --- // --- Indexes Table Rendering ---
let indexes_focused = matches!(add_table_state.current_focus, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable); let indexes_focused = matches!(add_table_state.current_focus(), AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable);
let indexes_border_style = if indexes_focused { let indexes_border_style = if indexes_focused {
Style::default().fg(theme.highlight) Style::default().fg(theme.highlight)
} else { } else {
Style::default().fg(theme.secondary) Style::default().fg(theme.secondary)
}; };
let index_rows: Vec<Row<'_>> = add_table_state let index_rows: Vec<Row<'_>> = add_table_state
.indexes .indexes()
.iter() .iter()
.map(|index_def| { // Use index_def now .map(|index_def| { // Use index_def now
Row::new(vec![ Row::new(vec![
@@ -432,18 +431,18 @@ pub fn render_add_table(
f.render_stateful_widget( f.render_stateful_widget(
indexes_table, indexes_table,
indexes_area, indexes_area,
&mut add_table_state.index_table_state, &mut add_table_state.index_table_state(),
); );
// --- Links Table Rendering --- // --- Links Table Rendering ---
let links_focused = matches!(add_table_state.current_focus, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable); let links_focused = matches!(add_table_state.current_focus(), AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable);
let links_border_style = if links_focused { let links_border_style = if links_focused {
Style::default().fg(theme.highlight) Style::default().fg(theme.highlight)
} else { } else {
Style::default().fg(theme.secondary) Style::default().fg(theme.secondary)
}; };
let link_rows: Vec<Row<'_>> = add_table_state let link_rows: Vec<Row<'_>> = add_table_state
.links .links()
.iter() .iter()
.map(|link_def| { .map(|link_def| {
Row::new(vec![ Row::new(vec![
@@ -477,7 +476,7 @@ pub fn render_add_table(
f.render_stateful_widget( f.render_stateful_widget(
links_table, links_table,
links_area, links_area,
&mut add_table_state.link_table_state, &mut add_table_state.link_table_state(),
); );
// --- Save/Cancel Buttons Rendering --- // --- Save/Cancel Buttons Rendering ---
@@ -491,51 +490,54 @@ pub fn render_add_table(
.split(bottom_buttons_area); .split(bottom_buttons_area);
let save_button = Paragraph::new(" Save table ") let save_button = Paragraph::new(" Save table ")
.style(get_button_style( .style(if add_table_state.current_focus() == AddTableFocus::SaveButton {
AddTableFocus::SaveButton, Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
add_table_state.current_focus, } else {
)) Style::default().fg(theme.secondary)
})
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block( .block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(get_button_border_style( .border_style(get_button_border_style(
add_table_state.current_focus == AddTableFocus::SaveButton, // Pass bool add_table_state.current_focus() == AddTableFocus::SaveButton, // Pass bool
theme, theme,
)), )),
); );
f.render_widget(save_button, bottom_button_chunks[0]); f.render_widget(save_button, bottom_button_chunks[0]);
let delete_button = Paragraph::new(" Delete Selected ") let delete_button = Paragraph::new(" Delete Selected ")
.style(get_button_style( .style(if add_table_state.current_focus() == AddTableFocus::DeleteSelectedButton {
AddTableFocus::DeleteSelectedButton, Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
add_table_state.current_focus, } else {
)) Style::default().fg(theme.secondary)
})
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block( .block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(get_button_border_style( .border_style(get_button_border_style(
add_table_state.current_focus == AddTableFocus::DeleteSelectedButton, // Pass bool add_table_state.current_focus() == AddTableFocus::DeleteSelectedButton,
theme, theme,
)), )),
); );
f.render_widget(delete_button, bottom_button_chunks[1]); f.render_widget(delete_button, bottom_button_chunks[1]);
let cancel_button = Paragraph::new(" Cancel ") let cancel_button = Paragraph::new(" Cancel ")
.style(get_button_style( .style(if add_table_state.current_focus() == AddTableFocus::CancelButton {
AddTableFocus::CancelButton, Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
add_table_state.current_focus, } else {
)) Style::default().fg(theme.secondary)
})
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block( .block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(get_button_border_style( .border_style(get_button_border_style(
add_table_state.current_focus == AddTableFocus::CancelButton, // Pass bool add_table_state.current_focus() == AddTableFocus::CancelButton,
theme, theme,
)), )),
); );

View File

@@ -0,0 +1,62 @@
// src/pages/forms/event.rs
use anyhow::Result;
use crossterm::event::Event;
use canvas::keymap::KeyEventOutcome;
use crate::{
state::app::state::AppState,
pages::forms::{FormState, logic},
modes::handlers::event::EventOutcome,
};
pub fn handle_form_event(
event: Event,
app_state: &mut AppState,
path: &str,
ideal_cursor_column: &mut usize,
) -> Result<EventOutcome> {
if let Event::Key(key_event) = event {
if let Some(editor) = app_state.editor_for_path(path) {
match editor.handle_key_event(key_event) {
KeyEventOutcome::Consumed(Some(msg)) => {
return Ok(EventOutcome::Ok(msg));
}
KeyEventOutcome::Consumed(None) => {
return Ok(EventOutcome::Ok("Form input updated".into()));
}
KeyEventOutcome::Pending => {
return Ok(EventOutcome::Ok("Waiting for next key...".into()));
}
KeyEventOutcome::NotMatched => {
// fall through to navigation / save / revert
}
}
}
}
Ok(EventOutcome::Ok(String::new()))
}
// Save wrapper
pub async fn save_form(
app_state: &mut AppState,
path: &str,
grpc_client: &mut crate::services::grpc_client::GrpcClient,
) -> Result<EventOutcome> {
let outcome = logic::save(app_state, path, grpc_client).await?;
let message = match outcome {
logic::SaveOutcome::NoChange => "No changes to save.".to_string(),
logic::SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
logic::SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
};
Ok(EventOutcome::DataSaved(outcome, message))
}
pub async fn revert_form(
app_state: &mut AppState,
path: &str,
grpc_client: &mut crate::services::grpc_client::GrpcClient,
) -> Result<EventOutcome> {
let message = logic::revert(app_state, path, grpc_client).await?;
Ok(EventOutcome::Ok(message))
}

View File

@@ -0,0 +1,39 @@
// src/pages/forms/loader.rs
use anyhow::{Context, Result};
use crate::{
state::app::state::AppState,
services::grpc_client::GrpcClient,
services::ui_service::UiService, // ✅ import UiService
config::binds::Config,
pages::forms::FormState,
};
pub async fn ensure_form_loaded_and_count(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
config: &Config,
profile: &str,
table: &str,
) -> Result<()> {
let path = format!("{}/{}", profile, table);
app_state.ensure_form_editor(&path, config, || {
FormState::new(profile.to_string(), table.to_string(), vec![])
});
if let Some(form_state) = app_state.form_state_for_path(&path) {
UiService::fetch_and_set_table_count(grpc_client, form_state)
.await
.context("Failed to fetch table count")?;
if form_state.total_count > 0 {
UiService::load_table_data_by_position(grpc_client, form_state)
.await
.context("Failed to load table data")?;
} else {
form_state.reset_to_empty();
}
}
Ok(())
}

View File

@@ -3,7 +3,11 @@
pub mod ui; pub mod ui;
pub mod state; pub mod state;
pub mod logic; pub mod logic;
pub mod event;
pub mod loader;
pub use ui::*; pub use ui::*;
pub use state::*; pub use state::*;
pub use logic::*; pub use logic::*;
pub use event::*;
pub use loader::*;

View File

@@ -67,9 +67,6 @@ pub fn handle_intro_selection(
} }
3 => { 3 => {
buffer_state.update_history(AppView::Register); buffer_state.update_history(AppView::Register);
// Register view requires focus reset
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index = 0;
} }
_ => return, _ => return,
} }

View File

@@ -3,23 +3,27 @@ use crate::movement::MovementAction;
#[derive(Default, Clone, Debug)] #[derive(Default, Clone, Debug)]
pub struct IntroState { pub struct IntroState {
pub selected_option: usize, pub focus_outside_canvas: bool,
pub focused_button_index: usize,
} }
impl IntroState { impl IntroState {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self {
focus_outside_canvas: true,
focused_button_index: 0,
}
} }
pub fn next_option(&mut self) { pub fn next_option(&mut self) {
if self.selected_option < 3 { if self.focused_button_index < 3 {
self.selected_option += 1; self.focused_button_index += 1;
} }
} }
pub fn previous_option(&mut self) { pub fn previous_option(&mut self) {
if self.selected_option > 0 { if self.focused_button_index > 0 {
self.selected_option -= 1 self.focused_button_index -= 1;
} }
} }
} }

View File

@@ -56,7 +56,8 @@ pub fn render_intro(f: &mut Frame, intro_state: &IntroState, area: Rect, theme:
let buttons = ["Continue", "Admin", "Login", "Register"]; let buttons = ["Continue", "Admin", "Login", "Register"];
for (i, &text) in buttons.iter().enumerate() { for (i, &text) in buttons.iter().enumerate() {
render_button(f, button_area[i], text, intro_state.selected_option == i, theme); let active = intro_state.focused_button_index == i;
render_button(f, button_area[i], text, active, theme);
} }
} }

View File

@@ -0,0 +1,71 @@
// src/pages/login/event.rs
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyModifiers};
use canvas::{keymap::KeyEventOutcome, AppMode as CanvasMode};
use crate::{
state::app::state::AppState,
pages::login::LoginFormState,
modes::handlers::event::EventOutcome,
};
use canvas::DataProvider;
/// Handles all Login page-specific events
pub fn handle_login_event(
event: Event,
app_state: &mut AppState,
login_page: &mut LoginFormState,
) -> Result<EventOutcome> {
if let Event::Key(key_event) = event {
let key_code = key_event.code;
let modifiers = key_event.modifiers;
// From buttons (outside) back into the canvas (ReadOnly) with Up/k from the left-most button
if login_page.focus_outside_canvas
&& login_page.focused_button_index == 0
&& matches!(key_code, KeyCode::Up | KeyCode::Char('k'))
&& modifiers.is_empty()
{
login_page.focus_outside_canvas = false;
login_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok(String::new()));
}
// Focus handoff: inside canvas → buttons
if !login_page.focus_outside_canvas {
let last_idx = login_page.editor.data_provider().field_count().saturating_sub(1);
let at_last = login_page.editor.current_field() >= last_idx;
if login_page.editor.mode() == CanvasMode::ReadOnly
&& at_last
&& matches!(
(key_code, modifiers),
(KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _)
)
{
login_page.focus_outside_canvas = true;
login_page.focused_button_index = 0;
login_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
}
}
// Forward to canvas if focus is inside
if !login_page.focus_outside_canvas {
match login_page.handle_key_event(key_event) {
KeyEventOutcome::Consumed(Some(msg)) => {
return Ok(EventOutcome::Ok(msg));
}
KeyEventOutcome::Consumed(None) => {
return Ok(EventOutcome::Ok("Login input updated".into()));
}
KeyEventOutcome::Pending => {
return Ok(EventOutcome::Ok("Waiting for next key...".into()));
}
KeyEventOutcome::NotMatched => {
// fall through to button handling
}
}
}
}
Ok(EventOutcome::Ok(String::new()))
}

View File

@@ -9,6 +9,7 @@ use crate::ui::handlers::context::DialogPurpose;
use common::proto::komp_ac::auth::LoginResponse; use common::proto::komp_ac::auth::LoginResponse;
use crate::pages::login::LoginFormState; use crate::pages::login::LoginFormState;
use crate::state::pages::auth::UserRole; use crate::state::pages::auth::UserRole;
use canvas::DataProvider;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use tokio::spawn; use tokio::spawn;
use tokio::sync::mpsc; use tokio::sync::mpsc;
@@ -108,7 +109,19 @@ pub async fn revert(
login_state: &mut LoginFormState, login_state: &mut LoginFormState,
app_state: &mut AppState, app_state: &mut AppState,
) -> String { ) -> String {
// Clear the underlying state
login_state.clear(); login_state.clear();
// Also clear values inside the editors data provider
{
let dp = login_state.editor.data_provider_mut();
dp.set_field_value(0, "".to_string());
dp.set_field_value(1, "".to_string());
dp.set_current_field(0);
dp.set_current_cursor_pos(0);
dp.set_has_unsaved_changes(false);
}
app_state.hide_dialog(); app_state.hide_dialog();
"Login reverted".to_string() "Login reverted".to_string()
} }
@@ -125,9 +138,6 @@ pub async fn back_to_main(
buffer_state.close_active_buffer(); buffer_state.close_active_buffer();
buffer_state.update_history(AppView::Intro); buffer_state.update_history(AppView::Intro);
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index = 0;
"Returned to main menu".to_string() "Returned to main menu".to_string()
} }

View File

@@ -3,7 +3,9 @@
pub mod state; pub mod state;
pub mod ui; pub mod ui;
pub mod logic; pub mod logic;
pub mod event;
pub use state::*; pub use state::*;
pub use ui::render_login; pub use ui::render_login;
pub use logic::*; pub use logic::*;
pub use event::*;

View File

@@ -127,6 +127,8 @@ impl DataProvider for LoginState {
pub struct LoginFormState { pub struct LoginFormState {
pub state: LoginState, pub state: LoginState,
pub editor: FormEditor<LoginState>, pub editor: FormEditor<LoginState>,
pub focus_outside_canvas: bool,
pub focused_button_index: usize,
} }
// manual debug because FormEditor doesnt implement debug // manual debug because FormEditor doesnt implement debug
@@ -150,7 +152,12 @@ impl LoginFormState {
pub fn new() -> Self { pub fn new() -> Self {
let state = LoginState::default(); let state = LoginState::default();
let editor = FormEditor::new(state.clone()); let editor = FormEditor::new(state.clone());
Self { state, editor } Self {
state,
editor,
focus_outside_canvas: false,
focused_button_index: 0,
}
} }
// === Delegates to LoginState fields === // === Delegates to LoginState fields ===

View File

@@ -80,11 +80,8 @@ pub fn render_login(
// Login Button // Login Button
let login_button_index = 0; let login_button_index = 0;
let login_active = if app_state.ui.focus_outside_canvas { let login_active = login_page.focus_outside_canvas
app_state.focused_button_index == login_button_index && login_page.focused_button_index == login_button_index;
} else {
false
};
let mut login_style = Style::default().fg(theme.fg); let mut login_style = Style::default().fg(theme.fg);
let mut login_border = Style::default().fg(theme.border); let mut login_border = Style::default().fg(theme.border);
if login_active { if login_active {
@@ -107,11 +104,8 @@ pub fn render_login(
// Return Button // Return Button
let return_button_index = 1; let return_button_index = 1;
let return_active = if app_state.ui.focus_outside_canvas { let return_active = login_page.focus_outside_canvas
app_state.focused_button_index == return_button_index && login_page.focused_button_index == return_button_index;
} else {
false
};
let mut return_style = Style::default().fg(theme.fg); let mut return_style = Style::default().fg(theme.fg);
let mut return_border = Style::default().fg(theme.border); let mut return_border = Style::default().fg(theme.border);
if return_active { if return_active {

View File

@@ -0,0 +1,72 @@
// src/pages/register/event.rs
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyModifiers};
use canvas::{keymap::KeyEventOutcome, AppMode as CanvasMode};
use canvas::DataProvider;
use crate::{
state::app::state::AppState,
pages::register::RegisterFormState,
modes::handlers::event::EventOutcome,
};
/// Handles all Register page-specific events.
/// Return a non-empty Ok(message) only when the page actually consumed the key,
/// otherwise return Ok("") to let global handling proceed.
pub fn handle_register_event(
event: Event,
app_state: &mut AppState,
register_page: &mut RegisterFormState,
)-> Result<EventOutcome> {
if let Event::Key(key_event) = event {
let key_code = key_event.code;
let modifiers = key_event.modifiers;
// From buttons (outside) back into the canvas (ReadOnly) with Up/k from the left-most button
if register_page.focus_outside_canvas
&& register_page.focused_button_index == 0
&& matches!(key_code, KeyCode::Up | KeyCode::Char('k'))
&& modifiers.is_empty()
{
register_page.focus_outside_canvas = false;
register_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok(String::new()));
}
// Focus handoff: inside canvas → buttons
if !register_page.focus_outside_canvas {
let last_idx = register_page.editor.data_provider().field_count().saturating_sub(1);
let at_last = register_page.editor.current_field() >= last_idx;
if register_page.editor.mode() == CanvasMode::ReadOnly
&& at_last
&& matches!(
(key_code, modifiers),
(KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _)
)
{
register_page.focus_outside_canvas = true;
register_page.focused_button_index = 0; // focus "Register" button
register_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
}
}
// Forward to canvas if focus is inside
if !register_page.focus_outside_canvas {
match register_page.handle_key_event(key_event) {
KeyEventOutcome::Consumed(Some(msg)) => {
return Ok(EventOutcome::Ok(msg));
}
KeyEventOutcome::Consumed(None) => {
return Ok(EventOutcome::Ok("Register input updated".into()));
}
KeyEventOutcome::Pending => {
return Ok(EventOutcome::Ok("Waiting for next key...".into()));
}
KeyEventOutcome::NotMatched => {
// fall through
}
}
}
}
Ok(EventOutcome::Ok(String::new()))
}

View File

@@ -54,8 +54,8 @@ pub async fn back_to_login(
buffer_state.update_history(AppView::Login); buffer_state.update_history(AppView::Login);
// Reset focus state // Reset focus state
app_state.ui.focus_outside_canvas = false; register_state.focus_outside_canvas = false;
app_state.focused_button_index = 0; register_state.focused_button_index = 0;
"Returned to main menu".to_string() "Returned to main menu".to_string()
} }

View File

@@ -5,8 +5,10 @@ pub mod ui;
pub mod state; pub mod state;
pub mod logic; pub mod logic;
pub mod suggestions; pub mod suggestions;
pub mod event;
// pub use state::*; // pub use state::*;
pub use ui::render_register; pub use ui::render_register;
pub use logic::*; pub use logic::*;
pub use state::*; pub use state::*;
pub use event::*;

View File

@@ -146,6 +146,8 @@ impl DataProvider for RegisterState {
pub struct RegisterFormState { pub struct RegisterFormState {
pub state: RegisterState, pub state: RegisterState,
pub editor: FormEditor<RegisterState>, pub editor: FormEditor<RegisterState>,
pub focus_outside_canvas: bool,
pub focused_button_index: usize,
} }
impl Default for RegisterFormState { impl Default for RegisterFormState {
@@ -174,7 +176,12 @@ impl RegisterFormState {
pub fn new() -> Self { pub fn new() -> Self {
let state = RegisterState::default(); let state = RegisterState::default();
let editor = FormEditor::new(state.clone()); let editor = FormEditor::new(state.clone());
Self { state, editor } Self {
state,
editor,
focus_outside_canvas: false,
focused_button_index: 0,
}
} }
// === Delegates to RegisterState === // === Delegates to RegisterState ===

View File

@@ -80,8 +80,9 @@ pub fn render_register(
// Register Button // Register Button
let register_button_index = 0; let register_button_index = 0;
let register_active = app_state.ui.focus_outside_canvas let register_active =
&& app_state.focused_button_index == register_button_index; register_page.focus_outside_canvas
&& register_page.focused_button_index == register_button_index;
let mut register_style = Style::default().fg(theme.fg); let mut register_style = Style::default().fg(theme.fg);
let mut register_border = Style::default().fg(theme.border); let mut register_border = Style::default().fg(theme.border);
if register_active { if register_active {
@@ -104,8 +105,9 @@ pub fn render_register(
// Return Button // Return Button
let return_button_index = 1; let return_button_index = 1;
let return_active = app_state.ui.focus_outside_canvas let return_active =
&& app_state.focused_button_index == return_button_index; register_page.focus_outside_canvas
&& register_page.focused_button_index == return_button_index;
let mut return_style = Style::default().fg(theme.fg); let mut return_style = Style::default().fg(theme.fg);
let mut return_border = Style::default().fg(theme.border); let mut return_border = Style::default().fg(theme.border);
if return_active { if return_active {

View File

@@ -1,7 +1,7 @@
// src/pages/routing/router.rs // src/pages/routing/router.rs
use crate::state::pages::auth::AuthState; use crate::state::pages::auth::AuthState;
use crate::pages::admin_panel::add_logic::state::AddLogicState; use crate::pages::admin_panel::add_logic::state::AddLogicFormState;
use crate::pages::admin_panel::add_table::state::AddTableState; use crate::pages::admin_panel::add_table::state::AddTableFormState;
use crate::pages::admin::AdminState; use crate::pages::admin::AdminState;
use crate::pages::forms::FormState; use crate::pages::forms::FormState;
use crate::pages::login::LoginFormState; use crate::pages::login::LoginFormState;
@@ -14,8 +14,8 @@ pub enum Page {
Login(LoginFormState), Login(LoginFormState),
Register(RegisterFormState), Register(RegisterFormState),
Admin(AdminState), Admin(AdminState),
AddLogic(AddLogicState), AddLogic(AddLogicFormState),
AddTable(AddTableState), AddTable(AddTableFormState),
Form(String), Form(String),
} }

View File

@@ -106,7 +106,6 @@ pub async fn handle_search_palette_event(
if should_close { if should_close {
app_state.search_state = None; app_state.search_state = None;
app_state.ui.show_search_palette = false; app_state.ui.show_search_palette = false;
app_state.ui.focus_outside_canvas = false;
} }
Ok(outcome_message) Ok(outcome_message)

View File

@@ -28,7 +28,6 @@ pub struct UiState {
pub show_login: bool, pub show_login: bool,
pub show_register: bool, pub show_register: bool,
pub show_search_palette: bool, pub show_search_palette: bool,
pub focus_outside_canvas: bool,
pub dialog: DialogState, pub dialog: DialogState,
} }
@@ -52,7 +51,6 @@ pub struct AppState {
// NEW: The "Rulebook" cache. We use Arc for efficient sharing. // NEW: The "Rulebook" cache. We use Arc for efficient sharing.
pub schema_cache: HashMap<String, Arc<TableStructureResponse>>, pub schema_cache: HashMap<String, Arc<TableStructureResponse>>,
pub focused_button_index: usize,
pub pending_table_structure_fetch: Option<(String, String)>, pub pending_table_structure_fetch: Option<(String, String)>,
pub search_state: Option<SearchState>, pub search_state: Option<SearchState>,
@@ -77,7 +75,6 @@ impl AppState {
current_view_table_name: None, current_view_table_name: None,
current_mode: AppMode::General, current_mode: AppMode::General,
schema_cache: HashMap::new(), // NEW: Initialize the cache schema_cache: HashMap::new(), // NEW: Initialize the cache
focused_button_index: 0,
pending_table_structure_fetch: None, pending_table_structure_fetch: None,
search_state: None, search_state: None,
ui: UiState::default(), ui: UiState::default(),
@@ -166,8 +163,7 @@ impl Default for UiState {
show_login: false, show_login: false,
show_register: false, show_register: false,
show_buffer_list: true, show_buffer_list: true,
show_search_palette: false, // ADDED show_search_palette: false,
focus_outside_canvas: false,
dialog: DialogState::default(), dialog: DialogState::default(),
} }
} }

View File

@@ -16,32 +16,27 @@ pub fn logout(
auth_state.user_id = None; auth_state.user_id = None;
auth_state.role = None; auth_state.role = None;
auth_state.decoded_username = None; auth_state.decoded_username = None;
// Delete stored auth data // Delete stored auth data
if let Err(e) = delete_auth_data() { if let Err(e) = delete_auth_data() {
error!("Failed to delete stored auth data: {}", e); error!("Failed to delete stored auth data: {}", e);
// Continue anyway - user is logged out in memory
} }
// Navigate to intro screen // Navigate to intro screen
buffer_state.history = vec![AppView::Intro]; buffer_state.history = vec![AppView::Intro];
buffer_state.active_index = 0; 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 // Hide any open dialogs
app_state.hide_dialog(); app_state.hide_dialog();
// Show logout confirmation dialog // Show logout confirmation dialog
app_state.show_dialog( app_state.show_dialog(
"Logged Out", "Logged Out",
"You have been successfully logged out.", "You have been successfully logged out.",
vec!["OK".to_string()], vec!["OK".to_string()],
DialogPurpose::LoginSuccess, // Reuse or create a new purpose DialogPurpose::LoginSuccess,
); );
info!("User logged out successfully."); info!("User logged out successfully.");
"Logged out successfully".to_string() "Logged out successfully".to_string()
} }

View File

@@ -12,10 +12,14 @@ use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::UserRole; use crate::state::pages::auth::UserRole;
use crate::pages::login::LoginFormState; use crate::pages::login::LoginFormState;
use crate::pages::register::RegisterFormState; use crate::pages::register::RegisterFormState;
use crate::pages::admin_panel::add_table;
use crate::pages::admin_panel::add_logic;
use crate::pages::admin::AdminState; use crate::pages::admin::AdminState;
use crate::pages::admin::AdminFocus; use crate::pages::admin::AdminFocus;
use crate::pages::admin::admin;
use crate::pages::intro::IntroState; use crate::pages::intro::IntroState;
use crate::pages::forms::{FormState, FieldDefinition}; use crate::pages::forms::{FormState, FieldDefinition};
use crate::pages::forms;
use crate::pages::routing::{Router, Page}; use crate::pages::routing::{Router, Page};
use crate::buffer::state::BufferState; use crate::buffer::state::BufferState;
use crate::buffer::state::AppView; use crate::buffer::state::AppView;
@@ -223,8 +227,17 @@ pub async fn run_ui() -> Result<()> {
|| app_state.ui.show_search_palette || app_state.ui.show_search_palette
|| event_handler.navigation_state.active; || event_handler.navigation_state.active;
if !overlay_active { if !overlay_active {
if let Page::Form(path) = &router.current { let inside_canvas = match &router.current {
if !app_state.ui.focus_outside_canvas { Page::Form(_) => true,
Page::Login(state) => !state.focus_outside_canvas,
Page::Register(state) => !state.focus_outside_canvas,
Page::AddTable(state) => !state.focus_outside_canvas,
Page::AddLogic(state) => !state.focus_outside_canvas,
_ => false,
};
if inside_canvas {
if let Page::Form(path) = &router.current {
if let Some(editor) = app_state.editor_for_path(path) { if let Some(editor) = app_state.editor_for_path(path) {
match editor.handle_key_event(*key_event) { match editor.handle_key_event(*key_event) {
KeyEventOutcome::Consumed(Some(msg)) => { KeyEventOutcome::Consumed(Some(msg)) => {
@@ -362,7 +375,9 @@ pub async fn run_ui() -> Result<()> {
vec!["OK".to_string()], vec!["OK".to_string()],
DialogPurpose::SaveTableSuccess, DialogPurpose::SaveTableSuccess,
); );
admin_state.add_table_state.has_unsaved_changes = false; if let Page::AddTable(page) = &mut router.current {
page.state.has_unsaved_changes = false;
}
} }
Err(e) => { Err(e) => {
event_handler.command_message = format!("Save failed: {}", e); event_handler.command_message = format!("Save failed: {}", e);
@@ -412,39 +427,29 @@ pub async fn run_ui() -> Result<()> {
admin_state = current.clone(); admin_state = current.clone();
} }
info!("Auth role at render: {:?}", auth_state.role); info!("Auth role at render: {:?}", auth_state.role);
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);
}
}
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() // Use the admin loader instead of inline logic
|| !matches!(admin_state.current_focus, if let Err(e) = admin::loader::refresh_admin_state(&mut grpc_client, &mut app_state, &mut admin_state).await {
AdminFocus::InsideProfilesList | error!("Failed to refresh admin state: {}", e);
AdminFocus::Tables | AdminFocus::InsideTablesList | event_handler.command_message = format!("Error refreshing admin data: {}", e);
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));
} }
router.navigate(Page::Admin(admin_state.clone())); router.navigate(Page::Admin(admin_state.clone()));
} }
AppView::AddTable => router.navigate(Page::AddTable(admin_state.add_table_state.clone())), AppView::AddTable => {
AppView::AddLogic => router.navigate(Page::AddLogic(admin_state.add_logic_state.clone())), if let Page::AddTable(page) = &mut router.current {
// Ensure keymap is set once (same as AddLogic)
page.editor.set_keymap(config.build_canvas_keymap());
} else {
// Page is created by admin navigation (Button2). No-op here.
}
}
AppView::AddLogic => {
if let Page::AddLogic(page) = &mut router.current {
// Ensure keymap is set once
page.editor.set_keymap(config.build_canvas_keymap());
}
}
AppView::Form(path) => { AppView::Form(path) => {
// Keep current_view_* consistent with the active buffer path // Keep current_view_* consistent with the active buffer path
if let Some((profile, table)) = path.split_once('/') { if let Some((profile, table)) = path.split_once('/') {
@@ -459,7 +464,7 @@ pub async fn run_ui() -> Result<()> {
} }
} }
if let Page::Form(current_path) = &router.current { if let Page::Form(_current_path) = &router.current {
let current_view_profile = app_state.current_view_profile_name.clone(); let current_view_profile = app_state.current_view_profile_name.clone();
let current_view_table = app_state.current_view_table_name.clone(); let current_view_table = app_state.current_view_table_name.clone();
@@ -475,52 +480,16 @@ pub async fn run_ui() -> Result<()> {
); );
needs_redraw = true; needs_redraw = true;
match UiService::load_table_view( // DELEGATE to the forms loader
match forms::loader::ensure_form_loaded_and_count(
&mut grpc_client, &mut grpc_client,
&mut app_state, &mut app_state,
&config,
prof_name, prof_name,
tbl_name, tbl_name,
) ).await {
.await Ok(()) => {
{ app_state.hide_dialog();
Ok(new_form_state) => {
// Set the new form state and fetch count
let path = format!("{}/{}", prof_name, tbl_name);
app_state.ensure_form_editor(&path, &config, || new_form_state);
if let Some(form_state) = app_state.form_state_for_path(&path) {
if let Err(e) = UiService::fetch_and_set_table_count(
&mut grpc_client,
form_state,
)
.await
{
app_state.update_dialog_content(
&format!("Error fetching count: {}", e),
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
} else if form_state.total_count > 0 {
if let Err(e) = UiService::load_table_data_by_position(
&mut grpc_client,
form_state,
)
.await
{
app_state.update_dialog_content(
&format!("Error loading data: {}", e),
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
} else {
app_state.hide_dialog();
}
} else {
form_state.reset_to_empty();
app_state.hide_dialog();
}
}
prev_view_profile_name = current_view_profile; prev_view_profile_name = current_view_profile;
prev_view_table_name = current_view_table; prev_view_table_name = current_view_table;
table_just_switched = true; table_just_switched = true;
@@ -531,10 +500,9 @@ pub async fn run_ui() -> Result<()> {
vec!["OK".to_string()], vec!["OK".to_string()],
DialogPurpose::LoginFailed, DialogPurpose::LoginFailed,
); );
app_state.current_view_profile_name = // Reset to previous state on error
prev_view_profile_name.clone(); app_state.current_view_profile_name = prev_view_profile_name.clone();
app_state.current_view_table_name = app_state.current_view_table_name = prev_view_table_name.clone();
prev_view_table_name.clone();
} }
} }
} }
@@ -542,67 +510,25 @@ pub async fn run_ui() -> Result<()> {
} }
} }
// Continue with the rest of the positioning logic... let needs_redraw_from_fetch = add_logic::loader::process_pending_table_structure_fetch(
// Now we can use CanvasState methods like get_current_input(), current_field(), etc. &mut app_state,
&mut router,
&mut grpc_client,
&mut event_handler.command_message,
).await.unwrap_or(false);
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() { if needs_redraw_from_fetch {
if let Page::AddLogic(state) = &mut router.current { needs_redraw = true;
if state.profile_name == profile_name
&& 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,
state,
&app_state.profile_tree,
)
.await
.unwrap_or_else(|e| {
error!("Error initializing add_logic_table_data: {}", e);
format!("Error fetching table structure: {}", e)
});
if !fetch_message.contains("Error") && !fetch_message.contains("Warning") {
info!("{}", fetch_message);
} else {
event_handler.command_message = fetch_message;
}
needs_redraw = true;
} else {
error!(
"Mismatch in pending_table_structure_fetch: app_state wants {}.{}, but AddLogic state is for {}.{:?}",
profile_name,
table_name,
state.profile_name,
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 Page::AddLogic(state) = &mut router.current { if let Page::AddLogic(state) = &mut router.current {
if let Some(table_name) = state.script_editor_awaiting_column_autocomplete.clone() { let needs_redraw_from_columns = add_logic::loader::maybe_fetch_columns_for_awaiting_table(
let profile_name = state.profile_name.clone(); &mut grpc_client,
state,
&mut event_handler.command_message,
).await.unwrap_or(false);
info!("Fetching columns for table selection: {}.{}", profile_name, table_name); if needs_redraw_from_columns {
match UiService::fetch_columns_for_table(&mut grpc_client, &profile_name, &table_name).await {
Ok(columns) => {
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);
state.script_editor_awaiting_column_autocomplete = None;
state.deactivate_script_editor_autocomplete();
event_handler.command_message = format!("Error loading columns for '{}': {}", table_name, e);
}
}
needs_redraw = true; needs_redraw = true;
} }
} }
@@ -716,7 +642,15 @@ pub async fn run_ui() -> Result<()> {
match current_mode { match current_mode {
AppMode::General => { AppMode::General => {
if app_state.ui.focus_outside_canvas { let outside_canvas = match &router.current {
Page::Login(state) => state.focus_outside_canvas,
Page::Register(state) => state.focus_outside_canvas,
Page::AddTable(state) => state.focus_outside_canvas,
Page::AddLogic(state) => state.focus_outside_canvas,
_ => false, // Form and Admin dont use this flag
};
if outside_canvas {
// Outside canvas → app decides // Outside canvas → app decides
terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?;
terminal.show_cursor()?; terminal.show_cursor()?;
@@ -733,6 +667,12 @@ pub async fn run_ui() -> Result<()> {
if let Page::Register(page) = &router.current { if let Page::Register(page) = &router.current {
let _ = CursorManager::update_for_mode(page.editor.mode()); let _ = CursorManager::update_for_mode(page.editor.mode());
} }
if let Page::AddTable(page) = &router.current {
let _ = CursorManager::update_for_mode(page.editor.mode());
}
if let Page::AddLogic(page) = &router.current {
let _ = CursorManager::update_for_mode(page.editor.mode());
}
} }
} }
AppMode::Command => { AppMode::Command => {