1090 lines
45 KiB
Rust
1090 lines
45 KiB
Rust
// src/modes/handlers/event.rs
|
||
use crate::config::binds::config::Config;
|
||
use crate::input::engine::{InputContext, InputEngine, InputOutcome};
|
||
use crate::input::action::{AppAction, BufferAction, CoreAction};
|
||
use crate::buffer::{AppView, BufferState, switch_buffer, toggle_buffer_list};
|
||
use crate::sidebar::toggle_sidebar;
|
||
use crate::search::event::handle_search_palette_event;
|
||
use crate::pages::admin_panel::add_logic;
|
||
use crate::pages::admin_panel::add_table;
|
||
use crate::pages::register::suggestions::RoleSuggestionsProvider;
|
||
use crate::pages::admin::main::logic::handle_admin_navigation;
|
||
use crate::pages::admin::admin;
|
||
use crate::modes::general::command_navigation::{
|
||
handle_command_navigation_event, NavigationState,
|
||
};
|
||
use crate::modes::{
|
||
common::{command_mode, commands::CommandHandler},
|
||
general::navigation,
|
||
handlers::mode_manager::{AppMode, ModeManager},
|
||
};
|
||
use crate::services::auth::AuthClient;
|
||
use crate::services::grpc_client::GrpcClient;
|
||
use canvas::AppMode as CanvasMode;
|
||
use canvas::DataProvider;
|
||
use crate::state::app::state::AppState;
|
||
use crate::pages::admin::AdminState;
|
||
use crate::state::pages::auth::AuthState;
|
||
use crate::state::pages::auth::UserRole;
|
||
use crate::pages::login::LoginState;
|
||
use crate::pages::register::RegisterState;
|
||
use crate::pages::intro::IntroState;
|
||
use crate::pages::login;
|
||
use crate::pages::register;
|
||
use crate::pages::intro;
|
||
use crate::pages::login::logic::LoginResult;
|
||
use crate::pages::register::RegisterResult;
|
||
use crate::pages::routing::{Router, Page};
|
||
use crate::movement::MovementAction;
|
||
use crate::dialog;
|
||
use crate::pages::forms;
|
||
use crate::pages::forms::FormState;
|
||
use crate::pages::forms::logic::{save, revert, SaveOutcome};
|
||
use crate::search::state::SearchState;
|
||
use crate::tui::{
|
||
terminal::core::TerminalCore,
|
||
};
|
||
use crate::ui::handlers::context::UiContext;
|
||
use canvas::KeyEventOutcome;
|
||
use canvas::SuggestionsProvider;
|
||
use anyhow::Result;
|
||
use common::proto::komp_ac::search::search_response::Hit;
|
||
use crossterm::event::{Event, KeyCode};
|
||
use tokio::sync::mpsc;
|
||
use tokio::sync::mpsc::unbounded_channel;
|
||
use tracing::info;
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum EventOutcome {
|
||
Ok(String),
|
||
Exit(String),
|
||
DataSaved(SaveOutcome, String),
|
||
ButtonSelected { context: UiContext, index: usize },
|
||
TableSelected { path: String },
|
||
}
|
||
|
||
impl EventOutcome {
|
||
pub fn get_message_if_ok(&self) -> String {
|
||
match self {
|
||
EventOutcome::Ok(msg) => msg.clone(),
|
||
_ => String::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
pub struct EventHandler {
|
||
pub command_mode: bool,
|
||
pub command_input: String,
|
||
pub command_message: String,
|
||
pub edit_mode_cooldown: bool,
|
||
pub ideal_cursor_column: usize,
|
||
pub input_engine: InputEngine,
|
||
pub auth_client: AuthClient,
|
||
pub grpc_client: GrpcClient,
|
||
pub login_result_sender: mpsc::Sender<LoginResult>,
|
||
pub register_result_sender: mpsc::Sender<RegisterResult>,
|
||
pub save_table_result_sender: add_table::nav::SaveTableResultSender,
|
||
pub save_logic_result_sender: add_logic::nav::SaveLogicResultSender,
|
||
pub navigation_state: NavigationState,
|
||
pub search_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
|
||
pub search_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
|
||
pub autocomplete_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
|
||
pub autocomplete_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
|
||
}
|
||
|
||
impl EventHandler {
|
||
pub async fn new(
|
||
login_result_sender: mpsc::Sender<LoginResult>,
|
||
register_result_sender: mpsc::Sender<RegisterResult>,
|
||
save_table_result_sender: add_table::nav::SaveTableResultSender,
|
||
save_logic_result_sender: add_logic::nav::SaveLogicResultSender,
|
||
grpc_client: GrpcClient,
|
||
) -> Result<Self> {
|
||
let (search_tx, search_rx) = unbounded_channel();
|
||
let (autocomplete_tx, autocomplete_rx) = unbounded_channel();
|
||
Ok(EventHandler {
|
||
command_mode: false,
|
||
command_input: String::new(),
|
||
command_message: String::new(),
|
||
edit_mode_cooldown: false,
|
||
ideal_cursor_column: 0,
|
||
input_engine: InputEngine::new(400, 5000),
|
||
auth_client: AuthClient::new().await?,
|
||
grpc_client,
|
||
login_result_sender,
|
||
register_result_sender,
|
||
save_table_result_sender,
|
||
save_logic_result_sender,
|
||
navigation_state: NavigationState::new(),
|
||
search_result_sender: search_tx,
|
||
search_result_receiver: search_rx,
|
||
autocomplete_result_sender: autocomplete_tx,
|
||
autocomplete_result_receiver: autocomplete_rx,
|
||
})
|
||
}
|
||
|
||
pub fn is_navigation_active(&self) -> bool {
|
||
self.navigation_state.active
|
||
}
|
||
|
||
pub fn activate_find_file(&mut self, options: Vec<String>) {
|
||
self.navigation_state.activate_find_file(options);
|
||
}
|
||
|
||
// Helper functions - replace the removed event_helper functions
|
||
fn get_current_field_for_state(&self, router: &Router, app_state: &AppState) -> usize {
|
||
match &router.current {
|
||
Page::Login(state) => state.current_field(),
|
||
Page::Register(state) => state.current_field(),
|
||
Page::Form(path) => app_state
|
||
.editor_for_path_ref(path)
|
||
.map(|e| e.data_provider().current_field())
|
||
.unwrap_or(0),
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
fn get_current_cursor_pos_for_state(&self, router: &Router, app_state: &AppState) -> usize {
|
||
match &router.current {
|
||
Page::Login(state) => state.current_cursor_pos(),
|
||
Page::Register(state) => state.current_cursor_pos(),
|
||
Page::Form(path) => app_state
|
||
.form_state_for_path_ref(path)
|
||
.map(|fs| fs.current_cursor_pos())
|
||
.unwrap_or(0),
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
fn get_has_unsaved_changes_for_state(&self, router: &Router, app_state: &AppState) -> bool {
|
||
match &router.current {
|
||
Page::Login(state) => state.has_unsaved_changes(),
|
||
Page::Register(state) => state.has_unsaved_changes(),
|
||
Page::Form(path) => app_state
|
||
.form_state_for_path_ref(path)
|
||
.map(|fs| fs.has_unsaved_changes())
|
||
.unwrap_or(false),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn get_current_input_for_state<'a>(
|
||
&'a self,
|
||
router: &'a Router,
|
||
app_state: &'a AppState,
|
||
) -> &'a str {
|
||
match &router.current {
|
||
Page::Login(state) => state.get_current_input(),
|
||
Page::Register(state) => state.get_current_input(),
|
||
Page::Form(path) => app_state
|
||
.form_state_for_path_ref(path)
|
||
.map(|fs| fs.get_current_input())
|
||
.unwrap_or(""),
|
||
_ => "",
|
||
}
|
||
}
|
||
|
||
fn set_current_cursor_pos_for_state(
|
||
&mut self,
|
||
router: &mut Router,
|
||
app_state: &mut AppState,
|
||
pos: usize,
|
||
) {
|
||
match &mut router.current {
|
||
Page::Login(state) => state.set_current_cursor_pos(pos),
|
||
Page::Register(state) => state.set_current_cursor_pos(pos),
|
||
Page::Form(path) => {
|
||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||
fs.set_current_cursor_pos(pos);
|
||
}
|
||
}
|
||
_ => {},
|
||
}
|
||
}
|
||
|
||
fn get_cursor_pos_for_mixed_state(&self, router: &Router, app_state: &AppState) -> usize {
|
||
match &router.current {
|
||
Page::Login(state) => state.current_cursor_pos(),
|
||
Page::Register(state) => state.current_cursor_pos(),
|
||
Page::Form(path) => app_state
|
||
.form_state_for_path_ref(path)
|
||
.map(|fs| fs.current_cursor_pos())
|
||
.unwrap_or(0),
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub async fn handle_event(
|
||
&mut self,
|
||
event: Event,
|
||
config: &Config,
|
||
terminal: &mut TerminalCore,
|
||
command_handler: &mut CommandHandler,
|
||
auth_state: &mut AuthState,
|
||
buffer_state: &mut BufferState,
|
||
app_state: &mut AppState,
|
||
router: &mut Router,
|
||
) -> Result<EventOutcome> {
|
||
if app_state.ui.show_search_palette {
|
||
if let Event::Key(key_event) = event {
|
||
info!(
|
||
"RAW KEY: code={:?} mods={:?} active_seq={} ",
|
||
key_event.code,
|
||
key_event.modifiers,
|
||
self.input_engine.has_active_sequence(),
|
||
);
|
||
if let Some(message) = handle_search_palette_event(
|
||
key_event,
|
||
app_state,
|
||
&mut self.grpc_client,
|
||
self.search_result_sender.clone(),
|
||
).await? {
|
||
return Ok(EventOutcome::Ok(message));
|
||
}
|
||
return Ok(EventOutcome::Ok(String::new()));
|
||
}
|
||
}
|
||
|
||
let mut current_mode =
|
||
ModeManager::derive_mode(app_state, self, router);
|
||
|
||
if current_mode == AppMode::General && self.navigation_state.active {
|
||
if let Event::Key(key_event) = event {
|
||
let outcome = handle_command_navigation_event(
|
||
&mut self.navigation_state,
|
||
key_event,
|
||
config,
|
||
)
|
||
.await?;
|
||
|
||
if !self.navigation_state.active {
|
||
self.command_message = outcome.get_message_if_ok();
|
||
current_mode =
|
||
ModeManager::derive_mode(app_state, self, router);
|
||
}
|
||
app_state.update_mode(current_mode);
|
||
return Ok(outcome);
|
||
}
|
||
app_state.update_mode(current_mode);
|
||
return Ok(EventOutcome::Ok(String::new()));
|
||
}
|
||
|
||
app_state.update_mode(current_mode);
|
||
|
||
let current_view = match &router.current {
|
||
Page::Intro(_) => AppView::Intro,
|
||
Page::Login(_) => AppView::Login,
|
||
Page::Register(_) => AppView::Register,
|
||
Page::Admin(_) => AppView::Admin,
|
||
Page::AddLogic(_) => AppView::AddLogic,
|
||
Page::AddTable(_) => AppView::AddTable,
|
||
Page::Form(path) => AppView::Form(path.clone()),
|
||
};
|
||
buffer_state.update_history(current_view);
|
||
|
||
if app_state.ui.dialog.dialog_show {
|
||
if let Event::Key(key_event) = event {
|
||
if let Some(dialog_result) = dialog::handle_dialog_event(
|
||
&Event::Key(key_event),
|
||
config,
|
||
app_state,
|
||
buffer_state,
|
||
router,
|
||
)
|
||
.await
|
||
{
|
||
return dialog_result;
|
||
}
|
||
} else if let Event::Resize(_, _) = event {
|
||
}
|
||
return Ok(EventOutcome::Ok(String::new()));
|
||
}
|
||
|
||
if let Event::Key(key_event) = event {
|
||
let key_code = key_event.code;
|
||
let modifiers = key_event.modifiers;
|
||
info!(
|
||
"RAW KEY: code={:?} mods={:?} pre_active_seq={}",
|
||
key_code, modifiers, self.input_engine.has_active_sequence()
|
||
);
|
||
|
||
let overlay_active = self.command_mode
|
||
|| app_state.ui.show_search_palette
|
||
|| self.navigation_state.active;
|
||
|
||
// Determine if canvas is in edit mode (we avoid capturing navigation then)
|
||
let in_form_edit_mode = matches!(
|
||
&router.current,
|
||
Page::Form(path) if {
|
||
if let Some(editor) = app_state.editor_for_path_ref(path) {
|
||
editor.mode() == CanvasMode::Edit
|
||
} else { false }
|
||
}
|
||
);
|
||
|
||
// Centralized key -> action resolution
|
||
let allow_nav = self.input_engine.has_active_sequence()
|
||
|| (!in_form_edit_mode && !overlay_active);
|
||
let input_ctx = InputContext {
|
||
app_mode: current_mode,
|
||
overlay_active,
|
||
allow_navigation_capture: allow_nav,
|
||
};
|
||
info!(
|
||
"InputContext: app_mode={:?}, overlay_active={}, in_form_edit_mode={}, allow_nav={}, has_active_seq={}",
|
||
current_mode, overlay_active, in_form_edit_mode, allow_nav, self.input_engine.has_active_sequence()
|
||
);
|
||
|
||
let outcome = self.input_engine.process_key(key_event, &input_ctx, config);
|
||
info!(
|
||
"ENGINE OUTCOME: {:?} post_active_seq={}",
|
||
outcome, self.input_engine.has_active_sequence()
|
||
);
|
||
match outcome {
|
||
InputOutcome::Action(action) => {
|
||
if let Some(outcome) = self
|
||
.handle_app_action(
|
||
action,
|
||
key_event, // pass original key
|
||
config,
|
||
terminal,
|
||
command_handler,
|
||
auth_state,
|
||
buffer_state,
|
||
app_state,
|
||
router,
|
||
)
|
||
.await?
|
||
{
|
||
return Ok(outcome);
|
||
}
|
||
// No early return on None (e.g., Navigate) — fall through
|
||
}
|
||
InputOutcome::Pending => {
|
||
// waiting for more keys in a sequence
|
||
return Ok(EventOutcome::Ok(String::new()));
|
||
}
|
||
InputOutcome::PassThrough => {
|
||
// fall through to page/canvas handlers
|
||
}
|
||
}
|
||
|
||
// LOGIN: canvas <-> buttons focus handoff
|
||
// Do not let Login canvas receive keys when overlays/palettes are active
|
||
|
||
if !overlay_active {
|
||
if let Page::Login(login_page) = &mut router.current {
|
||
let outcome =
|
||
login::event::handle_login_event(event.clone(), app_state, login_page)?;
|
||
// Only return if the login page actually consumed the key
|
||
if !outcome.get_message_if_ok().is_empty() {
|
||
return Ok(outcome);
|
||
}
|
||
} else if let Page::Register(register_page) = &mut router.current {
|
||
let outcome = crate::pages::register::event::handle_register_event(
|
||
event,
|
||
app_state,
|
||
register_page,
|
||
)?;
|
||
// Only stop if page actually consumed the key; else fall through to global handling
|
||
if !outcome.get_message_if_ok().is_empty() {
|
||
return Ok(outcome);
|
||
}
|
||
} else if let Page::Form(path_str) = &router.current {
|
||
let path = path_str.clone();
|
||
|
||
if let Event::Key(_key_event) = event {
|
||
// Do NOT call the input engine here again. The top-level
|
||
// process_key call above already ran for this key.
|
||
// If we are waiting for more leader keys, swallow the key.
|
||
if self.input_engine.has_active_sequence() {
|
||
return Ok(EventOutcome::Ok(String::new()));
|
||
}
|
||
|
||
// Otherwise, forward to the form editor/canvas.
|
||
let outcome = forms::event::handle_form_event(
|
||
event,
|
||
app_state,
|
||
&path,
|
||
&mut self.ideal_cursor_column,
|
||
)?;
|
||
if !outcome.get_message_if_ok().is_empty() {
|
||
return Ok(outcome);
|
||
}
|
||
}
|
||
} else if let Page::AddLogic(add_logic_page) = &mut router.current {
|
||
// Allow ":" (enter_command_mode) even when inside AddLogic canvas
|
||
if let Some(action) =
|
||
config.get_general_action(key_event.code, key_event.modifiers)
|
||
{
|
||
if action == "enter_command_mode"
|
||
&& !self.command_mode
|
||
&& !app_state.ui.show_search_palette
|
||
&& !self.navigation_state.active
|
||
{
|
||
self.command_mode = true;
|
||
self.command_input.clear();
|
||
self.command_message.clear();
|
||
self.input_engine.reset_sequence();
|
||
self.set_focus_outside(router, true);
|
||
return Ok(EventOutcome::Ok(String::new()));
|
||
}
|
||
}
|
||
let movement_action_early = if let Some(act) =
|
||
config.get_general_action(key_event.code, key_event.modifiers)
|
||
{
|
||
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 outcome = add_logic::event::handle_add_logic_event(
|
||
key_event,
|
||
movement_action_early,
|
||
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::AddTable(add_table_page) = &mut router.current {
|
||
// Allow ":" (enter_command_mode) even when inside AddTable canvas
|
||
if let Some(action) =
|
||
config.get_general_action(key_event.code, key_event.modifiers)
|
||
{
|
||
if action == "enter_command_mode"
|
||
&& !self.command_mode
|
||
&& !app_state.ui.show_search_palette
|
||
&& !self.navigation_state.active
|
||
{
|
||
self.command_mode = true;
|
||
self.command_input.clear();
|
||
self.command_message.clear();
|
||
self.input_engine.reset_sequence();
|
||
self.set_focus_outside(router, true);
|
||
return Ok(EventOutcome::Ok(String::new()));
|
||
}
|
||
}
|
||
|
||
// Handle AddTable before global actions so canvas gets first shot at keys.
|
||
// Map keys to MovementAction (same as AddLogic early handler)
|
||
let movement_action_early = if let Some(act) =
|
||
config.get_general_action(key_event.code, key_event.modifiers)
|
||
{
|
||
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 outcome = add_table::event::handle_add_table_event(
|
||
key_event,
|
||
movement_action_early,
|
||
config,
|
||
app_state,
|
||
add_table_page,
|
||
self.grpc_client.clone(),
|
||
self.save_table_result_sender.clone(),
|
||
)?;
|
||
// Only stop if the page consumed the key; else let global handling proceed.
|
||
if !outcome.get_message_if_ok().is_empty() {
|
||
return Ok(outcome);
|
||
}
|
||
} else if let Page::Admin(admin_state) = &mut router.current {
|
||
if matches!(auth_state.role, Some(UserRole::Admin)) {
|
||
if let Event::Key(key_event) = event {
|
||
if admin::event::handle_admin_event(
|
||
key_event,
|
||
config,
|
||
app_state,
|
||
buffer_state,
|
||
router,
|
||
&mut self.command_message,
|
||
)? {
|
||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Sidebar/buffer toggles now handled via AppAction in the engine
|
||
|
||
if current_mode == AppMode::General {
|
||
// General mode specific key mapping now handled via AppAction
|
||
}
|
||
|
||
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::Intro(state) => {
|
||
if state.handle_movement(ma) {
|
||
return Ok(EventOutcome::Ok(String::new()));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// Generic navigation for the rest (Intro/Login/Register/Form)
|
||
let nav_outcome = if matches!(&router.current, Page::AddTable(_) | Page::AddLogic(_)) {
|
||
// Skip generic navigation for AddTable/AddLogic (they have their own handlers)
|
||
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 {
|
||
UiContext::Intro => {
|
||
intro::handle_intro_selection(
|
||
app_state,
|
||
buffer_state,
|
||
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));
|
||
}
|
||
}
|
||
format!("Intro Option {} selected", index)
|
||
}
|
||
UiContext::Login => {
|
||
if let Page::Login(login_state) = &mut router.current {
|
||
match index {
|
||
0 => login::initiate_login(
|
||
login_state,
|
||
app_state,
|
||
self.auth_client.clone(),
|
||
self.login_result_sender.clone(),
|
||
),
|
||
1 => login::back_to_main(
|
||
login_state,
|
||
app_state,
|
||
buffer_state,
|
||
)
|
||
.await,
|
||
_ => "Invalid Login Option".to_string(),
|
||
}
|
||
} else {
|
||
"Invalid state".to_string()
|
||
}
|
||
}
|
||
UiContext::Register => {
|
||
if let Page::Register(register_state) = &mut router.current {
|
||
match index {
|
||
0 => register::initiate_registration(
|
||
register_state,
|
||
app_state,
|
||
self.auth_client.clone(),
|
||
self.register_result_sender.clone(),
|
||
),
|
||
1 => register::back_to_login(
|
||
register_state,
|
||
app_state,
|
||
buffer_state,
|
||
)
|
||
.await,
|
||
_ => "Invalid Login Option".to_string(),
|
||
}
|
||
} else {
|
||
"Invalid state".to_string()
|
||
}
|
||
}
|
||
UiContext::Admin => {
|
||
if let Page::Admin(admin_state) = &router.current {
|
||
admin::tui::handle_admin_selection(
|
||
app_state,
|
||
admin_state,
|
||
);
|
||
}
|
||
format!("Admin Option {} selected", index)
|
||
}
|
||
UiContext::Dialog => {
|
||
"Internal error: Unexpected dialog state".to_string()
|
||
}
|
||
};
|
||
return Ok(EventOutcome::Ok(message));
|
||
}
|
||
other => return other,
|
||
}
|
||
}
|
||
|
||
AppMode::Command => {
|
||
// Command-mode keys already handled by the engine.
|
||
// Collect characters not handled (typed command input).
|
||
match key_code {
|
||
KeyCode::Char(c) => {
|
||
self.command_input.push(c);
|
||
return Ok(EventOutcome::Ok(String::new()));
|
||
}
|
||
_ => {
|
||
self.input_engine.reset_sequence();
|
||
return Ok(EventOutcome::Ok(String::new()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if let Event::Resize(_, _) = event {
|
||
return Ok(EventOutcome::Ok("Resized".to_string()));
|
||
}
|
||
|
||
self.edit_mode_cooldown = false;
|
||
Ok(EventOutcome::Ok(self.command_message.clone()))
|
||
}
|
||
|
||
fn is_processed_command(&self, command: &str) -> bool {
|
||
matches!(command, "w" | "q" | "q!" | "wq" | "r")
|
||
}
|
||
|
||
async fn handle_core_action(
|
||
&mut self,
|
||
action: &str,
|
||
auth_state: &mut AuthState,
|
||
terminal: &mut TerminalCore,
|
||
app_state: &mut AppState,
|
||
router: &mut Router,
|
||
) -> Result<EventOutcome> {
|
||
match action {
|
||
"save" => {
|
||
if let Page::Login(login_state) = &mut router.current {
|
||
let message = login::logic::save(
|
||
auth_state,
|
||
login_state,
|
||
&mut self.auth_client,
|
||
app_state,
|
||
)
|
||
.await?;
|
||
Ok(EventOutcome::Ok(message))
|
||
} else {
|
||
if let Page::Form(path) = &router.current {
|
||
forms::event::save_form(app_state, path, &mut self.grpc_client).await
|
||
} else {
|
||
Ok(EventOutcome::Ok("Nothing to save".to_string()))
|
||
}
|
||
}
|
||
}
|
||
"force_quit" => {
|
||
if let Page::Form(path) = &router.current {
|
||
if let Some(editor) = app_state.editor_for_path(path) {
|
||
editor.cleanup_cursor()?;
|
||
}
|
||
}
|
||
terminal.cleanup()?;
|
||
Ok(EventOutcome::Exit(
|
||
"Force exiting without saving.".to_string(),
|
||
))
|
||
}
|
||
"save_and_quit" => {
|
||
let message = if let Page::Login(login_state) = &mut router.current {
|
||
login::logic::save(
|
||
auth_state,
|
||
login_state,
|
||
&mut self.auth_client,
|
||
app_state,
|
||
)
|
||
.await?
|
||
} else if let Page::Form(path) = &router.current {
|
||
let save_result = forms::event::save_form(app_state, path, &mut self.grpc_client).await?;
|
||
match save_result {
|
||
EventOutcome::DataSaved(_, msg) => msg,
|
||
EventOutcome::Ok(msg) => msg,
|
||
_ => "Saved".to_string(),
|
||
}
|
||
} else {
|
||
"No changes to save.".to_string()
|
||
};
|
||
|
||
if let Page::Form(path) = &router.current {
|
||
if let Some(editor) = app_state.editor_for_path(path) {
|
||
editor.cleanup_cursor()?;
|
||
}
|
||
}
|
||
terminal.cleanup()?;
|
||
Ok(EventOutcome::Exit(format!(
|
||
"{}. Exiting application.",
|
||
message
|
||
)))
|
||
}
|
||
"revert" => {
|
||
let message = if let Page::Login(login_state) = &mut router.current {
|
||
login::logic::revert(login_state, app_state).await
|
||
} else if let Page::Register(register_state) = &mut router.current {
|
||
register::revert(
|
||
register_state,
|
||
app_state,
|
||
)
|
||
.await
|
||
} else {
|
||
if let Page::Form(path) = &router.current {
|
||
return forms::event::revert_form(app_state, path, &mut self.grpc_client).await;
|
||
} else {
|
||
"Nothing to revert".to_string()
|
||
}
|
||
};
|
||
Ok(EventOutcome::Ok(message))
|
||
}
|
||
_ => Ok(EventOutcome::Ok(format!(
|
||
"Core action not handled: {}",
|
||
action
|
||
))),
|
||
}
|
||
}
|
||
|
||
fn is_mode_transition_action(action: &str) -> bool {
|
||
matches!(action,
|
||
"exit" |
|
||
"exit_edit_mode" |
|
||
"enter_edit_mode_before" |
|
||
"enter_edit_mode_after" |
|
||
"enter_command_mode" |
|
||
"exit_command_mode" |
|
||
"enter_highlight_mode" |
|
||
"enter_highlight_mode_linewise" |
|
||
"exit_highlight_mode" |
|
||
"save" |
|
||
"quit" |
|
||
"Force_quit" |
|
||
"save_and_quit" |
|
||
"revert" |
|
||
"enter_decider" |
|
||
"trigger_autocomplete" |
|
||
"suggestion_up" |
|
||
"suggestion_down" |
|
||
"previous_entry" |
|
||
"next_entry" |
|
||
"toggle_sidebar" |
|
||
"toggle_buffer_list" |
|
||
"next_buffer" |
|
||
"previous_buffer" |
|
||
"close_buffer" |
|
||
"open_search" |
|
||
"find_file_palette_toggle"
|
||
)
|
||
}
|
||
|
||
fn set_focus_outside(&mut self, router: &mut Router, outside: bool) {
|
||
match &mut router.current {
|
||
Page::Login(state) => state.focus_outside_canvas = outside,
|
||
Page::Register(state) => state.focus_outside_canvas = outside,
|
||
Page::Intro(state) => state.focus_outside_canvas = outside,
|
||
Page::Admin(state) => state.focus_outside_canvas = outside,
|
||
Page::AddLogic(state) => state.focus_outside_canvas = outside,
|
||
Page::AddTable(state) => state.focus_outside_canvas = outside,
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn set_focused_button(&mut self, router: &mut Router, index: usize) {
|
||
match &mut router.current {
|
||
Page::Login(state) => state.focused_button_index = index,
|
||
Page::Register(state) => state.focused_button_index = index,
|
||
Page::Intro(state) => state.focused_button_index = index,
|
||
Page::Admin(state) => state.focused_button_index = index,
|
||
Page::AddLogic(state) => state.focused_button_index = index,
|
||
Page::AddTable(state) => state.focused_button_index = index,
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn is_focus_outside(&self, router: &Router) -> bool {
|
||
match &router.current {
|
||
Page::Login(state) => state.focus_outside_canvas,
|
||
Page::Register(state) => state.focus_outside_canvas,
|
||
Page::Intro(state) => state.focus_outside_canvas,
|
||
Page::Admin(state) => state.focus_outside_canvas,
|
||
Page::AddLogic(state) => state.focus_outside_canvas,
|
||
Page::AddTable(state) => state.focus_outside_canvas,
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn focused_button(&self, router: &Router) -> usize {
|
||
match &router.current {
|
||
Page::Login(state) => state.focused_button_index,
|
||
Page::Register(state) => state.focused_button_index,
|
||
Page::Intro(state) => state.focused_button_index,
|
||
Page::Admin(state) => state.focused_button_index,
|
||
Page::AddLogic(state) => state.focused_button_index,
|
||
Page::AddTable(state) => state.focused_button_index,
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
async fn execute_command(
|
||
&mut self,
|
||
key_event: crossterm::event::KeyEvent,
|
||
config: &Config,
|
||
terminal: &mut TerminalCore,
|
||
command_handler: &mut CommandHandler,
|
||
app_state: &mut AppState,
|
||
router: &mut Router,
|
||
) -> Result<EventOutcome> {
|
||
let (mut current_position, total_count) = if let Page::Form(path) = &router.current {
|
||
if let Some(fs) = app_state.form_state_for_path_ref(path) {
|
||
(fs.current_position, fs.total_count)
|
||
} else {
|
||
(1, 0)
|
||
}
|
||
} else {
|
||
(1, 0)
|
||
};
|
||
|
||
let outcome = command_mode::handle_command_event(
|
||
key_event,
|
||
config,
|
||
app_state,
|
||
router,
|
||
&mut self.command_input,
|
||
&mut self.command_message,
|
||
&mut self.grpc_client,
|
||
command_handler,
|
||
terminal,
|
||
&mut current_position,
|
||
total_count,
|
||
)
|
||
.await?;
|
||
|
||
if let Page::Form(path) = &router.current {
|
||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||
fs.current_position = current_position;
|
||
}
|
||
}
|
||
|
||
self.command_mode = false;
|
||
self.input_engine.reset_sequence();
|
||
let new_mode = ModeManager::derive_mode(app_state, self, router);
|
||
app_state.update_mode(new_mode);
|
||
Ok(outcome)
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn handle_app_action(
|
||
&mut self,
|
||
action: AppAction,
|
||
key_event: crossterm::event::KeyEvent,
|
||
config: &Config,
|
||
terminal: &mut TerminalCore,
|
||
command_handler: &mut CommandHandler,
|
||
auth_state: &mut AuthState,
|
||
buffer_state: &mut BufferState,
|
||
app_state: &mut AppState,
|
||
router: &mut Router,
|
||
) -> Result<Option<EventOutcome>> {
|
||
match action {
|
||
AppAction::ToggleSidebar => {
|
||
app_state.ui.show_sidebar = !app_state.ui.show_sidebar;
|
||
let message = format!(
|
||
"Sidebar {}",
|
||
if app_state.ui.show_sidebar {
|
||
"shown"
|
||
} else {
|
||
"hidden"
|
||
}
|
||
);
|
||
Ok(Some(EventOutcome::Ok(message)))
|
||
}
|
||
AppAction::ToggleBufferList => {
|
||
app_state.ui.show_buffer_list = !app_state.ui.show_buffer_list;
|
||
let message = format!(
|
||
"Buffer {}",
|
||
if app_state.ui.show_buffer_list {
|
||
"shown"
|
||
} else {
|
||
"hidden"
|
||
}
|
||
);
|
||
Ok(Some(EventOutcome::Ok(message)))
|
||
}
|
||
AppAction::Buffer(BufferAction::Next) => {
|
||
if switch_buffer(buffer_state, true) {
|
||
return Ok(Some(EventOutcome::Ok(
|
||
"Switched to next buffer".to_string(),
|
||
)));
|
||
}
|
||
Ok(Some(EventOutcome::Ok(String::new())))
|
||
}
|
||
AppAction::Buffer(BufferAction::Previous) => {
|
||
if switch_buffer(buffer_state, false) {
|
||
return Ok(Some(EventOutcome::Ok(
|
||
"Switched to previous buffer".to_string(),
|
||
)));
|
||
}
|
||
Ok(Some(EventOutcome::Ok(String::new())))
|
||
}
|
||
AppAction::Buffer(BufferAction::Close) => {
|
||
let current_table_name = app_state.current_view_table_name.as_deref();
|
||
let message = buffer_state
|
||
.close_buffer_with_intro_fallback(current_table_name);
|
||
Ok(Some(EventOutcome::Ok(message)))
|
||
}
|
||
AppAction::OpenSearch => {
|
||
if let Page::Form(_) = &router.current {
|
||
if let Some(table_name) = app_state.current_view_table_name.clone() {
|
||
app_state.ui.show_search_palette = true;
|
||
app_state.search_state = Some(SearchState::new(table_name));
|
||
self.set_focus_outside(router, true);
|
||
return Ok(Some(EventOutcome::Ok(
|
||
"Search palette opened".to_string(),
|
||
)));
|
||
}
|
||
}
|
||
Ok(Some(EventOutcome::Ok(String::new())))
|
||
}
|
||
AppAction::FindFilePaletteToggle => {
|
||
if matches!(&router.current, Page::Form(_) | Page::Intro(_)) {
|
||
let mut all_table_paths: Vec<String> = app_state
|
||
.profile_tree
|
||
.profiles
|
||
.iter()
|
||
.flat_map(|profile| {
|
||
profile.tables.iter().map(move |table| {
|
||
format!("{}/{}", profile.name, table.name)
|
||
})
|
||
})
|
||
.collect();
|
||
all_table_paths.sort();
|
||
|
||
self.navigation_state.activate_find_file(all_table_paths);
|
||
self.command_mode = false;
|
||
self.command_input.clear();
|
||
self.command_message.clear();
|
||
self.input_engine.reset_sequence();
|
||
return Ok(Some(EventOutcome::Ok(
|
||
"Table selection palette activated".to_string(),
|
||
)));
|
||
}
|
||
Ok(Some(EventOutcome::Ok(String::new())))
|
||
}
|
||
AppAction::EnterCommandMode => {
|
||
if !self.is_in_form_edit_mode(router, app_state)
|
||
&& !self.command_mode
|
||
&& !app_state.ui.show_search_palette
|
||
&& !self.navigation_state.active
|
||
{
|
||
self.command_mode = true;
|
||
self.command_input.clear();
|
||
self.command_message.clear();
|
||
self.input_engine.reset_sequence();
|
||
|
||
// Keep focus outside so canvas won’t consume keystrokes
|
||
self.set_focus_outside(router, true);
|
||
}
|
||
Ok(Some(EventOutcome::Ok(String::new())))
|
||
}
|
||
AppAction::ExitCommandMode => {
|
||
self.command_input.clear();
|
||
self.command_message.clear();
|
||
self.command_mode = false;
|
||
self.input_engine.reset_sequence();
|
||
if let Page::Form(path) = &router.current {
|
||
if let Some(editor) = app_state.editor_for_path(path) {
|
||
editor.set_mode(CanvasMode::ReadOnly);
|
||
}
|
||
}
|
||
Ok(Some(EventOutcome::Ok(
|
||
"Exited command mode".to_string(),
|
||
)))
|
||
}
|
||
AppAction::CommandExecute => {
|
||
// Execute using the actual configured key that triggered the action
|
||
let out = self
|
||
.execute_command(
|
||
key_event,
|
||
config,
|
||
terminal,
|
||
command_handler,
|
||
app_state,
|
||
router,
|
||
)
|
||
.await?;
|
||
Ok(Some(out))
|
||
}
|
||
AppAction::CommandBackspace => {
|
||
self.command_input.pop();
|
||
self.input_engine.reset_sequence();
|
||
Ok(Some(EventOutcome::Ok(String::new())))
|
||
}
|
||
AppAction::Core(core) => {
|
||
let s = match core {
|
||
CoreAction::Save => "save",
|
||
CoreAction::ForceQuit => "force_quit",
|
||
CoreAction::SaveAndQuit => "save_and_quit",
|
||
CoreAction::Revert => "revert",
|
||
};
|
||
let out = self
|
||
.handle_core_action(s, auth_state, terminal, app_state, router)
|
||
.await?;
|
||
Ok(Some(out))
|
||
}
|
||
AppAction::Navigate(_ma) => {
|
||
// Movement is still handled by page/nav code paths that
|
||
// follow after PassThrough. We return None here to keep flow.
|
||
Ok(None)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn is_in_form_edit_mode(&self, router: &Router, app_state: &AppState) -> bool {
|
||
if let Page::Form(path) = &router.current {
|
||
if let Some(editor) = app_state.editor_for_path_ref(path) {
|
||
return editor.mode() == CanvasMode::Edit;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
}
|