feat: add direct pane navigation keybinds

This commit is contained in:
Ogulcan Celik 2026-04-02 23:13:47 +03:00
parent 1eb7dac77e
commit 290a57e333
9 changed files with 551 additions and 136 deletions

View File

@ -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

View File

@ -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 |

View File

@ -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

View File

@ -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);
@ -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,

View File

@ -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(),

View File

@ -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()),

View File

@ -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]

View File

@ -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"

279
src/ui.rs
View File

@ -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,72 +1587,59 @@ 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]
}
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 {
return;
};
if inner.width < 12 || inner.height < 6 {
return;
}
let title_style = Style::default()
.fg(app.palette.text)
.add_modifier(Modifier::BOLD);
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);
@ -1656,56 +1647,126 @@ fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) {
.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,
);
render_action_button(
frame,
keybind_help_close_button_rect(header),
Some("esc"),
"close",
Style::default()
.fg(app.palette.text)
.bg(app.palette.surface0)
.add_modifier(Modifier::BOLD),
);
let groups = keybind_help_groups(app);
let key_width = groups
.iter()
.flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count() as u16))
.flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count()))
.max()
.unwrap_or(8)
.min(inner.width.saturating_sub(8));
.unwrap_or(8);
let mut lines = Vec::new();
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),
);
y += 1;
lines.push((
group.len() + 1,
Line::from(vec![Span::styled(format!(" {group}"), heading_style)]),
));
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![
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, label_style),
])),
Rect::new(inner.x, y, inner.width, 1),
);
y += 1;
Span::styled(label.to_string(), label_style),
]),
));
}
y += 1;
lines.push((0, Line::raw("")));
}
lines
}
fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) {
dim_background(frame, frame.area());
let Some(inner) = render_modal_shell(frame, frame.area(), 76, 22, &app.palette) else {
return;
};
if inner.height < 6 || inner.width < 20 {
return;
}
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,
release_notes_close_button_rect(rows[0]),
Some("esc"),
"close",
Style::default()
.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 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 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,
"",
);
}
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")));
}
}