From 290a57e3331bb25dcd16284099bf1dc6afa68ee5 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Thu, 2 Apr 2026 23:13:47 +0300 Subject: feat: add direct pane navigation keybinds --- src/app/input.rs | 292 +++++++++++++++++++++++++++++++++++++++++++++++-------- src/app/mod.rs | 1 + src/app/state.rs | 17 ++++ 3 files changed, 270 insertions(+), 40 deletions(-) (limited to 'src/app') diff --git a/src/app/input.rs b/src/app/input.rs index d3a94596..d8ac86c3 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -59,6 +59,30 @@ fn terminal_direct_navigation_action(state: &AppState, key: &KeyEvent) -> Option { 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 } @@ -228,6 +252,66 @@ impl App { return; } + if self.state.mode == Mode::KeybindHelp { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) + if self + .state + .keybind_help_close_button_at(mouse.column, mouse.row) => + { + leave_modal(&mut self.state); + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some(target) = self + .state + .keybind_help_scrollbar_target_at(mouse.column, mouse.row) + { + match target { + ScrollbarClickTarget::Thumb { grab_row_offset } => { + self.state.drag = Some(DragState { + target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, + }); + } + ScrollbarClickTarget::Track { offset_from_bottom } => { + self.state + .set_keybind_help_offset_from_bottom(offset_from_bottom); + } + } + } else { + let rect = self.state.keybind_help_popup_rect(); + let inside = mouse.column >= rect.x + && mouse.column < rect.x + rect.width + && mouse.row >= rect.y + && mouse.row < rect.y + rect.height; + if !inside { + leave_modal(&mut self.state); + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(DragState { + target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, + }) = &self.state.drag + { + if let Some(offset_from_bottom) = self + .state + .keybind_help_offset_for_drag_row(mouse.row, *grab_row_offset) + { + self.state + .set_keybind_help_offset_from_bottom(offset_from_bottom); + } + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.state.drag = None; + } + MouseEventKind::ScrollUp => self.state.scroll_keybind_help(-3), + MouseEventKind::ScrollDown => self.state.scroll_keybind_help(3), + _ => {} + } + return; + } + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && self.state.on_sidebar_divider(mouse.column, mouse.row) { @@ -512,6 +596,7 @@ fn open_global_menu(state: &mut AppState) { } fn open_keybind_help(state: &mut AppState) { + state.keybind_help.scroll = 0; state.mode = Mode::KeybindHelp; } @@ -539,6 +624,12 @@ fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) { fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) { match key.code { + KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1), + KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1), + KeyCode::PageUp => state.scroll_keybind_help(-8), + KeyCode::PageDown => state.scroll_keybind_help(8), + KeyCode::Home => state.keybind_help.scroll = 0, + KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('?') => { leave_modal(state) } @@ -598,7 +689,7 @@ fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum NavigateAction { NewWorkspace, RenameWorkspace, @@ -610,6 +701,10 @@ enum NavigateAction { PreviousTab, NextTab, CloseTab, + FocusPaneLeft, + FocusPaneDown, + FocusPaneUp, + FocusPaneRight, SplitVertical, SplitHorizontal, ClosePane, @@ -743,6 +838,10 @@ fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { 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); @@ -1294,14 +1393,108 @@ impl AppState { GlobalMenuAction::ALL.get(idx).copied() } - pub(crate) fn keybind_help_rect(&self) -> Rect { - let area = self.screen_rect(); - let launcher = self.global_launcher_rect(); - let popup_w = 54u16.min(area.width.saturating_sub(2).max(1)); - let popup_h = 24u16.min(area.height.saturating_sub(2).max(1)); - let x = area.x + area.width.saturating_sub(popup_w); - let y = launcher.y.saturating_sub(popup_h); - Rect::new(x, y, popup_w, popup_h) + pub(crate) fn keybind_help_popup_rect(&self) -> Rect { + crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default() + } + + fn keybind_help_modal_inner(&self) -> Option { + self.onboarding_modal_inner(76, 22) + } + + fn keybind_help_close_button_at(&self, col: u16, row: u16) -> bool { + let Some(inner) = self.keybind_help_modal_inner() else { + return false; + }; + if inner.height < 4 || inner.width < 12 { + return false; + } + let button = + crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); + col >= button.x + && col < button.x + button.width + && row >= button.y + && row < button.y + button.height + } + + fn keybind_help_body_rect(&self) -> Option { + let inner = self.keybind_help_modal_inner()?; + if inner.height < 6 || inner.width < 4 { + return None; + } + let rows = ratatui::layout::Layout::vertical([ + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Min(1), + ratatui::layout::Constraint::Length(1), + ]) + .areas::<4>(inner); + Some(rows[2]) + } + + fn keybind_help_scroll_metrics(&self) -> Option { + let body = self.keybind_help_body_rect()?; + let viewport_rows = body.height.max(1) as usize; + let wrap_width = body.width.max(1) as usize; + let total_rows = crate::ui::keybind_help_lines(self) + .into_iter() + .map(|(width, _)| width.max(1).div_ceil(wrap_width)) + .sum::(); + let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); + Some(crate::pane::ScrollMetrics { + offset_from_bottom: max_offset_from_bottom + .saturating_sub(self.keybind_help.scroll as usize), + max_offset_from_bottom, + viewport_rows, + }) + } + + fn keybind_help_scrollbar_target_at(&self, col: u16, row: u16) -> Option { + let body = self.keybind_help_body_rect()?; + let metrics = self.keybind_help_scroll_metrics()?; + let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; + if !(col >= track.x + && col < track.x + track.width + && row >= track.y + && row < track.y + track.height) + { + return None; + } + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { + Some(ScrollbarClickTarget::Thumb { grab_row_offset }) + } else { + Some(ScrollbarClickTarget::Track { + offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), + }) + } + } + + fn keybind_help_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option { + let body = self.keybind_help_body_rect()?; + let metrics = self.keybind_help_scroll_metrics()?; + let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; + Some(crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + row, + grab_row_offset, + )) + } + + pub(crate) fn keybind_help_max_scroll(&self) -> u16 { + self.keybind_help_scroll_metrics() + .map(|metrics| metrics.max_offset_from_bottom as u16) + .unwrap_or(0) + } + + fn set_keybind_help_offset_from_bottom(&mut self, offset_from_bottom: usize) { + let max_scroll = self.keybind_help_max_scroll() as usize; + self.keybind_help.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; + } + + fn scroll_keybind_help(&mut self, delta: i16) { + let max_scroll = self.keybind_help_max_scroll(); + let current = self.keybind_help.scroll as i16; + self.keybind_help.scroll = current.saturating_add(delta).clamp(0, max_scroll as i16) as u16; } fn settings_popup_rect(&self) -> Rect { @@ -1488,34 +1681,6 @@ impl AppState { } if self.mode == Mode::KeybindHelp { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - let rect = self.keybind_help_rect(); - let inside = mouse.column >= rect.x - && mouse.column < rect.x + rect.width - && mouse.row >= rect.y - && mouse.row < rect.y + rect.height; - let inner = Rect::new( - rect.x + 1, - rect.y + 1, - rect.width.saturating_sub(2), - rect.height.saturating_sub(2), - ); - let close = crate::ui::keybind_help_close_button_rect(Rect::new( - inner.x, - inner.y, - inner.width, - 1, - )); - if modal_action_from_buttons( - mouse.column, - mouse.row, - &[(close, ModalAction::Close)], - ) == Some(ModalAction::Close) - || !inside - { - leave_modal(self); - } - } return None; } @@ -1739,7 +1904,8 @@ impl AppState { self.sidebar_width_auto = false; self.set_manual_sidebar_width(mouse.column); } - DragTarget::ReleaseNotesScrollbar { .. } => {} + DragTarget::ReleaseNotesScrollbar { .. } + | DragTarget::KeybindHelpScrollbar { .. } => {} } } else if let Some(sel) = &mut self.selection { sel.drag(mouse.column, mouse.row); @@ -2309,6 +2475,52 @@ mod tests { assert_eq!(state.mode, Mode::Navigate); } + #[test] + fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.focus_pane_left = Some((KeyCode::Left, KeyModifiers::ALT)); + state.keybinds.focus_pane_left_label = Some("alt+left".into()); + + let action = terminal_direct_navigation_action( + &state, + &KeyEvent::new(KeyCode::Left, KeyModifiers::ALT), + ); + + assert_eq!(action, Some(NavigateAction::FocusPaneLeft)); + } + + #[tokio::test] + async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.workspaces[0].test_split(Direction::Horizontal); + app.state.view.pane_infos = app.state.workspaces[0] + .active_tab() + .unwrap() + .layout + .panes(Rect::new(0, 0, 80, 24)); + let focused_before = app.state.workspaces[0].layout.focused(); + app.state.keybinds.focus_pane_left = Some((KeyCode::Char('h'), KeyModifiers::ALT)); + app.state.keybinds.focus_pane_left_label = Some("alt+h".into()); + + app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT)) + .await; + + assert_ne!(app.state.workspaces[0].layout.focused(), focused_before); + assert_eq!(app.state.mode, Mode::Terminal); + } + #[test] fn fullscreen_action_exits_navigate_mode() { let mut state = state_with_workspaces(&["test"]); @@ -2528,7 +2740,7 @@ mod tests { let mut app = app_for_mouse_test(); app.state.mode = Mode::KeybindHelp; - let rect = app.state.keybind_help_rect(); + let rect = app.state.keybind_help_popup_rect(); let inner = Rect::new( rect.x + 1, rect.y + 1, @@ -2536,7 +2748,7 @@ mod tests { rect.height.saturating_sub(2), ); let close = - crate::ui::keybind_help_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); + 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, diff --git a/src/app/mod.rs b/src/app/mod.rs index 5cc29e3a..5f5d5072 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -197,6 +197,7 @@ impl App { scroll: 0, preview: notes.preview, }), + keybind_help: state::KeybindHelpState { scroll: 0 }, view: state::ViewState { sidebar_rect: Rect::default(), tab_bar_rect: Rect::default(), diff --git a/src/app/state.rs b/src/app/state.rs index 91e9061e..dee805cd 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -461,6 +461,9 @@ pub(crate) enum DragTarget { ReleaseNotesScrollbar { grab_row_offset: u16, }, + KeybindHelpScrollbar { + grab_row_offset: u16, + }, SidebarDivider, } @@ -520,6 +523,10 @@ pub struct ReleaseNotesState { pub preview: bool, } +pub struct KeybindHelpState { + pub scroll: u16, +} + /// All application state — pure data, no channels or async runtime. /// Testable without PTYs or a tokio runtime. pub struct AppState { @@ -535,6 +542,7 @@ pub struct AppState { pub onboarding_step: usize, pub onboarding_list: SelectionListState, pub release_notes: Option, + pub keybind_help: KeybindHelpState, // View geometry (computed before render, consumed by render + mouse) pub view: ViewState, pub(crate) drag: Option, @@ -627,6 +635,7 @@ impl AppState { onboarding_step: 0, onboarding_list: SelectionListState::new(1), release_notes: None, + keybind_help: KeybindHelpState { scroll: 0 }, view: ViewState { sidebar_rect: Rect::default(), tab_bar_rect: Rect::default(), @@ -676,6 +685,14 @@ impl AppState { next_tab_label: None, close_tab: None, close_tab_label: None, + focus_pane_left: None, + focus_pane_left_label: None, + focus_pane_down: None, + focus_pane_down_label: None, + focus_pane_up: None, + focus_pane_up_label: None, + focus_pane_right: None, + focus_pane_right_label: None, split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()), split_vertical_label: "v".into(), split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()), -- cgit v1.2.3