diff --git a/client/src/modes/handlers/event.rs b/client/src/modes/handlers/event.rs index fd077ea..1678586 100644 --- a/client/src/modes/handlers/event.rs +++ b/client/src/modes/handlers/event.rs @@ -321,18 +321,6 @@ impl EventHandler { if !outcome.get_message_if_ok().is_empty() { return Ok(outcome); } - } else if let Page::AddLogic(add_logic_page) = &mut router.current { - let outcome = add_logic::event::handle_add_logic_event( - event, - config, - app_state, - add_logic_page, - self.grpc_client.clone(), - self.save_logic_result_sender.clone(), - )?; - if !outcome.get_message_if_ok().is_empty() { - return Ok(outcome); - } } else if let Page::Form(path) = &router.current { let outcome = forms::event::handle_form_event( event, @@ -497,6 +485,22 @@ impl EventHandler { return Ok(EventOutcome::Ok(self.command_message.clone())); } } + if let Page::AddLogic(add_logic_page) = &mut router.current { + let client_clone = self.grpc_client.clone(); + let sender_clone = self.save_logic_result_sender.clone(); + if add_logic::event::handle_add_logic_event( + key_event, + movement_action, + config, + app_state, + add_logic_page, + client_clone, + sender_clone, + &mut self.command_message, + ) { + return Ok(EventOutcome::Ok(self.command_message.clone())); + } + } // Generic navigation for the rest (Intro/Login/Register/Form) let nav_outcome = if matches!(&router.current, Page::AddTable(_) | Page::AddLogic(_)) { diff --git a/client/src/pages/admin_panel/add_logic/event.rs b/client/src/pages/admin_panel/add_logic/event.rs index f102dea..ccc4ee6 100644 --- a/client/src/pages/admin_panel/add_logic/event.rs +++ b/client/src/pages/admin_panel/add_logic/event.rs @@ -1,199 +1,149 @@ // src/pages/admin_panel/add_logic/event.rs -use anyhow::Result; -use crossterm::event::{Event, KeyCode, KeyModifiers}; use crate::config::binds::config::Config; -use crate::modes::handlers::event::EventOutcome; -use crate::pages::admin_panel::add_logic::state::{AddLogicFormState, AddLogicFocus}; +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::pages::admin_panel::add_logic::nav::SaveLogicResultSender; // keep type alias use crate::state::app::state::AppState; use canvas::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, +]; + +/// Return true if the event was handled and UI should be redrawn. pub fn handle_add_logic_event( - event: Event, + key_event: KeyEvent, + movement: Option, config: &Config, app_state: &mut AppState, add_logic_page: &mut AddLogicFormState, grpc_client: GrpcClient, save_logic_sender: SaveLogicResultSender, -) -> Result { - if let Event::Key(key_event) = event { - let st = &mut add_logic_page.state; - let key_code = key_event.code; - let modifiers = key_event.modifiers; - - // 1) Fullscreen Script Editor mode - if st.current_focus == AddLogicFocus::InsideScriptContent { - match key_code { - KeyCode::Esc if modifiers == KeyModifiers::NONE => { - st.current_focus = AddLogicFocus::ScriptContentPreview; - app_state.ui.focus_outside_canvas = true; - return Ok(EventOutcome::Ok("Exited script editing.".into())); - } - _ => { - let changed = { - let mut editor_borrow = st.script_content_editor.borrow_mut(); - TextEditor::handle_input( - &mut editor_borrow, - key_event, - &st.editor_keybinding_mode, - &mut st.vim_state, - ) - }; - if changed { - st.has_unsaved_changes = true; - return Ok(EventOutcome::Ok("Script updated".into())); - } else { - return Ok(EventOutcome::Ok(String::new())); - } + command_message: &mut String, +) -> bool { + // 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; + app_state.ui.focus_outside_canvas = true; + *command_message = "Exited script editing.".to_string(); + return true; + } + _ => { + 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; + *command_message = "Script updated".to_string(); } + return true; } } + } - // 2) Canvas inputs (three fields) – forward to FormEditor - let inside_canvas_inputs = matches!( - st.current_focus, - AddLogicFocus::InputLogicName + // 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 { - // Handoff from last field to Script Preview on "down"/"next" - let last_idx = add_logic_page - .editor - .data_provider() - .field_count() - .saturating_sub(1); + if inside_canvas_inputs { + 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; - - // Map to generic "down/next" via config or raw keys - let action_opt = config.get_general_action(key_code, modifiers); - let is_down_or_next = matches!( - action_opt, - Some("down") | Some("next") | Some("move_down") | Some("next_field") - ) - || matches!(key_code, KeyCode::Down) - || matches!(key_code, KeyCode::Char('j') if modifiers.is_empty()); - - if at_last && is_down_or_next { - st.last_canvas_field = last_idx; - st.current_focus = AddLogicFocus::ScriptContentPreview; - add_logic_page.focus_outside_canvas = true; + 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; app_state.ui.focus_outside_canvas = true; - return Ok(EventOutcome::Ok( - "Moved to Script Preview".to_string(), - )); - } - - // Normal canvas input - match add_logic_page.editor.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 to outside handling - } + *command_message = "Moved to Script Preview".to_string(); + return true; } } - // 3) Outside-canvas focus (Script Preview, Save, Cancel) - let action_opt = config.get_general_action(key_code, modifiers); + match add_logic_page.handle_key_event(key_event) { + canvas::keymap::KeyEventOutcome::Consumed(Some(msg)) => { + add_logic_page.sync_from_editor(); + if !msg.is_empty() { + *command_message = msg; + } + return true; + } + canvas::keymap::KeyEventOutcome::Consumed(None) => { + add_logic_page.sync_from_editor(); + return true; + } + canvas::keymap::KeyEventOutcome::Pending => return true, + canvas::keymap::KeyEventOutcome::NotMatched => {} + } + } - match action_opt { - Some("up") | Some("move_up") | Some("previous") | Some("previous_option") | Some("prev_field") => { - match st.current_focus { - AddLogicFocus::ScriptContentPreview => { - st.current_focus = AddLogicFocus::InputDescription; - add_logic_page.focus_outside_canvas = false; - app_state.ui.focus_outside_canvas = false; - return Ok(EventOutcome::Ok( - "Back to Description".to_string(), - )); - } - AddLogicFocus::SaveButton => { - st.current_focus = AddLogicFocus::ScriptContentPreview; - return Ok(EventOutcome::Ok( - "Back to Script Preview".to_string(), - )); - } - AddLogicFocus::CancelButton => { - st.current_focus = AddLogicFocus::SaveButton; - return Ok(EventOutcome::Ok("Back to Save".to_string())); - } - _ => {} - } - } - Some("down") | Some("move_down") | Some("next") | Some("next_option") | Some("next_field") => { - match st.current_focus { - AddLogicFocus::ScriptContentPreview => { - st.current_focus = AddLogicFocus::SaveButton; - return Ok(EventOutcome::Ok("Focus: Save".to_string())); - } - AddLogicFocus::SaveButton => { - st.current_focus = AddLogicFocus::CancelButton; - return Ok(EventOutcome::Ok("Focus: Cancel".to_string())); - } - _ => {} - } - } - Some("select") => { - match st.current_focus { - AddLogicFocus::ScriptContentPreview => { - st.current_focus = AddLogicFocus::InsideScriptContent; - add_logic_page.focus_outside_canvas = false; - app_state.ui.focus_outside_canvas = false; - return Ok(EventOutcome::Ok( - "Fullscreen script editing. Esc to exit.".into(), - )); - } - AddLogicFocus::SaveButton => { - if let Some(msg) = st.save_logic() { - return Ok(EventOutcome::Ok(msg)); - } - return Ok(EventOutcome::Ok( - "Saved (no changes)".to_string(), - )); - } - AddLogicFocus::CancelButton => { - // Keep this simple: you can wire buffer/view navigation where needed - return Ok(EventOutcome::Ok( - "Cancelled Add Logic".to_string(), - )); - } - AddLogicFocus::InputLogicName - | AddLogicFocus::InputTargetColumn - | AddLogicFocus::InputDescription => { - add_logic_page.focus_outside_canvas = false; - app_state.ui.focus_outside_canvas = false; - return Ok(EventOutcome::Ok(String::new())); - } - _ => {} - } - } - Some("esc") => { - if st.current_focus == AddLogicFocus::ScriptContentPreview { - // Go back to Description (last canvas field) - st.current_focus = AddLogicFocus::InputDescription; - add_logic_page.focus_outside_canvas = false; + // 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; + app_state.ui.focus_outside_canvas = !matches!( + add_logic_page.state.current_focus, + AddLogicFocus::InputLogicName + | AddLogicFocus::InputTargetColumn + | AddLogicFocus::InputDescription + ); + return true; + } + + match ma { + MovementAction::Select => match add_logic_page.state.current_focus { + AddLogicFocus::ScriptContentPreview => { + add_logic_page.state.current_focus = AddLogicFocus::InsideScriptContent; app_state.ui.focus_outside_canvas = false; - return Ok(EventOutcome::Ok( - "Back to Description".to_string(), - )); + *command_message = "Fullscreen script editing. Esc to exit.".to_string(); + return true; + } + AddLogicFocus::SaveButton => { + if let Some(msg) = add_logic_page.state.save_logic() { + *command_message = msg; + } else { + *command_message = "Saved (no changes)".to_string(); + } + return true; + } + AddLogicFocus::CancelButton => { + *command_message = "Cancelled Add Logic".to_string(); + return true; + } + _ => {} + }, + MovementAction::Esc => { + if add_logic_page.state.current_focus == AddLogicFocus::ScriptContentPreview { + add_logic_page.state.current_focus = AddLogicFocus::InputDescription; + app_state.ui.focus_outside_canvas = false; + *command_message = "Back to Description".to_string(); + return true; } } _ => {} } } - Ok(EventOutcome::Ok(String::new())) + false } diff --git a/client/src/pages/admin_panel/add_logic/state.rs b/client/src/pages/admin_panel/add_logic/state.rs index 70da1b3..cd7705d 100644 --- a/client/src/pages/admin_panel/add_logic/state.rs +++ b/client/src/pages/admin_panel/add_logic/state.rs @@ -1,7 +1,8 @@ // src/pages/admin_panel/add_logic/state.rs use crate::config::binds::config::{EditorConfig, EditorKeybindingMode}; use crate::components::common::text_editor::{TextEditor, VimState}; -use canvas::{DataProvider, AppMode, FormEditor}; +use canvas::{DataProvider, AppMode, FormEditor, SuggestionItem}; +use crossterm::event::KeyCode; use std::cell::RefCell; use std::rc::Rc; use tui_textarea::TextArea; @@ -98,6 +99,19 @@ impl AddLogicState { pub const INPUT_FIELD_COUNT: usize = 3; + /// Build canvas SuggestionItem list for target column + pub fn column_suggestions_sync(&self, query: &str) -> Vec { + 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. pub fn update_target_column_suggestions(&mut self) { let current_input = self.target_column_input.to_lowercase(); @@ -450,6 +464,84 @@ impl AddLogicFormState { &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) } } diff --git a/client/src/pages/admin_panel/add_logic/ui.rs b/client/src/pages/admin_panel/add_logic/ui.rs index 640fb7c..909b001 100644 --- a/client/src/pages/admin_panel/add_logic/ui.rs +++ b/client/src/pages/admin_panel/add_logic/ui.rs @@ -2,7 +2,7 @@ use crate::config::colors::themes::Theme; use crate::state::app::state::AppState; 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::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, @@ -168,21 +168,16 @@ pub fn render_add_logic( let editor = &add_logic_state.editor; let active_field_rect = render_canvas(f, canvas_area, editor, theme); - // --- Render Autocomplete for Target Column --- - if editor.mode() == canvas::AppMode::Edit && editor.current_field() == 1 { // Target Column field - if add_logic_state.in_target_column_suggestion_mode() && add_logic_state.show_target_column_suggestions() { - if !add_logic_state.target_column_suggestions().is_empty() { - if let Some(input_rect) = active_field_rect { - autocomplete::render_autocomplete_dropdown( - f, - input_rect, - f.area(), // Full frame area for clamping - theme, - add_logic_state.target_column_suggestions(), - add_logic_state.selected_target_column_suggestion_index(), - ); - } - } + // --- Canvas suggestions dropdown (Target Column, etc.) --- + if editor.mode() == canvas::AppMode::Edit { + if let Some(input_rect) = active_field_rect { + render_suggestions_dropdown( + f, + f.area(), + input_rect, + &DefaultCanvasTheme, + editor, + ); } }