feat: add direct pane navigation keybinds
This commit is contained in:
parent
1eb7dac77e
commit
290a57e333
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
292
src/app/input.rs
292
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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
279
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,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")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue