//! The Keybindings page, laid out like T3 Code's: a search field, then the //! bindings in T3 Code's groups (Navigation, Threads, Composer, …), each //! row the command, when it applies and its keys. The rows come from the //! app's live keymap, so they show whatever is bound; keys the composer //! handles itself are listed from the send shortcut setting. use gpui_kit::component::{h_flex, tooltip::Tooltip, v_flex}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use super::search::Item; use super::widgets::{Section, SettingRow, empty_row, search_field}; use super::{Page, SettingsView}; use crate::settings::{self, QuitShortcut, SendShortcut}; use crate::ui::metal; /// A command the app binds, as T3 Code names it. struct Command { /// T3 Code's command id, for the group and the tooltip. id: &'static str, label: &'static str, /// The GPUI actions that run it. actions: &'static [&'static str], } const COMMANDS: &[Command] = &[ Command { id: "sidebar.toggle", label: "Sidebar: Toggle", actions: &["vectrum_code::ToggleSidebar"] }, Command { id: "rightPanel.toggle", label: "Right Panel: Toggle", actions: &["vectrum_code::ToggleProjectPanel"] }, Command { id: "commandPalette.toggle", label: "Command Palette: Toggle", actions: &["vectrum_code::FocusSearch", "vectrum_code::ToggleCommandPalette"], }, Command { id: "projectSearch.toggle", label: "Project Search: Toggle", actions: &["vectrum_code::ProjectSearch"] }, Command { id: "chat.new", label: "Chat: New", actions: &["vectrum_code::NewThread"] }, Command { id: "chat.newLocal", label: "Chat: New Local", actions: &["vectrum_code::NewLocalThread"] }, Command { id: "chat.find", label: "Chat: Find", actions: &["vectrum_code::FindInThread"] }, Command { id: "thread.stop", label: "Thread: Stop", actions: &["vectrum_code::Interrupt"] }, Command { id: "thread.previous", label: "Thread: Previous", actions: &["vectrum_code::PreviousThread"] }, Command { id: "thread.next", label: "Thread: Next", actions: &["vectrum_code::NextThread"] }, Command { id: "thread.settle", label: "Thread: Settle", actions: &["vectrum_code::SettleThread"] }, Command { id: "thread.pin", label: "Thread: Pin", actions: &["vectrum_code::PinThread"] }, Command { id: "terminal.toggle", label: "Terminal: Toggle", actions: &["vectrum_code::ToggleTerminal"] }, Command { id: "terminal.split", label: "Terminal: Split", actions: &["vectrum_code::SplitTerminal"] }, Command { id: "terminal.splitVertical", label: "Terminal: Split Vertical", actions: &["vectrum_code::SplitTerminalVertical"], }, Command { id: "terminal.new", label: "Terminal: New", actions: &["vectrum_code::NewTerminal"] }, Command { id: "terminal.close", label: "Terminal: Close", actions: &["vectrum_code::CloseTerminal"] }, Command { id: "terminal.copy", label: "Terminal: Copy", actions: &["vectrum_code::TerminalCopy"] }, Command { id: "terminal.paste", label: "Terminal: Paste", actions: &["vectrum_code::TerminalPaste"] }, Command { id: "modelPicker.toggle", label: "Model Picker: Toggle", actions: &["vectrum_code::ToggleModelPicker"] }, Command { id: "preview.toggle", label: "Preview: Toggle", actions: &["vectrum_code::ToggleBrowser"] }, Command { id: "diff.toggle", label: "Diff: Toggle", actions: &["vectrum_code::ToggleDiff"] }, Command { id: "usage.open", label: "Usage: Open", actions: &["vectrum_code::OpenUsage"] }, Command { id: "project.add", label: "Project: Add", actions: &["vectrum_code::OpenProject"] }, Command { id: "settings.open", label: "Settings: Toggle", actions: &["vectrum_code::OpenSettings"] }, Command { id: "settings.close", label: "Settings: Close", actions: &["vectrum_code::CloseSettings"] }, Command { id: "app.quit", label: "App: Quit", actions: &["vectrum_code::Quit"] }, ]; /// Keys the composer and pickers handle themselves rather than through the /// keymap. const COMPOSER_COMMANDS: &[(&str, &str)] = &[ ("composer.send", "Composer: Send"), ("composer.newLine", "Composer: New Line"), ("composer.sendAlternate", "Composer: Opposite Queue or Steer Action"), ("composer.slashMenu", "Composer: Commands and Skills"), ("composer.skills", "Composer: Skills"), ("modelPicker.jump", "Model Picker: Jump"), ]; /// T3 Code's groups, in page order; a command joins the first group /// listing its id's prefix. const GROUPS: &[(&str, &[&str])] = &[ ("Navigation", &["sidebar", "rightPanel", "commandPalette", "filePicker", "projectSearch", "editor"]), ("Threads", &["thread", "chat", "pullRequest"]), ("Composer", &["composer", "modelPicker"]), ("Terminal", &["terminal"]), ("Preview & diff", &["preview", "diff"]), ("Appearance", &["theme", "appearance", "themeEditor"]), ("Project scripts", &["script"]), ]; fn group_of(id: &str) -> &'static str { let prefix = id.split('.').next().unwrap_or_default(); GROUPS.iter().find(|(_, prefixes)| prefixes.contains(&prefix)).map_or("Other", |(title, _)| title) } /// The search results for the commands, after every other setting. pub(super) fn search_items() -> impl Iterator { COMMANDS.iter().map(|command| (command.id, command.label)).chain(COMPOSER_COMMANDS.iter().copied()).map(|(id, label)| Item { id: "keybindings", title: label, page: Page::Keybindings, terms: id, fold: None, secondary: true, fallback: None, }) } /// `vectrum_code::ToggleCommandPalette` → `Toggle Command Palette`. fn title_case(action: &str) -> String { let name = action.rsplit("::").next().unwrap_or(action); let mut words = String::new(); for (ix, c) in name.chars().enumerate() { if ix > 0 && c.is_uppercase() { words.push(' '); } words.push(c); } words } /// What a binding's context means, in T3 Code's when-clause words. fn when_text(context: &str) -> String { context .replace("ChatView", "composerFocus") .replace("SettingsView", "settingsOpen") .replace("Terminal", "terminalFocus") .replace("Workspace", "always") } /// One row on the page. struct Binding { id: String, label: String, when: String, keys: Vec>, } impl SettingsView { pub(super) fn render_keybindings(&self, cx: &mut Context) -> Vec { let p = metal::palette(cx); let query = self.keybinding_search.read(cx).value().trim().to_lowercase(); let mut bindings = keymap_bindings(cx); bindings.extend(composer_bindings()); bindings.sort_by(|a, b| a.label.cmp(&b.label)); let shown: Vec = bindings .into_iter() .filter(|binding| { query.is_empty() || binding.label.to_lowercase().contains(&query) || binding.when.to_lowercase().contains(&query) || binding.id.to_lowercase().contains(&query) || binding.keys.iter().any(|keys| keys.join("+").to_lowercase().contains(&query)) }) .collect(); let toolbar = h_flex() .px(px(16.)) .py(px(12.)) .gap(px(8.)) .child(search_field(&self.keybinding_search, 0., cx).flex_1()) .into_any_element(); let mut pages = vec![ Section::new("Keybindings") .id("keybindings") .row(toolbar) .when(shown.is_empty(), |section| section.row(empty_row("No keybindings match your search.", p))) .render(cx), ]; let mut groups: Vec<&'static str> = GROUPS.iter().map(|(title, _)| *title).collect(); groups.push("Other"); for group in groups { let rows: Vec = shown .iter() .filter(|binding| group_of(&binding.id) == group) .map(|binding| binding_row(binding, cx)) .collect(); if !rows.is_empty() { pages.push(Section::new(group).rows(rows).render(cx)); } } pages } } /// The app's own bindings in the live keymap, one row per command and /// context, with every key that runs it. fn keymap_bindings(cx: &App) -> Vec { let keymap = cx.key_bindings(); let keymap = keymap.borrow(); let mut rows: Vec = Vec::new(); for binding in keymap.bindings() { let action = binding.action().name(); if !action.starts_with("vectrum_code::") { continue; } let command = COMMANDS.iter().find(|command| command.actions.contains(&action)); let (id, label) = match command { Some(command) => (command.id.to_string(), command.label.to_string()), None => (format!("app.{action}"), title_case(action)), }; let when = binding.predicate().map(|predicate| when_text(&predicate.to_string())).unwrap_or_else(|| "Always".into()); let keys: Vec = binding .keystrokes() .iter() .flat_map(|keystroke| { let modifiers = keystroke.modifiers(); let key = &keystroke.inner().key; let mut caps = Vec::new(); if modifiers.control { caps.push("Ctrl".to_string()); } if modifiers.alt { caps.push("Alt".to_string()); } if modifiers.shift { caps.push("Shift".to_string()); } if modifiers.platform { caps.push("Super".to_string()); } caps.push(key_label(key)); caps }) .collect(); match rows.iter_mut().find(|row| row.id == id && row.when == when) { Some(row) => { if !row.keys.contains(&keys) { row.keys.push(keys); } } None => rows.push(Binding { id, label, when, keys: vec![keys] }), } } let hold = settings::get(|settings| settings.quit_shortcut); if let Some(quit) = rows.iter_mut().find(|row| row.id == "app.quit") { quit.label = match hold { QuitShortcut::Press => "App: Quit", QuitShortcut::Hold => "App: Quit (hold, or press twice)", QuitShortcut::Double => "App: Quit (press twice)", } .into(); } rows } /// `escape` → `Esc`, `,` → `,`, `n` → `N`. fn key_label(key: &str) -> String { match key { "escape" => "Esc".into(), "enter" => "Enter".into(), "space" => "Space".into(), "tab" => "Tab".into(), "backspace" => "Backspace".into(), "up" => "↑".into(), "down" => "↓".into(), "left" => "←".into(), "right" => "→".into(), key if key.chars().count() == 1 => key.to_uppercase(), key => { let mut chars = key.chars(); chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default() } } } /// The composer's keys, which follow the send shortcut setting. fn composer_bindings() -> Vec { let send = settings::get(|settings| settings.send_shortcut); let caps = |keys: &[&str]| keys.iter().map(|key| key.to_string()).collect::>(); let row = |id: &str, label: &str, when: &str, keys: Vec>| Binding { id: id.into(), label: label.into(), when: when.into(), keys, }; let (send_keys, new_line, opposite) = match send { SendShortcut::Enter => (vec![caps(&["Enter"])], vec![caps(&["Shift", "Enter"])], caps(&["Ctrl", "Enter"])), SendShortcut::ModEnterMultiline => ( vec![caps(&["Enter"]), caps(&["Ctrl", "Enter"])], vec![caps(&["Shift", "Enter"])], caps(&["Ctrl", "Shift", "Enter"]), ), SendShortcut::ModEnter => (vec![caps(&["Ctrl", "Enter"])], vec![caps(&["Enter"])], caps(&["Ctrl", "Shift", "Enter"])), }; vec![ row("composer.send", "Composer: Send", "composerFocus", send_keys), row("composer.newLine", "Composer: New Line", "composerFocus", new_line), row("composer.sendAlternate", "Composer: Opposite Queue or Steer Action", "composerFocus && turnRunning", vec![opposite]), row("composer.slashMenu", "Composer: Commands and Skills", "composerFocus", vec![caps(&["/"])]), row("composer.skills", "Composer: Skills", "composerFocus", vec![caps(&["$"])]), row("modelPicker.jump", "Model Picker: Jump", "modelPickerOpen", vec![caps(&["Ctrl", "1–9"])]), ] } fn binding_row(binding: &Binding, cx: &App) -> AnyElement { let p = metal::palette(cx); let id = SharedString::from(binding.id.clone()); let when = binding.when.clone(); let keys = h_flex().gap(px(8.)).children(binding.keys.iter().map(|keys| { h_flex().gap(px(3.)).children(keys.iter().map(|key| metal::keycap(key.clone(), p))) })); let title = div() .id(SharedString::from(format!("keybinding-{}-{}", binding.id, binding.when))) .tooltip(move |window, cx| Tooltip::new(id.clone()).build(window, cx)) .child(binding.label.clone()); SettingRow::new("") .title_suffix(title.text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink)) .status_element( h_flex() .gap(px(6.)) .child("When") .child( div() .px(px(6.)) .rounded(px(6.)) .bg(p.ink.opacity(0.05)) .font_family(metal::MONO) .text_size(px(11.)) .text_color(p.ink2) .child(when), ), ) .control(v_flex().items_end().child(keys)) .into_any_element() }