use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, widgets::{Paragraph, Wrap}, Frame, }; use super::release_notes::release_notes_close_button_rect; use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar}; use super::widgets::{ modal_stack_areas, panel_contrast_fg, render_action_button, render_modal_header, render_modal_shell, }; use crate::app::AppState; fn optional_keybind_label(label: &Option) -> String { label.clone().unwrap_or_else(|| "unset".to_string()) } pub(super) fn keybind_help_groups( app: &AppState, ) -> Vec<(&'static str, Vec<(String, &'static str)>)> { let kb = &app.keybinds; let mut groups = Vec::new(); groups.push(( "global", vec![ ( crate::config::format_key_combo((app.prefix_code, app.prefix_mods)), "navigate mode", ), ("prefix + ?".to_string(), "keybinds"), ( optional_keybind_label(&kb.reload_config_label), "reload config", ), ], )); groups.push(( "navigation", vec![ ("esc".to_string(), "back"), ("↑ / ↓".to_string(), "workspace list"), ("h j k l / arrows".to_string(), "move focus"), ("tab / shift+tab".to_string(), "cycle pane"), ("enter".to_string(), "open workspace"), ("s".to_string(), "settings"), ("q".to_string(), "quit"), ], )); let mut 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.open_notification_target_label), "open notification target", ), ( optional_keybind_label(&kb.previous_workspace_label), "previous workspace", ), ( optional_keybind_label(&kb.next_workspace_label), "next workspace", ), ( optional_keybind_label(&kb.indexed_workspaces_label), "switch workspace 1-9", ), ( optional_keybind_label(&kb.previous_agent_label), "previous agent", ), (optional_keybind_label(&kb.next_agent_label), "next agent"), ( optional_keybind_label(&kb.indexed_agents_label), "focus agent 1-9", ), (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.indexed_tabs_label), "switch tab 1-9", ), (optional_keybind_label(&kb.close_tab_label), "close tab"), ]; if let Some(label) = &kb.detach_label { workspace_tab.insert(3, (label.clone(), "detach from server")); } groups.push(("workspaces / tabs", workspace_tab)); let panes = vec![ (kb.split_vertical_label.clone(), "split vertical"), (kb.split_horizontal_label.clone(), "split horizontal"), (kb.close_pane_label.clone(), "close pane"), (optional_keybind_label(&kb.rename_pane_label), "rename pane"), ( optional_keybind_label(&kb.edit_scrollback_label), "edit scrollback", ), (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)); if !kb.custom_commands.is_empty() { groups.push(( "custom", kb.custom_commands .iter() .map(|binding| (binding.label.clone(), "custom command")) .collect(), )); } groups } 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!(" {:(stack.header); render_modal_header(frame, header_rows[0], "keybinds", &app.palette); render_action_button( frame, release_notes_close_button_rect(header_rows[0]), Some("esc"), "close", Style::default() .fg(panel_contrast_fg(&app.palette)) .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)), header_rows[1], ); let body_area = stack.content; 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(" esc / enter ", Style::default().fg(app.palette.text)), ])), stack.footer.unwrap_or_default(), ); }