//! Input handling — translates crossterm key/mouse events into state mutations. use bytes::Bytes; use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use crate::input::TerminalKey; use ratatui::layout::{Direction, Rect}; use tracing::{debug, warn}; use crate::layout::{NavDirection, PaneInfo, SplitBorder}; use crate::selection::Selection; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ScrollbarClickTarget { Thumb { grab_row_offset: u16 }, Track { offset_from_bottom: usize }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg(test)] enum WheelRouting { HostScroll, MouseReport, AlternateScroll, } const WORKSPACE_DRAG_THRESHOLD: u16 = 1; use super::state::{ key_matches, AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, MenuListState, Mode, WorkspacePressState, }; use super::App; // --------------------------------------------------------------------------- // Key handling // --------------------------------------------------------------------------- fn is_modifier_only_key(code: &KeyCode) -> bool { matches!(code, KeyCode::Modifier(_)) } fn terminal_direct_navigation_action(state: &AppState, key: &KeyEvent) -> Option { let kb = &state.keybinds; if kb .previous_workspace .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::PreviousWorkspace); } if kb .next_workspace .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::NextWorkspace); } if kb .previous_tab .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::PreviousTab); } if kb .next_tab .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::NextTab); } if kb .focus_pane_left .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::FocusPaneLeft); } if kb .focus_pane_down .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::FocusPaneDown); } if kb .focus_pane_up .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::FocusPaneUp); } if kb .focus_pane_right .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::FocusPaneRight); } None } impl App { pub(super) async fn handle_key(&mut self, key: TerminalKey) { match self.state.mode { Mode::Terminal => self.handle_terminal_key(key).await, _ => { let key = key.as_key_event(); match self.state.mode { Mode::Onboarding => self.handle_onboarding_key(key), Mode::ReleaseNotes => self.handle_release_notes_key(key), Mode::Navigate => handle_navigate_key(&mut self.state, key), Mode::RenameWorkspace | Mode::RenameTab => { handle_rename_key(&mut self.state, key) } Mode::Resize => handle_resize_key(&mut self.state, key), Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key), Mode::ContextMenu => handle_context_menu_key(&mut self.state, key), Mode::Settings => self.handle_settings_key(key), Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key), Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key), Mode::Terminal => unreachable!(), } } } } pub(super) async fn handle_paste(&mut self, text: String) { if self.state.mode != Mode::Terminal { return; } if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) { if let Some(rt) = ws.focused_runtime() { let _ = rt.send_paste(text).await; } } } fn handle_onboarding_key(&mut self, key: KeyEvent) { match self.state.onboarding_step { 0 => match key.code { KeyCode::Right | KeyCode::Char('l') => { self.state.onboarding_step = 1; } KeyCode::Char('q') => self.state.should_quit = true, _ => match modal_action_from_key(&key, ONBOARDING_WELCOME_ACTIONS) { Some(ModalAction::Continue) => self.state.onboarding_step = 1, _ => {} }, }, _ => match key.code { KeyCode::Up | KeyCode::Char('k') => self.state.onboarding_list.move_prev(), KeyCode::Down | KeyCode::Char('j') => self.state.onboarding_list.move_next(4), KeyCode::Left | KeyCode::Char('h') => { self.state.onboarding_step = 0; } KeyCode::Char(c) if ('1'..='4').contains(&c) => { self.state .onboarding_list .select((c as usize) - ('1' as usize)); } KeyCode::Char('q') => self.state.should_quit = true, _ => match modal_action_from_key(&key, ONBOARDING_NOTIFICATION_ACTIONS) { Some(ModalAction::Back) => self.state.onboarding_step = 0, Some(ModalAction::Save) => self.complete_onboarding(), _ => {} }, }, } } fn handle_release_notes_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Up | KeyCode::Char('k') => self.scroll_release_notes(-1), KeyCode::Down | KeyCode::Char('j') => self.scroll_release_notes(1), KeyCode::PageUp => self.scroll_release_notes(-8), KeyCode::PageDown => self.scroll_release_notes(8), KeyCode::Home => { if let Some(notes) = &mut self.state.release_notes { notes.scroll = 0; } } KeyCode::End => { let max_scroll = self.state.release_notes_max_scroll(); if let Some(notes) = &mut self.state.release_notes { notes.scroll = max_scroll; } } _ => match modal_action_from_key(&key, RELEASE_NOTES_ACTIONS) { Some(ModalAction::Close) => self.dismiss_release_notes(), _ => {} }, } } fn handle_settings_key(&mut self, key: KeyEvent) { if let Some(action) = update_settings_state(&mut self.state, key) { match action { SettingsAction::SaveTheme(name) => self.save_theme(&name), SettingsAction::SaveSound(enabled) => self.save_sound(enabled), SettingsAction::SaveToast(enabled) => self.save_toast(enabled), } } } pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) { if self.state.mode == Mode::ReleaseNotes { match mouse.kind { MouseEventKind::Down(MouseButton::Left) if self .state .release_notes_close_button_at(mouse.column, mouse.row) => { self.dismiss_release_notes(); } MouseEventKind::Down(MouseButton::Left) => { if let Some(target) = self .state .release_notes_scrollbar_target_at(mouse.column, mouse.row) { match target { ScrollbarClickTarget::Thumb { grab_row_offset } => { self.state.drag = Some(DragState { target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, }); } ScrollbarClickTarget::Track { offset_from_bottom } => { self.state .set_release_notes_offset_from_bottom(offset_from_bottom); } } } } MouseEventKind::Drag(MouseButton::Left) => { if let Some(DragState { target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, }) = &self.state.drag { if let Some(offset_from_bottom) = self .state .release_notes_offset_for_drag_row(mouse.row, *grab_row_offset) { self.state .set_release_notes_offset_from_bottom(offset_from_bottom); } } } MouseEventKind::Up(MouseButton::Left) => { self.state.drag = None; } MouseEventKind::ScrollUp => self.scroll_release_notes(-3), MouseEventKind::ScrollDown => self.scroll_release_notes(3), _ => {} } return; } if self.state.mode == Mode::KeybindHelp { match mouse.kind { MouseEventKind::Down(MouseButton::Left) if self .state .keybind_help_close_button_at(mouse.column, mouse.row) => { leave_modal(&mut self.state); } MouseEventKind::Down(MouseButton::Left) => { if let Some(target) = self .state .keybind_help_scrollbar_target_at(mouse.column, mouse.row) { match target { ScrollbarClickTarget::Thumb { grab_row_offset } => { self.state.drag = Some(DragState { target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, }); } ScrollbarClickTarget::Track { offset_from_bottom } => { self.state .set_keybind_help_offset_from_bottom(offset_from_bottom); } } } else { let rect = self.state.keybind_help_popup_rect(); let inside = mouse.column >= rect.x && mouse.column < rect.x + rect.width && mouse.row >= rect.y && mouse.row < rect.y + rect.height; if !inside { leave_modal(&mut self.state); } } } MouseEventKind::Drag(MouseButton::Left) => { if let Some(DragState { target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, }) = &self.state.drag { if let Some(offset_from_bottom) = self .state .keybind_help_offset_for_drag_row(mouse.row, *grab_row_offset) { self.state .set_keybind_help_offset_from_bottom(offset_from_bottom); } } } MouseEventKind::Up(MouseButton::Left) => { self.state.drag = None; } MouseEventKind::ScrollUp => self.state.scroll_keybind_help(-3), MouseEventKind::ScrollDown => self.state.scroll_keybind_help(3), _ => {} } return; } if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && self.state.on_sidebar_divider(mouse.column, mouse.row) { let now = std::time::Instant::now(); let is_double_click = self .last_sidebar_divider_click .is_some_and(|last| now.duration_since(last) <= super::SIDEBAR_DOUBLE_CLICK_WINDOW); self.last_sidebar_divider_click = Some(now); if is_double_click { self.state.sidebar_width = self.state.default_sidebar_width; self.state.sidebar_width_auto = false; self.state.drag = None; return; } } if let Some(action) = self.state.handle_mouse(mouse) { match action { SettingsAction::SaveTheme(name) => self.save_theme(&name), SettingsAction::SaveSound(enabled) => self.save_sound(enabled), SettingsAction::SaveToast(enabled) => self.save_toast(enabled), } } } async fn handle_terminal_key(&mut self, key: TerminalKey) { self.state.clear_selection(); self.state.update_dismissed = true; let key_event = key.as_key_event(); if let Some(action) = terminal_direct_navigation_action(&self.state, &key_event) { debug!( code = ?key_event.code, modifiers = ?key_event.modifiers, kind = ?key_event.kind, action = ?action, "intercepted terminal direct navigation key before forwarding to pane" ); execute_navigate_action(&mut self.state, action); return; } if self.state.is_prefix(&key_event) { self.state.mode = Mode::Navigate; return; } if is_modifier_only_key(&key_event.code) { debug!( code = ?key_event.code, modifiers = ?key_event.modifiers, kind = ?key_event.kind, "dropping modifier-only terminal key event instead of forwarding it to pane" ); return; } if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) { if let Some(rt) = ws.focused_runtime() { rt.scroll_reset(); let protocol = rt.keyboard_protocol(); let bytes = rt.encode_terminal_key(key); if matches!(key_event.code, KeyCode::Esc) || key_event .modifiers .contains(crossterm::event::KeyModifiers::ALT) { debug!( code = ?key_event.code, modifiers = ?key_event.modifiers, kind = ?key_event.kind, protocol = ?protocol, encoded = ?bytes, "forwarding potentially-ambiguous terminal key to pane" ); } if bytes.is_empty() { if key.kind != crossterm::event::KeyEventKind::Release && !matches!( key.code, KeyCode::CapsLock | KeyCode::ScrollLock | KeyCode::NumLock | KeyCode::PrintScreen | KeyCode::Pause | KeyCode::Menu | KeyCode::KeypadBegin | KeyCode::Media(_) | KeyCode::Modifier(_) ) { warn!(code = ?key_event.code, mods = ?key_event.modifiers, state = ?key_event.state, "key produced empty encoding"); } } else { let _ = rt.send_bytes(Bytes::from(bytes)).await; } } } } } #[derive(Debug, Clone, PartialEq, Eq)] enum SettingsAction { SaveTheme(String), SaveSound(bool), SaveToast(bool), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ModalAction { Continue, Back, Save, Clear, Cancel, Confirm, Apply, Close, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ModalKeyBinding { Enter, Esc, EscOrQ, CtrlC, } impl ModalKeyBinding { fn matches(self, key: &KeyEvent) -> bool { match self { Self::Enter => key.code == KeyCode::Enter, Self::Esc => key.code == KeyCode::Esc, Self::EscOrQ => key.code == KeyCode::Esc || key.code == KeyCode::Char('q'), Self::CtrlC => { key.code == KeyCode::Char('c') && key.modifiers == crossterm::event::KeyModifiers::CONTROL } } } } #[derive(Debug, Clone, Copy)] struct ModalActionSpec { action: A, bindings: &'static [ModalKeyBinding], } fn modal_action_from_key(key: &KeyEvent, specs: &[ModalActionSpec]) -> Option { specs .iter() .find(|spec| spec.bindings.iter().any(|binding| binding.matches(key))) .map(|spec| spec.action) } fn modal_action_from_buttons(col: u16, row: u16, buttons: &[(Rect, A)]) -> Option { 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)] enum GlobalMenuAction { ApplyUpdate, WhatsNew, Keybinds, Settings, } fn global_menu_actions(state: &AppState) -> Vec { let mut actions = vec![GlobalMenuAction::Settings, GlobalMenuAction::Keybinds]; if state.latest_release_notes_available || state.update_available.is_some() { actions.push(GlobalMenuAction::WhatsNew); } if state.update_available.is_some() { actions.push(GlobalMenuAction::ApplyUpdate); } actions } fn normalize_theme_name(name: &str) -> String { name.to_lowercase().replace([' ', '_'], "-") } fn current_theme_index(theme_name: &str) -> usize { use crate::app::state::THEME_NAMES; let normalized = normalize_theme_name(theme_name); THEME_NAMES .iter() .position(|name| normalize_theme_name(name) == normalized) .unwrap_or(0) } fn preview_selected_theme(state: &mut AppState) { use crate::app::state::{Palette, THEME_NAMES}; let name = THEME_NAMES[state.settings.list.selected]; if let Some(palette) = Palette::from_name(name) { state.palette = palette; state.theme_name = name.to_string(); } } fn cancel_settings(state: &mut AppState) { if let Some(palette) = state.settings.original_palette.take() { state.palette = palette; } if let Some(theme_name) = state.settings.original_theme.take() { state.theme_name = theme_name; } leave_modal(state); } fn apply_settings(state: &mut AppState) -> Option { match state.settings.section { crate::app::state::SettingsSection::Theme => { let theme_name = state.theme_name.clone(); state.settings.original_palette = None; state.settings.original_theme = None; leave_modal(state); Some(SettingsAction::SaveTheme(theme_name)) } _ => { leave_modal(state); None } } } fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option { use crate::app::state::SettingsSection; match state.settings.section { SettingsSection::Theme => match key.code { KeyCode::Up | KeyCode::Char('k') => { let previous = state.settings.list.selected; state.settings.list.move_prev(); if state.settings.list.selected != previous { preview_selected_theme(state); } } KeyCode::Down | KeyCode::Char('j') => { let previous = state.settings.list.selected; state .settings .list .move_next(crate::app::state::THEME_NAMES.len()); if state.settings.list.selected != previous { preview_selected_theme(state); } } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { state.settings.section = SettingsSection::Sound; state.settings.list.selected = usize::from(!state.sound_enabled()); } _ => match modal_action_from_key(&key, SETTINGS_ACTIONS) { Some(ModalAction::Apply) => return apply_settings(state), Some(ModalAction::Close) => cancel_settings(state), _ => {} }, }, SettingsSection::Sound => match key.code { KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { state.settings.list.selected = 1 - state.settings.list.selected.min(1); } KeyCode::Enter | KeyCode::Char(' ') => { let enabled = state.settings.list.selected == 0; state.sound.enabled = enabled; return Some(SettingsAction::SaveSound(enabled)); } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { state.settings.section = SettingsSection::Toast; state.settings.list.selected = usize::from(!state.toast_config.enabled); } KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { state.settings.section = SettingsSection::Theme; state.settings.list.selected = current_theme_index(&state.theme_name); } _ => match modal_action_from_key(&key, SETTINGS_ACTIONS) { Some(ModalAction::Close) => cancel_settings(state), _ => {} }, }, SettingsSection::Toast => match key.code { KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { state.settings.list.selected = 1 - state.settings.list.selected.min(1); } KeyCode::Enter | KeyCode::Char(' ') => { let enabled = state.settings.list.selected == 0; state.toast_config.enabled = enabled; return Some(SettingsAction::SaveToast(enabled)); } KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { state.settings.section = SettingsSection::Sound; state.settings.list.selected = usize::from(!state.sound_enabled()); } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { state.settings.section = SettingsSection::Theme; state.settings.list.selected = current_theme_index(&state.theme_name); } _ => match modal_action_from_key(&key, SETTINGS_ACTIONS) { Some(ModalAction::Close) => cancel_settings(state), _ => {} }, }, } None } fn open_global_menu(state: &mut AppState) { state.global_menu = MenuListState::new(0); state.mode = Mode::GlobalMenu; } 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; } fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) { match action { GlobalMenuAction::ApplyUpdate => state.should_quit = true, GlobalMenuAction::WhatsNew => open_update_release_notes(state), GlobalMenuAction::Keybinds => open_keybind_help(state), GlobalMenuAction::Settings => open_settings(state), } } fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) { let actions = global_menu_actions(state); match key.code { KeyCode::Esc | KeyCode::Char('q') => 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); } } _ => {} } } 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('q') | KeyCode::Char('?') => { leave_modal(state) } _ => {} } } fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { state.update_dismissed = true; if state.is_prefix(&key) || key.code == KeyCode::Esc { leave_navigate_mode(state); return; } if let Some(action) = navigate_action_for_key(state, &key) { execute_navigate_action(state, action); return; } match key.code { KeyCode::Char('q') => state.should_quit = true, KeyCode::Enter => { if !state.workspaces.is_empty() { state.switch_workspace(state.selected); leave_navigate_mode(state); } } KeyCode::Char(c @ '1'..='9') => { let idx = (c as usize) - ('1' as usize); if idx < state.workspaces.len() { state.switch_workspace(idx); leave_navigate_mode(state); } } KeyCode::Char('s') => { open_settings(state); } KeyCode::Char('?') => open_keybind_help(state), KeyCode::Up => { if state.selected > 0 { state.selected -= 1; state.ensure_workspace_visible(state.selected); } } KeyCode::Down => { if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 { state.selected += 1; state.ensure_workspace_visible(state.selected); } } KeyCode::Char('h') | KeyCode::Left => state.navigate_pane(NavDirection::Left), KeyCode::Char('j') => state.navigate_pane(NavDirection::Down), KeyCode::Char('k') => state.navigate_pane(NavDirection::Up), KeyCode::Char('l') | KeyCode::Right => state.navigate_pane(NavDirection::Right), KeyCode::Tab => state.cycle_pane(false), KeyCode::BackTab => state.cycle_pane(true), _ => {} } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum NavigateAction { NewWorkspace, RenameWorkspace, CloseWorkspace, PreviousWorkspace, NextWorkspace, NewTab, RenameTab, PreviousTab, NextTab, CloseTab, FocusPaneLeft, FocusPaneDown, FocusPaneUp, FocusPaneRight, SplitVertical, SplitHorizontal, ClosePane, Fullscreen, EnterResizeMode, ToggleSidebar, } fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option { let kb = &state.keybinds; if key_matches(key, kb.new_workspace.0, kb.new_workspace.1) { return Some(NavigateAction::NewWorkspace); } if key_matches(key, kb.rename_workspace.0, kb.rename_workspace.1) { return Some(NavigateAction::RenameWorkspace); } if key_matches(key, kb.close_workspace.0, kb.close_workspace.1) { return Some(NavigateAction::CloseWorkspace); } if kb .previous_workspace .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::PreviousWorkspace); } if kb .next_workspace .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::NextWorkspace); } if key_matches(key, kb.new_tab.0, kb.new_tab.1) { return Some(NavigateAction::NewTab); } if kb .rename_tab .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::RenameTab); } if kb .previous_tab .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::PreviousTab); } if kb .next_tab .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::NextTab); } if kb .close_tab .is_some_and(|(code, mods)| key_matches(key, code, mods)) { return Some(NavigateAction::CloseTab); } if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) { return Some(NavigateAction::SplitVertical); } if key_matches(key, kb.split_horizontal.0, kb.split_horizontal.1) { return Some(NavigateAction::SplitHorizontal); } if key_matches(key, kb.close_pane.0, kb.close_pane.1) { return Some(NavigateAction::ClosePane); } if key_matches(key, kb.fullscreen.0, kb.fullscreen.1) { return Some(NavigateAction::Fullscreen); } if key_matches(key, kb.resize_mode.0, kb.resize_mode.1) { return Some(NavigateAction::EnterResizeMode); } if key_matches(key, kb.toggle_sidebar.0, kb.toggle_sidebar.1) { return Some(NavigateAction::ToggleSidebar); } None } fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { match action { NavigateAction::NewWorkspace => { state.request_new_workspace = true; leave_navigate_mode(state); } NavigateAction::RenameWorkspace => { if !state.workspaces.is_empty() { open_rename_workspace(state, state.selected); } } NavigateAction::CloseWorkspace => { if !state.workspaces.is_empty() { if state.confirm_close { open_confirm_close(state); } else { state.close_selected_workspace(); leave_navigate_mode(state); } } } NavigateAction::PreviousWorkspace => { state.previous_workspace(); leave_navigate_mode(state); } NavigateAction::NextWorkspace => { state.next_workspace(); leave_navigate_mode(state); } NavigateAction::NewTab => open_new_tab_dialog(state), NavigateAction::RenameTab => open_rename_active_tab(state, false), NavigateAction::PreviousTab => { state.previous_tab(); leave_navigate_mode(state); } NavigateAction::NextTab => { state.next_tab(); leave_navigate_mode(state); } NavigateAction::CloseTab => { state.close_tab(); leave_navigate_mode(state); } NavigateAction::FocusPaneLeft => state.navigate_pane(NavDirection::Left), NavigateAction::FocusPaneDown => state.navigate_pane(NavDirection::Down), NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up), NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right), NavigateAction::SplitVertical => { state.split_pane(Direction::Horizontal); leave_navigate_mode(state); } NavigateAction::SplitHorizontal => { state.split_pane(Direction::Vertical); leave_navigate_mode(state); } NavigateAction::ClosePane => { state.close_pane(); leave_navigate_mode(state); } NavigateAction::Fullscreen => { state.toggle_fullscreen(); leave_navigate_mode(state); } NavigateAction::EnterResizeMode => state.mode = Mode::Resize, NavigateAction::ToggleSidebar => { state.sidebar_collapsed = !state.sidebar_collapsed; leave_navigate_mode(state); } } } fn leave_navigate_mode(state: &mut AppState) { if state.active.is_some() { state.mode = Mode::Terminal; } } 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; } 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 open_new_tab_dialog(state: &mut AppState) { let next_tab_name = state .active .and_then(|i| state.workspaces.get(i)) .map(|ws| (ws.tabs.len() + 1).to_string()) .unwrap_or_else(|| "1".to_string()); state.creating_new_tab = true; state.requested_new_tab_name = None; state.name_input = next_tab_name; state.name_input_replace_on_type = true; state.mode = Mode::RenameTab; } /// Return to the appropriate mode after completing a modal action. /// Goes to Terminal if a workspace is active, otherwise Navigate. fn leave_modal(state: &mut AppState) { if state.active.is_some() { state.mode = Mode::Terminal; } else { state.mode = Mode::Navigate; } } const ONBOARDING_WELCOME_ACTIONS: &[ModalActionSpec] = &[ModalActionSpec { action: ModalAction::Continue, bindings: &[ModalKeyBinding::Enter], }]; const ONBOARDING_NOTIFICATION_ACTIONS: &[ModalActionSpec] = &[ ModalActionSpec { action: ModalAction::Back, bindings: &[ModalKeyBinding::Esc], }, ModalActionSpec { action: ModalAction::Save, bindings: &[ModalKeyBinding::Enter], }, ]; const RELEASE_NOTES_ACTIONS: &[ModalActionSpec] = &[ModalActionSpec { action: ModalAction::Close, bindings: &[ModalKeyBinding::Enter, ModalKeyBinding::EscOrQ], }]; const RENAME_ACTIONS: &[ModalActionSpec] = &[ ModalActionSpec { action: ModalAction::Save, bindings: &[ModalKeyBinding::Enter], }, ModalActionSpec { action: ModalAction::Clear, bindings: &[ModalKeyBinding::CtrlC], }, ModalActionSpec { action: ModalAction::Cancel, bindings: &[ModalKeyBinding::EscOrQ], }, ]; const CONFIRM_CLOSE_ACTIONS: &[ModalActionSpec] = &[ ModalActionSpec { action: ModalAction::Confirm, bindings: &[ModalKeyBinding::Enter], }, ModalActionSpec { action: ModalAction::Cancel, bindings: &[ModalKeyBinding::EscOrQ], }, ]; const SETTINGS_ACTIONS: &[ModalActionSpec] = &[ ModalActionSpec { action: ModalAction::Apply, bindings: &[ModalKeyBinding::Enter], }, ModalActionSpec { action: ModalAction::Close, bindings: &[ModalKeyBinding::EscOrQ], }, ]; fn open_settings(state: &mut AppState) { use crate::app::state::SettingsSection; // Save current state for cancel state.settings.original_palette = Some(state.palette.clone()); state.settings.original_theme = Some(state.theme_name.clone()); state.settings.section = SettingsSection::Theme; state.settings.list.selected = current_theme_index(&state.theme_name); state.mode = Mode::Settings; } 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); } } Mode::RenameTab if state.creating_new_tab => { state.request_new_tab = true; state.requested_new_tab_name = if new_name.is_empty() { None } else { Some(new_name) }; } Mode::RenameTab => { if !new_name.is_empty() { if let Some(ws) = state.active.and_then(|i| state.workspaces.get_mut(i)) { if let Some(tab) = ws.active_tab_mut() { tab.set_custom_name(new_name); } } } } _ => {} } 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); } _ => {} } } 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); } _ => {} } } 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), _ => {} } } fn open_confirm_close(state: &mut AppState) { state.mode = Mode::ConfirmClose; } 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; } } fn confirm_close_cancel(state: &mut AppState) { state.mode = Mode::Navigate; } 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), _ => {} } } 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), } } 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); } } _ => {} } } // --------------------------------------------------------------------------- // Mouse handling // --------------------------------------------------------------------------- impl AppState { fn onboarding_full_area(&self) -> ratatui::layout::Rect { self.view.sidebar_rect.union(self.view.terminal_area) } fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option { let area = self.onboarding_full_area(); let popup_w = popup_w.min(area.width.saturating_sub(4)); let popup_h = popup_h.min(area.height.saturating_sub(2)); if popup_w < 4 || popup_h < 4 { return None; } let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup = ratatui::layout::Rect::new(popup_x, popup_y, popup_w, popup_h); let block = ratatui::widgets::Block::default().borders(ratatui::widgets::Borders::ALL); Some(block.inner(popup)) } fn release_notes_modal_inner(&self) -> Option { self.onboarding_modal_inner(76, 20) } fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool { let Some(inner) = self.release_notes_modal_inner() else { return false; }; if inner.height < 4 || inner.width < 12 { return false; } let button = crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); col >= button.x && col < button.x + button.width && row >= button.y && row < button.y + button.height } fn rename_modal_inner(&self) -> Option { self.onboarding_modal_inner(56, 7) } fn release_notes_body_rect(&self) -> Option { let inner = self.release_notes_modal_inner()?; if inner.height < 8 || inner.width < 4 { return None; } Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) } fn release_notes_scroll_metrics(&self) -> Option { let notes = self.release_notes.as_ref()?; let body = self.release_notes_body_rect()?; let viewport_rows = body.height.max(1) as usize; let lines = crate::ui::release_notes_lines(¬es.body, &self.palette); let rows_for_width = |wrap_width: usize| { lines .iter() .map(|(width, _)| width.max(&1).div_ceil(wrap_width.max(1))) .sum::() }; let full_width = body.width.max(1) as usize; let mut total_rows = rows_for_width(full_width); let wrap_width = if total_rows > viewport_rows && full_width > 1 { body.width.saturating_sub(1).max(1) as usize } else { full_width }; total_rows = rows_for_width(wrap_width); let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); Some(crate::pane::ScrollMetrics { offset_from_bottom: max_offset_from_bottom.saturating_sub(notes.scroll as usize), max_offset_from_bottom, viewport_rows, }) } pub(crate) fn release_notes_max_scroll(&self) -> u16 { self.release_notes_scroll_metrics() .map(|metrics| metrics.max_offset_from_bottom as u16) .unwrap_or(0) } fn release_notes_scrollbar_target_at( &self, col: u16, row: u16, ) -> Option { let body = self.release_notes_body_rect()?; let metrics = self.release_notes_scroll_metrics()?; let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; if !(col >= track.x && col < track.x + track.width && row >= track.y && row < track.y + track.height) { return None; } if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { Some(ScrollbarClickTarget::Thumb { grab_row_offset }) } else { Some(ScrollbarClickTarget::Track { offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), }) } } fn release_notes_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option { let body = self.release_notes_body_rect()?; let metrics = self.release_notes_scroll_metrics()?; let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; Some(crate::ui::scrollbar_offset_from_drag_row( metrics, track, row, grab_row_offset, )) } fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) { let max_scroll = self.release_notes_max_scroll() as usize; if let Some(notes) = &mut self.release_notes { notes.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; } } fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) { if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { return; } match self.onboarding_step { 0 => { let Some(inner) = self.onboarding_modal_inner(64, 16) else { return; }; let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) .actions .unwrap_or_default(); let button = crate::ui::onboarding_welcome_continue_rect(actions); if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && modal_action_from_buttons( mouse.column, mouse.row, &[(button, ModalAction::Continue)], ) == Some(ModalAction::Continue) { self.onboarding_step = 1; } } _ => { let Some(inner) = self.onboarding_modal_inner(56, 14) else { return; }; let stack = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1); if mouse.row >= stack.content.y && mouse.row < stack.content.y + 4 { self.onboarding_list .select((mouse.row - stack.content.y) as usize); return; } let (back, save) = crate::ui::onboarding_notification_button_rects( stack.actions.unwrap_or_default(), ); if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { match modal_action_from_buttons( mouse.column, mouse.row, &[(back, ModalAction::Back), (save, ModalAction::Save)], ) { Some(ModalAction::Back) => self.onboarding_step = 0, Some(ModalAction::Save) => self.request_complete_onboarding = true, _ => {} } } } } } fn workspace_list_rect(&self) -> Rect { let sidebar = self.view.sidebar_rect; if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { return Rect::default(); } crate::ui::workspace_list_rect(sidebar) } fn workspace_list_scrollbar_target_at( &self, col: u16, row: u16, ) -> Option { let area = self.workspace_list_rect(); let metrics = crate::ui::workspace_list_scroll_metrics(self, area); let track = crate::ui::workspace_list_scrollbar_rect(self, area)?; if col < track.x || col >= track.x + track.width || row < track.y || row >= track.y + track.height { return None; } if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { Some(ScrollbarClickTarget::Thumb { grab_row_offset }) } else { Some(ScrollbarClickTarget::Track { offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), }) } } fn workspace_list_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option { let area = self.workspace_list_rect(); let metrics = crate::ui::workspace_list_scroll_metrics(self, area); let track = crate::ui::workspace_list_scrollbar_rect(self, area)?; Some(crate::ui::scrollbar_offset_from_drag_row( metrics, track, row, grab_row_offset, )) } fn set_workspace_list_offset_from_bottom(&mut self, offset_from_bottom: usize) { let area = self.workspace_list_rect(); let metrics = crate::ui::workspace_list_scroll_metrics(self, area); self.workspace_scroll = metrics .max_offset_from_bottom .saturating_sub(offset_from_bottom); } fn scroll_workspace_list(&mut self, delta: i16) { if delta.is_negative() { self.workspace_scroll = self .workspace_scroll .saturating_sub(delta.unsigned_abs() as usize); return; } for _ in 0..delta as usize { let cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); let Some(last) = cards.last() else { break; }; if last.ws_idx + 1 >= self.workspaces.len() { break; } self.workspace_scroll = self.workspace_scroll.saturating_add(1); } } pub(crate) fn sidebar_footer_rect(&self) -> Rect { let ws_area = self.workspace_list_rect(); if ws_area == Rect::default() { return Rect::default(); } let y = ws_area.y + ws_area.height.saturating_sub(1); Rect::new(ws_area.x, y, ws_area.width, 1) } pub(crate) fn sidebar_new_button_rect(&self) -> Rect { let footer = self.sidebar_footer_rect(); let width = 5u16.min(footer.width.max(1)); Rect::new(footer.x, footer.y, width, footer.height) } pub(crate) fn global_launcher_rect(&self) -> Rect { let footer = self.sidebar_footer_rect(); let width = if self.update_available.is_some() { 8 } else { 6 } .min(footer.width.max(1)); let x = footer.x + footer.width.saturating_sub(width); Rect::new(x, footer.y, width, footer.height) } pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> { let mut labels = vec!["settings", "keybinds"]; if self.latest_release_notes_available || self.update_available.is_some() { labels.push("what's new"); } if self.update_available.is_some() { labels.push("quit to apply update"); } labels } pub(crate) fn global_menu_rect(&self) -> Rect { let screen = self.screen_rect(); let launcher = self.global_launcher_rect(); let labels = self.global_menu_labels(); let content_width = labels .iter() .map(|label| label.chars().count() as u16) .max() .unwrap_or(8) .saturating_add(2); let menu_w = content_width.saturating_add(2).min(screen.width.max(1)); let menu_h = (labels.len() as u16 + 2).min(screen.height.max(1)); let max_x = screen.x + screen.width.saturating_sub(menu_w); let desired_x = launcher.x + launcher.width.saturating_sub(menu_w); let x = desired_x.min(max_x); let y = launcher.y.saturating_sub(menu_h); Rect::new(x, y, menu_w, menu_h) } fn global_menu_item_at(&self, col: u16, row: u16) -> Option { 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() } pub(crate) fn keybind_help_popup_rect(&self) -> Rect { crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default() } fn keybind_help_modal_inner(&self) -> Option { self.onboarding_modal_inner(76, 22) } fn keybind_help_close_button_at(&self, col: u16, row: u16) -> bool { let Some(inner) = self.keybind_help_modal_inner() else { return false; }; if inner.height < 4 || inner.width < 12 { return false; } let button = crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); col >= button.x && col < button.x + button.width && row >= button.y && row < button.y + button.height } fn keybind_help_body_rect(&self) -> Option { let inner = self.keybind_help_modal_inner()?; if inner.height < 6 || inner.width < 4 { return None; } Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) } fn keybind_help_scroll_metrics(&self) -> Option { let body = self.keybind_help_body_rect()?; let viewport_rows = body.height.max(1) as usize; let wrap_width = body.width.max(1) as usize; let total_rows = crate::ui::keybind_help_lines(self) .into_iter() .map(|(width, _)| width.max(1).div_ceil(wrap_width)) .sum::(); let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); Some(crate::pane::ScrollMetrics { offset_from_bottom: max_offset_from_bottom .saturating_sub(self.keybind_help.scroll as usize), max_offset_from_bottom, viewport_rows, }) } fn keybind_help_scrollbar_target_at(&self, col: u16, row: u16) -> Option { let body = self.keybind_help_body_rect()?; let metrics = self.keybind_help_scroll_metrics()?; let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; if !(col >= track.x && col < track.x + track.width && row >= track.y && row < track.y + track.height) { return None; } if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { Some(ScrollbarClickTarget::Thumb { grab_row_offset }) } else { Some(ScrollbarClickTarget::Track { offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), }) } } fn keybind_help_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option { let body = self.keybind_help_body_rect()?; let metrics = self.keybind_help_scroll_metrics()?; let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; Some(crate::ui::scrollbar_offset_from_drag_row( metrics, track, row, grab_row_offset, )) } pub(crate) fn keybind_help_max_scroll(&self) -> u16 { self.keybind_help_scroll_metrics() .map(|metrics| metrics.max_offset_from_bottom as u16) .unwrap_or(0) } fn set_keybind_help_offset_from_bottom(&mut self, offset_from_bottom: usize) { let max_scroll = self.keybind_help_max_scroll() as usize; self.keybind_help.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; } fn scroll_keybind_help(&mut self, delta: i16) { let max_scroll = self.keybind_help_max_scroll(); let current = self.keybind_help.scroll as i16; self.keybind_help.scroll = current.saturating_add(delta).clamp(0, max_scroll as i16) as u16; } fn settings_popup_rect(&self) -> Rect { crate::ui::centered_popup_rect(self.screen_rect(), 56, 20).unwrap_or_default() } fn settings_inner_rect(&self) -> Rect { let popup = self.settings_popup_rect(); Rect::new( popup.x + 1, popup.y + 1, popup.width.saturating_sub(2), popup.height.saturating_sub(2), ) } fn settings_tab_at(&self, col: u16, row: u16) -> Option { use crate::app::state::SettingsSection; let inner = self.settings_inner_rect(); let tab_y = inner.y + 1; if row != tab_y { return None; } let mut x = inner.x; for section in SettingsSection::ALL { let width = section.label().len() as u16 + 2; if col >= x && col < x + width { return Some(*section); } x += width + 1; } None } fn settings_content_rect(&self) -> Rect { let inner = self.settings_inner_rect(); crate::ui::modal_stack_areas(inner, 3, 2, 0, 1).content } fn settings_list_index_at(&self, col: u16, row: u16) -> Option { let area = self.settings_content_rect(); if row < area.y || row >= area.y + area.height || col < area.x || col >= area.x + area.width { return None; } match self.settings.section { crate::app::state::SettingsSection::Theme => { let max_visible = area.height as usize; let scroll = if self.settings.list.selected >= max_visible { self.settings.list.selected - max_visible + 1 } else { 0 }; let idx = scroll + (row - area.y) as usize; (idx < crate::app::state::THEME_NAMES.len()).then_some(idx) } crate::app::state::SettingsSection::Sound | crate::app::state::SettingsSection::Toast => { let list_y = area.y + 2; if row >= list_y && row < list_y + 2 { Some((row - list_y) as usize) } else { None } } } } fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option { use crate::app::state::SettingsSection; match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) { self.settings.section = section; self.settings.list.select(match section { SettingsSection::Theme => current_theme_index(&self.theme_name), SettingsSection::Sound => usize::from(!self.sound_enabled()), SettingsSection::Toast => usize::from(!self.toast_config.enabled), }); return None; } if let Some(idx) = self.settings_list_index_at(mouse.column, mouse.row) { self.settings.list.select(idx); return match self.settings.section { SettingsSection::Theme => { preview_selected_theme(self); None } SettingsSection::Sound => { let enabled = idx == 0; self.sound.enabled = enabled; Some(SettingsAction::SaveSound(enabled)) } SettingsSection::Toast => { let enabled = idx == 0; self.toast_config.enabled = enabled; Some(SettingsAction::SaveToast(enabled)) } }; } let inner = self.settings_inner_rect(); let (apply, close) = crate::ui::settings_button_rects(inner); match modal_action_from_buttons( mouse.column, mouse.row, &[(apply, ModalAction::Apply), (close, ModalAction::Close)], ) { Some(ModalAction::Apply) => apply_settings(self), Some(ModalAction::Close) => { cancel_settings(self); None } _ => { cancel_settings(self); None } } } _ => None, } } fn handle_mouse(&mut self, mouse: MouseEvent) -> Option { if self.mode == Mode::Onboarding { self.handle_onboarding_mouse(mouse); return None; } if self.mode == Mode::Settings { return self.handle_settings_mouse(mouse); } let launcher_enabled = !self.sidebar_collapsed && matches!( self.mode, Mode::Terminal | Mode::Navigate | Mode::Resize | Mode::GlobalMenu | Mode::KeybindHelp ); let launcher = self.global_launcher_rect(); let launcher_hit = launcher_enabled && mouse.column >= launcher.x && mouse.column < launcher.x + launcher.width && mouse.row >= launcher.y && mouse.row < launcher.y + launcher.height; if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu { let actions = global_menu_actions(self); let hovered = self .global_menu_item_at(mouse.column, mouse.row) .and_then(|action| actions.iter().position(|item| *item == action)); self.global_menu.hover(hovered); return None; } if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && launcher_hit { if self.mode == Mode::GlobalMenu { leave_modal(self); } else { open_global_menu(self); } return None; } if self.mode == Mode::GlobalMenu { if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) { apply_global_menu_action(self, action); } else { leave_modal(self); } } return None; } if self.mode == Mode::KeybindHelp { return None; } let sidebar = self.view.sidebar_rect; let in_sidebar = mouse.column >= sidebar.x && mouse.column < sidebar.x + sidebar.width && mouse.row >= sidebar.y && mouse.row < sidebar.y + sidebar.height; match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { self.selection = None; self.workspace_press = None; if self.mode == Mode::ConfirmClose { let popup = self.confirm_close_rect(); let inner = Rect::new( popup.x + 1, popup.y + 1, popup.width.saturating_sub(2), popup.height.saturating_sub(2), ); let (confirm, cancel) = crate::ui::confirm_close_button_rects(inner); match modal_action_from_buttons( mouse.column, mouse.row, &[ (confirm, ModalAction::Confirm), (cancel, ModalAction::Cancel), ], ) { Some(ModalAction::Confirm) => confirm_close_accept(self), Some(ModalAction::Cancel) | None => confirm_close_cancel(self), _ => {} } return None; } if matches!(self.mode, Mode::RenameWorkspace | Mode::RenameTab) { let action = self .rename_modal_inner() .map(crate::ui::rename_button_rects) .and_then(|(save, clear, cancel)| { modal_action_from_buttons( mouse.column, mouse.row, &[ (save, ModalAction::Save), (clear, ModalAction::Clear), (cancel, ModalAction::Cancel), ], ) }) .unwrap_or(ModalAction::Cancel); apply_rename_action(self, action); return None; } if self.mode == Mode::ContextMenu { let item_idx = self.context_menu_item_at(mouse.column, mouse.row); if let Some(menu) = self.context_menu.take() { if let Some(idx) = item_idx { apply_context_menu_action(self, menu, idx); } else { leave_modal(self); } } return None; } if self.on_sidebar_divider(mouse.column, mouse.row) { self.drag = Some(DragState { target: DragTarget::SidebarDivider, }); self.set_manual_sidebar_width(mouse.column); return None; } if !in_sidebar { if let Some(border) = self.find_border_at(mouse.column, mouse.row) { self.drag = Some(DragState { target: DragTarget::PaneSplit { path: border.path.clone(), direction: border.direction, area: border.area, }, }); return None; } if let Some((pane_id, target)) = self.scrollbar_target_at(mouse.column, mouse.row) { self.focus_pane(pane_id); match target { ScrollbarClickTarget::Thumb { grab_row_offset } => { self.drag = Some(DragState { target: DragTarget::PaneScrollbar { pane_id, grab_row_offset, }, }); } ScrollbarClickTarget::Track { offset_from_bottom } => { self.set_pane_scroll_offset(pane_id, offset_from_bottom); } } if self.mode != Mode::Terminal { self.mode = Mode::Terminal; } return None; } } if let Some(tab_idx) = self.tab_at(mouse.column, mouse.row) { self.switch_tab(tab_idx); self.mode = Mode::Terminal; return None; } if self.on_new_tab_button(mouse.column, mouse.row) { open_new_tab_dialog(self); return None; } if in_sidebar { if self.sidebar_collapsed { if let Some(idx) = self.collapsed_workspace_at_row(mouse.row) { self.switch_workspace(idx); self.mode = Mode::Terminal; return None; } if let Some((ws_idx, tab_idx, pane_id)) = self.collapsed_agent_detail_target_at(mouse.row) { self.switch_workspace(ws_idx); if let Some(ws) = self.workspaces.get_mut(ws_idx) { ws.switch_tab(tab_idx); ws.layout.focus_pane(pane_id); } self.mode = Mode::Terminal; } return None; } let new_button = self.sidebar_new_button_rect(); let on_new_button = mouse.row >= new_button.y && mouse.row < new_button.y + new_button.height && mouse.column >= new_button.x && mouse.column < new_button.x + new_button.width; if on_new_button { self.request_new_workspace = true; return None; } if let Some(target) = self.workspace_list_scrollbar_target_at(mouse.column, mouse.row) { match target { ScrollbarClickTarget::Thumb { grab_row_offset } => { self.drag = Some(DragState { target: DragTarget::WorkspaceListScrollbar { grab_row_offset }, }); } ScrollbarClickTarget::Track { offset_from_bottom } => { self.set_workspace_list_offset_from_bottom(offset_from_bottom); } } return None; } if let Some(idx) = self.workspace_at_row(mouse.row) { self.workspace_press = Some(WorkspacePressState { ws_idx: idx, start_col: mouse.column, start_row: mouse.row, }); return None; } if self.on_agent_panel_scope_toggle(mouse.column, mouse.row) { self.agent_panel_scope = match self.agent_panel_scope { AgentPanelScope::CurrentWorkspace => AgentPanelScope::AllWorkspaces, AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace, }; return None; } if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row) { self.switch_workspace(ws_idx); if let Some(ws) = self.workspaces.get_mut(ws_idx) { ws.switch_tab(tab_idx); ws.layout.focus_pane(pane_id); } self.mode = Mode::Terminal; return None; } } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { if self.forward_pane_mouse_button(&info, mouse) { self.selection = None; return None; } let (row, col) = ( mouse.row - info.inner_rect.y, mouse.column - info.inner_rect.x, ); self.selection = Some(Selection::anchor(info.id, row, col, info.inner_rect)); if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { if ws.layout.focused() != info.id { ws.layout.focus_pane(info.id); } } if self.mode != Mode::Terminal { self.mode = Mode::Terminal; } } else if let Some(info) = self.view.pane_infos.iter().find(|p| { mouse.column >= p.rect.x && mouse.column < p.rect.x + p.rect.width && mouse.row >= p.rect.y && mouse.row < p.rect.y + p.rect.height }) { let id = info.id; if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { if ws.layout.focused() != id { ws.layout.focus_pane(id); } } if self.mode != Mode::Terminal { self.mode = Mode::Terminal; } } } MouseEventKind::Drag(MouseButton::Left) => { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { if self.forward_pane_mouse_button(&info, mouse) { self.selection = None; return None; } } let workspace_drop_index = self.workspace_drop_index_at_row(mouse.row); if self.drag.is_none() { if let Some(press) = &self.workspace_press { let delta_col = mouse.column.abs_diff(press.start_col); let delta_row = mouse.row.abs_diff(press.start_row); if delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD { self.drag = Some(DragState { target: DragTarget::WorkspaceReorder { source_ws_idx: press.ws_idx, insert_idx: workspace_drop_index, }, }); } } } if let Some(DragState { target: DragTarget::WorkspaceReorder { insert_idx, .. }, }) = &mut self.drag { *insert_idx = workspace_drop_index; } else if let Some(drag) = &self.drag { match &drag.target { DragTarget::WorkspaceReorder { .. } => {} DragTarget::WorkspaceListScrollbar { grab_row_offset } => { if let Some(offset_from_bottom) = self.workspace_list_offset_for_drag_row(mouse.row, *grab_row_offset) { self.set_workspace_list_offset_from_bottom(offset_from_bottom); } } DragTarget::PaneSplit { path, direction, area, } => { let ratio = match direction { Direction::Horizontal => { (mouse.column.saturating_sub(area.x)) as f32 / area.width.max(1) as f32 } Direction::Vertical => { (mouse.row.saturating_sub(area.y)) as f32 / area.height.max(1) as f32 } }; let ratio = ratio.clamp(0.1, 0.9); let path = path.clone(); if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { ws.layout.set_ratio_at(&path, ratio); } } DragTarget::PaneScrollbar { pane_id, grab_row_offset, } => { if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row( *pane_id, mouse.row, *grab_row_offset, ) { self.set_pane_scroll_offset(*pane_id, offset_from_bottom); } } DragTarget::SidebarDivider => { self.set_manual_sidebar_width(mouse.column); } DragTarget::ReleaseNotesScrollbar { .. } | DragTarget::KeybindHelpScrollbar { .. } => {} } } else if let Some(sel) = &mut self.selection { sel.drag(mouse.column, mouse.row); } } MouseEventKind::Up(MouseButton::Left) => { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { if self.forward_pane_mouse_button(&info, mouse) { self.selection = None; self.workspace_press = None; self.drag = None; return None; } } let workspace_press = self.workspace_press.take(); match self.drag.take() { Some(DragState { target: DragTarget::WorkspaceReorder { source_ws_idx, insert_idx: Some(insert_idx), }, }) => { self.move_workspace(source_ws_idx, insert_idx); } Some(_) => {} None => { if let Some(press) = workspace_press { self.switch_workspace(press.ws_idx); self.mode = Mode::Terminal; return None; } let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click()); if was_click { self.selection = None; } else { self.copy_selection(); } } } } MouseEventKind::Up(MouseButton::Middle) | MouseEventKind::Up(MouseButton::Right) | MouseEventKind::Drag(MouseButton::Middle) | MouseEventKind::Drag(MouseButton::Right) if !in_sidebar => { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { let _ = self.forward_pane_mouse_button(&info, mouse); } } MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => { self.selection = None; self.handle_terminal_wheel(mouse); } MouseEventKind::ScrollUp if in_sidebar => { if crate::ui::should_show_scrollbar(crate::ui::workspace_list_scroll_metrics( self, self.workspace_list_rect(), )) { self.scroll_workspace_list(-1); } else if self.selected > 0 { self.selected -= 1; self.ensure_workspace_visible(self.selected); } } MouseEventKind::ScrollDown if in_sidebar => { if crate::ui::should_show_scrollbar(crate::ui::workspace_list_scroll_metrics( self, self.workspace_list_rect(), )) { self.scroll_workspace_list(1); } else if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 { self.selected += 1; self.ensure_workspace_visible(self.selected); } } MouseEventKind::Moved if self.mode == Mode::ContextMenu => { let hovered = self.context_menu_item_at(mouse.column, mouse.row); if let Some(menu) = &mut self.context_menu { menu.list.hover(hovered); } } MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => { if self .workspace_list_scrollbar_target_at(mouse.column, mouse.row) .is_some() { return None; } if let Some(idx) = self.workspace_at_row(mouse.row) { self.selected = idx; self.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Workspace { ws_idx: idx }, x: mouse.column, y: mouse.row, list: MenuListState::new(0), }); self.mode = Mode::ContextMenu; } } MouseEventKind::Down(MouseButton::Right) if self.tab_at(mouse.column, mouse.row).is_some() => { if let (Some(ws_idx), Some(tab_idx)) = (self.active, self.tab_at(mouse.column, mouse.row)) { self.switch_tab(tab_idx); self.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Tab { ws_idx, tab_idx }, x: mouse.column, y: mouse.row, list: MenuListState::new(0), }); self.mode = Mode::ContextMenu; } } MouseEventKind::Down(MouseButton::Right) if !in_sidebar => { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { if self.forward_pane_mouse_button(&info, mouse) { return None; } self.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Pane, x: mouse.column, y: mouse.row, list: MenuListState::new(0), }); self.mode = Mode::ContextMenu; } } _ => {} } None } fn on_sidebar_divider(&self, col: u16, row: u16) -> bool { if self.sidebar_collapsed { return false; } let sidebar = self.view.sidebar_rect; sidebar.width > 0 && col == sidebar.x + sidebar.width.saturating_sub(1) && row >= sidebar.y && row < sidebar.y + sidebar.height } fn set_manual_sidebar_width(&mut self, divider_col: u16) { let sidebar = self.view.sidebar_rect; let width = divider_col.saturating_sub(sidebar.x).saturating_add(1); self.sidebar_width = width.clamp(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH); } /// Find which workspace index a sidebar row belongs to (two-section layout). fn tab_at(&self, col: u16, row: u16) -> Option { self.view .tab_hit_areas .iter() .enumerate() .find_map(|(idx, area)| { (row >= area.y && row < area.y + area.height && col >= area.x && col < area.x + area.width) .then_some(idx) }) } fn on_new_tab_button(&self, col: u16, row: u16) -> bool { let area = self.view.new_tab_hit_area; area.width > 0 && row >= area.y && row < area.y + area.height && col >= area.x && col < area.x + area.width } fn workspace_at_row(&self, row: u16) -> Option { let footer = self.sidebar_footer_rect(); if footer == Rect::default() { return None; } let cards = if self.view.workspace_card_areas.is_empty() { crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) } else { self.view.workspace_card_areas.clone() }; cards.iter().find_map(|card| { (row >= card.rect.y && row < card.rect.y + card.rect.height).then_some(card.ws_idx) }) } fn collapsed_workspace_at_row(&self, row: u16) -> Option { if !self.sidebar_collapsed { return None; } let (ws_area, _, _) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect); if ws_area == Rect::default() || row < ws_area.y || row >= ws_area.y + ws_area.height { return None; } let idx = (row - ws_area.y) as usize; (idx < self.workspaces.len()).then_some(idx) } fn collapsed_detail_workspace_idx(&self) -> Option { if matches!( self.mode, Mode::Navigate | Mode::RenameWorkspace | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu | Mode::Settings | Mode::GlobalMenu | Mode::KeybindHelp ) { Some(self.selected) } else { self.active } } fn collapsed_agent_detail_target_at( &self, row: u16, ) -> Option<(usize, usize, crate::layout::PaneId)> { if !self.sidebar_collapsed { return None; } let (_, _, detail_area) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect); let detail_content_area = Rect::new( detail_area.x, detail_area.y, detail_area.width, detail_area.height.saturating_sub(1), ); if detail_content_area == Rect::default() || row < detail_content_area.y || row >= detail_content_area.y + detail_content_area.height { return None; } let ws_idx = self.collapsed_detail_workspace_idx()?; let ws = self.workspaces.get(ws_idx)?; let detail_idx = (row - detail_content_area.y) as usize; let details = ws.pane_details(); let detail = details.get(detail_idx)?; Some((ws_idx, detail.tab_idx, detail.pane_id)) } fn workspace_drop_index_at_row(&self, row: u16) -> Option { let area = self.workspace_list_rect(); let footer = self.sidebar_footer_rect(); if area == Rect::default() || row < area.y || row >= footer.y { return None; } let cards = if self.view.workspace_card_areas.is_empty() { crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) } else { self.view.workspace_card_areas.clone() }; if cards.is_empty() { return Some(0); } let mut insert_indices = Vec::with_capacity(cards.len() + 1); insert_indices.push(cards[0].ws_idx); insert_indices.extend(cards.iter().skip(1).map(|card| card.ws_idx)); insert_indices.push(cards.last().map(|card| card.ws_idx + 1).unwrap_or(0)); let mut best: Option<(usize, u16)> = None; for insert_idx in insert_indices { let Some(slot_row) = crate::ui::workspace_drop_indicator_row(&cards, area, insert_idx) else { continue; }; let distance = row.abs_diff(slot_row); match best { Some((best_idx, best_distance)) if distance > best_distance || (distance == best_distance && insert_idx < best_idx) => {} _ => best = Some((insert_idx, distance)), } } best.map(|(insert_idx, _)| insert_idx) } fn on_agent_panel_scope_toggle(&self, col: u16, row: u16) -> bool { if self.sidebar_collapsed { return false; } let (_, detail_area) = crate::ui::expanded_sidebar_sections(self.view.sidebar_rect); let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_scope); rect.width > 0 && col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height } fn agent_detail_target_at(&self, row: u16) -> Option<(usize, usize, crate::layout::PaneId)> { if self.sidebar_collapsed { return None; } let (_, detail_area) = crate::ui::expanded_sidebar_sections(self.view.sidebar_rect); if detail_area.height < 4 || row < detail_area.y + 3 || row >= detail_area.y + detail_area.height { return None; } let relative_row = row - (detail_area.y + 3); let entry_height = 3; if relative_row % entry_height == 2 { return None; } let detail_idx = (relative_row / entry_height) as usize; let details = crate::ui::agent_panel_entries(self); let detail = details.get(detail_idx)?; Some((detail.ws_idx, detail.tab_idx, detail.pane_id)) } fn screen_rect(&self) -> Rect { let sidebar = self.view.sidebar_rect; let terminal = self.view.terminal_area; let x = sidebar.x.min(terminal.x); let y = sidebar.y.min(terminal.y); let right = (sidebar.x + sidebar.width).max(terminal.x + terminal.width); let bottom = (sidebar.y + sidebar.height).max(terminal.y + terminal.height); Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y)) } pub(crate) fn context_menu_rect(&self) -> Option { let menu = self.context_menu.as_ref()?; let screen = self.screen_rect(); let max_item_w = menu .items() .iter() .map(|item| item.len() as u16) .max() .unwrap_or(0); let menu_w = (max_item_w + 4).max(14).min(screen.width.max(1)); let menu_h = (menu.items().len() as u16 + 2).min(screen.height.max(1)); let x = menu.x.min(screen.x + screen.width.saturating_sub(menu_w)); let y = menu.y.min(screen.y + screen.height.saturating_sub(menu_h)); Some(Rect::new(x, y, menu_w, menu_h)) } pub(crate) fn confirm_close_rect(&self) -> Rect { crate::ui::confirm_close_popup_rect(self.view.terminal_area).unwrap_or_default() } fn context_menu_item_at(&self, col: u16, row: u16) -> Option { let menu_rect = self.context_menu_rect()?; let inner_x = menu_rect.x + 1; let inner_y = menu_rect.y + 1; let inner_w = menu_rect.width.saturating_sub(2); let inner_h = menu_rect.height.saturating_sub(2); let item_count = self .context_menu .as_ref() .map(|menu| menu.items().len() as u16) .unwrap_or(0); if col >= inner_x && col < inner_x + inner_w && row >= inner_y && row < inner_y + inner_h.min(item_count) { Some((row - inner_y) as usize) } else { None } } fn find_border_at(&self, col: u16, row: u16) -> Option<&SplitBorder> { self.view.split_borders.iter().find(|b| match b.direction { Direction::Horizontal => { (col as i32 - b.pos as i32).unsigned_abs() <= 1 && row >= b.area.y && row < b.area.y + b.area.height } Direction::Vertical => { (row as i32 - b.pos as i32).unsigned_abs() <= 1 && col >= b.area.x && col < b.area.x + b.area.width } }) } fn pane_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { self.view.pane_infos.iter().find(|p| { col >= p.inner_rect.x && col < p.inner_rect.x + p.inner_rect.width && row >= p.inner_rect.y && row < p.inner_rect.y + p.inner_rect.height }) } fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> { self.pane_at(col, row) .or_else(|| self.pane_frame_at(col, row)) } fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { self.view.pane_infos.iter().find(|p| { col >= p.rect.x && col < p.rect.x + p.rect.width && row >= p.rect.y && row < p.rect.y + p.rect.height }) } fn focus_pane(&mut self, pane_id: crate::layout::PaneId) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { if ws.layout.focused() != pane_id { ws.layout.focus_pane(pane_id); } } } fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if let Some(rt) = ws.runtimes.get(&pane_id) { rt.scroll_up(lines); } } } fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if let Some(rt) = ws.runtimes.get(&pane_id) { rt.scroll_down(lines); } } } fn handle_terminal_wheel(&mut self, mouse: MouseEvent) { const LINES_PER_NOTCH: usize = 3; if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { self.focus_pane(info.id); if self.forward_pane_wheel(&info, mouse) { return; } match mouse.kind { MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH), MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH), _ => {} } return; } if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { self.focus_pane(info.id); match mouse.kind { MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH), MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH), _ => {} } return; } if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if let Some(rt) = ws.focused_runtime() { match mouse.kind { MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH), MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH), _ => {} } } } } fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { return false; }; let Some(rt) = ws.runtimes.get(&info.id) else { return false; }; let column = mouse.column.saturating_sub(info.inner_rect.x); let row = mouse.row.saturating_sub(info.inner_rect.y); let Some(bytes) = rt.encode_mouse_button(mouse.kind, column, row, mouse.modifiers) else { return false; }; rt.scroll_reset(); if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse button event"); } true } fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { return false; }; let Some(rt) = ws.runtimes.get(&info.id) else { return false; }; match rt.wheel_routing() { Some(crate::pane::WheelRouting::HostScroll) | None => false, Some(crate::pane::WheelRouting::MouseReport) => { rt.scroll_reset(); let column = mouse.column.saturating_sub(info.inner_rect.x); let row = mouse.row.saturating_sub(info.inner_rect.y); let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers) else { warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event"); return true; }; if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event"); } true } Some(crate::pane::WheelRouting::AlternateScroll) => { rt.scroll_reset(); let Some(bytes) = rt.encode_alternate_scroll(mouse.kind) else { return true; }; if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key"); } true } } } fn set_pane_scroll_offset(&self, pane_id: crate::layout::PaneId, offset_from_bottom: usize) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if let Some(rt) = ws.runtimes.get(&pane_id) { rt.set_scroll_offset_from_bottom(offset_from_bottom); } } } fn scrollbar_target_at( &self, col: u16, row: u16, ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> { let ws = self.active.and_then(|i| self.workspaces.get(i))?; let info = self.view.pane_infos.iter().find(|info| { crate::ui::pane_scrollbar_rect(info).is_some_and(|track| { col >= track.x && col < track.x + track.width && row >= track.y && row < track.y + track.height }) })?; let rt = ws.runtimes.get(&info.id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; } let track = crate::ui::pane_scrollbar_rect(info)?; if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset })) } else { Some(( info.id, ScrollbarClickTarget::Track { offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), }, )) } } fn scrollbar_offset_for_pane_row( &self, pane_id: crate::layout::PaneId, row: u16, grab_row_offset: u16, ) -> Option { let ws = self.active.and_then(|i| self.workspaces.get(i))?; let info = self .view .pane_infos .iter() .find(|info| info.id == pane_id)?; let track = crate::ui::pane_scrollbar_rect(info)?; let rt = ws.runtimes.get(&pane_id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; } Some(crate::ui::scrollbar_offset_from_drag_row( metrics, track, row, grab_row_offset, )) } } #[cfg(test)] fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting { if input_state.mouse_protocol_mode.reporting_enabled() { WheelRouting::MouseReport } else if input_state.alternate_screen && input_state.mouse_alternate_scroll { WheelRouting::AlternateScroll } else { WheelRouting::HostScroll } } // Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App impl AppState { pub(crate) fn split_pane(&mut self, direction: Direction) { // Actual PTY spawning happens in Workspace::split_focused // which needs events channel — this is called from navigate_key // where we don't have async context, so the workspace handles it let (rows, cols) = self.estimate_pane_size(); let new_rows = (rows / 2).max(4); let new_cols = (cols / 2).max(10); let cwd = self .active .and_then(|i| self.workspaces.get(i)) .and_then(|ws| ws.focused_runtime()) .and_then(|rt| rt.cwd()); if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { if let Ok(new_id) = ws.split_focused(direction, new_rows, new_cols, cwd, self.host_terminal_theme) { ws.layout.focus_pane(new_id); self.mode = Mode::Terminal; } } } } #[cfg(test)] mod tests { use super::*; use crate::{config::Config, detect::Agent, workspace::Workspace}; use crossterm::event::{KeyModifiers, MouseEvent}; use ratatui::layout::Rect; fn state_with_workspaces(names: &[&str]) -> AppState { let mut state = AppState::test_new(); state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect(); if !state.workspaces.is_empty() { state.active = Some(0); state.selected = 0; state.mode = Mode::Navigate; } state } fn app_for_mouse_test() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), true, None, None, api_rx, crate::api::EventHub::default(), ); app.state.mode = Mode::Terminal; app.state.update_available = None; app.state.latest_release_notes_available = false; app.state.view.sidebar_rect = Rect::new(0, 0, 26, 20); app.state.view.terminal_area = Rect::new(26, 0, 80, 20); app } fn mouse(kind: MouseEventKind, col: u16, row: u16) -> MouseEvent { MouseEvent { kind, column: col, row, modifiers: KeyModifiers::empty(), } } #[test] fn custom_rename_key_enters_rename_mode() { let mut state = state_with_workspaces(&["test"]); state.keybinds.rename_workspace = (KeyCode::Char('g'), KeyModifiers::empty()); state.keybinds.rename_workspace_label = "g".into(); handle_navigate_key( &mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), ); assert_eq!(state.mode, Mode::RenameWorkspace); assert_eq!(state.name_input, "test"); } #[test] fn custom_new_workspace_key_requests_and_exits_navigate() { let mut state = state_with_workspaces(&["test"]); state.keybinds.new_workspace = (KeyCode::Char('g'), KeyModifiers::empty()); state.keybinds.new_workspace_label = "g".into(); handle_navigate_key( &mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), ); assert!(state.request_new_workspace); assert_eq!(state.mode, Mode::Terminal); } #[test] fn custom_sidebar_toggle_key_toggles_and_exits_navigate() { let mut state = state_with_workspaces(&["test"]); state.keybinds.toggle_sidebar = (KeyCode::Char('g'), KeyModifiers::empty()); state.keybinds.toggle_sidebar_label = "g".into(); assert!(!state.sidebar_collapsed); handle_navigate_key( &mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), ); assert!(state.sidebar_collapsed); assert_eq!(state.mode, Mode::Terminal); } #[test] fn custom_resize_key_enters_resize_mode() { let mut state = state_with_workspaces(&["test"]); state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty()); state.keybinds.resize_mode_label = "g".into(); handle_navigate_key( &mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), ); assert_eq!(state.mode, Mode::Resize); } #[test] fn movement_action_stays_in_navigate_mode() { let mut state = state_with_workspaces(&["a", "b"]); state.selected = 0; handle_navigate_key( &mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), ); assert_eq!(state.selected, 1); assert_eq!(state.mode, Mode::Navigate); } #[test] fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() { let mut state = state_with_workspaces(&["test"]); state.keybinds.focus_pane_left = Some((KeyCode::Left, KeyModifiers::ALT)); state.keybinds.focus_pane_left_label = Some("alt+left".into()); let action = terminal_direct_navigation_action( &state, &KeyEvent::new(KeyCode::Left, KeyModifiers::ALT), ); assert_eq!(action, Some(NavigateAction::FocusPaneLeft)); } #[tokio::test] async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), true, None, None, api_rx, crate::api::EventHub::default(), ); app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.workspaces[0].test_split(Direction::Horizontal); app.state.view.pane_infos = app.state.workspaces[0] .active_tab() .unwrap() .layout .panes(Rect::new(0, 0, 80, 24)); let focused_before = app.state.workspaces[0].layout.focused(); app.state.keybinds.focus_pane_left = Some((KeyCode::Char('h'), KeyModifiers::ALT)); app.state.keybinds.focus_pane_left_label = Some("alt+h".into()); app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT)) .await; assert_ne!(app.state.workspaces[0].layout.focused(), focused_before); assert_eq!(app.state.mode, Mode::Terminal); } #[test] fn fullscreen_action_exits_navigate_mode() { let mut state = state_with_workspaces(&["test"]); state.workspaces[0].test_split(Direction::Horizontal); state.keybinds.fullscreen = (KeyCode::Char('g'), KeyModifiers::empty()); state.keybinds.fullscreen_label = "g".into(); handle_navigate_key( &mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), ); assert!(state.workspaces[0].zoomed); assert_eq!(state.mode, Mode::Terminal); } #[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 settings_cancel_restores_previewed_theme_from_other_sections() { let mut state = state_with_workspaces(&["test"]); let original_palette = state.palette.clone(); let original_theme = state.theme_name.clone(); open_settings(&mut state); update_settings_state( &mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), ); assert_ne!(state.theme_name, original_theme); update_settings_state( &mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), ); assert_eq!( state.settings.section, crate::app::state::SettingsSection::Sound ); update_settings_state( &mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), ); assert_eq!(state.mode, Mode::Terminal); assert_eq!(state.theme_name, original_theme); assert_eq!(state.palette.accent, original_palette.accent); assert_eq!(state.palette.panel_bg, original_palette.panel_bg); } #[test] fn settings_sound_toggle_returns_save_action() { let mut state = state_with_workspaces(&["test"]); open_settings(&mut state); state.settings.section = crate::app::state::SettingsSection::Sound; state.settings.list.selected = 0; let action = update_settings_state( &mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), ); assert_eq!(action, Some(SettingsAction::SaveSound(true))); assert!(state.sound.enabled); assert_eq!(state.mode, Mode::Settings); } #[test] fn question_mark_opens_keybind_help_from_navigate() { let mut state = state_with_workspaces(&["test"]); handle_navigate_key( &mut state, KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), ); assert_eq!(state.mode, Mode::KeybindHelp); } #[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"); 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 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 new_tab_action_opens_dialog_without_creating_tab() { let mut state = state_with_workspaces(&["test"]); execute_navigate_action(&mut state, NavigateAction::NewTab); assert_eq!(state.mode, Mode::RenameTab); assert!(state.creating_new_tab); assert_eq!(state.name_input, "2"); assert!(state.name_input_replace_on_type); assert!(!state.request_new_tab); assert_eq!(state.workspaces[0].tabs.len(), 1); } #[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 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); } #[test] fn clicking_launcher_opens_global_menu() { let mut app = app_for_mouse_test(); let rect = app.state.global_launcher_rect(); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), rect.x + rect.width.saturating_sub(1), rect.y, )); assert_eq!(app.state.mode, Mode::GlobalMenu); } #[test] fn hovering_global_menu_updates_highlight() { let mut app = app_for_mouse_test(); let launcher = app.state.global_launcher_rect(); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), launcher.x, launcher.y, )); let menu = app.state.global_menu_rect(); app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2)); assert_eq!(app.state.global_menu.highlighted, 1); } #[test] fn clicking_keybinds_menu_item_opens_help() { let mut app = app_for_mouse_test(); let launcher = app.state.global_launcher_rect(); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), launcher.x, launcher.y, )); let menu = app.state.global_menu_rect(); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), menu.x + 2, menu.y + 2, )); assert_eq!(app.state.mode, Mode::KeybindHelp); } #[test] fn clicking_settings_menu_item_opens_settings() { let mut app = app_for_mouse_test(); let launcher = app.state.global_launcher_rect(); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), launcher.x, launcher.y, )); let menu = app.state.global_menu_rect(); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), menu.x + 2, menu.y + 1, )); assert_eq!(app.state.mode, Mode::Settings); } #[test] fn update_pending_menu_surfaces_apply_update_action() { let mut app = app_for_mouse_test(); app.state.update_available = Some("0.3.2".into()); app.state.latest_release_notes_available = true; let launcher = app.state.global_launcher_rect(); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), launcher.x, launcher.y, )); assert_eq!( app.state.global_menu_labels(), vec!["settings", "keybinds", "what's new", "quit to apply update"] ); let menu = app.state.global_menu_rect(); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), menu.x + 2, menu.y + 4, )); assert!(app.state.should_quit); } #[test] fn whats_new_remains_in_menu_for_latest_installed_release_notes() { let mut app = app_for_mouse_test(); app.state.latest_release_notes_available = true; assert_eq!( app.state.global_menu_labels(), vec!["settings", "keybinds", "what's new"] ); } #[test] fn clicking_keybind_help_close_button_closes_overlay() { let mut app = app_for_mouse_test(); app.state.mode = Mode::KeybindHelp; let rect = app.state.keybind_help_popup_rect(); let inner = Rect::new( rect.x + 1, rect.y + 1, rect.width.saturating_sub(2), rect.height.saturating_sub(2), ); let close = crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), close.x, close.y, )); assert_eq!(app.state.mode, Mode::Navigate); } #[test] fn hovering_context_menu_updates_highlight() { let mut app = app_for_mouse_test(); app.state.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Workspace { ws_idx: 0 }, x: 2, y: 2, list: MenuListState::new(0), }); app.state.mode = Mode::ContextMenu; let menu = app.state.context_menu_rect().unwrap(); app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2)); assert_eq!(app.state.context_menu.unwrap().list.highlighted, 1); } #[test] fn onboarding_hover_does_not_change_selection() { let mut app = app_for_mouse_test(); app.state.mode = Mode::Onboarding; app.state.onboarding_step = 1; app.state.onboarding_list.select(1); let inner = app.state.onboarding_modal_inner(56, 14).unwrap(); let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content; app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y)); assert_eq!(app.state.onboarding_list.selected, 1); } #[test] fn onboarding_click_selects_notification_option() { let mut app = app_for_mouse_test(); app.state.mode = Mode::Onboarding; app.state.onboarding_step = 1; app.state.onboarding_list.select(0); let inner = app.state.onboarding_modal_inner(56, 14).unwrap(); let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), content.x + 2, content.y + 2, )); assert_eq!(app.state.onboarding_list.selected, 2); } #[test] fn settings_hover_does_not_change_selection() { let mut app = app_for_mouse_test(); open_settings(&mut app.state); app.state.settings.list.select(0); let area = app.state.settings_content_rect(); app.handle_mouse(mouse(MouseEventKind::Moved, area.x + 2, area.y + 2)); assert_eq!(app.state.settings.list.selected, 0); } #[test] fn clicking_confirm_close_accepts_workspace_close() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; app.state.active = Some(0); app.state.selected = 1; app.state.mode = Mode::ConfirmClose; let popup = app.state.confirm_close_rect(); let inner = Rect::new( popup.x + 1, popup.y + 1, popup.width.saturating_sub(2), popup.height.saturating_sub(2), ); let (confirm, _) = crate::ui::confirm_close_button_rects(inner); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), confirm.x, confirm.y, )); assert_eq!(app.state.workspaces.len(), 1); assert_eq!(app.state.mode, Mode::Terminal); } #[test] fn clicking_confirm_close_accepts_after_workspace_context_menu_close() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Workspace { ws_idx: 1 }, x: 2, y: 2, list: MenuListState::new(1), }); app.state.mode = Mode::ContextMenu; handle_context_menu_key( &mut app.state, KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), ); assert_eq!(app.state.mode, Mode::ConfirmClose); assert_eq!(app.state.selected, 1); let popup = app.state.confirm_close_rect(); let inner = Rect::new( popup.x + 1, popup.y + 1, popup.width.saturating_sub(2), popup.height.saturating_sub(2), ); let (confirm, _) = crate::ui::confirm_close_button_rects(inner); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), confirm.x + 1, confirm.y, )); assert_eq!(app.state.workspaces.len(), 1); assert_eq!(app.state.workspaces[0].display_name(), "a"); } #[test] fn clicking_agent_detail_row_switches_to_correct_tab_and_pane() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); ws.tabs[0].set_custom_name("main".into()); let first_pane = ws.tabs[0].root_pane; ws.tabs[0] .panes .get_mut(&first_pane) .unwrap() .detected_agent = Some(Agent::Pi); let second_tab = ws.test_add_tab(Some("logs")); let second_pane = ws.tabs[second_tab].root_pane; ws.tabs[second_tab] .panes .get_mut(&second_pane) .unwrap() .detected_agent = Some(Agent::Claude); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 16)); assert_eq!(app.state.workspaces[0].active_tab, 1); assert_eq!( app.state.workspaces[0].tabs[1].layout.focused(), second_pane ); assert_eq!(app.state.mode, Mode::Terminal); } #[test] fn clicking_agent_panel_toggle_switches_scope() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; let (_, detail_area) = crate::ui::expanded_sidebar_sections(app.state.view.sidebar_rect); let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_scope); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), toggle.x, toggle.y, )); assert_eq!(app.state.agent_panel_scope, AgentPanelScope::AllWorkspaces); } #[test] fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() { let mut app = app_for_mouse_test(); let mut first = Workspace::test_new("one"); let first_pane = first.tabs[0].root_pane; first.tabs[0] .panes .get_mut(&first_pane) .unwrap() .detected_agent = Some(Agent::Pi); let mut second = Workspace::test_new("two"); let second_pane = second.tabs[0].root_pane; second.tabs[0] .panes .get_mut(&second_pane) .unwrap() .detected_agent = Some(Agent::Claude); app.state.workspaces = vec![first, second]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.agent_panel_scope = AgentPanelScope::AllWorkspaces; let (_, detail_area) = crate::ui::expanded_sidebar_sections(app.state.view.sidebar_rect); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), detail_area.x + 2, detail_area.y + 6, )); assert_eq!(app.state.active, Some(1)); assert_eq!(app.state.selected, 1); assert_eq!(app.state.workspaces[1].active_tab, 0); assert_eq!( app.state.workspaces[1].tabs[0].layout.focused(), second_pane ); } #[test] fn clicking_collapsed_agent_row_switches_to_correct_tab_and_pane() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let first_pane = ws.tabs[0].root_pane; ws.tabs[0] .panes .get_mut(&first_pane) .unwrap() .detected_agent = Some(Agent::Pi); let second_tab = ws.test_add_tab(Some("logs")); let second_pane = ws.tabs[second_tab].root_pane; ws.tabs[second_tab] .panes .get_mut(&second_pane) .unwrap() .detected_agent = Some(Agent::Claude); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.sidebar_collapsed = true; app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); app.state.view.terminal_area = Rect::new(4, 0, 80, 20); let (_, _, detail_area) = crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), detail_area.x, detail_area.y + 1, )); assert_eq!(app.state.workspaces[0].active_tab, 1); assert_eq!( app.state.workspaces[0].tabs[1].layout.focused(), second_pane ); assert_eq!(app.state.mode, Mode::Terminal); } #[test] fn clicking_workspace_switches_on_mouse_up() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; app.state.active = Some(0); app.state.selected = 0; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let target_row = app.state.view.workspace_card_areas[1].rect.y; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), 2, target_row, )); assert_eq!(app.state.active, Some(0)); assert!(app.state.workspace_press.is_some()); app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); assert_eq!(app.state.active, Some(1)); assert_eq!(app.state.selected, 1); assert!(app.state.workspace_press.is_none()); } #[test] fn dragging_workspace_reorders_without_changing_identity() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![ Workspace::test_new("a"), Workspace::test_new("b"), Workspace::test_new("c"), ]; let active_id = app.state.workspaces[1].id.clone(); let selected_id = app.state.workspaces[2].id.clone(); app.state.active = Some(1); app.state.selected = 2; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let source_row = app.state.view.workspace_card_areas[1].rect.y; let target_row = crate::ui::workspace_drop_indicator_row( &app.state.view.workspace_card_areas, app.state.workspace_list_rect(), 0, ) .unwrap(); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), 2, source_row, )); app.handle_mouse(mouse( MouseEventKind::Drag(MouseButton::Left), 2, target_row, )); assert!(matches!( app.state.drag.as_ref().map(|drag| &drag.target), Some(DragTarget::WorkspaceReorder { source_ws_idx: 1, insert_idx: Some(0), }) )); app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); let names: Vec<_> = app .state .workspaces .iter() .map(|ws| ws.display_name()) .collect(); assert_eq!(names, vec!["b", "a", "c"]); assert_eq!(app.state.active, Some(0)); assert_eq!(app.state.selected, 2); assert_eq!(app.state.workspaces[0].id, active_id); assert_eq!(app.state.workspaces[2].id, selected_id); } #[test] fn top_drop_slot_is_distinct_from_gap_below_first_workspace() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0)); assert_eq!(app.state.workspace_drop_index_at_row(1), Some(0)); assert_eq!(app.state.workspace_drop_index_at_row(2), Some(0)); assert_eq!(app.state.workspace_drop_index_at_row(3), Some(1)); } #[test] fn bottom_drop_slot_stays_below_last_workspace_not_footer() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![ Workspace::test_new("a"), Workspace::test_new("b"), Workspace::test_new("c"), ]; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let cards = &app.state.view.workspace_card_areas; let bottom_slot = crate::ui::workspace_drop_indicator_row( cards, app.state.workspace_list_rect(), cards.len(), ) .unwrap(); let last = cards.last().unwrap().rect; assert_eq!(bottom_slot, last.y + last.height); assert!(bottom_slot < app.state.sidebar_footer_rect().y.saturating_sub(1)); } #[test] fn dragging_sidebar_divider_sets_manual_width() { let mut app = app_for_mouse_test(); app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 5)); assert_eq!(app.state.sidebar_width, 31); } #[test] fn double_clicking_sidebar_divider_resets_default_width() { let mut app = app_for_mouse_test(); app.state.default_sidebar_width = 26; app.state.sidebar_width = 30; app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 25, 5)); app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); assert_eq!(app.state.sidebar_width, 26); assert!(app.state.drag.is_none()); } #[test] fn wheel_routing_prefers_mouse_reporting() { let input_state = crate::pane::InputState { alternate_screen: true, application_cursor: false, bracketed_paste: false, focus_reporting: false, mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, mouse_alternate_scroll: true, }; assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport); } #[test] fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() { let input_state = crate::pane::InputState { alternate_screen: true, application_cursor: false, bracketed_paste: false, focus_reporting: false, mouse_protocol_mode: crate::input::MouseProtocolMode::None, mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, mouse_alternate_scroll: true, }; assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll); } #[test] fn wheel_routing_falls_back_to_host_scrollback() { let input_state = crate::pane::InputState { alternate_screen: false, application_cursor: false, bracketed_paste: false, focus_reporting: false, mouse_protocol_mode: crate::input::MouseProtocolMode::None, mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, mouse_alternate_scroll: true, }; assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll); } }