aboutsummaryrefslogtreecommitdiff
path: root/src/app/input.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-02 23:13:47 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-02 23:25:10 +0300
commit290a57e3331bb25dcd16284099bf1dc6afa68ee5 (patch)
tree8c65e20a815e15dd692ee4c5a5e547c172f148f3 /src/app/input.rs
parent1eb7dac77e7b62ece76c43d75a349580f936b2c6 (diff)
feat: add direct pane navigation keybinds
Diffstat (limited to 'src/app/input.rs')
-rw-r--r--src/app/input.rs292
1 files changed, 252 insertions, 40 deletions
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<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;
+ }
+ 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<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;
+ }
+
+ 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);
@@ -2310,6 +2476,52 @@ mod tests {
}
#[test]
+ fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.focus_pane_left = Some((KeyCode::Left, KeyModifiers::ALT));
+ state.keybinds.focus_pane_left_label = Some("alt+left".into());
+
+ let action = terminal_direct_navigation_action(
+ &state,
+ &KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
+ );
+
+ assert_eq!(action, Some(NavigateAction::FocusPaneLeft));
+ }
+
+ #[tokio::test]
+ async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.workspaces[0].test_split(Direction::Horizontal);
+ app.state.view.pane_infos = app.state.workspaces[0]
+ .active_tab()
+ .unwrap()
+ .layout
+ .panes(Rect::new(0, 0, 80, 24));
+ let focused_before = app.state.workspaces[0].layout.focused();
+ app.state.keybinds.focus_pane_left = Some((KeyCode::Char('h'), KeyModifiers::ALT));
+ app.state.keybinds.focus_pane_left_label = Some("alt+h".into());
+
+ app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT))
+ .await;
+
+ assert_ne!(app.state.workspaces[0].layout.focused(), focused_before);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[test]
fn fullscreen_action_exits_navigate_mode() {
let mut state = state_with_workspaces(&["test"]);
state.workspaces[0].test_split(Direction::Horizontal);
@@ -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,