diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-21 16:30:04 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-21 16:30:04 +0300 |
| commit | 3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch) | |
| tree | c457a7cf0af271dea1a412474fad5a33c1b60be8 /src/app/input | |
| parent | 2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff) | |
feat: add persistent server-client sessions
Make herdr persistent by default.
Launching herdr now starts or reattaches to a background session
server. Clients can detach and reattach while panes and agent
processes keep running. Session mode now supports multi-client attach,
auto-detect startup, and a thin-client/headless-server split.
This also refactors the large app, ui, input, pane, config,
workspace, and persistence modules into smaller focused submodules,
while preserving behavior and colocating tests with the code they
exercise.
Upgrade notes:
- persistence mode is now the default
- in-app quit detaches the current client instead of stopping the server
- use `herdr server stop` to stop the background session
- use `--no-session` for the old single-process behavior
- default socket paths now live under the config directory
Diffstat (limited to 'src/app/input')
| -rw-r--r-- | src/app/input/mod.rs | 321 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 739 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 1258 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 780 | ||||
| -rw-r--r-- | src/app/input/overlays.rs | 483 | ||||
| -rw-r--r-- | src/app/input/selection.rs | 68 | ||||
| -rw-r--r-- | src/app/input/settings.rs | 352 | ||||
| -rw-r--r-- | src/app/input/sidebar.rs | 1219 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 440 |
9 files changed, 5660 insertions, 0 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs new file mode 100644 index 00000000..2f069fea --- /dev/null +++ b/src/app/input/mod.rs @@ -0,0 +1,321 @@ +//! Input handling — translates crossterm key/mouse events into state mutations. + +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; + +use crate::input::TerminalKey; +use ratatui::layout::Direction; + +#[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; +const TAB_DRAG_THRESHOLD: u16 = 1; + +mod modal; +mod mouse; +mod navigate; +mod overlays; +mod selection; +mod settings; +mod sidebar; +mod terminal; + +pub(crate) use self::{ + modal::{ + handle_confirm_close_key, handle_context_menu_key, handle_global_menu_key, + handle_keybind_help_key, handle_rename_key, handle_resize_key, + }, + navigate::{handle_navigate_key, terminal_direct_navigation_action}, +}; +use self::{ + modal::{ + modal_action_from_key, ModalAction, ONBOARDING_NOTIFICATION_ACTIONS, + ONBOARDING_WELCOME_ACTIONS, RELEASE_NOTES_ACTIONS, + }, + settings::SettingsAction, +}; +use super::state::{AppState, Mode}; +use super::App; + +// --------------------------------------------------------------------------- +// Key handling +// --------------------------------------------------------------------------- + +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 => self.handle_navigate_key(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; + } + } + } + + pub(crate) 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; + } + _ => 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)); + } + _ => match modal_action_from_key(&key, ONBOARDING_NOTIFICATION_ACTIONS) { + Some(ModalAction::Back) => self.state.onboarding_step = 0, + Some(ModalAction::Save) => self.complete_onboarding(), + _ => {} + }, + }, + } + } + + pub(crate) 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(), + _ => {} + }, + } + } + + pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) { + if self.handle_overlay_mouse(mouse) { + 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.mark_session_dirty(); + 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), + } + } + + if let Some(content) = self.state.request_clipboard_write.take() { + if self + .event_tx + .try_send(crate::events::AppEvent::ClipboardWrite { content }) + .is_err() + { + tracing::warn!("failed to queue clipboard write event"); + } + } + } +} + +// --------------------------------------------------------------------------- +// Mouse handling +// --------------------------------------------------------------------------- + +// 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.pane_scrollback_limit_bytes, + self.host_terminal_theme, + ) { + ws.layout.focus_pane(new_id); + self.mark_session_dirty(); + self.mode = Mode::Terminal; + } + } + } +} + +#[cfg(test)] +fn state_with_workspaces(names: &[&str]) -> AppState { + let mut state = AppState::test_new(); + state.workspaces = names + .iter() + .map(|name| crate::workspace::Workspace::test_new(name)) + .collect(); + if !state.workspaces.is_empty() { + state.active = Some(0); + state.selected = 0; + state.mode = Mode::Navigate; + } + state +} + +#[cfg(test)] +fn app_for_mouse_test() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::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 = ratatui::layout::Rect::new(0, 0, 26, 20); + app.state.view.terminal_area = ratatui::layout::Rect::new(26, 0, 80, 20); + app +} + +#[cfg(test)] +fn mouse( + kind: crossterm::event::MouseEventKind, + col: u16, + row: u16, +) -> crossterm::event::MouseEvent { + crossterm::event::MouseEvent { + kind, + column: col, + row, + modifiers: crossterm::event::KeyModifiers::empty(), + } +} + +#[cfg(test)] +fn numbered_lines_bytes(count: usize) -> Vec<u8> { + (0..count) + .map(|i| format!("{i:06}\r\n")) + .collect::<String>() + .into_bytes() +} + +#[cfg(test)] +fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot { + crate::persist::capture( + &state.workspaces, + state.active, + state.selected, + state.agent_panel_scope, + state.sidebar_width, + state.sidebar_section_split, + ) +} + +#[cfg(test)] +fn root_layout_ratio(snapshot: &crate::persist::SessionSnapshot) -> Option<f32> { + match &snapshot.workspaces.first()?.tabs.first()?.layout { + crate::persist::LayoutSnapshot::Split { ratio, .. } => Some(*ratio), + crate::persist::LayoutSnapshot::Pane(_) => None, + } +} + +#[cfg(test)] +fn unique_temp_path(name: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) +} + +#[cfg(test)] +fn wait_for_file(path: &std::path::Path) -> String { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + if let Ok(content) = std::fs::read_to_string(path) { + return content; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("timed out waiting for {}", path.display()); +} diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs new file mode 100644 index 00000000..2ba9b6f4 --- /dev/null +++ b/src/app/input/modal.rs @@ -0,0 +1,739 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Direction, Rect}; + +use crate::{ + app::state::{key_matches, AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode}, + layout::NavDirection, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ModalAction { + Continue, + Back, + Save, + Clear, + Cancel, + Confirm, + Apply, + Close, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ModalKeyBinding { + Enter, + Esc, + CtrlC, +} + +impl ModalKeyBinding { + fn matches(self, key: &KeyEvent) -> bool { + match self { + Self::Enter => key.code == KeyCode::Enter, + Self::Esc => key.code == KeyCode::Esc, + Self::CtrlC => { + key.code == KeyCode::Char('c') + && key.modifiers == crossterm::event::KeyModifiers::CONTROL + } + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct ModalActionSpec<A> { + pub action: A, + pub bindings: &'static [ModalKeyBinding], +} + +pub(super) fn modal_action_from_key<A: Copy>( + key: &KeyEvent, + specs: &[ModalActionSpec<A>], +) -> Option<A> { + specs + .iter() + .find(|spec| spec.bindings.iter().any(|binding| binding.matches(key))) + .map(|spec| spec.action) +} + +pub(super) fn modal_action_from_buttons<A: Copy>( + col: u16, + row: u16, + buttons: &[(Rect, A)], +) -> Option<A> { + buttons.iter().find_map(|(rect, action)| { + (col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height) + .then_some(*action) + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GlobalMenuAction { + Quit, + WhatsNew, + Keybinds, + ReloadKeybinds, + Settings, +} + +pub(super) fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> { + let mut actions = vec![ + GlobalMenuAction::Settings, + GlobalMenuAction::Keybinds, + GlobalMenuAction::ReloadKeybinds, + ]; + if state.update_available.is_some() || state.latest_release_notes_available { + actions.push(GlobalMenuAction::WhatsNew); + } + actions.push(GlobalMenuAction::Quit); + actions +} + +pub(super) fn open_global_menu(state: &mut AppState) { + state.global_menu = MenuListState::new(0); + state.mode = Mode::GlobalMenu; +} + +pub(super) fn open_keybind_help(state: &mut AppState) { + state.keybind_help.scroll = 0; + state.mode = Mode::KeybindHelp; +} + +fn open_update_release_notes(state: &mut AppState) { + let Some(notes) = crate::release_notes::load_latest() else { + return; + }; + + state.release_notes = Some(crate::app::state::ReleaseNotesState { + version: notes.version, + body: notes.body, + scroll: 0, + preview: notes.preview, + }); + state.mode = Mode::ReleaseNotes; +} + +pub(super) fn request_quit_or_detach(state: &mut AppState) { + if state.quit_detaches { + state.detach_requested = true; + } else { + state.should_quit = true; + } +} + +pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) { + match action { + GlobalMenuAction::Quit => request_quit_or_detach(state), + GlobalMenuAction::WhatsNew => open_update_release_notes(state), + GlobalMenuAction::Keybinds => open_keybind_help(state), + GlobalMenuAction::ReloadKeybinds => { + state.request_reload_keybinds = true; + leave_modal(state); + } + GlobalMenuAction::Settings => super::settings::open_settings(state), + } +} + +pub(crate) fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) { + let actions = global_menu_actions(state); + match key.code { + KeyCode::Esc => leave_modal(state), + KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(), + KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()), + KeyCode::Enter => { + if let Some(action) = actions.get(state.global_menu.highlighted).copied() { + apply_global_menu_action(state, action); + } + } + _ => {} + } +} + +pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1), + KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1), + KeyCode::PageUp => state.scroll_keybind_help(-8), + KeyCode::PageDown => state.scroll_keybind_help(8), + KeyCode::Home => state.keybind_help.scroll = 0, + KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => leave_modal(state), + _ => {} + } +} + +pub(super) fn open_rename_workspace(state: &mut AppState, ws_idx: usize) { + state.selected = ws_idx; + state.name_input = state.workspaces[ws_idx].display_name(); + state.name_input_replace_on_type = false; + state.mode = Mode::RenameWorkspace; +} + +pub(super) fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool) { + state.creating_new_tab = false; + state.requested_new_tab_name = None; + if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) { + if let Some(name) = ws.active_tab_display_name() { + state.name_input = name; + state.name_input_replace_on_type = replace_on_type; + state.mode = Mode::RenameTab; + } + } +} + +fn next_new_tab_default_name(state: &AppState) -> String { + state + .active + .and_then(|i| state.workspaces.get(i)) + .map(|ws| (ws.tabs.len() + 1).to_string()) + .unwrap_or_else(|| "1".to_string()) +} + +pub(super) fn open_new_tab_dialog(state: &mut AppState) { + state.creating_new_tab = true; + state.requested_new_tab_name = None; + state.name_input = next_new_tab_default_name(state); + state.name_input_replace_on_type = true; + state.mode = Mode::RenameTab; +} + +pub(super) fn leave_modal(state: &mut AppState) { + if state.active.is_some() { + state.mode = Mode::Terminal; + } else { + state.mode = Mode::Navigate; + } +} + +pub(super) const ONBOARDING_WELCOME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec { + action: ModalAction::Continue, + bindings: &[ModalKeyBinding::Enter], +}]; + +pub(super) const ONBOARDING_NOTIFICATION_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ + ModalActionSpec { + action: ModalAction::Back, + bindings: &[ModalKeyBinding::Esc], + }, + ModalActionSpec { + action: ModalAction::Save, + bindings: &[ModalKeyBinding::Enter], + }, +]; + +pub(super) const RELEASE_NOTES_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec { + action: ModalAction::Close, + bindings: &[ModalKeyBinding::Enter, ModalKeyBinding::Esc], +}]; + +pub(super) const RENAME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ + ModalActionSpec { + action: ModalAction::Save, + bindings: &[ModalKeyBinding::Enter], + }, + ModalActionSpec { + action: ModalAction::Clear, + bindings: &[ModalKeyBinding::CtrlC], + }, + ModalActionSpec { + action: ModalAction::Cancel, + bindings: &[ModalKeyBinding::Esc], + }, +]; + +pub(super) const CONFIRM_CLOSE_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ + ModalActionSpec { + action: ModalAction::Confirm, + bindings: &[ModalKeyBinding::Enter], + }, + ModalActionSpec { + action: ModalAction::Cancel, + bindings: &[ModalKeyBinding::Esc], + }, +]; + +pub(super) const SETTINGS_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ + ModalActionSpec { + action: ModalAction::Apply, + bindings: &[ModalKeyBinding::Enter], + }, + ModalActionSpec { + action: ModalAction::Close, + bindings: &[ModalKeyBinding::Esc], + }, +]; + +pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { + match action { + ModalAction::Save => { + let new_name = if state.name_input.trim().is_empty() { + state.name_input.clone() + } else { + state.name_input.trim().to_string() + }; + match state.mode { + Mode::RenameWorkspace if !state.workspaces.is_empty() => { + if !new_name.is_empty() { + state.workspaces[state.selected].set_custom_name(new_name); + state.mark_session_dirty(); + } + } + Mode::RenameTab if state.creating_new_tab => { + state.request_new_tab = true; + let default_name = next_new_tab_default_name(state); + state.requested_new_tab_name = + if new_name.is_empty() || new_name == default_name { + None + } else { + Some(new_name) + }; + } + Mode::RenameTab => { + if let Some(ws) = state.active.and_then(|i| state.workspaces.get_mut(i)) { + if let Some(tab) = ws.active_tab_mut() { + let keep_auto_name = + tab.is_auto_named() && new_name == tab.number.to_string(); + if !new_name.is_empty() && !keep_auto_name { + tab.set_custom_name(new_name); + state.mark_session_dirty(); + } + } + } + } + _ => {} + } + state.creating_new_tab = false; + state.name_input.clear(); + state.name_input_replace_on_type = false; + leave_modal(state); + } + ModalAction::Clear => { + state.name_input.clear(); + state.name_input_replace_on_type = false; + } + ModalAction::Cancel => { + state.creating_new_tab = false; + state.requested_new_tab_name = None; + state.name_input.clear(); + state.name_input_replace_on_type = false; + leave_modal(state); + } + _ => {} + } +} + +pub(crate) fn handle_rename_key(state: &mut AppState, key: KeyEvent) { + if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) { + apply_rename_action(state, action); + return; + } + + match key.code { + KeyCode::Backspace => { + if state.name_input_replace_on_type { + state.name_input.clear(); + state.name_input_replace_on_type = false; + } else { + state.name_input.pop(); + } + } + KeyCode::Char(c) => { + if state.name_input_replace_on_type { + state.name_input.clear(); + state.name_input_replace_on_type = false; + } + state.name_input.push(c); + } + _ => {} + } +} + +pub(crate) fn handle_resize_key(state: &mut AppState, key: KeyEvent) { + if key.code == KeyCode::Esc + || key.code == KeyCode::Enter + || key_matches( + &key, + state.keybinds.resize_mode.0, + state.keybinds.resize_mode.1, + ) + { + if state.active.is_some() { + state.mode = Mode::Terminal; + } else { + state.mode = Mode::Navigate; + } + return; + } + + match key.code { + KeyCode::Char('h') | KeyCode::Left => state.resize_pane(NavDirection::Left), + KeyCode::Char('l') | KeyCode::Right => state.resize_pane(NavDirection::Right), + KeyCode::Char('j') | KeyCode::Down => state.resize_pane(NavDirection::Down), + KeyCode::Char('k') | KeyCode::Up => state.resize_pane(NavDirection::Up), + _ => {} + } +} + +pub(super) fn open_confirm_close(state: &mut AppState) { + state.mode = Mode::ConfirmClose; +} + +pub(super) fn confirm_close_accept(state: &mut AppState) { + state.close_selected_workspace(); + if state.workspaces.is_empty() { + state.mode = Mode::Navigate; + } else { + state.mode = Mode::Terminal; + } +} + +pub(super) fn confirm_close_cancel(state: &mut AppState) { + state.mode = Mode::Navigate; +} + +pub(crate) fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) { + match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) { + Some(ModalAction::Confirm) => confirm_close_accept(state), + Some(ModalAction::Cancel) => confirm_close_cancel(state), + _ => {} + } +} + +pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: usize) { + let item = menu.items().get(idx).copied(); + match (menu.kind, item) { + (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => { + open_rename_workspace(state, ws_idx); + } + (ContextMenuKind::Workspace { ws_idx }, Some("Close")) => { + state.selected = ws_idx; + if state.confirm_close { + open_confirm_close(state); + } else { + state.close_selected_workspace(); + state.mode = Mode::Navigate; + } + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + open_new_tab_dialog(state); + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + open_rename_active_tab(state, false); + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + state.close_tab(); + state.mode = if state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + (ContextMenuKind::Pane, Some("Split vertical")) => { + state.split_pane(Direction::Horizontal); + state.mode = Mode::Terminal; + } + (ContextMenuKind::Pane, Some("Split horizontal")) => { + state.split_pane(Direction::Vertical); + state.mode = Mode::Terminal; + } + (ContextMenuKind::Pane, Some("Fullscreen")) => { + state.toggle_fullscreen(); + state.mode = Mode::Terminal; + } + (ContextMenuKind::Pane, Some("Close pane")) => { + state.close_pane(); + state.mode = if state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + _ => leave_modal(state), + } +} + +pub(crate) fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + state.context_menu = None; + leave_modal(state); + } + KeyCode::Up => { + if let Some(menu) = &mut state.context_menu { + menu.list.move_prev(); + } + } + KeyCode::Down => { + if let Some(menu) = &mut state.context_menu { + menu.list.move_next(menu.items().len()); + } + } + KeyCode::Enter => { + if let Some(menu) = state.context_menu.take() { + let idx = menu.list.highlighted; + apply_context_menu_action(state, menu, idx); + } + } + _ => {} + } +} + +impl AppState { + pub(super) fn global_menu_item_at(&self, col: u16, row: u16) -> Option<GlobalMenuAction> { + let rect = self.global_menu_rect(); + if col <= rect.x + || col >= rect.x + rect.width.saturating_sub(1) + || row <= rect.y + || row >= rect.y + rect.height.saturating_sub(1) + { + return None; + } + let idx = (row - rect.y - 1) as usize; + global_menu_actions(self).get(idx).copied() + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::layout::Rect; + + use super::super::{capture_snapshot, state_with_workspaces}; + use super::*; + + #[test] + fn custom_resize_key_exits_resize_mode() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::Resize; + state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.resize_mode_label = "g".into(); + + handle_resize_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn rename_modal_keyboard_and_mouse_share_actions() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::RenameWorkspace; + state.name_input = "hello".into(); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert!(state.name_input.is_empty()); + + state.name_input = "renamed".into(); + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + assert_eq!(state.mode, Mode::Terminal); + assert_eq!(state.workspaces[0].display_name(), "renamed"); + let snapshot = capture_snapshot(&state); + assert_eq!( + snapshot.workspaces[0].custom_name.as_deref(), + Some("renamed") + ); + + state.view.sidebar_rect = Rect::new(0, 0, 26, 20); + state.view.terminal_area = Rect::new(26, 0, 80, 20); + state.mode = Mode::RenameWorkspace; + state.name_input = "mouse".into(); + let inner = state.rename_modal_inner().unwrap(); + let (save, _, _) = crate::ui::rename_button_rects(inner); + let action = modal_action_from_buttons(save.x, save.y, &[(save, ModalAction::Save)]); + assert_eq!(action, Some(ModalAction::Save)); + } + + #[test] + fn tab_rename_updates_captured_snapshot() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::RenameTab; + state.name_input = "logs".into(); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + let snapshot = capture_snapshot(&state); + assert_eq!( + snapshot.workspaces[0].tabs[0].custom_name.as_deref(), + Some("logs") + ); + } + + #[test] + fn rename_cancel_returns_to_terminal_when_workspace_is_active() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::RenameTab; + state.name_input = "test".into(); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(state.name_input.is_empty()); + } + + #[test] + fn rename_modal_replaces_prefilled_text_on_first_type() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::RenameTab; + state.name_input = "2".into(); + state.name_input_replace_on_type = true; + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()), + ); + assert_eq!(state.name_input, "n"); + assert!(!state.name_input_replace_on_type); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()), + ); + assert_eq!(state.name_input, "ne"); + } + + #[test] + fn open_rename_active_tab_can_prefill_default_new_tab_name() { + let mut state = state_with_workspaces(&["test"]); + state.workspaces[0].test_add_tab(None); + state.workspaces[0].switch_tab(1); + + open_rename_active_tab(&mut state, true); + + assert_eq!(state.mode, Mode::RenameTab); + assert_eq!(state.name_input, "2"); + assert!(state.name_input_replace_on_type); + } + + #[test] + fn cancel_new_tab_dialog_leaves_workspace_unchanged() { + let mut state = state_with_workspaces(&["test"]); + open_new_tab_dialog(&mut state); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(!state.creating_new_tab); + assert!(!state.request_new_tab); + assert!(state.requested_new_tab_name.is_none()); + assert_eq!(state.workspaces[0].tabs.len(), 1); + } + + #[test] + fn saving_new_tab_dialog_requests_creation_with_name() { + let mut state = state_with_workspaces(&["test"]); + open_new_tab_dialog(&mut state); + state.name_input = "logs".into(); + state.name_input_replace_on_type = false; + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(!state.creating_new_tab); + assert!(state.request_new_tab); + assert_eq!(state.requested_new_tab_name.as_deref(), Some("logs")); + } + + #[test] + fn saving_new_tab_dialog_with_default_name_keeps_tab_auto_named() { + let mut state = state_with_workspaces(&["test"]); + open_new_tab_dialog(&mut state); + + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(!state.creating_new_tab); + assert!(state.request_new_tab); + assert!(state.requested_new_tab_name.is_none()); + } + + #[test] + fn closing_first_auto_tab_resets_remaining_auto_tab_and_next_prompt() { + let mut state = state_with_workspaces(&["test"]); + open_new_tab_dialog(&mut state); + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + state.workspaces[0].test_add_tab(state.requested_new_tab_name.as_deref()); + state.request_new_tab = false; + state.requested_new_tab_name = None; + + state.workspaces[0].close_tab(0); + state.workspaces[0].switch_tab(0); + + assert_eq!(state.workspaces[0].tabs[0].display_name(), "1"); + assert!(state.workspaces[0].tabs[0].custom_name.is_none()); + + open_new_tab_dialog(&mut state); + assert_eq!(state.name_input, "2"); + } + + #[test] + fn renaming_auto_tab_to_its_default_number_keeps_it_auto_named() { + let mut state = state_with_workspaces(&["test"]); + state.workspaces[0].test_add_tab(None); + state.workspaces[0].switch_tab(1); + + open_rename_active_tab(&mut state, false); + handle_rename_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert!(state.workspaces[0].tabs[1].custom_name.is_none()); + assert_eq!(state.workspaces[0].tabs[1].display_name(), "2"); + } + + #[test] + fn confirm_close_keyboard_actions_are_direct_not_focused() { + let mut state = state_with_workspaces(&["a", "b"]); + state.mode = Mode::ConfirmClose; + state.selected = 1; + + handle_confirm_close_key( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + ); + assert_eq!(state.mode, Mode::Navigate); + assert_eq!(state.workspaces.len(), 2); + + state.mode = Mode::ConfirmClose; + handle_confirm_close_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + assert_eq!(state.workspaces.len(), 1); + } +} diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs new file mode 100644 index 00000000..0349d236 --- /dev/null +++ b/src/app/input/mouse.rs @@ -0,0 +1,1258 @@ +use bytes::Bytes; +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::{Direction, Rect}; +use tracing::warn; + +use crate::{ + app::state::{ + AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, + MenuListState, Mode, TabPressState, WorkspacePressState, + }, + layout::{PaneInfo, SplitBorder}, + selection::Selection, +}; + +#[cfg(test)] +use super::WheelRouting; +use super::{ + modal::{ + apply_context_menu_action, apply_global_menu_action, apply_rename_action, + confirm_close_accept, confirm_close_cancel, global_menu_actions, leave_modal, + modal_action_from_buttons, open_global_menu, open_new_tab_dialog, ModalAction, + }, + settings::SettingsAction, + ScrollbarClickTarget, TAB_DRAG_THRESHOLD, WORKSPACE_DRAG_THRESHOLD, +}; + +impl AppState { + pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> { + 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 self.on_sidebar_section_divider(mouse.column, mouse.row) { + self.drag = Some(DragState { + target: DragTarget::SidebarSectionDivider, + }); + self.set_sidebar_section_split(mouse.row); + 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 self.on_tab_scroll_left_button(mouse.column, mouse.row) { + self.scroll_tabs_left(); + return None; + } + if self.on_tab_scroll_right_button(mouse.column, mouse.row) { + self.scroll_tabs_right(); + return None; + } + if let (Some(ws_idx), Some(tab_idx)) = + (self.active, self.tab_at(mouse.column, mouse.row)) + { + self.tab_press = Some(TabPressState { + ws_idx, + tab_idx, + start_col: mouse.column, + start_row: mouse.row, + }); + 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 self.on_collapsed_sidebar_toggle(mouse.column, mouse.row) { + self.sidebar_collapsed = false; + return None; + } + + 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); + self.switch_tab(tab_idx); + self.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, + }; + self.agent_panel_scroll = 0; + self.mark_session_dirty(); + return None; + } + + if let Some(target) = + self.agent_panel_scrollbar_target_at(mouse.column, mouse.row) + { + match target { + ScrollbarClickTarget::Thumb { grab_row_offset } => { + self.drag = Some(DragState { + target: DragTarget::AgentPanelScrollbar { grab_row_offset }, + }); + } + ScrollbarClickTarget::Track { offset_from_bottom } => { + self.set_agent_panel_offset_from_bottom(offset_from_bottom); + } + } + return None; + } + + if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row) + { + self.switch_workspace(ws_idx); + self.switch_tab(tab_idx); + self.focus_pane(pane_id); + self.mode = Mode::Terminal; + return None; + } + } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { + self.focus_pane(info.id); + if self.mode != Mode::Terminal { + self.mode = Mode::Terminal; + } + + 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, + self.pane_scroll_metrics(info.id), + )); + } 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; + self.focus_pane(id); + if self.mode != Mode::Terminal { + self.mode = Mode::Terminal; + } + } + } + + MouseEventKind::Drag(MouseButton::Left) => { + if self.selection.is_some() { + self.update_selection_drag(mouse.column, mouse.row); + return None; + } + + 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); + let tab_drop_index = self.tab_drop_index_at(mouse.column, 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, + }, + }); + } + } else if let Some(press) = &self.tab_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) >= TAB_DRAG_THRESHOLD { + self.drag = Some(DragState { + target: DragTarget::TabReorder { + ws_idx: press.ws_idx, + source_tab_idx: press.tab_idx, + insert_idx: tab_drop_index, + }, + }); + } + } + } + + if let Some(DragState { + target: DragTarget::WorkspaceReorder { insert_idx, .. }, + }) = &mut self.drag + { + *insert_idx = workspace_drop_index; + } else if let Some(DragState { + target: + DragTarget::TabReorder { + ws_idx, insert_idx, .. + }, + }) = &mut self.drag + { + if self.active == Some(*ws_idx) { + *insert_idx = tab_drop_index; + } + } else if let Some(drag) = &self.drag { + match &drag.target { + DragTarget::WorkspaceReorder { .. } | DragTarget::TabReorder { .. } => {} + 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::AgentPanelScrollbar { grab_row_offset } => { + if let Some(offset_from_bottom) = + self.agent_panel_offset_for_drag_row(mouse.row, *grab_row_offset) + { + self.set_agent_panel_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); + self.mark_session_dirty(); + } + } + 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::SidebarSectionDivider => { + self.set_sidebar_section_split(mouse.row); + } + DragTarget::ReleaseNotesScrollbar { .. } + | DragTarget::KeybindHelpScrollbar { .. } => {} + } + } + } + + MouseEventKind::Up(MouseButton::Left) => { + if self.selection.is_some() { + self.workspace_press = None; + self.tab_press = None; + self.drag = 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(); + } + return None; + } + + 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.tab_press = None; + self.drag = None; + return None; + } + } + + let workspace_press = self.workspace_press.take(); + let tab_press = self.tab_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(DragState { + target: + DragTarget::TabReorder { + ws_idx, + source_tab_idx, + insert_idx: Some(insert_idx), + }, + }) => { + if self.active == Some(ws_idx) { + self.move_tab(source_tab_idx, insert_idx); + self.mode = Mode::Terminal; + } + } + Some(_) => {} + None => { + if let Some(press) = workspace_press { + self.switch_workspace(press.ws_idx); + self.mode = Mode::Terminal; + return None; + } + if let Some(press) = tab_press { + if self.active == Some(press.ws_idx) { + self.switch_tab(press.tab_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::Drag(MouseButton::Middle) + 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 => { + if self.on_tab_bar(mouse.column, mouse.row) { + match mouse.kind { + MouseEventKind::ScrollUp => self.scroll_tabs_left(), + MouseEventKind::ScrollDown => self.scroll_tabs_right(), + _ => {} + } + } else if !self.scroll_selection_with_wheel(mouse) { + self.selection = None; + self.handle_terminal_wheel(mouse); + } + } + + MouseEventKind::ScrollUp if in_sidebar => { + let agent_area = self.agent_panel_rect(); + let over_agent_panel = agent_area != Rect::default() + && mouse.row >= agent_area.y + && mouse.row < agent_area.y + agent_area.height; + if over_agent_panel { + if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( + self, agent_area, + )) { + self.scroll_agent_panel(-1); + } + } else 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 => { + let agent_area = self.agent_panel_rect(); + let over_agent_panel = agent_area != Rect::default() + && mouse.row >= agent_area.y + && mouse.row < agent_area.y + agent_area.height; + if over_agent_panel { + if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( + self, agent_area, + )) { + self.scroll_agent_panel(1); + } + } else 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() { + self.focus_pane(info.id); + self.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Pane, + x: mouse.column, + y: mouse.row, + list: MenuListState::new(0), + }); + self.mode = Mode::ContextMenu; + } + } + + _ => {} + } + + None + } + + pub(super) 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<Rect> { + 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<usize> { + 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 + } + } + + pub(super) fn tab_at(&self, col: u16, row: u16) -> Option<usize> { + self.view + .tab_hit_areas + .iter() + .enumerate() + .find_map(|(idx, area)| { + (area.width > 0 + && row >= area.y + && row < area.y + area.height + && col >= area.x + && col < area.x + area.width) + .then_some(idx) + }) + } + + pub(super) fn on_tab_bar(&self, col: u16, row: u16) -> bool { + let area = self.view.tab_bar_rect; + area.width > 0 + && row >= area.y + && row < area.y + area.height + && col >= area.x + && col < area.x + area.width + } + + pub(super) fn on_tab_scroll_left_button(&self, col: u16, row: u16) -> bool { + let area = self.view.tab_scroll_left_hit_area; + area.width > 0 + && row >= area.y + && row < area.y + area.height + && col >= area.x + && col < area.x + area.width + } + + pub(super) fn on_tab_scroll_right_button(&self, col: u16, row: u16) -> bool { + let area = self.view.tab_scroll_right_hit_area; + area.width > 0 + && row >= area.y + && row < area.y + area.height + && col >= area.x + && col < area.x + area.width + } + + pub(super) fn tab_drop_index_at(&self, col: u16, row: u16) -> Option<usize> { + if !self.on_tab_bar(col, row) { + return None; + } + + let visible_tabs: Vec<_> = self + .view + .tab_hit_areas + .iter() + .enumerate() + .filter(|(_, rect)| rect.width > 0) + .collect(); + let (first_idx, first_rect) = *visible_tabs.first()?; + let (last_idx, last_rect) = *visible_tabs.last()?; + + if self.on_tab_scroll_left_button(col, row) { + return Some(0); + } + if self.on_tab_scroll_right_button(col, row) { + return self + .active + .and_then(|idx| self.workspaces.get(idx)) + .map(|ws| ws.tabs.len()); + } + + let left_edge = if first_idx == 0 { + first_rect.x + } else { + self.view.tab_scroll_left_hit_area.x + self.view.tab_scroll_left_hit_area.width + }; + let right_edge = if self + .active + .and_then(|idx| self.workspaces.get(idx)) + .is_some_and(|ws| last_idx + 1 >= ws.tabs.len()) + { + last_rect.x + last_rect.width + } else { + self.view.tab_scroll_right_hit_area.x.saturating_sub(1) + }; + + if col <= left_edge { + return Some(first_idx); + } + if col >= right_edge { + return Some(last_idx + 1); + } + + for (idx, rect) in visible_tabs { + let midpoint = rect.x + rect.width / 2; + if col < midpoint { + return Some(idx); + } + if col < rect.x + rect.width { + return Some(idx + 1); + } + } + + Some(last_idx + 1) + } + + pub(super) 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 + } + + pub(super) 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 + } + }) + } + + pub(super) 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 + }) + } + + pub(super) fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> { + self.pane_at(col, row) + .or_else(|| self.pane_frame_at(col, row)) + } + + pub(super) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> { + self.view.pane_infos.iter().find(|info| info.id == pane_id) + } + + pub(super) 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 + }) + } + + pub(super) 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); + self.mark_session_dirty(); + } + } + } + + pub(super) 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); + } + } + } + + pub(super) 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); + } + } + } + + pub(super) fn pane_scroll_metrics( + &self, + pane_id: crate::layout::PaneId, + ) -> Option<crate::pane::ScrollMetrics> { + self.active + .and_then(|i| self.workspaces.get(i)) + .and_then(|ws| ws.runtime(pane_id)) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + } + + pub(super) 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), + _ => {} + } + } + } + } + + pub(super) 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 + } + + pub(super) 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 + } + } + } + + pub(super) 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); + } + } + } + + pub(super) 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), + }, + )) + } + } + + pub(super) fn scrollbar_offset_for_pane_row( + &self, + pane_id: crate::layout::PaneId, + row: u16, + grab_row_offset: u16, + ) -> Option<usize> { + 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)] +pub(super) 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 + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; + use ratatui::layout::{Direction, Rect}; + + use super::super::{ + app_for_mouse_test, capture_snapshot, handle_context_menu_key, mouse, root_layout_ratio, + }; + use super::*; + use crate::{ + app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode}, + workspace::Workspace, + }; + + #[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 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 dragging_pane_split_updates_captured_layout_ratio() { + 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.workspaces[0].test_split(Direction::Horizontal); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let border = app.state.view.split_borders[0].clone(); + let before = capture_snapshot(&app.state); + let drag_row = border.area.y.saturating_add(1); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + border.pos, + drag_row, + )); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + border.pos.saturating_add(6), + drag_row, + )); + + let after = capture_snapshot(&app.state); + assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); + } + + #[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); + } +} diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs new file mode 100644 index 00000000..26769dfe --- /dev/null +++ b/src/app/input/navigate.rs @@ -0,0 +1,780 @@ +use std::process::{Command, Stdio}; + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::Direction; + +use crate::{ + app::{ + state::{key_matches, AppState, Mode}, + App, + }, + layout::NavDirection, +}; + +pub(crate) fn terminal_direct_navigation_action( + state: &AppState, + key: &KeyEvent, +) -> Option<NavigateAction> { + 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) fn handle_navigate_key(&mut self, key: KeyEvent) { + self.state.update_dismissed = true; + + if self.state.is_prefix(&key) || key.code == KeyCode::Esc { + leave_navigate_mode(&mut self.state); + return; + } + + if let Some(action) = navigate_action_for_key(&self.state, &key) { + execute_navigate_action(&mut self.state, action); + return; + } + + if handle_navigate_reserved_key(&mut self.state, key) { + return; + } + + if let Some(binding) = navigate_custom_command_for_key(&self.state, &key) { + self.launch_custom_command(binding); + } + } + + fn launch_custom_command(&mut self, binding: crate::config::CustomCommandKeybind) { + let previous_toast = self.state.toast.clone(); + let result = match binding.action { + crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding), + crate::config::CustomCommandAction::Pane => self.spawn_pane_command(&binding.command), + }; + match result { + Ok(()) => leave_navigate_mode(&mut self.state), + Err(err) => { + self.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::NeedsAttention, + title: "custom command failed".to_string(), + context: err.to_string(), + }); + self.sync_toast_deadline(previous_toast); + } + } + } + + fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) { + let mut env = vec![( + crate::api::SOCKET_PATH_ENV_VAR.to_string(), + crate::api::socket_path().display().to_string(), + )]; + if let Ok(current_exe) = std::env::current_exe() { + env.push(( + "HERDR_BIN_PATH".to_string(), + current_exe.display().to_string(), + )); + } + + let mut cwd = None; + if let Some(ws_idx) = self.state.active { + env.push(( + "HERDR_ACTIVE_WORKSPACE_ID".to_string(), + self.public_workspace_id(ws_idx), + )); + if let Some(workspace) = self.state.workspaces.get(ws_idx) { + let tab_idx = workspace.active_tab_index(); + if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { + env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id)); + } + if let Some(pane_id) = workspace.focused_pane_id() { + if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { + env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); + } + if let Some(pane_cwd) = workspace + .active_tab() + .and_then(|tab| tab.cwd_for_pane(pane_id)) + { + env.push(( + "HERDR_ACTIVE_PANE_CWD".to_string(), + pane_cwd.display().to_string(), + )); + if pane_cwd.is_dir() { + cwd = Some(pane_cwd); + } + } + } + } + } + (env, cwd) + } + + fn spawn_custom_command( + &self, + binding: &crate::config::CustomCommandKeybind, + ) -> std::io::Result<()> { + let mut command = Command::new("/bin/sh"); + command + .arg("-lc") + .arg(&binding.command) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let (env, cwd) = self.custom_command_env(); + command.envs(env); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + command.spawn()?; + Ok(()) + } + + fn spawn_pane_command(&mut self, command: &str) -> std::io::Result<()> { + let Some(ws_idx) = self.state.active else { + return Err(std::io::Error::other("no active workspace")); + }; + let (rows, cols) = self.state.estimate_pane_size(); + let new_rows = rows.max(4); + let new_cols = cols.max(10); + let (env, _) = self.custom_command_env(); + + let ws = self + .state + .workspaces + .get_mut(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let tab_idx = ws.active_tab_index(); + let previous_focus = ws + .focused_pane_id() + .ok_or_else(|| std::io::Error::other("no focused pane"))?; + let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); + let cwd = ws + .active_tab() + .and_then(|tab| tab.cwd_for_pane(previous_focus)); + let new_pane_id = ws.split_focused_command( + Direction::Horizontal, + new_rows, + new_cols, + cwd, + command, + &env, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + )?; + ws.active_tab_mut() + .expect("workspace must have an active tab") + .layout + .focus_pane(new_pane_id); + ws.active_tab_mut() + .expect("workspace must have an active tab") + .zoomed = true; + self.overlay_panes.insert( + new_pane_id, + super::super::OverlayPaneState { + ws_idx, + tab_idx, + previous_focus, + previous_zoomed, + }, + ); + self.state.mode = Mode::Terminal; + Ok(()) + } +} + +fn navigate_custom_command_for_key( + state: &AppState, + key: &KeyEvent, +) -> Option<crate::config::CustomCommandKeybind> { + state + .keybinds + .custom_commands + .iter() + .find(|binding| key_matches(key, binding.key.0, binding.key.1)) + .cloned() +} + +pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('q') => { + super::modal::request_quit_or_detach(state); + leave_navigate_mode(state); + true + } + KeyCode::Enter => { + if !state.workspaces.is_empty() { + state.switch_workspace(state.selected); + leave_navigate_mode(state); + } + true + } + 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); + } + true + } + KeyCode::Char('s') => { + super::settings::open_settings(state); + true + } + KeyCode::Char('?') => { + super::modal::open_keybind_help(state); + true + } + KeyCode::Up => { + if state.selected > 0 { + state.selected -= 1; + state.ensure_workspace_visible(state.selected); + } + true + } + KeyCode::Down => { + if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 { + state.selected += 1; + state.ensure_workspace_visible(state.selected); + } + true + } + KeyCode::Char('h') | KeyCode::Left => { + state.navigate_pane(NavDirection::Left); + true + } + KeyCode::Char('j') => { + state.navigate_pane(NavDirection::Down); + true + } + KeyCode::Char('k') => { + state.navigate_pane(NavDirection::Up); + true + } + KeyCode::Char('l') | KeyCode::Right => { + state.navigate_pane(NavDirection::Right); + true + } + KeyCode::Tab => { + state.cycle_pane(false); + true + } + KeyCode::BackTab => { + state.cycle_pane(true); + true + } + _ => false, + } +} + +#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key +pub(crate) 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; + } + + let _ = handle_navigate_reserved_key(state, key); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NavigateAction { + NewWorkspace, + RenameWorkspace, + CloseWorkspace, + PreviousWorkspace, + NextWorkspace, + NewTab, + RenameTab, + PreviousTab, + NextTab, + CloseTab, + FocusPaneLeft, + FocusPaneDown, + FocusPaneUp, + FocusPaneRight, + SplitVertical, + SplitHorizontal, + ClosePane, + Fullscreen, + EnterResizeMode, + ToggleSidebar, + Detach, +} + +fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> { + 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); + } + if kb + .detach + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::Detach); + } + None +} + +pub(super) 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() { + super::modal::open_rename_workspace(state, state.selected); + } + } + NavigateAction::CloseWorkspace => { + if !state.workspaces.is_empty() { + if state.confirm_close { + super::modal::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 => super::modal::open_new_tab_dialog(state), + NavigateAction::RenameTab => super::modal::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); + } + NavigateAction::Detach => { + state.detach_requested = true; + leave_navigate_mode(state); + } + } +} + +fn leave_navigate_mode(state: &mut AppState) { + if state.active.is_some() { + state.mode = Mode::Terminal; + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::layout::{Direction, Rect}; + + use super::super::{state_with_workspaces, unique_temp_path, wait_for_file}; + use super::*; + use crate::{app::App, config::Config, input::TerminalKey, workspace::Workspace}; + + #[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 custom_command_runs_from_prefix_key_in_navigate_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; + + let output_path = unique_temp_path("custom-command-keybind"); + let command = format!( + "printf '%s\\n%s\\n%s\\n' \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'", + output_path.display() + ); + app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { + key: (KeyCode::Char('g'), KeyModifiers::empty()), + label: "g".into(), + command, + action: crate::config::CustomCommandAction::Shell, + }]; + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + assert_eq!(app.state.mode, Mode::Navigate); + + app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) + .await; + + let content = wait_for_file(&output_path); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], app.state.workspaces[0].id); + assert_eq!(lines[1], format!("{}:1", app.state.workspaces[0].id)); + assert_eq!(lines[2], format!("{}-1", app.state.workspaces[0].id)); + assert_eq!(app.state.mode, Mode::Terminal); + + let _ = std::fs::remove_file(output_path); + } + + #[tokio::test] + async fn pane_overlay_command_opens_and_closes_after_exit() { + 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(), + ); + let workspace = Workspace::new( + std::env::current_dir().unwrap_or_else(|_| "/".into()), + 24, + 80, + app.state.pane_scrollback_limit_bytes, + app.state.host_terminal_theme, + app.event_tx.clone(), + app.render_notify.clone(), + app.render_dirty.clone(), + ) + .expect("workspace should spawn"); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let output_path = unique_temp_path("custom-pane-command"); + let command = format!("printf done > '{}'", output_path.display()); + app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { + key: (KeyCode::Char('g'), KeyModifiers::empty()), + label: "g".into(), + command, + action: crate::config::CustomCommandAction::Pane, + }]; + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) + .await; + + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); + assert!(app.state.workspaces[0].tabs[0].zoomed); + + let _ = wait_for_file(&output_path); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + if app.drain_internal_events() + && app.state.workspaces[0].tabs[0].layout.pane_count() == 1 + { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); + assert!(!app.state.workspaces[0].tabs[0].zoomed); + assert_eq!(app.state.mode, Mode::Terminal); + let _ = std::fs::remove_file(output_path); + } + + #[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 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 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 persistence_mode_navigate_q_detaches_instead_of_quitting_server() { + let mut state = crate::app::state::AppState::test_new(); + state.quit_detaches = true; + + assert!(handle_navigate_reserved_key( + &mut state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()) + )); + assert!(state.detach_requested); + assert!(!state.should_quit); + } +} diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs new file mode 100644 index 00000000..6475895e --- /dev/null +++ b/src/app/input/overlays.rs @@ -0,0 +1,483 @@ +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::Rect, + widgets::{Block, Borders}, +}; + +use crate::app::{ + state::{AppState, DragState, DragTarget, Mode}, + App, +}; + +use super::{ + modal::{leave_modal, modal_action_from_buttons, ModalAction}, + ScrollbarClickTarget, +}; + +impl App { + pub(super) fn handle_overlay_mouse(&mut self, mouse: MouseEvent) -> bool { + 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 true; + } + + 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 true; + } + + false + } +} + +impl AppState { + pub(super) fn onboarding_full_area(&self) -> Rect { + self.view.sidebar_rect.union(self.view.terminal_area) + } + + pub(super) fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option<Rect> { + 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 = Rect::new(popup_x, popup_y, popup_w, popup_h); + Some(Block::default().borders(Borders::ALL).inner(popup)) + } + + fn release_notes_modal_inner(&self) -> Option<Rect> { + self.onboarding_modal_inner( + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + ) + } + + 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 + } + + pub(super) fn rename_modal_inner(&self) -> Option<Rect> { + self.onboarding_modal_inner(56, 7) + } + + fn release_notes_body_rect(&self) -> Option<Rect> { + let inner = self.release_notes_modal_inner()?; + if inner.height < 8 || inner.width < 4 { + return None; + } + let body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content; + let preview = self + .release_notes + .as_ref() + .is_some_and(|notes| notes.preview); + Some(crate::ui::release_notes_sections(body, preview).notes_body) + } + + fn release_notes_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> { + 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_display_lines(notes, &self.palette); + + let rows_for_width = |wrap_width: usize| { + lines + .iter() + .map(|(width, _)| width.max(&1).div_ceil(wrap_width.max(1))) + .sum::<usize>() + }; + + 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<ScrollbarClickTarget> { + 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<usize> { + 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; + } + } + + pub(super) 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, + _ => {} + } + } + } + } + } + + pub(super) 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<Rect> { + 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<Rect> { + 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<crate::pane::ScrollMetrics> { + 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::<usize>(); + 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<ScrollbarClickTarget> { + 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<usize> { + 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; + } + + pub(super) 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; + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{MouseButton, MouseEventKind}; + use ratatui::layout::Rect; + + use super::super::{app_for_mouse_test, mouse}; + use super::*; + + #[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 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); + } +} diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs new file mode 100644 index 00000000..953e9b26 --- /dev/null +++ b/src/app/input/selection.rs @@ -0,0 +1,68 @@ +use crossterm::event::{MouseEvent, MouseEventKind}; + +use crate::app::state::AppState; + +impl AppState { + fn update_selection_cursor( + &mut self, + pane_id: crate::layout::PaneId, + screen_col: u16, + screen_row: u16, + ) { + let Some(info) = self.pane_info_by_id(pane_id).cloned() else { + return; + }; + let metrics = self.pane_scroll_metrics(pane_id); + if let Some(selection) = self.selection.as_mut() { + selection.drag(screen_col, screen_row, info.inner_rect, metrics); + } + } + + fn selection_edge_scroll_lines(distance: u16) -> usize { + usize::from(distance).saturating_mul(3).clamp(3, 15) + } + + pub(super) fn update_selection_drag(&mut self, screen_col: u16, screen_row: u16) { + let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else { + return; + }; + let Some(info) = self.pane_info_by_id(pane_id).cloned() else { + return; + }; + + let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1); + if screen_row < info.inner_rect.y { + self.scroll_pane_up( + pane_id, + Self::selection_edge_scroll_lines(info.inner_rect.y - screen_row), + ); + } else if screen_row > bottom { + self.scroll_pane_down( + pane_id, + Self::selection_edge_scroll_lines(screen_row - bottom), + ); + } + + self.update_selection_cursor(pane_id, screen_col, screen_row); + } + + pub(super) fn scroll_selection_with_wheel(&mut self, mouse: MouseEvent) -> bool { + const LINES_PER_NOTCH: usize = 3; + + let Some(selection) = self.selection.as_ref() else { + return false; + }; + if !selection.is_in_progress() { + return false; + } + let pane_id = selection.pane_id; + self.focus_pane(pane_id); + match mouse.kind { + MouseEventKind::ScrollUp => self.scroll_pane_up(pane_id, LINES_PER_NOTCH), + MouseEventKind::ScrollDown => self.scroll_pane_down(pane_id, LINES_PER_NOTCH), + _ => return false, + } + self.update_selection_cursor(pane_id, mouse.column, mouse.row); + true + } +} diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs new file mode 100644 index 00000000..9433b7d8 --- /dev/null +++ b/src/app/input/settings.rs @@ -0,0 +1,352 @@ +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::Rect; + +use crate::app::{ + state::{AppState, SettingsSection, THEME_NAMES}, + App, Mode, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum SettingsAction { + SaveTheme(String), + SaveSound(bool), + SaveToast(bool), +} + +impl App { + pub(crate) 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), + } + } + } +} + +fn normalize_theme_name(name: &str) -> String { + name.to_lowercase().replace([' ', '_'], "-") +} + +fn current_theme_index(theme_name: &str) -> usize { + 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; + + 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; + } + super::modal::leave_modal(state); +} + +fn apply_settings(state: &mut AppState) -> Option<SettingsAction> { + match state.settings.section { + SettingsSection::Theme => { + let theme_name = state.theme_name.clone(); + state.settings.original_palette = None; + state.settings.original_theme = None; + super::modal::leave_modal(state); + Some(SettingsAction::SaveTheme(theme_name)) + } + _ => { + super::modal::leave_modal(state); + None + } + } +} + +pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<SettingsAction> { + 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(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 super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { + Some(super::modal::ModalAction::Apply) => return apply_settings(state), + Some(super::modal::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 super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { + Some(super::modal::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 super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { + Some(super::modal::ModalAction::Close) => cancel_settings(state), + _ => {} + }, + }, + } + + None +} + +pub(super) fn open_settings(state: &mut AppState) { + 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; +} + +impl AppState { + 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<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 + } + + pub(crate) 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<usize> { + 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 { + 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 < THEME_NAMES.len()).then_some(idx) + } + SettingsSection::Sound | SettingsSection::Toast => { + let list_y = area.y + 2; + if row >= list_y && row < list_y + 2 { + Some((row - list_y) as usize) + } else { + None + } + } + } + } + + pub(super) fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> { + 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 super::modal::modal_action_from_buttons( + mouse.column, + mouse.row, + &[ + (apply, super::modal::ModalAction::Apply), + (close, super::modal::ModalAction::Close), + ], + ) { + Some(super::modal::ModalAction::Apply) => apply_settings(self), + Some(super::modal::ModalAction::Close) => { + cancel_settings(self); + None + } + _ => { + cancel_settings(self); + None + } + } + } + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; + + use super::super::{app_for_mouse_test, mouse, state_with_workspaces}; + use super::*; + + #[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 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); + } +} diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs new file mode 100644 index 00000000..34845861 --- /dev/null +++ b/src/app/input/sidebar.rs @@ -0,0 +1,1219 @@ +use ratatui::layout::Rect; + +use crate::app::state::{AppState, Mode}; + +use super::ScrollbarClickTarget; + +impl AppState { + pub(super) 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, self.sidebar_section_split) + } + + pub(super) fn agent_panel_rect(&self) -> Rect { + let sidebar = self.view.sidebar_rect; + if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { + return Rect::default(); + } + let (_, detail_area) = + crate::ui::expanded_sidebar_sections(sidebar, self.sidebar_section_split); + detail_area + } + + pub(super) fn workspace_list_scrollbar_target_at( + &self, + col: u16, + row: u16, + ) -> Option<ScrollbarClickTarget> { + 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), + }) + } + } + + pub(super) fn workspace_list_offset_for_drag_row( + &self, + row: u16, + grab_row_offset: u16, + ) -> Option<usize> { + 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, + )) + } + + pub(super) 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); + } + + pub(super) 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(super) fn agent_panel_scrollbar_target_at( + &self, + col: u16, + row: u16, + ) -> Option<ScrollbarClickTarget> { + let area = self.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(self, area); + let track = crate::ui::agent_panel_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), + }) + } + } + + pub(super) fn agent_panel_offset_for_drag_row( + &self, + row: u16, + grab_row_offset: u16, + ) -> Option<usize> { + let area = self.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(self, area); + let track = crate::ui::agent_panel_scrollbar_rect(self, area)?; + Some(crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + row, + grab_row_offset, + )) + } + + pub(super) fn set_agent_panel_offset_from_bottom(&mut self, offset_from_bottom: usize) { + let area = self.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(self, area); + self.agent_panel_scroll = metrics + .max_offset_from_bottom + .saturating_sub(offset_from_bottom); + } + + pub(super) fn scroll_agent_panel(&mut self, delta: i16) { + let area = self.agent_panel_rect(); + let max_scroll = crate::ui::agent_panel_scroll_metrics(self, area).max_offset_from_bottom; + if delta.is_negative() { + self.agent_panel_scroll = self + .agent_panel_scroll + .saturating_sub(delta.unsigned_abs() as usize); + } else { + self.agent_panel_scroll = self + .agent_panel_scroll + .saturating_add(delta as usize) + .min(max_scroll); + } + } + + 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", "reload keybinds"]; + if self.update_available.is_some() { + labels.push("update ready"); + } else if self.latest_release_notes_available { + labels.push("what's new"); + } + labels.push(if self.quit_detaches { "detach" } else { "quit" }); + 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| { + let extra = if *label == "update ready" { 2 } else { 0 }; + label.chars().count() as u16 + extra + }) + .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) + } + + pub(super) 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 + } + + pub(super) fn on_collapsed_sidebar_toggle(&self, col: u16, row: u16) -> bool { + if !self.sidebar_collapsed { + return false; + } + let rect = crate::ui::collapsed_sidebar_toggle_rect(self.view.sidebar_rect); + rect.width > 0 + && col >= rect.x + && col < rect.x + rect.width + && row >= rect.y + && row < rect.y + rect.height + } + + pub(super) 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); + self.mark_session_dirty(); + } + + pub(super) fn on_sidebar_section_divider(&self, col: u16, row: u16) -> bool { + if self.sidebar_collapsed { + return false; + } + let rect = crate::ui::sidebar_section_divider_rect( + self.view.sidebar_rect, + self.sidebar_section_split, + ); + rect.width > 0 + && col >= rect.x + && col < rect.x + rect.width + && row >= rect.y + && row < rect.y + rect.height + } + + pub(super) fn set_sidebar_section_split(&mut self, row: u16) { + let sidebar = self.view.sidebar_rect; + let content_height = sidebar.height; + if content_height < 6 { + return; + } + let relative_y = row.saturating_sub(sidebar.y); + let ratio = (relative_y as f32) / (content_height as f32); + self.sidebar_section_split = ratio.clamp(0.1, 0.9); + self.mark_session_dirty(); + } + + pub(super) fn workspace_at_row(&self, row: u16) -> Option<usize> { + 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) + }) + } + + pub(super) fn collapsed_workspace_at_row(&self, row: u16) -> Option<usize> { + 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<usize> { + 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 + } + } + + pub(super) 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)) + } + + pub(super) fn workspace_drop_index_at_row(&self, row: u16) -> Option<usize> { + 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) + } + + pub(super) 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, + self.sidebar_section_split, + ); + 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 + } + + pub(super) fn agent_detail_target_at( + &self, + row: u16, + ) -> Option<(usize, usize, crate::layout::PaneId)> { + if self.sidebar_collapsed { + return None; + } + + let detail_area = self.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area); + let body = crate::ui::agent_panel_body_rect( + detail_area, + crate::ui::should_show_scrollbar(metrics), + ); + if body.height < 2 || row < body.y || row >= body.y + body.height { + return None; + } + + let mut row_y = body.y; + for detail in crate::ui::agent_panel_entries(self) + .into_iter() + .skip(self.agent_panel_scroll) + { + if row_y.saturating_add(1) >= body.y + body.height { + break; + } + if row == row_y || row == row_y + 1 { + return Some((detail.ws_idx, detail.tab_idx, detail.pane_id)); + } + row_y = row_y.saturating_add(2); + if row_y < body.y + body.height { + row_y = row_y.saturating_add(1); + } + } + None + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use crossterm::event::{MouseButton, MouseEventKind}; + use ratatui::layout::Rect; + + use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path}; + use crate::{ + app::state::{AgentPanelScope, DragTarget, Mode}, + detect::Agent, + workspace::Workspace, + }; + + #[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 clicking_reload_keybinds_menu_item_requests_reload() { + 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 + 3, + )); + + assert!(app.state.request_reload_keybinds); + assert_eq!(app.state.mode, Mode::Navigate); + } + + #[test] + fn update_pending_menu_surfaces_update_ready_entry() { + 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", + "reload keybinds", + "update ready", + "quit" + ] + ); + assert!(!app.state.should_quit); + } + + #[test] + fn persistence_mode_menu_surfaces_detach_action() { + let mut app = app_for_mouse_test(); + app.state.quit_detaches = 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", "reload keybinds", "detach"] + ); + + let menu = app.state.global_menu_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + menu.x + 2, + menu.y + 4, + )); + + assert!(app.state.detach_requested); + 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", + "reload keybinds", + "what's new", + "quit" + ] + ); + } + + #[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); + let snapshot = capture_snapshot(&app.state); + assert_eq!(snapshot.workspaces[0].active_tab, second_tab); + assert_eq!( + snapshot.workspaces[0].tabs[second_tab].focused, + Some(second_pane.raw()) + ); + } + + #[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; + app.state.agent_panel_scroll = 3; + + let (_, detail_area) = crate::ui::expanded_sidebar_sections( + app.state.view.sidebar_rect, + app.state.sidebar_section_split, + ); + 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); + assert_eq!(app.state.agent_panel_scroll, 0); + let snapshot = capture_snapshot(&app.state); + assert_eq!(snapshot.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.state.sidebar_section_split, + ); + 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 scrolling_agent_panel_with_wheel_updates_agent_panel_scroll() { + 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); + + for (tab_name, agent) in [ + ("logs", Agent::Claude), + ("review", Agent::Codex), + ("ops", Agent::Gemini), + ] { + let tab_idx = ws.test_add_tab(Some(tab_name)); + let pane_id = ws.tabs[tab_idx].root_pane; + ws.tabs[tab_idx] + .panes + .get_mut(&pane_id) + .unwrap() + .detected_agent = Some(agent); + } + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let detail_area = app.state.agent_panel_rect(); + assert!(crate::ui::should_show_scrollbar( + crate::ui::agent_panel_scroll_metrics(&app.state, detail_area) + )); + + app.handle_mouse(mouse( + MouseEventKind::ScrollDown, + detail_area.x + 1, + detail_area.y + 4, + )); + + assert_eq!(app.state.agent_panel_scroll, 1); + assert_eq!(app.state.selected, 0); + } + + #[test] + fn clicking_scrolled_agent_detail_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); + + for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] { + let tab_idx = ws.test_add_tab(Some(tab_name)); + let pane_id = ws.tabs[tab_idx].root_pane; + ws.tabs[tab_idx] + .panes + .get_mut(&pane_id) + .unwrap() + .detected_agent = Some(agent); + } + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.agent_panel_scroll = 1; + + let detail_area = app.state.agent_panel_rect(); + let body = crate::ui::agent_panel_body_rect(detail_area, true); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + body.x + 1, + body.y, + )); + + assert_eq!(app.state.workspaces[0].active_tab, second_tab); + assert_eq!( + app.state.workspaces[0].tabs[second_tab].layout.focused(), + second_pane + ); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[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_collapsed_sidebar_toggle_expands_sidebar() { + let mut app = app_for_mouse_test(); + 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 toggle = crate::ui::collapsed_sidebar_toggle_rect(app.state.view.sidebar_rect); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + toggle.x, + toggle.y, + )); + + assert!(!app.state.sidebar_collapsed); + } + + #[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()); + let snapshot = capture_snapshot(&app.state); + assert_eq!(snapshot.active, Some(1)); + assert_eq!(snapshot.selected, 1); + } + + #[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); + let snapshot = capture_snapshot(&app.state); + let captured_names: Vec<_> = snapshot + .workspaces + .iter() + .map(|ws| ws.custom_name.clone().unwrap()) + .collect(); + assert_eq!(captured_names, vec!["b", "a", "c"]); + } + + #[test] + fn clicking_tab_scroll_button_reveals_hidden_tabs_without_renaming() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + ws.test_add_tab(Some("logs")); + ws.test_add_tab(Some("review")); + ws.test_add_tab(Some("ops")); + ws.test_add_tab(Some("notes")); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20)); + + let right = app.state.view.tab_scroll_right_hit_area; + assert!(right.width > 0); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + right.x + 1, + right.y, + )); + + assert_eq!(app.state.tab_scroll, 1); + assert!(!app.state.tab_scroll_follow_active); + assert_eq!(app.state.workspaces[0].active_tab, 0); + assert_eq!(app.state.view.tab_hit_areas[0].width, 0); + assert!(app.state.workspaces[0].tabs[0].custom_name.is_none()); + assert_eq!( + app.state.workspaces[0].tabs[1].custom_name.as_deref(), + Some("logs") + ); + } + + #[test] + fn clicking_last_visible_tab_at_right_edge_does_not_overscroll() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + for name in [ + "one", "two", "three", "four", "five", "six", "seven", "eight", + ] { + ws.test_add_tab(Some(name)); + } + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.tab_scroll = usize::MAX; + app.state.tab_scroll_follow_active = false; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20)); + + let last_idx = app.state.workspaces[0].tabs.len() - 1; + let target = app.state.view.tab_hit_areas[last_idx]; + let clamped_scroll = app.state.tab_scroll; + assert!(target.width > 0, "last tab should already be visible"); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + target.x + 1, + target.y, + )); + app.handle_mouse(mouse( + MouseEventKind::Up(MouseButton::Left), + target.x + 1, + target.y, + )); + + assert_eq!(app.state.workspaces[0].active_tab, last_idx); + assert_eq!(app.state.tab_scroll, clamped_scroll); + assert!(app.state.view.tab_hit_areas[last_idx].width > 0); + } + + #[test] + fn dragging_tab_reorders_auto_and_custom_names_without_materializing_numbers() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + ws.test_add_tab(Some("foo")); + ws.test_add_tab(None); + let moved_root = ws.tabs[0].root_pane; + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + + let source = app.state.view.tab_hit_areas[0]; + let last = app.state.view.tab_hit_areas[2]; + let drop_col = last.x + last.width; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + source.x + 1, + source.y, + )); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + drop_col, + source.y, + )); + assert!(matches!( + app.state.drag.as_ref().map(|drag| &drag.target), + Some(DragTarget::TabReorder { + ws_idx: 0, + source_tab_idx: 0, + insert_idx: Some(3), + }) + )); + app.handle_mouse(mouse( + MouseEventKind::Up(MouseButton::Left), + drop_col, + source.y, + )); + + let labels: Vec<_> = app.state.workspaces[0] + .tabs + .iter() + .map(|tab| tab.display_name()) + .collect(); + assert_eq!(labels, vec!["foo", "2", "3"]); + assert_eq!( + app.state.workspaces[0].tabs[0].custom_name.as_deref(), + Some("foo") + ); + assert!(app.state.workspaces[0].tabs[1].custom_name.is_none()); + assert!(app.state.workspaces[0].tabs[2].custom_name.is_none()); + assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root); + assert_eq!(app.state.workspaces[0].active_tab, 2); + } + + fn temp_git_repo(branch: &str) -> std::path::PathBuf { + let repo = unique_temp_path("sidebar-drop-slot-repo"); + fs::create_dir_all(repo.join(".git")).unwrap(); + fs::write( + repo.join(".git/HEAD"), + format!("ref: refs/heads/{branch}\n"), + ) + .unwrap(); + repo + } + + #[test] + fn top_drop_slot_is_distinct_from_gap_below_first_workspace() { + let mut app = app_for_mouse_test(); + let first_repo = temp_git_repo("main"); + let second_repo = temp_git_repo("main"); + + let mut first = Workspace::test_new("a"); + let first_root = first.tabs[0].root_pane; + first.identity_cwd = first_repo.clone(); + first.tabs[0] + .pane_cwds + .insert(first_root, first_repo.clone()); + + let mut second = Workspace::test_new("b"); + let second_root = second.tabs[0].root_pane; + second.identity_cwd = second_repo.clone(); + second.tabs[0] + .pane_cwds + .insert(second_root, second_repo.clone()); + + app.state.workspaces = vec![first, second]; + 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)); + + let _ = fs::remove_dir_all(first_repo); + let _ = fs::remove_dir_all(second_repo); + } + + #[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); + let snapshot = capture_snapshot(&app.state); + assert_eq!(snapshot.sidebar_width, Some(31)); + } + + #[test] + fn dragging_sidebar_section_divider_sets_split_ratio() { + let mut app = app_for_mouse_test(); + let divider = crate::ui::sidebar_section_divider_rect( + app.state.view.sidebar_rect, + app.state.sidebar_section_split, + ); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + divider.x + 1, + divider.y, + )); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + divider.x + 1, + divider.y + 4, + )); + + assert!(app.state.sidebar_section_split > 0.5); + let snapshot = capture_snapshot(&app.state); + assert_eq!( + snapshot.sidebar_section_split, + Some(app.state.sidebar_section_split) + ); + } + + #[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()); + let snapshot = capture_snapshot(&app.state); + assert_eq!(snapshot.sidebar_width, Some(26)); + } +} diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs new file mode 100644 index 00000000..910a49bd --- /dev/null +++ b/src/app/input/terminal.rs @@ -0,0 +1,440 @@ +use bytes::Bytes; +use crossterm::event::KeyCode; +use tracing::{debug, warn}; + +use crate::{ + app::{App, Mode}, + input::TerminalKey, +}; + +struct PreparedPaneInput { + ws_idx: usize, + pane_id: crate::layout::PaneId, + bytes: Bytes, +} + +fn is_modifier_only_key(code: &KeyCode) -> bool { + matches!(code, KeyCode::Modifier(_)) +} + +impl App { + pub(crate) fn handle_terminal_key_headless(&mut self, key: TerminalKey) { + let Some(input) = self.prepare_terminal_key_forward(key) else { + return; + }; + if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) { + let _ = runtime.try_send_bytes(input.bytes); + } + } + + fn prepare_terminal_key_forward(&mut self, key: TerminalKey) -> Option<PreparedPaneInput> { + self.state.clear_selection(); + self.state.update_dismissed = true; + + let key_event = key.as_key_event(); + + if let Some(action) = super::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" + ); + super::navigate::execute_navigate_action(&mut self.state, action); + return None; + } + + if self.state.is_prefix(&key_event) { + self.state.mode = Mode::Navigate; + return None; + } + + 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 None; + } + + let ws_idx = self.state.active?; + let ws = self.state.workspaces.get(ws_idx)?; + let pane_id = ws.focused_pane_id()?; + let rt = ws.runtimes.get(&pane_id)?; + 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"); + } + return None; + } + + Some(PreparedPaneInput { + ws_idx, + pane_id, + bytes: Bytes::from(bytes), + }) + } + + pub(super) async fn handle_terminal_key(&mut self, key: TerminalKey) { + let Some(input) = self.prepare_terminal_key_forward(key) else { + return; + }; + if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) { + let _ = runtime.send_bytes(input.bytes).await; + } + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind}; + use ratatui::layout::Rect; + + use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes}; + use super::*; + use crate::{config::Config, workspace::Workspace}; + + #[tokio::test] + async fn dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); + let info = pane_infos[0].clone(); + ws.tabs[0].runtimes.insert( + pane_id, + crate::pane::PaneRuntime::test_with_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 16 * 1024, + &numbered_lines_bytes(64), + ), + ); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + let start_metrics = app.state.workspaces[0] + .runtime(pane_id) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + .expect("initial scroll metrics"); + let start_row = info.inner_rect.y; + let start_col = info.inner_rect.x + 2; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + start_col, + start_row, + )); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + start_col, + info.inner_rect.y.saturating_sub(1), + )); + + let end_metrics = app.state.workspaces[0] + .runtime(pane_id) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + .expect("scroll metrics after drag"); + assert_eq!( + end_metrics.offset_from_bottom, + start_metrics.offset_from_bottom + 3 + ); + + let selection = app.state.selection.as_ref().expect("selection after drag"); + assert!(selection.is_visible()); + assert_eq!( + selection.ordered_cells(), + ( + ( + (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, + 2, + ), + (start_metrics.max_offset_from_bottom as u32, 2), + ) + ); + } + + #[tokio::test] + async fn releasing_dragged_selection_clears_highlight_after_copy() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); + let info = pane_infos[0].clone(); + ws.tabs[0].runtimes.insert( + pane_id, + crate::pane::PaneRuntime::test_with_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 16 * 1024, + &numbered_lines_bytes(64), + ), + ); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + let row = info.inner_rect.y; + let start_col = info.inner_rect.x + 1; + let end_col = info.inner_rect.x + 4; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + start_col, + row, + )); + app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); + assert!(app.state.selection.is_some()); + + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); + + assert!(app.state.selection.is_none()); + } + + #[tokio::test] + async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); + let info = pane_infos[0].clone(); + ws.tabs[0].runtimes.insert( + pane_id, + crate::pane::PaneRuntime::test_with_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 16 * 1024, + &numbered_lines_bytes(64), + ), + ); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + let start_metrics = app.state.workspaces[0] + .runtime(pane_id) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + .expect("initial scroll metrics"); + let top_row = info.inner_rect.y; + let col = info.inner_rect.x + 2; + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row)); + app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row)); + + let end_metrics = app.state.workspaces[0] + .runtime(pane_id) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + .expect("scroll metrics after wheel"); + assert_eq!( + end_metrics.offset_from_bottom, + start_metrics.offset_from_bottom + 3 + ); + + let selection = app.state.selection.as_ref().expect("selection after wheel"); + assert!(selection.is_visible()); + assert_eq!( + selection.ordered_cells(), + ( + ( + (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, + 2, + ), + (start_metrics.max_offset_from_bottom as u32, 2), + ) + ); + } + + #[tokio::test] + async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_left_button() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let first_pane = ws.tabs[0].root_pane; + let second_pane = ws.test_split(ratatui::layout::Direction::Vertical); + + let terminal_area = Rect::new(26, 2, 80, 18); + let pane_infos = ws.tabs[0].layout.panes(terminal_area); + let first_info = pane_infos + .iter() + .find(|p| p.id == first_pane) + .unwrap() + .clone(); + let second_info = pane_infos + .iter() + .find(|p| p.id == second_pane) + .unwrap() + .clone(); + + ws.tabs[0].runtimes.insert( + first_pane, + crate::pane::PaneRuntime::test_with_screen_bytes( + first_info.inner_rect.width.max(1), + first_info.inner_rect.height.max(1), + b"", + ), + ); + ws.tabs[0].runtimes.insert( + second_pane, + crate::pane::PaneRuntime::test_with_screen_bytes( + second_info.inner_rect.width.max(1), + second_info.inner_rect.height.max(1), + b"\x1b[?1002h", + ), + ); + + ws.tabs[0].layout.focus_pane(first_pane); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + second_info.inner_rect.x + 2, + second_info.inner_rect.y + 2, + )); + + assert_eq!( + app.state.workspaces[0].tabs[0].layout.focused(), + second_pane + ); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[tokio::test] + async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_right_button() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let first_pane = ws.tabs[0].root_pane; + let second_pane = ws.test_split(ratatui::layout::Direction::Vertical); + + let terminal_area = Rect::new(26, 2, 80, 18); + let pane_infos = ws.tabs[0].layout.panes(terminal_area); + let first_info = pane_infos + .iter() + .find(|p| p.id == first_pane) + .unwrap() + .clone(); + let second_info = pane_infos + .iter() + .find(|p| p.id == second_pane) + .unwrap() + .clone(); + + ws.tabs[0].runtimes.insert( + first_pane, + crate::pane::PaneRuntime::test_with_screen_bytes( + first_info.inner_rect.width.max(1), + first_info.inner_rect.height.max(1), + b"", + ), + ); + ws.tabs[0].runtimes.insert( + second_pane, + crate::pane::PaneRuntime::test_with_screen_bytes( + second_info.inner_rect.width.max(1), + second_info.inner_rect.height.max(1), + b"\x1b[?1002h", + ), + ); + + ws.tabs[0].layout.focus_pane(first_pane); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + second_info.inner_rect.x + 2, + second_info.inner_rect.y + 2, + )); + + assert_eq!( + app.state.workspaces[0].tabs[0].layout.focused(), + second_pane + ); + assert_eq!(app.state.mode, Mode::ContextMenu); + assert!(app.state.context_menu.is_some()); + } + + #[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(ratatui::layout::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); + } +} |
