aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-24 02:57:03 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-24 02:59:39 +0300
commit80a314a9089b28c1bfdc2cdf5196e9a2eb9f11ba (patch)
treefca593cafa027f4283adcec80c2f8908ef79708e /src/app/input
parenta132a7c3b9ade3ad4636e03b1f0930b5fa711766 (diff)
feat: add session navigator
refs #157
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/mod.rs3
-rw-r--r--src/app/input/modal.rs119
-rw-r--r--src/app/input/navigate.rs27
-rw-r--r--src/app/input/overlays.rs143
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));