diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-24 02:57:03 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-24 02:59:39 +0300 |
| commit | 80a314a9089b28c1bfdc2cdf5196e9a2eb9f11ba (patch) | |
| tree | fca593cafa027f4283adcec80c2f8908ef79708e /src/app/input | |
| parent | a132a7c3b9ade3ad4636e03b1f0930b5fa711766 (diff) | |
feat: add session navigator
refs #157
Diffstat (limited to 'src/app/input')
| -rw-r--r-- | src/app/input/mod.rs | 3 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 119 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 27 | ||||
| -rw-r--r-- | src/app/input/overlays.rs | 143 |
4 files changed, 283 insertions, 9 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index ef8b93f3..44491631 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -34,7 +34,7 @@ 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, + handle_keybind_help_key, handle_navigator_key, handle_rename_key, handle_resize_key, }, navigate::terminal_direct_navigation_action, settings::open_settings_at, @@ -83,6 +83,7 @@ impl App { Mode::Settings => self.handle_settings_key(key_event), Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event), Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key_event), + Mode::Navigator => handle_navigator_key(&mut self.state, key_event), Mode::Terminal => unreachable!(), } } diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index acd481b8..6821e64a 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -2,7 +2,9 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::layout::{Direction, Rect}; use crate::{ - app::state::{AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode}, + app::state::{ + AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode, NavigatorStateFilter, + }, input::TerminalKey, layout::NavDirection, }; @@ -150,6 +152,121 @@ pub(crate) fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) { } } +pub(crate) fn handle_navigator_key(state: &mut AppState, key: KeyEvent) { + if state.navigator.search_focused { + match key.code { + KeyCode::Esc => { + if state.navigator.query.is_empty() { + state.navigator.search_focused = false; + leave_modal(state); + } else { + state.navigator.query.clear(); + state.navigator.state_filter = None; + state.navigator.search_focused = false; + state.clamp_navigator_selection(); + } + } + KeyCode::Enter => { + state.accept_navigator_selection(); + } + KeyCode::Backspace => { + state.navigator.state_filter = None; + state.navigator.query.pop(); + state.clamp_navigator_selection(); + } + KeyCode::Up => state.move_navigator_selection(-1), + KeyCode::Down => state.move_navigator_selection(1), + KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => { + state.move_navigator_selection(1) + } + KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => { + state.move_navigator_selection(-1) + } + KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => { + state.navigator.query.clear(); + state.navigator.state_filter = None; + state.clamp_navigator_selection(); + } + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + state.navigator.state_filter = None; + state.navigator.query.push(c); + state.clamp_navigator_selection(); + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Esc => { + if state.navigator.query.is_empty() && state.navigator.state_filter.is_none() { + leave_modal(state); + } else { + state.navigator.query.clear(); + state.navigator.state_filter = None; + state.clamp_navigator_selection(); + } + } + KeyCode::Enter => { + state.accept_navigator_selection(); + } + KeyCode::Char('/') => { + state.navigator.query.clear(); + state.navigator.state_filter = None; + state.navigator.search_focused = true; + state.clamp_navigator_selection(); + } + KeyCode::Backspace if state.navigator.state_filter.is_some() => { + state.navigator.state_filter = None; + state.clamp_navigator_selection(); + } + KeyCode::Char('a') if key.modifiers.is_empty() => { + state.navigator.query.clear(); + state.navigator.state_filter = None; + state.clamp_navigator_selection(); + } + KeyCode::Char('b') if key.modifiers.is_empty() => { + state.navigator.query.clear(); + state.navigator.state_filter = Some(NavigatorStateFilter::Blocked); + state.clamp_navigator_selection(); + } + KeyCode::Char('w') if key.modifiers.is_empty() => { + state.navigator.query.clear(); + state.navigator.state_filter = Some(NavigatorStateFilter::Working); + state.clamp_navigator_selection(); + } + KeyCode::Char('i') if key.modifiers.is_empty() => { + state.navigator.query.clear(); + state.navigator.state_filter = Some(NavigatorStateFilter::Idle); + state.clamp_navigator_selection(); + } + KeyCode::Char('d') if key.modifiers.is_empty() => { + state.navigator.query.clear(); + state.navigator.state_filter = Some(NavigatorStateFilter::Done); + state.clamp_navigator_selection(); + } + KeyCode::Char('j') | KeyCode::Down => state.move_navigator_selection(1), + KeyCode::Char('k') | KeyCode::Up => state.move_navigator_selection(-1), + KeyCode::Char('d') if key.modifiers == KeyModifiers::CONTROL => { + state.move_navigator_selection((state.navigator_body_rect().height / 2).max(1) as isize) + } + KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => state + .move_navigator_selection(-((state.navigator_body_rect().height / 2).max(1) as isize)), + KeyCode::Char(' ') => state.toggle_selected_navigator_workspace(), + KeyCode::Home => { + state.navigator.selected = 0; + state.ensure_navigator_selection_visible(); + } + KeyCode::End | KeyCode::Char('G') => { + state.navigator.selected = state.navigator_rows().len().saturating_sub(1); + state.ensure_navigator_selection_visible(); + } + _ => {} + } +} + 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), diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index da3d49bf..4e8a50b3 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -490,6 +490,7 @@ pub(crate) enum NavigateAction { ReloadConfig, OpenNotificationTarget, Detach, + OpenNavigator, } fn indexed_navigation_action( @@ -588,6 +589,7 @@ fn action_for_key( NavigateAction::OpenNotificationTarget, ), (&kb.detach, NavigateAction::Detach), + (&kb.goto, NavigateAction::OpenNavigator), ] { if action_matches(bindings, key, dispatch) { return Some(action); @@ -780,6 +782,7 @@ pub(super) fn execute_navigate_action_in_context( super::modal::request_detach(state); leave_navigate_mode(state); } + NavigateAction::OpenNavigator => state.open_navigator(), } finish_action_context(state, context, previous_mode); @@ -930,6 +933,18 @@ mod tests { } #[test] + fn default_goto_key_opens_navigator() { + let mut state = state_with_workspaces(&["test"]); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Navigator); + } + + #[test] fn custom_rename_key_enters_rename_mode() { let mut state = state_with_workspaces(&["test"]); state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g"); @@ -1502,8 +1517,8 @@ mod tests { output_path.display() ); app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("g"), - label: "prefix+g".into(), + bindings: crate::config::ActionKeybinds::prefix("m"), + label: "prefix+m".into(), command, action: crate::config::CustomCommandAction::Shell, }]; @@ -1515,7 +1530,7 @@ mod tests { .await; assert_eq!(app.state.mode, Mode::Prefix); - app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) + app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty())) .await; let content = wait_for_file(&output_path); @@ -1561,8 +1576,8 @@ mod tests { 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 { - bindings: crate::config::ActionKeybinds::prefix("g"), - label: "prefix+g".into(), + bindings: crate::config::ActionKeybinds::prefix("m"), + label: "prefix+m".into(), command, action: crate::config::CustomCommandAction::Pane, }]; @@ -1572,7 +1587,7 @@ mod tests { app.state.prefix_mods, )) .await; - app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) + app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty())) .await; assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs index 365de6b6..7d2a8ff3 100644 --- a/src/app/input/overlays.rs +++ b/src/app/input/overlays.rs @@ -5,7 +5,7 @@ use ratatui::{ }; use crate::app::{ - state::{AppState, DragState, DragTarget, Mode}, + state::{AppState, DragState, DragTarget, Mode, NavigatorTarget}, App, }; @@ -14,6 +14,10 @@ use super::{ ScrollbarClickTarget, }; +fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { + col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height +} + impl App { pub(super) fn handle_overlay_mouse(&mut self, mouse: MouseEvent) -> bool { if self.state.mode == Mode::ReleaseNotes { @@ -119,6 +123,60 @@ impl App { return true; } + if self.state.mode == Mode::Navigator { + match mouse.kind { + MouseEventKind::Moved => { + if let Some(idx) = self.state.navigator_row_index_at(mouse.column, mouse.row) { + self.state.navigator.selected = idx; + self.state.ensure_navigator_selection_visible(); + } + } + MouseEventKind::Down(MouseButton::Left) => { + if self + .state + .navigator_search_contains(mouse.column, mouse.row) + { + self.state.navigator.search_focused = true; + } else if let Some(idx) = + self.state.navigator_row_index_at(mouse.column, mouse.row) + { + self.state.navigator.selected = idx; + let target = self + .state + .navigator_rows() + .get(idx) + .map(|row| (row.target.clone(), row.is_workspace)); + if let Some((NavigatorTarget::Workspace { .. }, true)) = target { + if self.state.navigator_row_caret_at(mouse.column) { + self.state.toggle_selected_navigator_workspace(); + } else { + self.state.accept_navigator_selection(); + } + } else { + self.state.accept_navigator_selection(); + } + } else if !self.state.navigator_popup_contains(mouse.column, mouse.row) { + leave_modal(&mut self.state); + } + } + MouseEventKind::ScrollUp => { + self.state.navigator.scroll = self.state.navigator.scroll.saturating_sub(3); + self.state.navigator.selected = self.state.navigator.scroll; + self.state.clamp_navigator_selection(); + } + MouseEventKind::ScrollDown => { + let viewport = self.state.navigator_body_rect().height as usize; + let max = self.state.navigator_max_scroll(viewport); + self.state.navigator.scroll = + self.state.navigator.scroll.saturating_add(3).min(max); + self.state.navigator.selected = self.state.navigator.scroll; + self.state.clamp_navigator_selection(); + } + _ => {} + } + return true; + } + if self.state.mode == Mode::KeybindHelp { match mouse.kind { MouseEventKind::Down(MouseButton::Left) @@ -188,6 +246,89 @@ impl AppState { self.view.sidebar_rect.union(self.view.terminal_area) } + pub(crate) fn navigator_popup_rect(&self) -> Rect { + let area = self.onboarding_full_area(); + let margin_x = (area.width / 16).max(2); + let margin_y = (area.height / 10).max(1); + let width = area.width.saturating_sub(margin_x.saturating_mul(2)); + let height = area.height.saturating_sub(margin_y.saturating_mul(2)); + Rect::new( + area.x + margin_x, + area.y + margin_y, + width.max(4), + height.max(4), + ) + } + + pub(crate) fn navigator_inner_rect(&self) -> Rect { + Block::default() + .borders(Borders::ALL) + .inner(self.navigator_popup_rect()) + } + + pub(crate) fn navigator_search_rect(&self) -> Rect { + let inner = self.navigator_inner_rect(); + Rect::new(inner.x, inner.y, inner.width, inner.height.min(1)) + } + + pub(crate) fn navigator_body_rect(&self) -> Rect { + let inner = self.navigator_inner_rect(); + if inner.height <= 4 { + return Rect::default(); + } + Rect::new( + inner.x, + inner.y + 2, + inner.width, + inner.height.saturating_sub(4), + ) + } + + pub(crate) fn navigator_detail_rect(&self) -> Rect { + let inner = self.navigator_inner_rect(); + Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(2), + inner.width, + inner.height.min(1), + ) + } + + pub(crate) fn navigator_footer_rect(&self) -> Rect { + let inner = self.navigator_inner_rect(); + Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(1), + inner.width, + inner.height.min(1), + ) + } + + pub(crate) fn navigator_popup_contains(&self, col: u16, row: u16) -> bool { + rect_contains(self.navigator_popup_rect(), col, row) + } + + pub(crate) fn navigator_search_contains(&self, col: u16, row: u16) -> bool { + rect_contains(self.navigator_search_rect(), col, row) + } + + pub(crate) fn navigator_row_index_at(&self, col: u16, row: u16) -> Option<usize> { + let body = self.navigator_body_rect(); + if !rect_contains(body, col, row) { + return None; + } + let idx = self + .navigator + .scroll + .saturating_add(row.saturating_sub(body.y) as usize); + (idx < self.navigator_rows().len()).then_some(idx) + } + + pub(crate) fn navigator_row_caret_at(&self, col: u16) -> bool { + let body = self.navigator_body_rect(); + col <= body.x.saturating_add(3) + } + 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)); |
