Files
komp_ac/client/src/modes/handlers/event.rs
2025-09-01 07:41:13 +02:00

924 lines
39 KiB
Rust

// src/modes/handlers/event.rs
use crate::config::binds::config::Config;
use crate::config::binds::key_sequences::KeySequenceTracker;
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;
#[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 key_sequence_tracker: KeySequenceTracker,
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,
key_sequence_tracker: KeySequenceTracker::new(400),
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 {
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;
// LOGIN: canvas <-> buttons focus handoff
// Do not let Login canvas receive keys when overlays/palettes are active
let overlay_active = self.command_mode
|| app_state.ui.show_search_palette
|| self.navigation_state.active;
if !overlay_active {
if let Page::Login(login_page) = &mut router.current {
let outcome =
login::event::handle_login_event(event, 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) = &router.current {
let outcome = forms::event::handle_form_event(
event,
app_state,
path,
&mut self.ideal_cursor_column,
)?;
// Only return if the form page actually consumed the key
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.key_sequence_tracker.reset();
app_state.ui.focus_outside_canvas = 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::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()));
}
}
}
}
}
if toggle_sidebar(
&mut app_state.ui,
config,
key_code,
modifiers,
) {
let message = format!(
"Sidebar {}",
if app_state.ui.show_sidebar {
"shown"
} else {
"hidden"
}
);
return Ok(EventOutcome::Ok(message));
}
if toggle_buffer_list(
&mut app_state.ui,
config,
key_code,
modifiers,
) {
let message = format!(
"Buffer {}",
if app_state.ui.show_buffer_list {
"shown"
} else {
"hidden"
}
);
return Ok(EventOutcome::Ok(message));
}
if current_mode == AppMode::General {
if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.global,
key_code,
modifiers,
) {
match action {
"next_buffer" => {
if switch_buffer(buffer_state, true) {
return Ok(EventOutcome::Ok(
"Switched to next buffer".to_string(),
));
}
}
"previous_buffer" => {
if switch_buffer(buffer_state, false) {
return Ok(EventOutcome::Ok(
"Switched to previous buffer".to_string(),
));
}
}
"close_buffer" => {
let current_table_name =
app_state.current_view_table_name.as_deref();
let message = buffer_state
.close_buffer_with_intro_fallback(
current_table_name,
);
return Ok(EventOutcome::Ok(message));
}
_ => {}
}
}
if let Some(action) = config.get_general_action(key_code, modifiers) {
if action == "open_search" {
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));
app_state.ui.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(
"Search palette opened".to_string(),
));
}
}
}
// Allow ":" / ctrl+; to enter command mode only when outside canvas.
if action == "enter_command_mode" {
if app_state.ui.focus_outside_canvas
&& !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.key_sequence_tracker.reset();
// Keep focus outside so canvas won't receive keys
app_state.ui.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(String::new()));
}
}
}
}
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()));
}
}
_ => {}
}
}
if let Page::AddTable(add_table_state) = &mut router.current {
let client_clone = self.grpc_client.clone();
let sender_clone = self.save_table_result_sender.clone();
let outcome = crate::pages::admin_panel::add_table::event::handle_add_table_event(
key_event,
movement_action,
config,
app_state,
add_table_state,
client_clone,
sender_clone,
)?;
if !outcome.get_message_if_ok().is_empty() {
return Ok(outcome);
}
}
// 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 => {
if config.is_exit_command_mode(key_code, modifiers) {
self.command_input.clear();
self.command_message.clear();
self.command_mode = false;
self.key_sequence_tracker.reset();
if let Page::Form(path) = &router.current {
if let Some(editor) = app_state.editor_for_path(path) {
editor.set_mode(CanvasMode::ReadOnly);
}
}
return Ok(EventOutcome::Ok(
"Exited command mode".to_string(),
));
}
if config.is_command_execute(key_code, modifiers) {
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.key_sequence_tracker.reset();
let new_mode = ModeManager::derive_mode(
app_state,
self,
router,
);
app_state.update_mode(new_mode);
return Ok(outcome);
}
if key_code == KeyCode::Backspace {
self.command_input.pop();
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok(String::new()));
}
if let KeyCode::Char(c) = key_code {
if c == 'f' {
self.key_sequence_tracker.add_key(key_code);
let sequence =
self.key_sequence_tracker.get_sequence();
if config.matches_key_sequence_generalized(
&sequence,
) == Some("find_file_palette_toggle")
{
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.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok(
"Table selection palette activated"
.to_string(),
));
} else {
self.key_sequence_tracker.reset();
self.command_input.push('f');
if sequence.len() > 1
&& sequence[0] == KeyCode::Char('f')
{
self.command_input.push('f');
}
self.command_message = "Find File not available in this view."
.to_string();
return Ok(EventOutcome::Ok(
self.command_message.clone(),
));
}
}
if config.is_key_sequence_prefix(&sequence) {
return Ok(EventOutcome::Ok(String::new()));
}
}
if c != 'f'
&& !self.key_sequence_tracker.current_sequence.is_empty()
{
self.key_sequence_tracker.reset();
}
self.command_input.push(c);
return Ok(EventOutcome::Ok(String::new()));
}
self.key_sequence_tracker.reset();
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"
)
}
}