From d641ad1bbbb6d3daa3fb162712f3a23275eb4c5e Mon Sep 17 00:00:00 2001 From: Priec Date: Wed, 27 Aug 2025 01:06:54 +0200 Subject: [PATCH] centralized general movement --- client/config.toml | 16 ++- client/src/lib.rs | 1 + client/src/modes/handlers/event.rs | 129 ++++++++++++--------- client/src/movement/actions.rs | 12 ++ client/src/movement/lib.rs | 32 +++++ client/src/movement/mod.rs | 6 + client/src/pages/intro/state.rs | 23 ++++ client/src/state/pages/add_table.rs | 173 ++++++++++++++++++++++++++++ client/src/state/pages/admin.rs | 120 +++++++++++++++++++ 9 files changed, 451 insertions(+), 61 deletions(-) create mode 100644 client/src/movement/actions.rs create mode 100644 client/src/movement/lib.rs create mode 100644 client/src/movement/mod.rs diff --git a/client/config.toml b/client/config.toml index 948a7c8..d096d83 100644 --- a/client/config.toml +++ b/client/config.toml @@ -7,16 +7,14 @@ previous_buffer = ["space+b+p"] close_buffer = ["space+b+d"] [keybindings.general] -move_up = ["k", "Up"] -move_down = ["j", "Down"] -next_option = ["l", "Right"] -previous_option = ["h", "Left"] +up = ["k", "Up"] +down = ["j", "Down"] +left = ["h", "Left"] +right = ["l", "Right"] +next = ["Tab"] +previous = ["Shift+Tab"] select = ["Enter"] -toggle_sidebar = ["ctrl+t"] -toggle_buffer_list = ["ctrl+b"] -next_field = ["Tab"] -prev_field = ["Shift+Tab"] -exit_table_scroll = ["esc"] +esc = ["esc"] open_search = ["ctrl+f"] [keybindings.common] diff --git a/client/src/lib.rs b/client/src/lib.rs index dffb5e4..4acbd89 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -14,6 +14,7 @@ pub mod dialog; pub mod search; pub mod bottom_panel; pub mod pages; +pub mod movement; pub use ui::run_ui; diff --git a/client/src/modes/handlers/event.rs b/client/src/modes/handlers/event.rs index 12566c1..a5844aa 100644 --- a/client/src/modes/handlers/event.rs +++ b/client/src/modes/handlers/event.rs @@ -371,6 +371,58 @@ impl EventHandler { match current_mode { AppMode::General => { + // Map keys to MovementAction + 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 + }; + + // Let the current page handle decoupled movement first + if let Some(ma) = movement_action { + match &mut router.current { + Page::AddTable(state) => { + if state.handle_movement(ma) { + // Keep UI focus consistent with inputs vs. outer elements + use crate::state::pages::add_table::AddTableFocus; + let is_canvas_input = matches!( + state.current_focus, + AddTableFocus::InputTableName + | AddTableFocus::InputColumnName + | AddTableFocus::InputColumnType + ); + app_state.ui.focus_outside_canvas = !is_canvas_input; + return Ok(EventOutcome::Ok(String::new())); + } + } + Page::Admin(state) => { + if state.handle_movement(app_state, ma) { + return Ok(EventOutcome::Ok(String::new())); + } + } + Page::Intro(state) => { + if state.handle_movement(ma) { + return Ok(EventOutcome::Ok(String::new())); + } + } + _ => {} + } + } + + // Optional page-specific handlers (non-movement or rich actions) if let Page::Admin(admin_state) = &mut router.current { if auth_state.role.as_deref() == Some("admin") { if admin_nav::handle_admin_navigation( @@ -401,7 +453,7 @@ impl EventHandler { router, ) { return Ok(EventOutcome::Ok( - self.command_message.clone(), + self.command_message.clone(), )); } @@ -423,16 +475,25 @@ impl EventHandler { } } - let nav_outcome = navigation::handle_navigation_event( - key_event, - config, - app_state, - router, - &mut self.command_mode, - &mut self.command_input, - &mut self.command_message, - &mut self.navigation_state, - ).await; + // Generic navigation for the rest (Intro/Login/Register/Form) + let nav_outcome = if matches!( + &router.current, + Page::Admin(_) | Page::AddTable(_) | Page::AddLogic(_) + ) { + Ok(EventOutcome::Ok(String::new())) + } else { + navigation::handle_navigation_event( + key_event, + config, + app_state, + router, + &mut self.command_mode, + &mut self.command_input, + &mut self.command_message, + &mut self.navigation_state, + ).await + }; + match nav_outcome { Ok(EventOutcome::ButtonSelected { context, index }) => { let message = match context { @@ -443,14 +504,8 @@ impl EventHandler { index, ); if let Page::Admin(admin_state) = &mut router.current { - if !app_state - .profile_tree - .profiles - .is_empty() - { - admin_state - .profile_list_state - .select(Some(0)); + if !app_state.profile_tree.profiles.is_empty() { + admin_state.profile_list_state.select(Some(0)); } } format!("Intro Option {} selected", index) @@ -506,8 +561,9 @@ impl EventHandler { } format!("Admin Option {} selected", index) } - UiContext::Dialog => "Internal error: Unexpected dialog state" - .to_string(), + UiContext::Dialog => { + "Internal error: Unexpected dialog state".to_string() + } }; return Ok(EventOutcome::Ok(message)); } @@ -515,37 +571,6 @@ impl EventHandler { } } - AppMode::General => { - match &router.current { - Page::Form(_) - | Page::Login(_) - | Page::Register(_) - | Page::AddTable(_) - | Page::AddLogic(_) => { - if !app_state.ui.focus_outside_canvas { - if let Some(editor) = &mut app_state.form_editor { - editor.set_keymap(config.build_canvas_keymap()); - match editor.handle_key_event(key_event) { - KeyEventOutcome::Consumed(Some(msg)) => { - return Ok(EventOutcome::Ok(msg)); - } - KeyEventOutcome::Consumed(None) => { - return Ok(EventOutcome::Ok(String::new())); - } - KeyEventOutcome::Pending => { - return Ok(EventOutcome::Ok(String::new())); - } - KeyEventOutcome::NotMatched => { - // fall through to client actions - } - } - } - } - } - _ => {} - } - } - AppMode::Command => { if config.is_exit_command_mode(key_code, modifiers) { self.command_input.clear(); diff --git a/client/src/movement/actions.rs b/client/src/movement/actions.rs new file mode 100644 index 0000000..74b6251 --- /dev/null +++ b/client/src/movement/actions.rs @@ -0,0 +1,12 @@ +// src/movement/actions.rs +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MovementAction { + Next, + Previous, + Up, + Down, + Left, + Right, + Select, + Esc, +} diff --git a/client/src/movement/lib.rs b/client/src/movement/lib.rs new file mode 100644 index 0000000..3fd8032 --- /dev/null +++ b/client/src/movement/lib.rs @@ -0,0 +1,32 @@ +// src/movement/lib.rs + +use crate::movement::MovementAction; + +#[inline] +pub fn move_focus( + order: &[T], + current: &mut T, + action: MovementAction, +) -> bool { + if order.is_empty() { + return false; + } + if let Some(pos) = order.iter().position(|k| *k == *current) { + match action { + MovementAction::Previous | MovementAction::Up | MovementAction::Left => { + if pos > 0 { + *current = order[pos - 1]; + return true; + } + } + MovementAction::Next | MovementAction::Down | MovementAction::Right => { + if pos + 1 < order.len() { + *current = order[pos + 1]; + return true; + } + } + _ => {} + } + } + false +} diff --git a/client/src/movement/mod.rs b/client/src/movement/mod.rs new file mode 100644 index 0000000..0501ad9 --- /dev/null +++ b/client/src/movement/mod.rs @@ -0,0 +1,6 @@ +// src/movement/mod.rs +pub mod actions; +pub mod lib; + +pub use actions::MovementAction; +pub use lib::move_focus; diff --git a/client/src/pages/intro/state.rs b/client/src/pages/intro/state.rs index 5f859be..1fbf514 100644 --- a/client/src/pages/intro/state.rs +++ b/client/src/pages/intro/state.rs @@ -1,4 +1,5 @@ // src/state/pages/intro.rs +use crate::movement::MovementAction; #[derive(Default, Clone, Debug)] pub struct IntroState { @@ -23,3 +24,25 @@ impl IntroState { } } +impl IntroState { + pub fn handle_movement(&mut self, action: MovementAction) -> bool { + match action { + MovementAction::Next | MovementAction::Right | MovementAction::Down => { + self.next_option(); + true + } + MovementAction::Previous | MovementAction::Left | MovementAction::Up => { + self.previous_option(); + true + } + MovementAction::Select => { + // Actual selection handled in event loop (UiContext::Intro) + true + } + MovementAction::Esc => { + // Nothing special for Intro, but could be used to quit + true + } + } + } +} diff --git a/client/src/state/pages/add_table.rs b/client/src/state/pages/add_table.rs index 606ad02..4e5cd11 100644 --- a/client/src/state/pages/add_table.rs +++ b/client/src/state/pages/add_table.rs @@ -3,6 +3,7 @@ use canvas::{DataProvider, AppMode}; use ratatui::widgets::TableState; use serde::{Deserialize, Serialize}; +use crate::movement::{move_focus, MovementAction}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ColumnDefinition { @@ -208,3 +209,175 @@ impl DataProvider for AddTableState { false // AddTableState doesn’t use suggestions } } + + +impl AddTableState { + pub fn handle_movement(&mut self, action: MovementAction) -> bool { + use AddTableFocus::*; + + // Linear outer focus order + const ORDER: [AddTableFocus; 10] = [ + InputTableName, + InputColumnName, + InputColumnType, + AddColumnButton, + ColumnsTable, + IndexesTable, + LinksTable, + SaveButton, + DeleteSelectedButton, + CancelButton, + ]; + + // Enter "inside" on Select from outer panes + match (self.current_focus, action) { + (ColumnsTable, MovementAction::Select) => { + if !self.columns.is_empty() && self.column_table_state.selected().is_none() { + self.column_table_state.select(Some(0)); + } + self.current_focus = InsideColumnsTable; + return true; + } + (IndexesTable, MovementAction::Select) => { + if !self.indexes.is_empty() && self.index_table_state.selected().is_none() { + self.index_table_state.select(Some(0)); + } + self.current_focus = InsideIndexesTable; + return true; + } + (LinksTable, MovementAction::Select) => { + if !self.links.is_empty() && self.link_table_state.selected().is_none() { + self.link_table_state.select(Some(0)); + } + self.current_focus = InsideLinksTable; + return true; + } + _ => {} + } + + // Handle "inside" states: Up/Down/Select/Esc; block outer movement keys + match self.current_focus { + InsideColumnsTable => { + match action { + MovementAction::Up => { + if let Some(i) = self.column_table_state.selected() { + let next = i.saturating_sub(1); + self.column_table_state.select(Some(next)); + } else if !self.columns.is_empty() { + self.column_table_state.select(Some(0)); + } + return true; + } + MovementAction::Down => { + if let Some(i) = self.column_table_state.selected() { + let last = self.columns.len().saturating_sub(1); + let next = if i < last { i + 1 } else { i }; + self.column_table_state.select(Some(next)); + } else if !self.columns.is_empty() { + self.column_table_state.select(Some(0)); + } + return true; + } + MovementAction::Select => { + if let Some(i) = self.column_table_state.selected() { + if let Some(col) = self.columns.get_mut(i) { + col.selected = !col.selected; + self.has_unsaved_changes = true; + } + } + 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) + } +} diff --git a/client/src/state/pages/admin.rs b/client/src/state/pages/admin.rs index 2b18ba8..d7628dc 100644 --- a/client/src/state/pages/admin.rs +++ b/client/src/state/pages/admin.rs @@ -3,6 +3,8 @@ use ratatui::widgets::ListState; use crate::state::pages::add_table::AddTableState; use crate::state::pages::add_logic::AddLogicState; +use crate::movement::{move_focus, MovementAction}; +use crate::state::app::state::AppState; // Define the focus states for the admin panel panes #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -180,3 +182,121 @@ impl AdminState { } } +impl AdminState { + pub fn handle_movement( + &mut self, + app: &AppState, + action: MovementAction, + ) -> bool { + use AdminFocus::*; + + const ORDER: [AdminFocus; 5] = [ + ProfilesPane, + Tables, + Button1, + Button2, + Button3, + ]; + + // Enter "inside" on Select + match (self.current_focus, action) { + (ProfilesPane, MovementAction::Select) => { + if !app.profile_tree.profiles.is_empty() + && self.profile_list_state.selected().is_none() + { + self.profile_list_state.select(Some(0)); + } + self.current_focus = InsideProfilesList; + return true; + } + (Tables, MovementAction::Select) => { + let p_idx = self + .selected_profile_index + .or_else(|| self.profile_list_state.selected()); + if let Some(pi) = p_idx { + let len = app + .profile_tree + .profiles + .get(pi) + .map(|p| p.tables.len()) + .unwrap_or(0); + if len > 0 && self.table_list_state.selected().is_none() { + self.table_list_state.select(Some(0)); + } + } + self.current_focus = InsideTablesList; + return true; + } + _ => {} + } + + // Inside navigation + Esc to exit; don't consume Select (admin_nav handles it) + match self.current_focus { + InsideProfilesList => match action { + MovementAction::Up => { + if !app.profile_tree.profiles.is_empty() { + let curr = self.profile_list_state.selected().unwrap_or(0); + let next = curr.saturating_sub(1); + self.profile_list_state.select(Some(next)); + } + true + } + MovementAction::Down => { + let len = app.profile_tree.profiles.len(); + if len > 0 { + let curr = self.profile_list_state.selected().unwrap_or(0); + let next = if curr + 1 < len { curr + 1 } else { curr }; + self.profile_list_state.select(Some(next)); + } + true + } + MovementAction::Esc => { + self.current_focus = ProfilesPane; + true + } + MovementAction::Next | MovementAction::Previous => true, // block outer moves + MovementAction::Select => false, + _ => false, + }, + InsideTablesList => { + let tables_len = { + let p_idx = self + .selected_profile_index + .or_else(|| self.profile_list_state.selected()); + p_idx.and_then(|pi| app.profile_tree.profiles.get(pi)) + .map(|p| p.tables.len()) + .unwrap_or(0) + }; + match action { + MovementAction::Up => { + if tables_len > 0 { + let curr = self.table_list_state.selected().unwrap_or(0); + let next = curr.saturating_sub(1); + self.table_list_state.select(Some(next)); + } + true + } + MovementAction::Down => { + if tables_len > 0 { + let curr = self.table_list_state.selected().unwrap_or(0); + let next = if curr + 1 < tables_len { curr + 1 } else { curr }; + self.table_list_state.select(Some(next)); + } + true + } + MovementAction::Esc => { + self.current_focus = Tables; + true + } + MovementAction::Next | MovementAction::Previous => true, // block outer moves + MovementAction::Select => false, + _ => false, + } + } + _ => { + // Default: outer navigation via helper + return move_focus(&ORDER, &mut self.current_focus, action); + } + } + } +}