diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-02 23:13:47 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-02 23:25:10 +0300 |
| commit | 290a57e3331bb25dcd16284099bf1dc6afa68ee5 (patch) | |
| tree | 8c65e20a815e15dd692ee4c5a5e547c172f148f3 /src/ui.rs | |
| parent | 1eb7dac77e7b62ece76c43d75a349580f936b2c6 (diff) | |
feat: add direct pane navigation keybinds
Diffstat (limited to 'src/ui.rs')
| -rw-r--r-- | src/ui.rs | 277 |
1 files changed, 184 insertions, 93 deletions
@@ -1555,6 +1555,10 @@ fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) { } } +fn optional_keybind_label(label: &Option<String>) -> String { + label.clone().unwrap_or_else(|| "unset".to_string()) +} + fn keybind_help_groups(app: &AppState) -> Vec<(&'static str, Vec<(String, &'static str)>)> { let kb = &app.keybinds; let mut groups = Vec::new(); @@ -1583,129 +1587,186 @@ fn keybind_help_groups(app: &AppState) -> Vec<(&'static str, Vec<(String, &'stat ], )); - let mut workspace_tab = vec![ + let workspace_tab = vec![ (kb.new_workspace_label.clone(), "new workspace"), (kb.rename_workspace_label.clone(), "rename workspace"), (kb.close_workspace_label.clone(), "close workspace"), + ( + optional_keybind_label(&kb.previous_workspace_label), + "previous workspace", + ), + ( + optional_keybind_label(&kb.next_workspace_label), + "next workspace", + ), (kb.new_tab_label.clone(), "new tab"), + (optional_keybind_label(&kb.rename_tab_label), "rename tab"), + ( + optional_keybind_label(&kb.previous_tab_label), + "previous tab", + ), + (optional_keybind_label(&kb.next_tab_label), "next tab"), + (optional_keybind_label(&kb.close_tab_label), "close tab"), ]; - if let Some(label) = &kb.previous_workspace_label { - workspace_tab.push((label.clone(), "previous workspace")); - } - if let Some(label) = &kb.next_workspace_label { - workspace_tab.push((label.clone(), "next workspace")); - } - if let Some(label) = &kb.rename_tab_label { - workspace_tab.push((label.clone(), "rename tab")); - } - if let Some(label) = &kb.previous_tab_label { - workspace_tab.push((label.clone(), "previous tab")); - } - if let Some(label) = &kb.next_tab_label { - workspace_tab.push((label.clone(), "next tab")); - } - if let Some(label) = &kb.close_tab_label { - workspace_tab.push((label.clone(), "close tab")); - } groups.push(("workspaces / tabs", workspace_tab)); - groups.push(( - "panes", - vec![ - (kb.split_vertical_label.clone(), "split vertical"), - (kb.split_horizontal_label.clone(), "split horizontal"), - (kb.close_pane_label.clone(), "close pane"), - (kb.fullscreen_label.clone(), "fullscreen"), - (kb.resize_mode_label.clone(), "resize mode"), - (kb.toggle_sidebar_label.clone(), "toggle sidebar"), - ], - )); + let panes = vec![ + (kb.split_vertical_label.clone(), "split vertical"), + (kb.split_horizontal_label.clone(), "split horizontal"), + (kb.close_pane_label.clone(), "close pane"), + (kb.fullscreen_label.clone(), "fullscreen"), + (kb.resize_mode_label.clone(), "resize mode"), + (kb.toggle_sidebar_label.clone(), "toggle sidebar"), + ( + optional_keybind_label(&kb.focus_pane_left_label), + "focus pane left", + ), + ( + optional_keybind_label(&kb.focus_pane_down_label), + "focus pane down", + ), + ( + optional_keybind_label(&kb.focus_pane_up_label), + "focus pane up", + ), + ( + optional_keybind_label(&kb.focus_pane_right_label), + "focus pane right", + ), + ]; + groups.push(("panes", panes)); groups } -pub(crate) fn keybind_help_close_button_rect(area: Rect) -> Rect { - action_button_row_rects( - area, - &[ActionButtonSpec { - hint: Some("esc"), - label: "close", - }], - 0, - 0, - )[0] +pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> { + let heading_style = Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD); + let key_style = Style::default() + .fg(app.palette.mauve) + .add_modifier(Modifier::BOLD); + let label_style = Style::default().fg(app.palette.text); + + let groups = keybind_help_groups(app); + let key_width = groups + .iter() + .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count())) + .max() + .unwrap_or(8); + + let mut lines = Vec::new(); + + for (group, entries) in groups { + lines.push(( + group.len() + 1, + Line::from(vec![Span::styled(format!(" {group}"), heading_style)]), + )); + for (key, label) in entries { + let padded_key = format!(" {:<width$} ", key, width = key_width); + let width = padded_key.chars().count() + label.chars().count(); + lines.push(( + width, + Line::from(vec![ + Span::styled(padded_key, key_style), + Span::styled(label.to_string(), label_style), + ]), + )); + } + lines.push((0, Line::raw(""))); + } + + lines } fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) { - let rect = app.keybind_help_rect(); - let Some(inner) = render_panel_shell(frame, rect, app.palette.accent, app.palette.panel_bg) - else { + dim_background(frame, frame.area()); + + let Some(inner) = render_modal_shell(frame, frame.area(), 76, 22, &app.palette) else { return; }; - if inner.width < 12 || inner.height < 6 { + if inner.height < 6 || inner.width < 20 { return; } - let title_style = Style::default() - .fg(app.palette.text) - .add_modifier(Modifier::BOLD); - let heading_style = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let key_style = Style::default() - .fg(app.palette.mauve) - .add_modifier(Modifier::BOLD); - let label_style = Style::default().fg(app.palette.text); - let header = Rect::new(inner.x, inner.y, inner.width, 1); - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled(" keybinds", title_style)])), - header, - ); + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .areas::<4>(inner); + + render_modal_header(frame, rows[0], "keybinds", &app.palette); render_action_button( frame, - keybind_help_close_button_rect(header), + release_notes_close_button_rect(rows[0]), Some("esc"), "close", Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) + .fg(app.palette.panel_bg) + .bg(app.palette.accent) .add_modifier(Modifier::BOLD), ); + frame.render_widget( + Paragraph::new(" available commands and configured shortcuts") + .style(Style::default().fg(app.palette.overlay1)), + rows[1], + ); - let groups = keybind_help_groups(app); - let key_width = groups - .iter() - .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count() as u16)) - .max() - .unwrap_or(8) - .min(inner.width.saturating_sub(8)); + let body_area = rows[2]; + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: app + .keybind_help_max_scroll() + .saturating_sub(app.keybind_help.scroll) as usize, + max_offset_from_bottom: app.keybind_help_max_scroll() as usize, + viewport_rows: body_area.height.max(1) as usize, + }; + let track = release_notes_scrollbar_rect(body_area, metrics); + let text_area = track + .map(|_| { + Rect::new( + body_area.x, + body_area.y, + body_area.width.saturating_sub(1), + body_area.height, + ) + }) + .unwrap_or(body_area); - let mut y = inner.y + 2; - for (group, entries) in groups { - if y >= inner.y + inner.height { - break; - } - frame.render_widget( - Paragraph::new(Span::styled(format!(" {group}"), heading_style)), - Rect::new(inner.x, y, inner.width, 1), + let body = Paragraph::new( + keybind_help_lines(app) + .into_iter() + .map(|(_, line)| line) + .collect::<Vec<_>>(), + ) + .wrap(Wrap { trim: false }) + .scroll((app.keybind_help.scroll, 0)); + frame.render_widget(body, text_area); + if let Some(track) = track { + render_scrollbar( + frame, + metrics, + track, + app.palette.overlay0, + app.palette.overlay1, + "▐", ); - y += 1; - for (key, label) in entries { - if y >= inner.y + inner.height { - break; - } - let padded_key = format!(" {:<width$} ", key, width = key_width as usize); - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(padded_key, key_style), - Span::styled(label, label_style), - ])), - Rect::new(inner.x, y, inner.width, 1), - ); - y += 1; - } - y += 1; } + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)), + Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)), + Span::styled(" · ", Style::default().fg(app.palette.overlay0)), + Span::styled("jump", Style::default().fg(app.palette.overlay0)), + Span::styled(" pgup / pgdn ", Style::default().fg(app.palette.text)), + Span::styled(" · ", Style::default().fg(app.palette.overlay0)), + Span::styled("close", Style::default().fg(app.palette.overlay0)), + Span::styled(" q / esc / enter ", Style::default().fg(app.palette.text)), + ])), + rows[3], + ); } /// Floating overlay for resize mode. @@ -2493,4 +2554,34 @@ mod tests { assert_eq!(scrollbar_offset_from_drag_row(metrics, track, row, grab), 7); } + + #[test] + fn keybind_help_shows_unset_for_optional_actions() { + let app = crate::app::state::AppState::test_new(); + let groups = keybind_help_groups(&app); + + let workspace_tab = groups + .iter() + .find(|(name, _)| *name == "workspaces / tabs") + .expect("workspace tab group") + .1 + .clone(); + let panes = groups + .iter() + .find(|(name, _)| *name == "panes") + .expect("panes group") + .1 + .clone(); + + assert!(workspace_tab.contains(&("unset".to_string(), "previous workspace"))); + assert!(workspace_tab.contains(&("unset".to_string(), "next workspace"))); + assert!(workspace_tab.contains(&("unset".to_string(), "rename tab"))); + assert!(workspace_tab.contains(&("unset".to_string(), "previous tab"))); + assert!(workspace_tab.contains(&("unset".to_string(), "next tab"))); + assert!(workspace_tab.contains(&("unset".to_string(), "close tab"))); + assert!(panes.contains(&("unset".to_string(), "focus pane left"))); + assert!(panes.contains(&("unset".to_string(), "focus pane down"))); + assert!(panes.contains(&("unset".to_string(), "focus pane up"))); + assert!(panes.contains(&("unset".to_string(), "focus pane right"))); + } } |
