diff options
Diffstat (limited to 'src/app/input/modal.rs')
| -rw-r--r-- | src/app/input/modal.rs | 739 |
1 files changed, 739 insertions, 0 deletions
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs new file mode 100644 index 00000000..2ba9b6f4 --- /dev/null +++ b/src/app/input/modal.rs @@ -0,0 +1,739 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Direction, Rect}; + +use crate::{ + app::state::{key_matches, AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode}, + layout::NavDirection, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ModalAction { + Continue, + Back, + Save, + Clear, + Cancel, + Confirm, + Apply, + Close, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ModalKeyBinding { + Enter, + Esc, + CtrlC, +} + +impl ModalKeyBinding { + fn matches(self, key: &KeyEvent) -> bool { + match self { + Self::Enter => key.code == KeyCode::Enter, + Self::Esc => key.code == KeyCode::Esc, + Self::CtrlC => { + key.code == KeyCode::Char('c') + && key.modifiers == crossterm::event::KeyModifiers::CONTROL + } + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct ModalActionSpec<A> { + pub action: A, + pub bindings: &'static [ModalKeyBinding], +} + +pub(super) fn modal_action_from_key<A: Copy>( + key: &KeyEvent, + specs: &[ModalActionSpec<A>], +) -> Option<A> { + specs + .iter() + .find(|spec| spec.bindings.iter().any(|binding| binding.matches(key))) + .map(|spec| spec.action) +} + +pub(super) fn modal_action_from_buttons<A: Copy>( + col: u16, + row: u16, + buttons: &[(Rect, A)], +) -> Option<A> { + buttons.iter().find_map(|(rect, action)| { + (col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height) + .then_some(*action) + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GlobalMenuAction { + Quit, + WhatsNew, + Keybinds, + ReloadKeybinds, + Settings, +} + +pub(super) fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> { + let mut actions = vec![ + GlobalMenuAction::Settings, + GlobalMenuAction::Keybinds, + GlobalMenuAction::ReloadKeybinds, + ]; + if state.update_available.is_some() || state.latest_release_notes_available { + actions.push(GlobalMenuAction::WhatsNew); + } + actions.push(GlobalMenuAction::Quit); + actions +} + +pub(super) fn open_global_menu(state: &mut AppState) { + state.global_menu = MenuListState::new(0); + state.mode = Mode::GlobalMenu; +} + +pub(super) fn open_keybind_help(state: &mut AppState) { + state.keybind_help.scroll = 0; + state.mode = Mode::KeybindHelp; +} + +fn open_update_release_notes(state: &mut AppState) { + let Some(notes) = crate::release_notes::load_latest() else { + return; + }; + + state.release_notes = Some(crate::app::state::ReleaseNotesState { + version: notes.version, + body: notes.body, + scroll: 0, + preview: notes.preview, + }); + state.mode = Mode::ReleaseNotes; +} + +pub(super) fn request_quit_or_detach(state: &mut AppState) { + if state.quit_detaches { + state.detach_requested = true; + } else { + state.should_quit = true; + } +} + +pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) { + match action { + GlobalMenuAction::Quit => request_quit_or_detach(state), + GlobalMenuAction::WhatsNew => open_update_release_notes(state), + GlobalMenuAction::Keybinds => open_keybind_help(state), + GlobalMenuAction::ReloadKeybinds => { + state.request_reload_keybinds = true; + leave_modal(state); + } + GlobalMenuAction::Settings => super::settings::open_settings(state), + } +} + +pub(crate) fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) { + let actions = global_menu_actions(state); + match key.code { + KeyCode::Esc => leave_modal(state), + KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(), + KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()), + KeyCode::Enter => { + if let Some(action) = actions.get(state.global_menu.highlighted).copied() { + apply_global_menu_action(state, action); + } + } + _ => {} + } +} + +pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1), + KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1), + KeyCode::PageUp => state.scroll_keybind_help(-8), + KeyCode::PageDown => state.scroll_keybind_help(8), + KeyCode::Home => state.keybind_help.scroll = 0, + KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => leave_modal(state), + _ => {} + } +} + +pub(super) fn open_rename_workspace(state: &mut AppState, ws_idx: usize) { + state.selected = ws_idx; + state.name_input = state.workspaces[ws_idx].display_name(); + state.name_input_replace_on_type = false; + state.mode = Mode::RenameWorkspace; +} + +pub(super) fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool) { + state.creating_new_tab = false; + state.requested_new_tab_name = None; + if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) { + if let Some(name) = ws.active_tab_display_name() { + state.name_input = name; + state.name_input_replace_on_type = replace_on_type; + state.mode = Mode::RenameTab; + } + } +} + +fn next_new_tab_default_name(state: &AppState) -> String { + state + .active + .and_then(|i| state.workspaces.get(i)) + .map(|ws| (ws.tabs.len() + 1).to_string()) + .unwrap_or_else(|| "1".to_string()) +} + +pub(super) fn open_new_tab_dialog(state: &mut AppState) { + state.creating_new_tab = true; + state.requested_new_tab_name = None; + state.name_input = next_new_tab_default_name(state); + state.name_input_replace_on_type = true; + state.mode = Mode::RenameTab; +} + +pub(super) fn leave_modal(state: &mut AppState) { + if state.active.is_some() { + state.mode = Mode::Terminal; + } else { + state.mode = Mode::Navigate; + } +} + +pub(super) const ONBOARDING_WELCOME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec { + action: ModalAction::Continue, + bindings: &[ModalKeyBinding::Enter], +}]; + +pub(super) const ONBOARDING_NOTIFICATION_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ + ModalActionSpec { + action: ModalAction::Back, + bindings: &[ModalKeyBinding::Esc], + }, + ModalActionSpec { + action: ModalAction::Save, + bindings: &[ModalKeyBinding::Enter], + }, +]; + +pub(super) const RELEASE_NOTES_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec { + action: ModalAction::Close, + bindings: &[ModalKeyBinding::Enter, ModalKeyBinding::Esc], +}]; + +pub(super) const RENAME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ + ModalActionSpec { + action: ModalAction::Save, + bindings: &[ModalKeyBinding::Enter], + }, + ModalActionSpec { + action: ModalAction::Clear, + bindings: &[ModalKeyBinding::CtrlC], + }, + ModalActionSpec { + action: ModalAction::Cancel, + bindings: &[ModalKeyBinding::Esc], + }, +]; + +pub(super) const CONFIRM_CLOSE_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ + ModalActionSpec { + action: ModalAction::Confirm, + bindings: &[ModalKeyBinding::Enter], + }, + ModalActionSpec { + action: ModalAction::Cancel, + bindings: &[ModalKeyBinding::Esc], + }, +]; + +pub(super) const SETTINGS_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ + ModalActionSpec { + action: ModalAction::Apply, + bindings: &[ModalKeyBinding::Enter], + }, + ModalActionSpec { + action: ModalAction::Close, + bindings: &[ModalKeyBinding::Esc], + }, +]; + +pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { + match action { + ModalAction::Save => { + let new_name = if state.name_input.trim().is_empty() { + state.name_input.clone() + } else { + state.name_input.trim().to_string() + }; + match state.mode { + Mode::RenameWorkspace if !state.workspaces.is_empty() => { + if !new_name.is_empty() { + state.workspaces[state.selected].set_custom_name(new_name); + state.mark_session_dirty(); + } + } + Mode::RenameTab if state.creating_new_tab => { + state.request_new_tab = true; + let default_name = next_new_tab_default_name(state); + state.requested_new_tab_name = + if new_name.is_empty() || new_name == default_name { + None + } else { + Some(new_name) + }; + } + Mode::RenameTab => { + if let Some(ws) = state.active.and_then(|i| state.workspaces.get_mut(i)) { + if let Some(tab) = ws.active_tab_mut() { + let keep_auto_name = + tab.is_auto_named() && new_name == tab.number.to_string(); + if !new_name.is_empty() && !keep_auto_name { + tab.set_custom_name(new_name); + state.mark_session_dirty(); + } + } + } + } + _ => {} + } + state.creating_new_tab = false; + state.name_input.clear(); + state.name_input_replace_on_type = false; + leave_modal(state); + } + ModalAction::Clear => { + state.name_input.clear(); + state.name_input_replace_on_type = false; + } + ModalAction::Cancel => { + state.creating_new_tab = false; + state.requested_new_tab_name = None; + state.name_input.clear(); + state.name_input_replace_on_type = false; + leave_modal(state); + } + _ => {} + } +} + +pub(crate) fn handle_rename_key(state: &mut AppState, key: KeyEvent) { + if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) { + apply_rename_action(state, action); + return; + } + + match key.code { + KeyCode::Backspace => { + if state.name_input_replace_on_type { + state.name_input.clear(); + state.name_input_replace_on_type = false; + } else { + state.name_input.pop(); + } + } + KeyCode::Char(c) => { + if state.name_input_replace_on_type { + state.name_input.clear(); + state.name_input_replace_on_type = false; + } + state.name_input.push(c); + } + _ => {} + } +} + +pub(crate) fn handle_resize_key(state: &mut AppState, key: KeyEvent) { + if key.code == KeyCode::Esc + || key.code == KeyCode::Enter + || key_matches( + &key, + state.keybinds.resize_mode.0, + state.keybinds.resize_mode.1, + ) + { + if state.active.is_some() { + state.mode = Mode::Terminal; + } else { + state.mode = Mode::Navigate; + } + return; + } + + match key.code { + KeyCode::Char('h') | KeyCode::Left => state.resize_pane(NavDirection::Left), + KeyCode::Char('l') | KeyCode::Right => state.resize_pane(NavDirection::Right), + KeyCode::Char('j') | KeyCode::Down => state.resize_pane(NavDirection::Down), + KeyCode::Char('k') | KeyCode::Up => state.resize_pane(NavDirection::Up), + _ => {} + } +} + +pub(super) fn open_confirm_close(state: &mut AppState) { + state.mode = Mode::ConfirmClose; +} + +pub(super) fn confirm_close_accept(state: &mut AppState) { + state.close_selected_workspace(); + if state.workspaces.is_empty() { + state.mode = Mode::Navigate; + } else { + state.mode = Mode::Terminal; + } +} + +pub(super) fn confirm_close_cancel(state: &mut AppState) { + state.mode = Mode::Navigate; +} + +pub(crate) fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) { + match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) { + Some(ModalAction::Confirm) => confirm_close_accept(state), + Some(ModalAction::Cancel) => confirm_close_cancel(state), + _ => {} + } +} + +pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: usize) { + let item = menu.items().get(idx).copied(); + match (menu.kind, item) { + (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => { + open_rename_workspace(state, ws_idx); + } + (ContextMenuKind::Workspace { ws_idx }, Some("Close")) => { + state.selected = ws_idx; + if state.confirm_close { + open_confirm_close(state); + } else { + state.close_selected_workspace(); + state.mode = Mode::Navigate; + } + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + open_new_tab_dialog(state); + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + open_rename_active_tab(state, false); + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + state.close_tab(); + state.mode = if state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + (ContextMenuKind::Pane, Some("Split vertical")) => { + state.split_pane(Direction::Horizontal); + state.mode = Mode::Terminal; + } + (ContextMenuKind::Pane, Some("Split horizontal")) => { + state.split_pane(Direction::Vertical); + state.mode = Mode::Terminal; + } + (ContextMenuKind::Pane, Some("Fullscreen")) => { + state.toggle_fullscreen(); + state.mode = Mode::Terminal; + } + (ContextMenuKind::Pane, Some("Close pane")) => { + state.close_pane(); + state.mode = if state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + _ => leave_modal(state), + } +} + +pub(crate) fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + state.context_menu = None; + leave_modal(state); + } + KeyCode::Up => { + if let Some(menu) = &mut state.context_menu { + menu.list.move_prev(); + } + } + KeyCode::Down => { + if let Some(menu) = &mut state.context_menu { + menu.list.move_next(menu.items().len()); + } + } + KeyCode::Enter => { + if let Some(menu) = state.context_menu.take() { + let idx = menu.list.highlighted; + apply_context_menu_action(state, menu, idx); + } + } + _ => {} + } +} + +impl AppState { + pub(super) fn global_menu_item_at(&self, col: u16, row: u16) -> Option<GlobalMenuAction> { + let rect = self.global_menu_rect(); + if col <= rect.x + || col >= rect.x + rect.width.saturating_sub(1) + || row <= rect.y + || row >= rect.y + rect.height.saturating_sub(1) + { + return None; + } + let idx = (row - rect.y - 1) as usize; + global_menu_actions(self).get(idx).copied() + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::layout::Rect; + + use super::super::{capture_snapshot, state_with_workspaces}; + use super::*; + + #[test] + fn custom_resize_key_exits_resize_mode() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::Resize; + state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.resize_mode_label = "g".into(); + + handle_resize_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn rename_modal_keyboard_and_mouse_share_actions() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::RenameWorkspace; + state.name_input = "hello".into(); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert!(state.name_input.is_empty()); + + state.name_input = "renamed".into(); + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + assert_eq!(state.mode, Mode::Terminal); + assert_eq!(state.workspaces[0].display_name(), "renamed"); + let snapshot = capture_snapshot(&state); + assert_eq!( + snapshot.workspaces[0].custom_name.as_deref(), + Some("renamed") + ); + + state.view.sidebar_rect = Rect::new(0, 0, 26, 20); + state.view.terminal_area = Rect::new(26, 0, 80, 20); + state.mode = Mode::RenameWorkspace; + state.name_input = "mouse".into(); + let inner = state.rename_modal_inner().unwrap(); + let (save, _, _) = crate::ui::rename_button_rects(inner); + let action = modal_action_from_buttons(save.x, save.y, &[(save, ModalAction::Save)]); + assert_eq!(action, Some(ModalAction::Save)); + } + + #[test] + fn tab_rename_updates_captured_snapshot() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::RenameTab; + state.name_input = "logs".into(); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + let snapshot = capture_snapshot(&state); + assert_eq!( + snapshot.workspaces[0].tabs[0].custom_name.as_deref(), + Some("logs") + ); + } + + #[test] + fn rename_cancel_returns_to_terminal_when_workspace_is_active() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::RenameTab; + state.name_input = "test".into(); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(state.name_input.is_empty()); + } + + #[test] + fn rename_modal_replaces_prefilled_text_on_first_type() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::RenameTab; + state.name_input = "2".into(); + state.name_input_replace_on_type = true; + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()), + ); + assert_eq!(state.name_input, "n"); + assert!(!state.name_input_replace_on_type); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()), + ); + assert_eq!(state.name_input, "ne"); + } + + #[test] + fn open_rename_active_tab_can_prefill_default_new_tab_name() { + let mut state = state_with_workspaces(&["test"]); + state.workspaces[0].test_add_tab(None); + state.workspaces[0].switch_tab(1); + + open_rename_active_tab(&mut state, true); + + assert_eq!(state.mode, Mode::RenameTab); + assert_eq!(state.name_input, "2"); + assert!(state.name_input_replace_on_type); + } + + #[test] + fn cancel_new_tab_dialog_leaves_workspace_unchanged() { + let mut state = state_with_workspaces(&["test"]); + open_new_tab_dialog(&mut state); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(!state.creating_new_tab); + assert!(!state.request_new_tab); + assert!(state.requested_new_tab_name.is_none()); + assert_eq!(state.workspaces[0].tabs.len(), 1); + } + + #[test] + fn saving_new_tab_dialog_requests_creation_with_name() { + let mut state = state_with_workspaces(&["test"]); + open_new_tab_dialog(&mut state); + state.name_input = "logs".into(); + state.name_input_replace_on_type = false; + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(!state.creating_new_tab); + assert!(state.request_new_tab); + assert_eq!(state.requested_new_tab_name.as_deref(), Some("logs")); + } + + #[test] + fn saving_new_tab_dialog_with_default_name_keeps_tab_auto_named() { + let mut state = state_with_workspaces(&["test"]); + open_new_tab_dialog(&mut state); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(!state.creating_new_tab); + assert!(state.request_new_tab); + assert!(state.requested_new_tab_name.is_none()); + } + + #[test] + fn closing_first_auto_tab_resets_remaining_auto_tab_and_next_prompt() { + let mut state = state_with_workspaces(&["test"]); + open_new_tab_dialog(&mut state); + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + state.workspaces[0].test_add_tab(state.requested_new_tab_name.as_deref()); + state.request_new_tab = false; + state.requested_new_tab_name = None; + + state.workspaces[0].close_tab(0); + state.workspaces[0].switch_tab(0); + + assert_eq!(state.workspaces[0].tabs[0].display_name(), "1"); + assert!(state.workspaces[0].tabs[0].custom_name.is_none()); + + open_new_tab_dialog(&mut state); + assert_eq!(state.name_input, "2"); + } + + #[test] + fn renaming_auto_tab_to_its_default_number_keeps_it_auto_named() { + let mut state = state_with_workspaces(&["test"]); + state.workspaces[0].test_add_tab(None); + state.workspaces[0].switch_tab(1); + + open_rename_active_tab(&mut state, false); + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(state.workspaces[0].tabs[1].custom_name.is_none()); + assert_eq!(state.workspaces[0].tabs[1].display_name(), "2"); + } + + #[test] + fn confirm_close_keyboard_actions_are_direct_not_focused() { + let mut state = state_with_workspaces(&["a", "b"]); + state.mode = Mode::ConfirmClose; + state.selected = 1; + + handle_confirm_close_key( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + ); + assert_eq!(state.mode, Mode::Navigate); + assert_eq!(state.workspaces.len(), 2); + + state.mode = Mode::ConfirmClose; + handle_confirm_close_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + assert_eq!(state.workspaces.len(), 1); + } +} |
