aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md3
-rw-r--r--CONFIGURATION.md15
-rw-r--r--README.md2
-rw-r--r--src/app/input.rs292
-rw-r--r--src/app/mod.rs1
-rw-r--r--src/app/state.rs17
-rw-r--r--src/config.rs72
-rw-r--r--src/main.rs6
-rw-r--r--src/ui.rs277
9 files changed, 550 insertions, 135 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e4fde27..6d347a55 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
## Unreleased
+### Added
+- Added optional direct pane-focus keybindings for terminal mode, so you can switch panes with modifier shortcuts like `alt+h` or `alt+right` without entering navigate mode first.
+
## [0.2.4] - 2026-04-01
### Fixed
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index b754cf36..63034f13 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -36,7 +36,7 @@ keybindings live under `[keys]`.
supported syntax:
- plain keys: `n`, `x`, `-`, `` ` ``
- modifiers: `ctrl+b`, `shift+n`, `alt+x`
-- special keys: `enter`, `esc`, `tab`, `backspace`
+- special keys: `enter`, `esc`, `tab`, `backspace`, `left`, `right`, `up`, `down`
- function keys: `f1`, `f12`
- uppercase letters also imply shift: `D` works like `shift+d`
@@ -59,6 +59,8 @@ close_pane = "x"
fullscreen = "f"
resize_mode = "r"
toggle_sidebar = "b"
+focus_pane_left = "alt+h"
+focus_pane_right = "alt+right"
```
### key reference
@@ -69,6 +71,17 @@ toggle_sidebar = "b"
| `new_workspace` | `n` | create a new workspace |
| `rename_workspace` | `shift+n` | rename selected workspace |
| `close_workspace` | `d` | close selected workspace |
+| `previous_workspace` | unset | switch to the previous workspace directly from terminal mode |
+| `next_workspace` | unset | switch to the next workspace directly from terminal mode |
+| `new_tab` | `c` | create a new tab |
+| `rename_tab` | unset | rename the active tab |
+| `previous_tab` | unset | switch to the previous tab directly from terminal mode |
+| `next_tab` | unset | switch to the next tab directly from terminal mode |
+| `close_tab` | unset | close the active tab |
+| `focus_pane_left` | unset | focus the pane to the left directly from terminal mode |
+| `focus_pane_down` | unset | focus the pane below directly from terminal mode |
+| `focus_pane_up` | unset | focus the pane above directly from terminal mode |
+| `focus_pane_right` | unset | focus the pane to the right directly from terminal mode |
| `split_vertical` | `v` | split pane vertically (side by side) |
| `split_horizontal` | `-` | split pane horizontally (stacked) |
| `close_pane` | `x` | close focused pane |
diff --git a/README.md b/README.md
index 4e49f284..3eee25f2 100644
--- a/README.md
+++ b/README.md
@@ -161,6 +161,8 @@ common defaults:
- `r` resize mode
- `b` toggle sidebar
+optional direct bindings can also switch workspaces, tabs, or panes from terminal mode without going through the prefix first. for example, you can bind `focus_pane_left = "alt+h"` or `focus_pane_right = "alt+right"` in your config.
+
full keybinding and config reference: [`CONFIGURATION.md`](./CONFIGURATION.md)
### sidebar
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,
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<ReleaseNotesState>,
+ pub keybind_help: KeybindHelpState,
// View geometry (computed before render, consumed by render + mouse)
pub view: ViewState,
pub(crate) drag: Option<DragState>,
@@ -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()),
diff --git a/src/config.rs b/src/config.rs
index bbbab461..c901c2d6 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -96,6 +96,14 @@ pub struct KeysConfig {
pub next_tab: String,
/// Close the active tab. Unset by default.
pub close_tab: String,
+ /// Focus the pane to the left in terminal mode. Unset by default.
+ pub focus_pane_left: String,
+ /// Focus the pane below in terminal mode. Unset by default.
+ pub focus_pane_down: String,
+ /// Focus the pane above in terminal mode. Unset by default.
+ pub focus_pane_up: String,
+ /// Focus the pane to the right in terminal mode. Unset by default.
+ pub focus_pane_right: String,
/// Split pane vertically (side by side). Default: "v"
pub split_vertical: String,
/// Split pane horizontally (stacked). Default: "-"
@@ -207,6 +215,10 @@ impl Default for KeysConfig {
previous_tab: "".into(),
next_tab: "".into(),
close_tab: "".into(),
+ focus_pane_left: "".into(),
+ focus_pane_down: "".into(),
+ focus_pane_up: "".into(),
+ focus_pane_right: "".into(),
split_vertical: "v".into(),
split_horizontal: "-".into(),
close_pane: "x".into(),
@@ -479,6 +491,26 @@ impl Config {
),
optional_binding("keys.next_tab", &self.keys.next_tab, &mut diagnostics),
optional_binding("keys.close_tab", &self.keys.close_tab, &mut diagnostics),
+ optional_binding(
+ "keys.focus_pane_left",
+ &self.keys.focus_pane_left,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ "keys.focus_pane_down",
+ &self.keys.focus_pane_down,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ "keys.focus_pane_up",
+ &self.keys.focus_pane_up,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ "keys.focus_pane_right",
+ &self.keys.focus_pane_right,
+ &mut diagnostics,
+ ),
];
use std::collections::HashMap;
@@ -519,6 +551,14 @@ impl Config {
next_tab_label: optional_bindings[4].1.clone(),
close_tab: optional_bindings[5].0,
close_tab_label: optional_bindings[5].1.clone(),
+ focus_pane_left: optional_bindings[6].0,
+ focus_pane_left_label: optional_bindings[6].1.clone(),
+ focus_pane_down: optional_bindings[7].0,
+ focus_pane_down_label: optional_bindings[7].1.clone(),
+ focus_pane_up: optional_bindings[8].0,
+ focus_pane_up_label: optional_bindings[8].1.clone(),
+ focus_pane_right: optional_bindings[9].0,
+ focus_pane_right_label: optional_bindings[9].1.clone(),
split_vertical: bindings[4].value,
split_vertical_label: bindings[4].label.clone(),
split_horizontal: bindings[5].value,
@@ -560,6 +600,14 @@ pub struct Keybinds {
pub next_tab_label: Option<String>,
pub close_tab: Option<(KeyCode, KeyModifiers)>,
pub close_tab_label: Option<String>,
+ pub focus_pane_left: Option<(KeyCode, KeyModifiers)>,
+ pub focus_pane_left_label: Option<String>,
+ pub focus_pane_down: Option<(KeyCode, KeyModifiers)>,
+ pub focus_pane_down_label: Option<String>,
+ pub focus_pane_up: Option<(KeyCode, KeyModifiers)>,
+ pub focus_pane_up_label: Option<String>,
+ pub focus_pane_right: Option<(KeyCode, KeyModifiers)>,
+ pub focus_pane_right_label: Option<String>,
pub split_vertical: (KeyCode, KeyModifiers),
pub split_vertical_label: String,
pub split_horizontal: (KeyCode, KeyModifiers),
@@ -792,6 +840,10 @@ fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> {
"esc" | "escape" => KeyCode::Esc,
"tab" => KeyCode::Tab,
"backspace" | "bs" => KeyCode::Backspace,
+ "left" => KeyCode::Left,
+ "right" => KeyCode::Right,
+ "up" => KeyCode::Up,
+ "down" => KeyCode::Down,
s if s.len() == 1 => {
let ch = key_str.chars().next().unwrap();
if ch.is_ascii_uppercase() {
@@ -899,6 +951,14 @@ mod tests {
parse_key_combo("esc"),
Some((KeyCode::Esc, KeyModifiers::empty()))
);
+ assert_eq!(
+ parse_key_combo("left"),
+ Some((KeyCode::Left, KeyModifiers::empty()))
+ );
+ assert_eq!(
+ parse_key_combo("alt+right"),
+ Some((KeyCode::Right, KeyModifiers::ALT))
+ );
}
#[test]
@@ -982,6 +1042,8 @@ close_pane = "ctrl+w"
fullscreen = "z"
resize_mode = "ctrl+r"
toggle_sidebar = "tab"
+focus_pane_left = "alt+h"
+focus_pane_right = "alt+right"
"#;
let config: Config = toml::from_str(toml).unwrap();
let (code, mods) = config.prefix_key();
@@ -1010,6 +1072,16 @@ toggle_sidebar = "tab"
assert_eq!(kb.fullscreen.0, KeyCode::Char('z'));
assert_eq!(kb.resize_mode, (KeyCode::Char('r'), KeyModifiers::CONTROL));
assert_eq!(kb.toggle_sidebar, (KeyCode::Tab, KeyModifiers::empty()));
+ assert_eq!(
+ kb.focus_pane_left,
+ Some((KeyCode::Char('h'), KeyModifiers::ALT))
+ );
+ assert_eq!(
+ kb.focus_pane_right,
+ Some((KeyCode::Right, KeyModifiers::ALT))
+ );
+ assert_eq!(kb.focus_pane_down, None);
+ assert_eq!(kb.focus_pane_up, None);
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index a5a43b39..4d0a9442 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -98,7 +98,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
[keys]
# Prefix key to enter navigate mode (default: "ctrl+b")
# Examples: "ctrl+b", "f12", "esc", "-"
-# Accepted syntax: plain keys, ctrl/shift/alt modifiers, and special keys like enter/tab/esc
+# Accepted syntax: plain keys, ctrl/shift/alt modifiers, and special keys like enter/tab/esc/left/right/up/down
# Most reliable bindings are plain keys, ctrl+letter, esc/tab/enter, and function keys.
# alt+... and punctuation-with-modifiers may depend on your terminal/tmux setup.
# prefix = "ctrl+b"
@@ -114,6 +114,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# previous_tab = "" # optional, unset by default
# next_tab = "" # optional, unset by default
# close_tab = "" # optional, unset by default
+# focus_pane_left = "" # optional, unset by default
+# focus_pane_down = "" # optional, unset by default
+# focus_pane_up = "" # optional, unset by default
+# focus_pane_right = "" # optional, unset by default
# split_vertical = "v"
# split_horizontal = "-"
# close_pane = "x"
diff --git a/src/ui.rs b/src/ui.rs
index c48d1e7c..d729b918 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -1555,6 +1555,10 @@ fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) {
}
}
+fn optional_keybind_label(label: &Option<String>) -> String {
+ label.clone().unwrap_or_else(|| "unset".to_string())
+}
+
fn keybind_help_groups(app: &AppState) -> Vec<(&'static str, Vec<(String, &'static str)>)> {
let kb = &app.keybinds;
let mut groups = Vec::new();
@@ -1583,129 +1587,186 @@ fn keybind_help_groups(app: &AppState) -> Vec<(&'static str, Vec<(String, &'stat
],
));
- let mut workspace_tab = vec![
+ let workspace_tab = vec![
(kb.new_workspace_label.clone(), "new workspace"),
(kb.rename_workspace_label.clone(), "rename workspace"),
(kb.close_workspace_label.clone(), "close workspace"),
+ (
+ optional_keybind_label(&kb.previous_workspace_label),
+ "previous workspace",
+ ),
+ (
+ optional_keybind_label(&kb.next_workspace_label),
+ "next workspace",
+ ),
(kb.new_tab_label.clone(), "new tab"),
+ (optional_keybind_label(&kb.rename_tab_label), "rename tab"),
+ (
+ optional_keybind_label(&kb.previous_tab_label),
+ "previous tab",
+ ),
+ (optional_keybind_label(&kb.next_tab_label), "next tab"),
+ (optional_keybind_label(&kb.close_tab_label), "close tab"),
];
- if let Some(label) = &kb.previous_workspace_label {
- workspace_tab.push((label.clone(), "previous workspace"));
- }
- if let Some(label) = &kb.next_workspace_label {
- workspace_tab.push((label.clone(), "next workspace"));
- }
- if let Some(label) = &kb.rename_tab_label {
- workspace_tab.push((label.clone(), "rename tab"));
- }
- if let Some(label) = &kb.previous_tab_label {
- workspace_tab.push((label.clone(), "previous tab"));
- }
- if let Some(label) = &kb.next_tab_label {
- workspace_tab.push((label.clone(), "next tab"));
- }
- if let Some(label) = &kb.close_tab_label {
- workspace_tab.push((label.clone(), "close tab"));
- }
groups.push(("workspaces / tabs", workspace_tab));
- groups.push((
- "panes",
- vec![
- (kb.split_vertical_label.clone(), "split vertical"),
- (kb.split_horizontal_label.clone(), "split horizontal"),
- (kb.close_pane_label.clone(), "close pane"),
- (kb.fullscreen_label.clone(), "fullscreen"),
- (kb.resize_mode_label.clone(), "resize mode"),
- (kb.toggle_sidebar_label.clone(), "toggle sidebar"),
- ],
- ));
+ let panes = vec![
+ (kb.split_vertical_label.clone(), "split vertical"),
+ (kb.split_horizontal_label.clone(), "split horizontal"),
+ (kb.close_pane_label.clone(), "close pane"),
+ (kb.fullscreen_label.clone(), "fullscreen"),
+ (kb.resize_mode_label.clone(), "resize mode"),
+ (kb.toggle_sidebar_label.clone(), "toggle sidebar"),
+ (
+ optional_keybind_label(&kb.focus_pane_left_label),
+ "focus pane left",
+ ),
+ (
+ optional_keybind_label(&kb.focus_pane_down_label),
+ "focus pane down",
+ ),
+ (
+ optional_keybind_label(&kb.focus_pane_up_label),
+ "focus pane up",
+ ),
+ (
+ optional_keybind_label(&kb.focus_pane_right_label),
+ "focus pane right",
+ ),
+ ];
+ groups.push(("panes", panes));
groups
}
-pub(crate) fn keybind_help_close_button_rect(area: Rect) -> Rect {
- action_button_row_rects(
- area,
- &[ActionButtonSpec {
- hint: Some("esc"),
- label: "close",
- }],
- 0,
- 0,
- )[0]
+pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> {
+ let heading_style = Style::default()
+ .fg(app.palette.accent)
+ .add_modifier(Modifier::BOLD);
+ let key_style = Style::default()
+ .fg(app.palette.mauve)
+ .add_modifier(Modifier::BOLD);
+ let label_style = Style::default().fg(app.palette.text);
+
+ let groups = keybind_help_groups(app);
+ let key_width = groups
+ .iter()
+ .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count()))
+ .max()
+ .unwrap_or(8);
+
+ let mut lines = Vec::new();
+
+ for (group, entries) in groups {
+ lines.push((
+ group.len() + 1,
+ Line::from(vec![Span::styled(format!(" {group}"), heading_style)]),
+ ));
+ for (key, label) in entries {
+ let padded_key = format!(" {:<width$} ", key, width = key_width);
+ let width = padded_key.chars().count() + label.chars().count();
+ lines.push((
+ width,
+ Line::from(vec![
+ Span::styled(padded_key, key_style),
+ Span::styled(label.to_string(), label_style),
+ ]),
+ ));
+ }
+ lines.push((0, Line::raw("")));
+ }
+
+ lines
}
fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) {
- let rect = app.keybind_help_rect();
- let Some(inner) = render_panel_shell(frame, rect, app.palette.accent, app.palette.panel_bg)
- else {
+ dim_background(frame, frame.area());
+
+ let Some(inner) = render_modal_shell(frame, frame.area(), 76, 22, &app.palette) else {
return;
};
- if inner.width < 12 || inner.height < 6 {
+ if inner.height < 6 || inner.width < 20 {
return;
}
- let title_style = Style::default()
- .fg(app.palette.text)
- .add_modifier(Modifier::BOLD);
- let heading_style = Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
- let key_style = Style::default()
- .fg(app.palette.mauve)
- .add_modifier(Modifier::BOLD);
- let label_style = Style::default().fg(app.palette.text);
- let header = Rect::new(inner.x, inner.y, inner.width, 1);
- frame.render_widget(
- Paragraph::new(Line::from(vec![Span::styled(" keybinds", title_style)])),
- header,
- );
+ let rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Min(1),
+ Constraint::Length(1),
+ ])
+ .areas::<4>(inner);
+
+ render_modal_header(frame, rows[0], "keybinds", &app.palette);
render_action_button(
frame,
- keybind_help_close_button_rect(header),
+ release_notes_close_button_rect(rows[0]),
Some("esc"),
"close",
Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0)
+ .fg(app.palette.panel_bg)
+ .bg(app.palette.accent)
.add_modifier(Modifier::BOLD),
);
+ frame.render_widget(
+ Paragraph::new(" available commands and configured shortcuts")
+ .style(Style::default().fg(app.palette.overlay1)),
+ rows[1],
+ );
- let groups = keybind_help_groups(app);
- let key_width = groups
- .iter()
- .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count() as u16))
- .max()
- .unwrap_or(8)
- .min(inner.width.saturating_sub(8));
+ let body_area = rows[2];
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: app
+ .keybind_help_max_scroll()
+ .saturating_sub(app.keybind_help.scroll) as usize,
+ max_offset_from_bottom: app.keybind_help_max_scroll() as usize,
+ viewport_rows: body_area.height.max(1) as usize,
+ };
+ let track = release_notes_scrollbar_rect(body_area, metrics);
+ let text_area = track
+ .map(|_| {
+ Rect::new(
+ body_area.x,
+ body_area.y,
+ body_area.width.saturating_sub(1),
+ body_area.height,
+ )
+ })
+ .unwrap_or(body_area);
- let mut y = inner.y + 2;
- for (group, entries) in groups {
- if y >= inner.y + inner.height {
- break;
- }
- frame.render_widget(
- Paragraph::new(Span::styled(format!(" {group}"), heading_style)),
- Rect::new(inner.x, y, inner.width, 1),
+ let body = Paragraph::new(
+ keybind_help_lines(app)
+ .into_iter()
+ .map(|(_, line)| line)
+ .collect::<Vec<_>>(),
+ )
+ .wrap(Wrap { trim: false })
+ .scroll((app.keybind_help.scroll, 0));
+ frame.render_widget(body, text_area);
+ if let Some(track) = track {
+ render_scrollbar(
+ frame,
+ metrics,
+ track,
+ app.palette.overlay0,
+ app.palette.overlay1,
+ "▐",
);
- y += 1;
- for (key, label) in entries {
- if y >= inner.y + inner.height {
- break;
- }
- let padded_key = format!(" {:<width$} ", key, width = key_width as usize);
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(padded_key, key_style),
- Span::styled(label, label_style),
- ])),
- Rect::new(inner.x, y, inner.width, 1),
- );
- y += 1;
- }
- y += 1;
}
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)),
+ Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)),
+ Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
+ Span::styled("jump", Style::default().fg(app.palette.overlay0)),
+ Span::styled(" pgup / pgdn ", Style::default().fg(app.palette.text)),
+ Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
+ Span::styled("close", Style::default().fg(app.palette.overlay0)),
+ Span::styled(" q / esc / enter ", Style::default().fg(app.palette.text)),
+ ])),
+ rows[3],
+ );
}
/// Floating overlay for resize mode.
@@ -2493,4 +2554,34 @@ mod tests {
assert_eq!(scrollbar_offset_from_drag_row(metrics, track, row, grab), 7);
}
+
+ #[test]
+ fn keybind_help_shows_unset_for_optional_actions() {
+ let app = crate::app::state::AppState::test_new();
+ let groups = keybind_help_groups(&app);
+
+ let workspace_tab = groups
+ .iter()
+ .find(|(name, _)| *name == "workspaces / tabs")
+ .expect("workspace tab group")
+ .1
+ .clone();
+ let panes = groups
+ .iter()
+ .find(|(name, _)| *name == "panes")
+ .expect("panes group")
+ .1
+ .clone();
+
+ assert!(workspace_tab.contains(&("unset".to_string(), "previous workspace")));
+ assert!(workspace_tab.contains(&("unset".to_string(), "next workspace")));
+ assert!(workspace_tab.contains(&("unset".to_string(), "rename tab")));
+ assert!(workspace_tab.contains(&("unset".to_string(), "previous tab")));
+ assert!(workspace_tab.contains(&("unset".to_string(), "next tab")));
+ assert!(workspace_tab.contains(&("unset".to_string(), "close tab")));
+ assert!(panes.contains(&("unset".to_string(), "focus pane left")));
+ assert!(panes.contains(&("unset".to_string(), "focus pane down")));
+ assert!(panes.contains(&("unset".to_string(), "focus pane up")));
+ assert!(panes.contains(&("unset".to_string(), "focus pane right")));
+ }
}