diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-02 19:05:24 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-02 23:24:55 +0300 |
| commit | 61204f7d0c68f723e82fb3c8c98ad066e527863b (patch) | |
| tree | 1fefe3f419cabb502d7a5625407dd9e195eefbea /src | |
| parent | b695adee60e50764f4713c8e586ca02fb844f99a (diff) | |
feat: refactor keybind discoverability surfaces
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input.rs | 424 | ||||
| -rw-r--r-- | src/app/mod.rs | 1 | ||||
| -rw-r--r-- | src/app/state.rs | 11 | ||||
| -rw-r--r-- | src/config.rs | 61 | ||||
| -rw-r--r-- | src/ui.rs | 548 |
5 files changed, 814 insertions, 231 deletions
diff --git a/src/app/input.rs b/src/app/input.rs index 9a4c9aaf..6169b42f 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -78,6 +78,8 @@ impl App { Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key), Mode::ContextMenu => handle_context_menu_key(&mut self.state, key), Mode::Settings => self.handle_settings_key(key), + Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key), + Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key), Mode::Terminal => unreachable!(), } } @@ -306,6 +308,16 @@ enum SettingsAction { SaveToast(bool), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GlobalMenuAction { + Keybinds, + Settings, +} + +impl GlobalMenuAction { + const ALL: [Self; 2] = [Self::Keybinds, Self::Settings]; +} + fn normalize_theme_name(name: &str) -> String { name.to_lowercase().replace([' ', '_'], "-") } @@ -426,6 +438,49 @@ fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<Settings None } +fn open_global_menu(state: &mut AppState) { + state.global_menu_selected = 0; + state.mode = Mode::GlobalMenu; +} + +fn open_keybind_help(state: &mut AppState) { + state.mode = Mode::KeybindHelp; +} + +fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) { + match action { + GlobalMenuAction::Keybinds => open_keybind_help(state), + GlobalMenuAction::Settings => open_settings(state), + } +} + +fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => leave_modal(state), + KeyCode::Up | KeyCode::Char('k') => { + state.global_menu_selected = state.global_menu_selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + state.global_menu_selected = + (state.global_menu_selected + 1).min(GlobalMenuAction::ALL.len() - 1); + } + KeyCode::Enter => { + let action = GlobalMenuAction::ALL[state.global_menu_selected]; + apply_global_menu_action(state, action); + } + _ => {} + } +} + +fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) { + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('?') => { + leave_modal(state) + } + _ => {} + } +} + fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { state.update_dismissed = true; @@ -457,6 +512,7 @@ fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { KeyCode::Char('s') => { open_settings(state); } + KeyCode::Char('?') => open_keybind_help(state), KeyCode::Up => { if state.selected > 0 { state.selected -= 1; @@ -911,7 +967,7 @@ impl AppState { return false; } let button = - ratatui::layout::Rect::new(inner.x + inner.width.saturating_sub(11), inner.y, 9, 1); + 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 @@ -1038,12 +1094,15 @@ impl AppState { let Some(inner) = self.onboarding_modal_inner(64, 15) else { return; }; - let footer_y = inner.y + 9; - let button_x = inner.x; - let button_w = 14; - if mouse.row == footer_y - && mouse.column >= button_x - && mouse.column < button_x + button_w + let button = crate::ui::onboarding_welcome_continue_rect(Rect::new( + inner.x, + inner.y + 9, + inner.width, + 1, + )); + if mouse.row == button.y + && mouse.column >= button.x + && mouse.column < button.x + button.width { self.onboarding_step = 1; } @@ -1061,15 +1120,16 @@ impl AppState { return; } - let footer_y = inner.y + 6; - let back_x = inner.x; - let back_w = 10; - let save_x = inner.x + 12; - let save_w = 10; - if mouse.row == footer_y { - if mouse.column >= back_x && mouse.column < back_x + back_w { + let (back, save) = crate::ui::onboarding_notification_button_rects(Rect::new( + inner.x, + inner.y + 6, + inner.width, + 1, + )); + if mouse.row == back.y { + if mouse.column >= back.x && mouse.column < back.x + back.width { self.onboarding_step = 0; - } else if mouse.column >= save_x && mouse.column < save_x + save_w { + } else if mouse.column >= save.x && mouse.column < save.x + save.width { self.request_complete_onboarding = true; } } @@ -1077,6 +1137,72 @@ impl AppState { } } + pub(crate) fn sidebar_footer_rect(&self) -> Rect { + let sidebar = self.view.sidebar_rect; + if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { + return Rect::default(); + } + let content = Rect::new( + sidebar.x, + sidebar.y, + sidebar.width.saturating_sub(1), + sidebar.height, + ); + let total_h = content.height as usize; + let ws_h = (total_h + 1) / 2; + let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16); + let y = ws_area.y + ws_area.height.saturating_sub(1); + Rect::new(ws_area.x, y, ws_area.width, 1) + } + + pub(crate) fn sidebar_new_button_rect(&self) -> Rect { + let footer = self.sidebar_footer_rect(); + let width = 5u16.min(footer.width.max(1)); + Rect::new(footer.x, footer.y, width, footer.height) + } + + pub(crate) fn global_launcher_rect(&self) -> Rect { + let footer = self.sidebar_footer_rect(); + let width = 6u16.min(footer.width.max(1)); + let x = footer.x + footer.width.saturating_sub(width); + Rect::new(x, footer.y, width, footer.height) + } + + pub(crate) fn global_menu_rect(&self) -> Rect { + let screen = self.screen_rect(); + let launcher = self.global_launcher_rect(); + let menu_w = 14u16.min(screen.width.max(1)); + let menu_h = (GlobalMenuAction::ALL.len() as u16 + 2).min(screen.height.max(1)); + let max_x = screen.x + screen.width.saturating_sub(menu_w); + let desired_x = launcher.x + launcher.width.saturating_sub(menu_w); + let x = desired_x.min(max_x); + let y = launcher.y.saturating_sub(menu_h); + Rect::new(x, y, menu_w, menu_h) + } + + fn global_menu_item_at(&self, col: u16, row: u16) -> Option<GlobalMenuAction> { + let rect = self.global_menu_rect(); + if col <= rect.x + || col >= rect.x + rect.width.saturating_sub(1) + || row <= rect.y + || row >= rect.y + rect.height.saturating_sub(1) + { + return None; + } + let idx = (row - rect.y - 1) as usize; + 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) + } + fn settings_popup_rect(&self) -> Rect { let area = self.screen_rect(); let popup_w = 56u16.min(area.width.saturating_sub(4)); @@ -1121,9 +1247,10 @@ impl AppState { Rect::new(inner.x, y, inner.width, inner.y + inner.height - y) } - fn settings_list_index_at(&self, row: u16) -> Option<usize> { + fn settings_list_index_at(&self, col: u16, row: u16) -> Option<usize> { let area = self.settings_content_rect(); - if row < area.y || row >= area.y + area.height { + if row < area.y || row >= area.y + area.height || col < area.x || col >= area.x + area.width + { return None; } @@ -1152,16 +1279,13 @@ impl AppState { fn settings_button_at(&self, col: u16, row: u16) -> Option<&'static str> { let inner = self.settings_inner_rect(); - let footer_y = inner.y + inner.height.saturating_sub(1); - if row != footer_y { + let (apply, close) = crate::ui::settings_button_rects(inner); + if row != apply.y { return None; } - let total_w = 7u16 + 2 + 7u16; - let apply_x = inner.x + inner.width.saturating_sub(total_w) / 2; - let close_x = apply_x + 9; - if col >= apply_x && col < apply_x + 7 { + if col >= apply.x && col < apply.x + apply.width { Some("apply") - } else if col >= close_x && col < close_x + 7 { + } else if col >= close.x && col < close.x + close.width { Some("close") } else { None @@ -1172,24 +1296,6 @@ impl AppState { use crate::app::state::SettingsSection; match mouse.kind { - MouseEventKind::Moved => { - if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) { - self.settings.section = section; - self.settings.selected = match section { - SettingsSection::Theme => current_theme_index(&self.theme_name), - SettingsSection::Sound => usize::from(!self.sound_enabled()), - SettingsSection::Toast => usize::from(!self.toast_config.enabled), - }; - return None; - } - if let Some(idx) = self.settings_list_index_at(mouse.row) { - self.settings.selected = idx; - if self.settings.section == SettingsSection::Theme { - preview_selected_theme(self); - } - } - None - } MouseEventKind::Down(MouseButton::Left) => { if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) { self.settings.section = section; @@ -1200,7 +1306,7 @@ impl AppState { }; return None; } - if let Some(idx) = self.settings_list_index_at(mouse.row) { + if let Some(idx) = self.settings_list_index_at(mouse.column, mouse.row) { self.settings.selected = idx; return match self.settings.section { SettingsSection::Theme => { @@ -1245,6 +1351,66 @@ impl AppState { return self.handle_settings_mouse(mouse); } + let launcher_enabled = !self.sidebar_collapsed + && matches!( + self.mode, + Mode::Terminal + | Mode::Navigate + | Mode::Resize + | Mode::GlobalMenu + | Mode::KeybindHelp + ); + let launcher = self.global_launcher_rect(); + let launcher_hit = launcher_enabled + && mouse.column >= launcher.x + && mouse.column < launcher.x + launcher.width + && mouse.row >= launcher.y + && mouse.row < launcher.y + launcher.height; + + if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu { + if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) { + self.global_menu_selected = GlobalMenuAction::ALL + .iter() + .position(|item| *item == action) + .unwrap_or(0); + } + return None; + } + + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && launcher_hit { + if self.mode == Mode::GlobalMenu { + leave_modal(self); + } else { + open_global_menu(self); + } + return None; + } + + if self.mode == Mode::GlobalMenu { + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) { + apply_global_menu_action(self, action); + } else { + leave_modal(self); + } + } + return None; + } + + 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; + if !inside { + leave_modal(self); + } + } + return None; + } + let sidebar = self.view.sidebar_rect; let in_sidebar = mouse.column >= sidebar.x && mouse.column < sidebar.x + sidebar.width @@ -1355,11 +1521,12 @@ impl AppState { return None; } - let total_h = sidebar.height as usize; - let ws_h = (total_h + 1) / 2; - let ws_bottom = sidebar.y + ws_h as u16; - let new_row = ws_bottom.saturating_sub(1); - if mouse.row == new_row { + let new_button = self.sidebar_new_button_rect(); + let on_new_button = mouse.row >= new_button.y + && mouse.row < new_button.y + new_button.height + && mouse.column >= new_button.x + && mouse.column < new_button.x + new_button.width; + if on_new_button { self.request_new_workspace = true; return None; } @@ -1584,17 +1751,16 @@ impl AppState { } fn workspace_at_row(&self, row: u16) -> Option<usize> { - let sidebar = self.view.sidebar_rect; - let total_h = sidebar.height as usize; - let ws_h = (total_h + 1) / 2; - let ws_bottom = sidebar.y + ws_h as u16; - let new_row = ws_bottom.saturating_sub(1); + let footer = self.sidebar_footer_rect(); + if footer == Rect::default() { + return None; + } - if row < sidebar.y || row >= new_row { + if row < self.view.sidebar_rect.y || row >= footer.y { return None; } - let mut row_y = sidebar.y; + let mut row_y = self.view.sidebar_rect.y; for (i, ws) in self.workspaces.iter().enumerate() { let has_branch = ws.branch().is_some(); let card_h: u16 = if has_branch { 2 } else { 1 }; @@ -1602,7 +1768,7 @@ impl AppState { return Some(i); } row_y += card_h + 1; // +1 for gap - if row_y >= new_row { + if row_y >= footer.y { break; } } @@ -1652,18 +1818,13 @@ impl AppState { popup.width.saturating_sub(2), popup.height.saturating_sub(2), ); - let confirm_w = 9u16; - let cancel_w = 8u16; - let gap = 2u16; - let total_w = confirm_w + gap + cancel_w; - let x = inner.x + inner.width.saturating_sub(total_w) / 2; - let y = inner.y + 2.min(inner.height.saturating_sub(1)); - if row != y { + let (confirm, cancel) = crate::ui::confirm_close_button_rects(inner); + if row != confirm.y { return None; } - if col >= x && col < x + confirm_w { + if col >= confirm.x && col < confirm.x + confirm.width { Some(true) - } else if col >= x + confirm_w + gap && col < x + total_w { + } else if col >= cancel.x && col < cancel.x + cancel.width { Some(false) } else { None @@ -2135,6 +2296,139 @@ mod tests { } #[test] + fn question_mark_opens_keybind_help_from_navigate() { + let mut state = state_with_workspaces(&["test"]); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), + ); + + assert_eq!(state.mode, Mode::KeybindHelp); + } + + #[test] + fn clicking_launcher_opens_global_menu() { + let mut app = app_for_mouse_test(); + let rect = app.state.global_launcher_rect(); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + rect.x + rect.width.saturating_sub(1), + rect.y, + )); + + assert_eq!(app.state.mode, Mode::GlobalMenu); + } + + #[test] + fn clicking_keybinds_menu_item_opens_help() { + let mut app = app_for_mouse_test(); + let launcher = app.state.global_launcher_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + launcher.x, + launcher.y, + )); + + let menu = app.state.global_menu_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + menu.x + 2, + menu.y + 1, + )); + + assert_eq!(app.state.mode, Mode::KeybindHelp); + } + + #[test] + fn clicking_settings_menu_item_opens_settings() { + let mut app = app_for_mouse_test(); + let launcher = app.state.global_launcher_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + launcher.x, + launcher.y, + )); + + let menu = app.state.global_menu_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + menu.x + 2, + menu.y + 2, + )); + + assert_eq!(app.state.mode, Mode::Settings); + } + + #[test] + fn clicking_confirm_close_accepts_workspace_close() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; + app.state.active = Some(0); + app.state.selected = 1; + app.state.mode = Mode::ConfirmClose; + + let popup = app.state.confirm_close_rect(); + let inner = Rect::new( + popup.x + 1, + popup.y + 1, + popup.width.saturating_sub(2), + popup.height.saturating_sub(2), + ); + let (confirm, _) = crate::ui::confirm_close_button_rects(inner); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + confirm.x, + confirm.y, + )); + + assert_eq!(app.state.workspaces.len(), 1); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[test] + fn clicking_confirm_close_accepts_after_workspace_context_menu_close() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.state.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Workspace { ws_idx: 1 }, + x: 2, + y: 2, + selected: 1, + }); + app.state.mode = Mode::ContextMenu; + handle_context_menu_key( + &mut app.state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + assert_eq!(app.state.mode, Mode::ConfirmClose); + assert_eq!(app.state.selected, 1); + + let popup = app.state.confirm_close_rect(); + let inner = Rect::new( + popup.x + 1, + popup.y + 1, + popup.width.saturating_sub(2), + popup.height.saturating_sub(2), + ); + let (confirm, _) = crate::ui::confirm_close_button_rects(inner); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + confirm.x + 1, + confirm.y, + )); + + assert_eq!(app.state.workspaces.len(), 1); + assert_eq!(app.state.workspaces[0].display_name(), "a"); + } + + #[test] fn dragging_sidebar_divider_sets_manual_width() { let mut app = app_for_mouse_test(); diff --git a/src/app/mod.rs b/src/app/mod.rs index b69c324e..126d7c92 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -237,6 +237,7 @@ impl App { original_palette: None, original_theme: None, }, + global_menu_selected: 0, }; for ws in &mut state.workspaces { diff --git a/src/app/state.rs b/src/app/state.rs index 5770154d..32da8dfe 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -344,6 +344,8 @@ pub enum Mode { ConfirmClose, ContextMenu, Settings, + GlobalMenu, + KeybindHelp, } // --------------------------------------------------------------------------- @@ -512,6 +514,8 @@ pub struct AppState { pub theme_name: String, /// Settings panel state. pub settings: SettingsState, + /// Currently selected item in the bottom-right global launcher menu. + pub global_menu_selected: usize, } impl AppState { @@ -609,13 +613,19 @@ impl AppState { close_workspace: (KeyCode::Char('d'), KeyModifiers::empty()), close_workspace_label: "d".into(), previous_workspace: None, + previous_workspace_label: None, next_workspace: None, + next_workspace_label: None, new_tab: (KeyCode::Char('c'), KeyModifiers::empty()), new_tab_label: "c".into(), rename_tab: None, + rename_tab_label: None, previous_tab: None, + previous_tab_label: None, next_tab: None, + next_tab_label: None, close_tab: None, + close_tab_label: None, split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()), split_vertical_label: "v".into(), split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()), @@ -638,6 +648,7 @@ impl AppState { original_palette: None, original_theme: None, }, + global_menu_selected: 0, } } } diff --git a/src/config.rs b/src/config.rs index 6f5d17ae..bbbab461 100644 --- a/src/config.rs +++ b/src/config.rs @@ -370,9 +370,9 @@ impl Config { field: &'static str, configured_label: &str, diagnostics: &mut Vec<String>, - ) -> (Option<(KeyCode, KeyModifiers)>, String) { + ) -> (Option<(KeyCode, KeyModifiers)>, Option<String>) { if configured_label.trim().is_empty() { - return (None, String::new()); + return (None, None); } let (value, diag) = parse_key_combo_with_diagnostic( configured_label, @@ -381,9 +381,9 @@ impl Config { ); if let Some(diag) = diag { diagnostics.push(diag); - (None, String::new()) + (None, None) } else { - (Some(value), configured_label.to_string()) + (Some(value), Some(configured_label.to_string())) } } @@ -506,13 +506,19 @@ impl Config { close_workspace: bindings[2].value, close_workspace_label: bindings[2].label.clone(), previous_workspace: optional_bindings[0].0, + previous_workspace_label: optional_bindings[0].1.clone(), next_workspace: optional_bindings[1].0, + next_workspace_label: optional_bindings[1].1.clone(), new_tab: bindings[3].value, new_tab_label: bindings[3].label.clone(), rename_tab: optional_bindings[2].0, + rename_tab_label: optional_bindings[2].1.clone(), previous_tab: optional_bindings[3].0, + previous_tab_label: optional_bindings[3].1.clone(), next_tab: optional_bindings[4].0, + next_tab_label: optional_bindings[4].1.clone(), close_tab: optional_bindings[5].0, + close_tab_label: optional_bindings[5].1.clone(), split_vertical: bindings[4].value, split_vertical_label: bindings[4].label.clone(), split_horizontal: bindings[5].value, @@ -541,13 +547,19 @@ pub struct Keybinds { pub close_workspace: (KeyCode, KeyModifiers), pub close_workspace_label: String, pub previous_workspace: Option<(KeyCode, KeyModifiers)>, + pub previous_workspace_label: Option<String>, pub next_workspace: Option<(KeyCode, KeyModifiers)>, + pub next_workspace_label: Option<String>, pub new_tab: (KeyCode, KeyModifiers), pub new_tab_label: String, pub rename_tab: Option<(KeyCode, KeyModifiers)>, + pub rename_tab_label: Option<String>, pub previous_tab: Option<(KeyCode, KeyModifiers)>, + pub previous_tab_label: Option<String>, pub next_tab: Option<(KeyCode, KeyModifiers)>, + pub next_tab_label: Option<String>, pub close_tab: Option<(KeyCode, KeyModifiers)>, + pub close_tab_label: Option<String>, pub split_vertical: (KeyCode, KeyModifiers), pub split_vertical_label: String, pub split_horizontal: (KeyCode, KeyModifiers), @@ -796,6 +808,47 @@ fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { Some((code, modifiers)) } +pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { + let (code, modifiers) = binding; + let mut parts = Vec::new(); + if modifiers.contains(KeyModifiers::CONTROL) { + parts.push("ctrl".to_string()); + } + if modifiers.contains(KeyModifiers::ALT) { + parts.push("alt".to_string()); + } + if modifiers.contains(KeyModifiers::SHIFT) { + parts.push("shift".to_string()); + } + + let key = match code { + KeyCode::Char(' ') => "space".to_string(), + KeyCode::Char(c) => c.to_string(), + KeyCode::Enter => "enter".to_string(), + KeyCode::Esc => "esc".to_string(), + KeyCode::Tab => "tab".to_string(), + KeyCode::BackTab => "shift+tab".to_string(), + KeyCode::Backspace => "backspace".to_string(), + KeyCode::Left => "left".to_string(), + KeyCode::Right => "right".to_string(), + KeyCode::Up => "up".to_string(), + KeyCode::Down => "down".to_string(), + KeyCode::F(n) => format!("f{n}"), + _ => format!("{:?}", code).to_lowercase(), + }; + + if matches!(code, KeyCode::BackTab) { + return if parts.is_empty() { + key + } else { + format!("{}+tab", parts.join("+")) + }; + } + + parts.push(key); + parts.join("+") +} + fn parse_key_combo_with_diagnostic( s: &str, field: &str, @@ -103,6 +103,8 @@ pub fn render(app: &AppState, frame: &mut Frame) { } Mode::Settings => render_settings_overlay(app, frame, frame.area()), Mode::RenameWorkspace | Mode::RenameTab => render_rename_overlay(app, frame, frame.area()), + Mode::GlobalMenu => render_global_launcher_menu(app, frame), + Mode::KeybindHelp => render_keybind_help_overlay(app, frame), Mode::Terminal => {} } @@ -320,6 +322,8 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { | Mode::ConfirmClose | Mode::ContextMenu | Mode::Settings + | Mode::GlobalMenu + | Mode::KeybindHelp ); let p = &app.palette; @@ -387,6 +391,8 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { | Mode::ConfirmClose | Mode::ContextMenu | Mode::Settings + | Mode::GlobalMenu + | Mode::KeybindHelp ); let sep_style = if is_navigating { Style::default().fg(p.accent) @@ -572,11 +578,19 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig row_y += 1; } - // "new" at the bottom of workspace section + // Footer actions for workspace section if list_bottom > area.y { + let new_rect = app.sidebar_new_button_rect(); frame.render_widget( Paragraph::new(Span::styled("new", Style::default().fg(p.overlay0))), - Rect::new(area.x, list_bottom, area.width, 1), + new_rect, + ); + + let menu_rect = app.global_launcher_rect(); + frame.render_widget( + Paragraph::new(Span::styled("menu", Style::default().fg(p.overlay0))) + .alignment(Alignment::Right), + menu_rect, ); } } @@ -1123,16 +1137,15 @@ fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) { .style(Style::default().fg(app.palette.overlay1)), rows[1], ); - frame.render_widget( - Paragraph::new(Span::styled( - " [ close ] ", - Style::default() - .fg(app.palette.panel_bg) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - )) - .alignment(Alignment::Right), - rows[0], + 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), ); let body_area = rows[3]; @@ -1221,6 +1234,33 @@ fn render_release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<Line<'a>> { lines } +pub(crate) fn release_notes_close_button_rect(area: Rect) -> Rect { + let width = action_button_width(Some("esc"), "close"); + Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1) +} + +pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect { + Rect::new( + area.x, + area.y, + action_button_width(Some("↵"), "continue"), + 1, + ) +} + +pub(crate) fn onboarding_notification_button_rects(area: Rect) -> (Rect, Rect) { + let rects = centered_button_row( + area, + &[ + action_button_width(Some("esc"), "back"), + action_button_width(Some("↵"), "save"), + ], + 2, + 0, + ); + (rects[0], rects[1]) +} + fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { let Some(inner) = render_modal_shell(frame, area, 64, 15, &app.palette) else { return; @@ -1308,21 +1348,26 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { ])), rows[7], ); + let continue_rect = onboarding_welcome_continue_rect(rows[9]); + render_action_button( + frame, + continue_rect, + Some("↵"), + "continue", + Style::default() + .fg(app.palette.panel_bg) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD), + ); frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled( - " continue ", - Style::default() - .fg(app.palette.panel_bg) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " readme has config and more", - Style::default().fg(app.palette.overlay0), - ), - ])), - rows[9], + Paragraph::new(" readme has config and more") + .style(Style::default().fg(app.palette.overlay0)), + Rect::new( + continue_rect.x + continue_rect.width + 2, + rows[9].y, + rows[9].width.saturating_sub(continue_rect.width + 2), + 1, + ), ); } @@ -1376,19 +1421,26 @@ fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect ); } - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" [ back ] ", Style::default().fg(app.palette.overlay0)), - Span::raw(" "), - Span::styled( - " [ save ] ", - Style::default() - .fg(app.palette.panel_bg) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - ])), - rows[6], + let (back_rect, save_rect) = onboarding_notification_button_rects(rows[6]); + render_action_button( + frame, + back_rect, + Some("esc"), + "back", + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0) + .add_modifier(Modifier::BOLD), + ); + render_action_button( + frame, + save_rect, + Some("↵"), + "save", + Style::default() + .fg(app.palette.panel_bg) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD), ); } @@ -1398,96 +1450,216 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { .fg(app.palette.accent) .add_modifier(Modifier::BOLD); let dim = Style::default().fg(app.palette.overlay0); - let label = Style::default().fg(app.palette.text); - - let kb = &app.keybinds; - let line1 = Line::from(vec![ - Span::styled(format!(" {}", kb.new_workspace_label), key), - Span::styled(" new ws ", dim), - Span::styled(kb.rename_workspace_label.as_str(), key), - Span::styled(" rename ws ", dim), - Span::styled(kb.close_workspace_label.as_str(), key), - Span::styled(" close ws ", dim), - Span::styled(kb.new_tab_label.as_str(), key), - Span::styled(" new tab ", dim), - Span::styled(kb.split_vertical_label.as_str(), key), - Span::styled(" split│ ", dim), - Span::styled(kb.split_horizontal_label.as_str(), key), - Span::styled(" split─ ", dim), - Span::styled(kb.close_pane_label.as_str(), key), - Span::styled(" close pane ", dim), - Span::styled(kb.fullscreen_label.as_str(), key), - Span::styled(" fullscreen", dim), - ]); - - let ws_name = app - .active - .and_then(|i| app.workspaces.get(i)) - .map(|ws| ws.display_name()) - .unwrap_or_else(|| "—".to_string()); - - let pane_info = app - .active - .and_then(|i| app.workspaces.get(i)) - .filter(|ws| ws.layout.pane_count() > 1) - .map(|ws| { - let ids = ws.layout.pane_ids(); - let pos = ids - .iter() - .position(|id| *id == ws.layout.focused()) - .unwrap_or(0); - format!(" [{}/{}]", pos + 1, ids.len()) - }) - .unwrap_or_default(); let mode_style = Style::default() .fg(app.palette.panel_bg) .bg(app.palette.accent) .add_modifier(Modifier::BOLD); - let line2 = Line::from(vec![ + let kb = &app.keybinds; + let line = Line::from(vec![ Span::styled(" NAVIGATE ", mode_style), Span::raw(" "), - Span::styled(ws_name, label), - Span::styled(&pane_info, dim), - Span::raw(" "), Span::styled("esc", key), Span::styled(" back ", dim), Span::styled("↑↓", key), Span::styled(" ws ", dim), Span::styled("⇥", key), Span::styled(" pane ", dim), + Span::styled(kb.new_tab_label.as_str(), key), + Span::styled(" new tab ", dim), + Span::styled(kb.split_vertical_label.as_str(), key), + Span::styled(" split│ ", dim), + Span::styled(kb.split_horizontal_label.as_str(), key), + Span::styled(" split─ ", dim), + Span::styled(kb.close_pane_label.as_str(), key), + Span::styled(" close ", dim), + Span::styled(kb.fullscreen_label.as_str(), key), + Span::styled(" full ", dim), Span::styled(kb.resize_mode_label.as_str(), key), Span::styled(" resize ", dim), - Span::styled(kb.toggle_sidebar_label.as_str(), key), - Span::styled(" sidebar ", dim), + Span::styled("?", key), + Span::styled(" keybinds ", dim), Span::styled("s", key), - Span::styled(" settings ", dim), - Span::styled("⏎", key), - Span::styled(" open ", dim), - Span::styled("q", key), - Span::styled(" quit", dim), + Span::styled(" settings", dim), ]); - let overlay_height = 2; - let overlay_y = area.y + area.height.saturating_sub(overlay_height); - let overlay_area = Rect::new(area.x, overlay_y, area.width, overlay_height); + let overlay_y = area.y + area.height.saturating_sub(1); + let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - // Clear the area behind the overlay frame.render_widget(Clear, overlay_area); - let bg = Style::default().bg(app.palette.panel_bg); let buf = frame.buffer_mut(); - for y in overlay_area.y..overlay_area.y + overlay_area.height { - for x in overlay_area.x..overlay_area.x + overlay_area.width { - buf[(x, y)].set_style(bg); + for x in overlay_area.x..overlay_area.x + overlay_area.width { + buf[(x, overlay_y)].set_style(bg); + } + frame.render_widget(Paragraph::new(line), overlay_area); +} + +fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) { + let rect = app.global_menu_rect(); + let Some(inner) = render_panel_shell(frame, rect, app.palette.accent, app.palette.panel_bg) + else { + return; + }; + + let items = ["keybinds", "settings"]; + for (idx, item) in items.iter().enumerate() { + let y = inner.y + idx as u16; + if y >= inner.y + inner.height { + break; } + let selected = idx == app.global_menu_selected; + let style = if selected { + Style::default() + .fg(app.palette.panel_bg) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(app.palette.text) + }; + frame.render_widget( + Paragraph::new(format!(" {item}")) + .style(style) + .alignment(Alignment::Left), + Rect::new(inner.x, y, inner.width, 1), + ); + } +} + +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"), + ], + )); + + 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"), + (kb.new_tab_label.clone(), "new 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 [row1, row2] = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(overlay_area); - frame.render_widget(Paragraph::new(line1), row1); - frame.render_widget(Paragraph::new(line2), row2); + groups +} + +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); + 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 hint_style = Style::default().fg(app.palette.overlay0); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" keybinds", title_style), + Span::styled(" esc to close", hint_style), + ])), + Rect::new(inner.x, inner.y, inner.width, 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 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; + 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; + } } /// Floating overlay for resize mode. @@ -1527,18 +1699,17 @@ fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) { /// Centered popup confirmation dialog with dimmed background. pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) { - let save_w = 10u16; - let clear_w = 11u16; - let cancel_w = 12u16; - let gap = 2u16; - let total_w = save_w + gap + clear_w + gap + cancel_w; - let x = inner.x + inner.width.saturating_sub(total_w) / 2; - let y = inner.y + 3; - ( - Rect::new(x, y, save_w, 1), - Rect::new(x + save_w + gap, y, clear_w, 1), - Rect::new(x + save_w + gap + clear_w + gap, y, cancel_w, 1), - ) + let rects = centered_button_row( + inner, + &[ + action_button_width(Some("↵"), "save"), + action_button_width(Some("^c"), "clear"), + action_button_width(Some("esc"), "cancel"), + ], + 2, + 3, + ); + (rects[0], rects[1], rects[2]) } fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) { @@ -1581,32 +1752,35 @@ fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner); - frame.render_widget( - Paragraph::new(" ↵ save ").style( - Style::default() - .fg(app.palette.panel_bg) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), + render_action_button( + frame, save_rect, + Some("↵"), + "save", + Style::default() + .fg(app.palette.panel_bg) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD), ); - frame.render_widget( - Paragraph::new(" ^c clear ").style( - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ), + render_action_button( + frame, clear_rect, + Some("^c"), + "clear", + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0) + .add_modifier(Modifier::BOLD), ); - frame.render_widget( - Paragraph::new(" esc cancel ").style( - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ), + render_action_button( + frame, cancel_rect, + Some("esc"), + "cancel", + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0) + .add_modifier(Modifier::BOLD), ); } @@ -1679,8 +1853,12 @@ fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let (confirm_rect, cancel_rect) = confirm_close_button_rects(inner); let confirm_selected = app.confirm_close_selected_confirm; - frame.render_widget( - Paragraph::new(" confirm ").style(if confirm_selected { + render_action_button( + frame, + confirm_rect, + Some("↵"), + "confirm", + if confirm_selected { Style::default() .fg(app.palette.panel_bg) .bg(app.palette.red) @@ -1690,11 +1868,14 @@ fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { .fg(app.palette.text) .bg(app.palette.surface0) .add_modifier(Modifier::BOLD) - }), - confirm_rect, + }, ); - frame.render_widget( - Paragraph::new(" cancel ").style(if confirm_selected { + render_action_button( + frame, + cancel_rect, + Some("esc"), + "cancel", + if confirm_selected { Style::default() .fg(app.palette.text) .bg(app.palette.surface0) @@ -1704,12 +1885,37 @@ fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { .fg(app.palette.panel_bg) .bg(app.palette.accent) .add_modifier(Modifier::BOLD) - }), - cancel_rect, + }, ); } } +pub(crate) fn action_button_text(hint: Option<&str>, label: &str) -> String { + match hint { + Some(hint) => format!(" {hint} {label} "), + None => format!(" {label} "), + } +} + +pub(crate) fn action_button_width(hint: Option<&str>, label: &str) -> u16 { + action_button_text(hint, label).chars().count() as u16 +} + +fn render_action_button( + frame: &mut Frame, + rect: Rect, + hint: Option<&str>, + label: &str, + style: Style, +) { + frame.render_widget( + Paragraph::new(action_button_text(hint, label)) + .style(style) + .alignment(Alignment::Center), + rect, + ); +} + fn centered_button_row(inner: Rect, widths: &[u16], gap: u16, row_offset: u16) -> Vec<Rect> { let total_w = widths .iter() @@ -1733,13 +1939,29 @@ fn centered_button_row(inner: Rect, widths: &[u16], gap: u16, row_offset: u16) - .collect() } -fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) { - let rects = centered_button_row(inner, &[9, 8], 2, 2); +pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) { + let rects = centered_button_row( + inner, + &[ + action_button_width(Some("↵"), "confirm"), + action_button_width(Some("esc"), "cancel"), + ], + 2, + 2, + ); (rects[0], rects[1]) } -fn settings_button_rects(inner: Rect) -> (Rect, Rect) { - let rects = centered_button_row(inner, &[7, 7], 2, inner.height.saturating_sub(1)); +pub(crate) fn settings_button_rects(inner: Rect) -> (Rect, Rect) { + let rects = centered_button_row( + inner, + &[ + action_button_width(Some("↵"), "apply"), + action_button_width(Some("esc"), "close"), + ], + 2, + inner.height.saturating_sub(1), + ); (rects[0], rects[1]) } @@ -1850,23 +2072,25 @@ fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let footer_y = inner.y + inner.height - 1; if footer_y > y { let (apply_rect, close_rect) = settings_button_rects(inner); - frame.render_widget( - Paragraph::new(" apply ").style( - Style::default() - .fg(p.panel_bg) - .bg(p.accent) - .add_modifier(Modifier::BOLD), - ), + render_action_button( + frame, apply_rect, + Some("↵"), + "apply", + Style::default() + .fg(p.panel_bg) + .bg(p.accent) + .add_modifier(Modifier::BOLD), ); - frame.render_widget( - Paragraph::new(" close ").style( - Style::default() - .fg(p.text) - .bg(p.surface0) - .add_modifier(Modifier::BOLD), - ), + render_action_button( + frame, close_rect, + Some("esc"), + "close", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), ); let hint_area = Rect::new(inner.x, footer_y.saturating_sub(1), inner.width, 1); |
