diff options
Diffstat (limited to 'src/app/input/mod.rs')
| -rw-r--r-- | src/app/input/mod.rs | 321 |
1 files changed, 321 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()); +} |
