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 { + 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 { + 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 { + 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::(); + 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 { + 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 { + 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, 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, + pub keybind_help: KeybindHelpState, // View geometry (computed before render, consumed by render + mouse) pub view: ViewState, pub(crate) drag: Option, @@ -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, pub close_tab: Option<(KeyCode, KeyModifiers)>, pub close_tab_label: Option, + pub focus_pane_left: Option<(KeyCode, KeyModifiers)>, + pub focus_pane_left_label: Option, + pub focus_pane_down: Option<(KeyCode, KeyModifiers)>, + pub focus_pane_down_label: Option, + pub focus_pane_up: Option<(KeyCode, KeyModifiers)>, + pub focus_pane_up_label: Option, + pub focus_pane_right: Option<(KeyCode, KeyModifiers)>, + pub focus_pane_right_label: Option, 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 { + 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!(" {:(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::>(), + ) + .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"))); + } }