use std::borrow::Cow; use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use enum_iterator::{all, Sequence}; use fuzzy_match::match_indices_case_insensitive; use galaxyui::actions::StandardAction; use galaxyui::keymap::{ BindingDescription, BindingId, BindingLens, CustomTag, DescriptionContext, EditableBindingLens, IsBindingValid, Keystroke, Trigger, }; use galaxyui::platform::OperatingSystem; use galaxyui::{Action, AppContext, SingletonEntity}; use itertools::Itertools; use lazy_static::lazy_static; use regex::Regex; use crate::keyboard::{remove_custom_keybinding, write_custom_keybinding, UserDefinedKeybinding}; use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier}; pub const MAC_MENUS_CONTEXT: DescriptionContext = DescriptionContext::Custom("mac_menus"); // CustomActions are attached to menu items, and may be attached to Bindings. #[derive(Copy, Clone, Debug, PartialEq, Eq, Sequence)] #[repr(isize)] pub enum CustomAction { NewTab, NewFile, ShowAboutGalaxy, ShowSettings, ConfigureKeybindings, ShowAccount, ShowAppearance, ReferAFriend, ViewChangelog, FocusInput, ClearBlocks, AddNextOccurrence, AddCursorAbove, AddCursorBelow, CycleNextSession, CyclePrevSession, Cut, Copy, Paste, Undo, Redo, CommandPalette, AISearch, ClearEditor, Find, SelectAll, Workflows, HistorySearch, SaveCurrentConfig, History, IncreaseFontSize, DecreaseFontSize, ResetFontSize, IncreaseZoom, DecreaseZoom, ResetZoom, RenameTab, SplitPaneRight, SplitPaneLeft, SplitPaneUp, SplitPaneDown, MoveTabLeft, MoveTabRight, ActivateNextTab, ActivatePreviousTab, ActivateNextPane, ActivatePreviousPane, NavigationPalette, SelectBlockAbove, SelectBlockBelow, SelectAllBlocks, CreateBlockPermalink, ToggleBookmarkBlock, FindWithinBlock, CopyBlock, CopyBlockCommand, CopyBlockOutput, ViewSharedBlocks, CloseTab, CloseOtherTabs, CloseTabsRight, ToggleMaximizePane, LaunchConfigPalette, FilesPalette, TriggerWelcomeBlock, CommandSearch, ToggleResourceCenter, ToggleKeybindingsPage, ScrollToTopOfSelectedBlocks, ScrollToBottomOfSelectedBlocks, ToggleSyncAllTerminalInputsInAllTabs, ToggleSyncTerminalInputsInCurrentTab, DisableSyncTerminalInputs, ReopenClosedSession, ToggleGalaxyDrive, AddWindow, CloseCurrentSession, CloseWindow, NewPersonalWorkflow, NewPersonalNotebook, NewPersonalEnvVars, NewTeamWorkflow, NewTeamNotebook, NewTeamEnvVars, SearchDrive, OpenTeamSettings, ShareCurrentSession, SharePaneContents, #[cfg(windows)] WindowsPaste, #[cfg(windows)] WindowsCopy, /// Also applies to legacy Warp AI (toggles the panel) NewAgentModePane, /// Also applies to legacy Warp AI (attaches the selection to the panel editor) AttachSelectionAsAgentModeContext, OpenAIFactCollection, OpenMCPServerCollection, ToggleProjectExplorer, NewPersonalAIPrompt, NewTeamAIPrompt, OpenRepository, NewTerminalTab, NewAgentTab, GoToLine, ToggleGlobalSearch, ToggleConversationListView, } lazy_static! { /// Maps for converting from custom tags back to the action enum /// This layer of indirection is necessary because the UI framework can't /// know about particular Warp specific actions, so it deals with all actions /// as plain isizes. Within Warp though we want to deal with them as the enum type. pub static ref CUSTOM_TAG_TO_ACTION: HashMap = HashMap::from_iter(all::().map(|action| { (action as isize, action) })); /// Regex that matches whether the the normalized form of a [`Keystroke`] matches a control /// character. ASCII control characters constitute the first 31 values of ASCII characters. /// Though they have their own ASCII codepoints, they are typed into the keyboard using /// `ctrl-XX`, see . /// /// As an example, the ETX character (represented as `^C` in caret notation) is sent to /// the PTY when the user presses `ctrl-c`. /// /// ## Control Characters List /// The full list of these control characters (and their corresponding name) are documented /// below: /// * `^@`: Null /// * `^A`: Start of Header /// * `^B`: Start of Text /// * `^C`: End of Text /// * `^D`: End of Transmission /// * `^E`: Enquiry /// * `^F`: Acknowledge /// * `^G`: Bell /// * `^H`: BackSpace /// * `^I`: Horizontal Tabulation /// * `^J`: Line Feed /// * `^K`: Vertical Tabulation /// * `^L`: Form Feed /// * `^M`: Carriage Return /// * `^N`: Shift Out /// * `^O`: Shift In /// * `^P`: Data Link Escape /// * `^Q`: Device Control 1 (XON) /// * `^R`: Device Control 2 /// * `^S`: Device Control 3 (XOFF) /// * `^T`: Device Control 4 /// * `^U`: Negative acknowledge /// * `^V`: Synchronous Idle /// * `^W`: End of Transmission Block /// * `^X`: Cancel /// * `^Y`: End of Medium /// * `^Z`: Substitute /// * `^[`: Escape /// * `^\`: File Separator /// * `^]`: Group Separator /// * `^^`: Record Separator /// * `^_`: Unit Separator /// * `^?`: Delete /// /// ## Note /// Though caret notation uses uppercase letters (`^C` instead of `^c`), we validate using /// _lowercase_ characters because it is impossible to create a [`Keystroke`] of the form /// `ctrl-[A-Z]`. See [`Keystroke::parse`]. pub static ref CONTROL_CHARACTER_KEY_REGEX: Regex = Regex::new(r"^ctrl-[a-z@\[\\\]^_?]$").expect("should be able to construct regex"); /// Set of actions on Mac that should be considered valid bindings even though they aren't PTY /// compliant. We weren't always diligent about avoiding bindings that could conflict with /// character codes, unfortunately some bindings on Mac currently conflict with the PTY. We have /// this allowlist to special case these legacy actions for the purposes of binding validation. pub static ref MAC_PTY_NON_COMPLIANT_ACTIONS: HashSet<&'static str> = HashSet::from_iter(["terminal:warpify_subshell", "terminal:open_block_list_context_menu_via_keybinding"]); /// Set of actions on Windows that should be considered valid bindings even though they aren't /// PTY compliant. Windows users expect pasting to work using both `ctrl-v` and `ctrl-shift-v`, /// so we allowlist the terminal paste action for the purposes of binding validation. pub static ref WINDOWS_PTY_NON_COMPLIANT_KEYSTROKES: HashSet = HashSet::from_iter([Keystroke::parse("ctrl-v").expect("should be able to construct ctrl-v keystroke")]); /// Set of keystrokes that should be considered valid bindings on all platforms even though /// they aren't PTY compliant. pub static ref PTY_NON_COMPLIANT_KEYSTROKES: HashSet = HashSet::from_iter([ // Windows users expect ctrl-c to copy any selected text to the clipboard. To avoid // introducing multiple codepaths for handling ctrl-c, we register ctrl-c as a binding // on TerminalView on all platforms. Keystroke::parse("ctrl-c").expect("should be able to construct ctrl-c keystroke"), // The resume conversation binding uses cmd-shift-R on Mac and should be allowed Keystroke::parse("cmd-shift-R").expect("should be able to construct cmd-shift-R keystroke") ]); } impl From for CustomTag { fn from(action: CustomAction) -> Self { action as CustomTag } } impl From for CustomAction { fn from(tag: CustomTag) -> Self { *CUSTOM_TAG_TO_ACTION .get(&tag) .expect("All custom actions are handled.") } } pub fn trigger_to_keystroke(trigger: &Trigger) -> Option { match trigger { Trigger::Keystrokes(keys) => keys.first().cloned(), // Custom actions don't have keyboard shortcuts associated with the actions themselves, // they are set separately in app/src/lib.rs as part of creating the Menu. As a result, // we need to map those to the appropriate keyboard shortcut. Trigger::Custom(custom) => custom_tag_to_keystroke(*custom), // Similarly, Standard Actions have their keyboard shortcuts set when creating the menu Trigger::Standard(standard) => match standard { StandardAction::Close => mac_only_keystroke("cmd-shift-W"), // "cmd-q" to quit and "cmd-h" to hide are the standard bindings for these actions on // Mac. StandardAction::Quit => mac_only_keystroke("cmd-q"), StandardAction::Hide => mac_only_keystroke("cmd-h"), StandardAction::HideOtherApps => Keystroke::parse("cmdorctrl-alt-h").ok(), StandardAction::ToggleFullScreen => mac_only_keystroke("cmd-ctrl-f"), StandardAction::Paste => Keystroke::parse(cmd_or_ctrl_shift("v")).ok(), StandardAction::ShowAllApps | StandardAction::BringAllToFront | StandardAction::Minimize | StandardAction::Zoom => None, }, Trigger::Empty => None, } } /// Returns the corresponding [`Keystroke`], if any, of a [`CustomTag`]. pub fn custom_tag_to_keystroke(custom: CustomTag) -> Option { match custom.into() { CustomAction::FocusInput => Keystroke::parse(cmd_or_ctrl_shift("l")).ok(), CustomAction::NewTab => Keystroke::parse(cmd_or_ctrl_shift("t")).ok(), CustomAction::Cut => Keystroke::parse("cmdorctrl-x").ok(), CustomAction::Copy => Keystroke::parse(cmd_or_ctrl_shift("c")).ok(), CustomAction::Paste => Keystroke::parse(cmd_or_ctrl_shift("v")).ok(), #[cfg(windows)] CustomAction::WindowsPaste => Keystroke::parse("ctrl-v").ok(), #[cfg(windows)] CustomAction::WindowsCopy => Keystroke::parse("ctrl-c").ok(), CustomAction::Undo => Keystroke::parse("cmdorctrl-z").ok(), CustomAction::Redo => Keystroke::parse("cmdorctrl-shift-Z").ok(), CustomAction::ClearEditor => Keystroke::parse("ctrl-c").ok(), CustomAction::CycleNextSession => Keystroke::parse("ctrl-tab").ok(), CustomAction::CyclePrevSession => Keystroke::parse("ctrl-shift-tab").ok(), CustomAction::ShowSettings => Keystroke::parse("cmdorctrl-,").ok(), CustomAction::AddNextOccurrence => Keystroke::parse("ctrl-g").ok(), CustomAction::AddCursorAbove => Keystroke::parse("ctrl-shift-up").ok(), CustomAction::AddCursorBelow => Keystroke::parse("ctrl-shift-down").ok(), CustomAction::CommandPalette => Keystroke::parse(cmd_or_ctrl_shift("p")).ok(), CustomAction::AISearch => Keystroke::parse("ctrl-`").ok(), CustomAction::Find => Keystroke::parse(cmd_or_ctrl_shift("f")).ok(), CustomAction::SelectAll => Keystroke::parse("cmdorctrl-a").ok(), CustomAction::CommandSearch => Keystroke::parse("ctrl-r").ok(), CustomAction::Workflows => Keystroke::parse("ctrl-shift-R").ok(), CustomAction::History => Keystroke::parse("up").ok(), CustomAction::IncreaseFontSize => Keystroke::parse("shift-cmdorctrl-+").ok(), CustomAction::DecreaseFontSize => Keystroke::parse("shift-cmdorctrl-_").ok(), CustomAction::ResetFontSize => Keystroke::parse("cmdorctrl-0").ok(), CustomAction::IncreaseZoom => Keystroke::parse("cmdorctrl-=").ok(), CustomAction::DecreaseZoom => Keystroke::parse("cmdorctrl--").ok(), CustomAction::ResetZoom => Keystroke::parse("cmdorctrl-0").ok(), CustomAction::SplitPaneRight => Keystroke::parse(cmd_or_ctrl_shift("d")).ok(), CustomAction::SplitPaneDown => { if OperatingSystem::get().is_mac() { Keystroke::parse("cmd-shift-D").ok() } else { // On non-Mac platforms, we can't use `ctrl-shift-D` for `SplitPaneRight` since // we already use that for `SplitPaneRight` above. Instead we use // `ctrl-shift-E`, which matches what Hyper uses. See https://github.com/vercel/hyper/blob/9c72409f5138c03a5a74fcc4dba9109217b4524a/app/keymaps/linux.json#L32. Keystroke::parse("ctrl-shift-E").ok() } } CustomAction::MoveTabLeft => { if OperatingSystem::get().is_mac() { Keystroke::parse("shift-ctrl-left").ok() } else { Keystroke::parse("shift-ctrl-pageup").ok() } } CustomAction::MoveTabRight => { if OperatingSystem::get().is_mac() { Keystroke::parse("shift-ctrl-right").ok() } else { Keystroke::parse("shift-ctrl-pagedown").ok() } } CustomAction::ActivateNextTab => { if OperatingSystem::get().is_mac() { Keystroke::parse("shift-cmd-}").ok() } else { Keystroke::parse("ctrl-pagedown").ok() } } CustomAction::ActivatePreviousTab => { if OperatingSystem::get().is_mac() { Keystroke::parse("shift-cmd-{").ok() } else { Keystroke::parse("ctrl-pageup").ok() } } CustomAction::ActivateNextPane => { if OperatingSystem::get().is_mac() { Keystroke::parse("cmd-]").ok() } else { Keystroke::parse("ctrl-shift-}").ok() } } CustomAction::ActivatePreviousPane => { if OperatingSystem::get().is_mac() { Keystroke::parse("cmd-[").ok() } else { Keystroke::parse("ctrl-shift-{").ok() } } CustomAction::NavigationPalette => mac_only_keystroke("cmd-shift-P"), CustomAction::LaunchConfigPalette => mac_only_keystroke("ctrl-cmd-l"), CustomAction::FilesPalette => Keystroke::parse(cmd_or_ctrl_shift("o")).ok(), CustomAction::ClearBlocks => Keystroke::parse(cmd_or_ctrl_shift("k")).ok(), CustomAction::SelectBlockAbove => Keystroke::parse("cmdorctrl-up").ok(), CustomAction::SelectBlockBelow => Keystroke::parse("cmdorctrl-down").ok(), // Set this to mac-only. On Linux this conflicts with the binding to save a workflow. CustomAction::CreateBlockPermalink => mac_only_keystroke("cmd-shift-S"), CustomAction::ToggleBookmarkBlock => Keystroke::parse(cmd_or_ctrl_shift("b")).ok(), CustomAction::CopyBlockOutput => Keystroke::parse("cmdorctrl-alt-shift-C").ok(), // Set this to mac-only. On Linux this conflicts with the general binding to copy. CustomAction::CopyBlockCommand => mac_only_keystroke("cmd-shift-C"), // Set this to mac-only. On Linux this conflicts with the cmd-enter keybindings // (used for actions on the input suggestions menu, and for accepting passive code diffs). CustomAction::ToggleMaximizePane => mac_only_keystroke("cmd-shift-enter"), // Note: The base character '/' is used instead of '?' as mac registers keybindings // differently compared to the app which saves the resulting character used with shift // TODO: resolve these keybinding differences CustomAction::ToggleResourceCenter => Keystroke::parse("ctrl-shift-/").ok(), CustomAction::ToggleKeybindingsPage => Keystroke::parse("cmdorctrl-/").ok(), CustomAction::ScrollToTopOfSelectedBlocks => Keystroke::parse("cmdorctrl-shift-up").ok(), CustomAction::ScrollToBottomOfSelectedBlocks => { Keystroke::parse("cmdorctrl-shift-down").ok() } CustomAction::CopyBlock => Keystroke::parse(cmd_or_ctrl_shift("c")).ok(), CustomAction::FindWithinBlock => Keystroke::parse(cmd_or_ctrl_shift("f")).ok(), CustomAction::ToggleSyncTerminalInputsInCurrentTab => { Keystroke::parse("alt-cmdorctrl-i").ok() } CustomAction::ReopenClosedSession => { if OperatingSystem::get().is_mac() { Keystroke::parse("cmd-shift-T").ok() } else { // Use a custom keybinding for linux/windows since the binding would otherwise // conflict with the binding for creating a new tab. Keystroke::parse("ctrl-alt-t").ok() } } // This is one of the app's hardcoded keybindings. CustomAction::AddWindow => Keystroke::parse(cmd_or_ctrl_shift("n")).ok(), CustomAction::ToggleGalaxyDrive => { if OperatingSystem::get().is_mac() { Keystroke::parse("cmd-\\").ok() } else { Keystroke::parse("ctrl-shift-|").ok() } } CustomAction::CloseWindow => mac_only_keystroke("cmd-shift-W"), CustomAction::CloseCurrentSession => Keystroke::parse(cmd_or_ctrl_shift("w")).ok(), CustomAction::ViewChangelog => Keystroke::parse(cmd_or_ctrl_shift("alt-o")).ok(), CustomAction::NewAgentModePane => Keystroke::parse("ctrl-space").ok(), CustomAction::AttachSelectionAsAgentModeContext => { Keystroke::parse("ctrl-shift-space").ok() } CustomAction::ToggleProjectExplorer => { if OperatingSystem::get().is_mac() { Keystroke::parse("ctrl-1").ok() } else { Keystroke::parse("alt-1").ok() } } CustomAction::OpenRepository => { if OperatingSystem::get().is_mac() { Keystroke::parse("cmd-shift-O").ok() } else { Keystroke::parse("alt-shift-O").ok() } } CustomAction::GoToLine => Keystroke::parse("ctrl-g").ok(), CustomAction::ToggleGlobalSearch => { if OperatingSystem::get().is_mac() { Keystroke::parse("ctrl-3").ok() } else { Keystroke::parse("alt-3").ok() } } CustomAction::ToggleConversationListView => { if OperatingSystem::get().is_mac() { Keystroke::parse("ctrl-2").ok() } else { Keystroke::parse("alt-2").ok() } } CustomAction::NewTerminalTab | CustomAction::NewFile | CustomAction::ShowAboutGalaxy | CustomAction::SplitPaneLeft | CustomAction::SelectAllBlocks | CustomAction::SplitPaneUp | CustomAction::ConfigureKeybindings | CustomAction::RenameTab | CustomAction::CloseTab | CustomAction::CloseOtherTabs | CustomAction::CloseTabsRight | CustomAction::ReferAFriend | CustomAction::ViewSharedBlocks | CustomAction::ShowAccount | CustomAction::ShowAppearance | CustomAction::SaveCurrentConfig | CustomAction::TriggerWelcomeBlock | CustomAction::HistorySearch | CustomAction::DisableSyncTerminalInputs | CustomAction::ToggleSyncAllTerminalInputsInAllTabs | CustomAction::NewPersonalWorkflow | CustomAction::NewPersonalNotebook | CustomAction::NewPersonalEnvVars | CustomAction::NewTeamWorkflow | CustomAction::NewTeamNotebook | CustomAction::NewTeamEnvVars | CustomAction::SearchDrive | CustomAction::OpenTeamSettings | CustomAction::ShareCurrentSession | CustomAction::SharePaneContents | CustomAction::OpenAIFactCollection | CustomAction::OpenMCPServerCollection | CustomAction::NewPersonalAIPrompt | CustomAction::NewTeamAIPrompt | CustomAction::NewAgentTab => None, } } /// Get the keystroke currently assigned to a binding. Returns `None` if the binding does not exist /// or is unassigned. pub fn keybinding_name_to_keystroke(binding_name: &str, ctx: &AppContext) -> Option { ctx.get_binding_by_name(binding_name) .and_then(|binding| trigger_to_keystroke(binding.trigger)) } /// Get keybinding display string from binding name. Unset keybindings will return None. pub fn keybinding_name_to_display_string(binding_name: &str, ctx: &AppContext) -> Option { keybinding_name_to_keystroke(binding_name, ctx).map(|keystroke| keystroke.displayed()) } /// Get normalized keybinding string from binding name. Unset keybindings will return None. pub fn keybinding_name_to_normalized_string( binding_name: &str, ctx: &AppContext, ) -> Option { keybinding_name_to_keystroke(binding_name, ctx).map(|keystroke| keystroke.normalized()) } /// Sets a custom keybinding for an editable binding using the given keystroke. Will /// persist the keybinding to the user's config file and emit a KeybindingChangedEvent. pub fn set_custom_keybinding(binding_name: &str, keystroke: &Keystroke, ctx: &mut AppContext) { ctx.set_custom_trigger( binding_name.into(), Trigger::Keystrokes(vec![keystroke.clone()]), ); write_custom_keybinding( binding_name.into(), UserDefinedKeybinding::keystroke(keystroke.clone()), ); KeybindingChangedNotifier::handle(ctx).update(ctx, |_, ctx| { ctx.emit(KeybindingChangedEvent::BindingChanged { binding_name: binding_name.into(), new_trigger: Some(keystroke.clone()), }) }); } /// Reset an editable binding back to its default trigger. Will persist this change to /// the user's config file and emit a KeybindingChangedEvent with the default trigger. /// Returns the default keystroke for the binding. pub fn reset_keybinding_to_default(binding_name: &str, ctx: &mut AppContext) -> Option { ctx.remove_custom_trigger(binding_name); remove_custom_keybinding(binding_name); let default_keystroke = ctx .editable_bindings() .find(|binding| binding.name == binding_name) .and_then(|binding| trigger_to_keystroke(binding.trigger)); KeybindingChangedNotifier::handle(ctx).update(ctx, |_, ctx| { ctx.emit(KeybindingChangedEvent::BindingChanged { binding_name: binding_name.into(), new_trigger: default_keystroke.clone(), }) }); default_keystroke } #[derive(Clone, Debug)] pub struct CommandBinding { pub name: String, pub description: BindingDescription, pub trigger: Option, pub action: Option>, pub group: Option, /// The ID of the binding. If the [`CommandBinding`] was created from an /// [`EditableBindingLens`] or [`BindingLens`] this is the id of the lens. Otherwise a new ID /// is constructed. pub id: BindingId, } /// SearchScore is a helper struct for ranking the result of the search. /// `keystroke_score` represents the proximity between search term keystroke with /// the candidate keystroke. If the score is None, it means the candidate keystroke is not /// a valid subset of the search term keystroke. /// The fuzzy_search_score helps on the secondary ranking -- if two candidate keystrokes are /// both not valid subset of the search term keystroke, then we rank these by the score /// of the fuzzy_search. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct SearchScore { keystroke_score: Option, fuzzy_search_score: i64, } impl Ord for SearchScore { fn cmp(&self, other: &Self) -> Ordering { match self.keystroke_score.cmp(&other.keystroke_score) { Ordering::Equal => self.fuzzy_search_score.cmp(&other.fuzzy_search_score), ordering => ordering, } } } impl PartialOrd for SearchScore { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } fn convert_search_term_to_keystroke(search_term: &str) -> Option { let mut search_keystroke: Keystroke = Default::default(); let mut key_set = false; for element in search_term.split_whitespace() { match element.to_ascii_lowercase().as_str() { "command" | "cmd" => { if search_keystroke.cmd { return None; } search_keystroke.cmd = true } "control" | "ctrl" => { if search_keystroke.ctrl { return None; } search_keystroke.ctrl = true } "alt" | "option" => { if search_keystroke.alt { return None; } search_keystroke.alt = true } "shift" => { if search_keystroke.shift { return None; } search_keystroke.shift = true } "meta" => { if search_keystroke.meta { return None; } search_keystroke.meta = true } key => { if key_set { return None; } key_set = true; search_keystroke.key = key.to_string() } } } // Internally we uppercase the key when shift modifier is true. if search_keystroke.shift && search_keystroke.key.len() == 1 { search_keystroke.key = search_keystroke.key.to_ascii_uppercase(); } Some(search_keystroke) } pub fn filter_bindings_including_keystroke<'a>( bindings_iter: impl Iterator, search_term: &str, description_for: DescriptionContext, ) -> impl Iterator>, &'a CommandBinding)> { let search_keystroke = convert_search_term_to_keystroke(search_term); bindings_iter .filter_map(move |binding| { if search_term.is_empty() { Some((Default::default(), None, binding)) } else { let keystroke_search_score = if let Some(search_keystroke) = &search_keystroke { binding.trigger.as_ref().and_then(|candidate_keystroke| { let score = keystroke_includes(search_keystroke, candidate_keystroke); if score > 0 { Some(score) } else { None } }) } else { None }; let fuzzy_search_result = match_indices_case_insensitive( binding.description.in_context(description_for), search_term, ); match (keystroke_search_score, fuzzy_search_result) { // If keystroke matched, don't include fuzzy search highlights. (Some(keystroke_score), Some(fuzzy_search_result)) => Some(( SearchScore { keystroke_score: Some(keystroke_score), fuzzy_search_score: fuzzy_search_result.score, }, None, binding, )), (None, Some(fuzzy_search_result)) => Some(( SearchScore { fuzzy_search_score: fuzzy_search_result.score, ..Default::default() }, None, binding, )), (Some(keystroke_score), None) => Some(( SearchScore { keystroke_score: Some(keystroke_score), ..Default::default() }, None, binding, )), _ => None, } } }) .sorted_by(|(score1, _, _), (score2, _, _)| score2.cmp(score1)) .map(|(_, indices, binding)| (indices, binding)) } /// Check if the keystroke could be a possible candidate of the search keystroke and give a score /// based on proximity. /// The scores are generated as follows: for each field of the keystroke (alt, cmd, etc), the comparison /// between the search and candidate keystroke could yield three possible results - a strict /// match (candidate and search has the same value), a potential match (candidate has the value /// set to true but search is missing the value), a mismatch (candidate has the value set to /// false but search is set to true). /// For a strict match, we increase the score by multiplying it by two. For a potential match, /// we keep the original score by multiplying it by one. For a mismatch, we multiply by zero /// to mark that the candidate keystroke is not a valid subset of the search keystroke. fn keystroke_includes(search_keystroke: &Keystroke, candidate_keystroke: &Keystroke) -> usize { fn modifier_match( search_keystroke_condition: bool, candidate_keystroke_condition: bool, ) -> usize { match (search_keystroke_condition, candidate_keystroke_condition) { (false, false) | (true, true) => 2, // match gives a score of 2. (false, true) => 1, // keep the same score if the keystroke term is true but search_keystroke does not include the term. (true, false) => 0, // if the keystroke term is false but search_keystorke is true, return 0 score } } let key_match_score = if search_keystroke.key == candidate_keystroke.key { 2 } else { usize::from(search_keystroke.key.is_empty()) }; modifier_match(search_keystroke.alt, candidate_keystroke.alt) * modifier_match(search_keystroke.cmd, candidate_keystroke.cmd) * modifier_match(search_keystroke.ctrl, candidate_keystroke.ctrl) * modifier_match(search_keystroke.meta, candidate_keystroke.meta) * modifier_match(search_keystroke.shift, candidate_keystroke.shift) * key_match_score } impl CommandBinding { pub fn new(name: String, description: String, trigger: Option) -> Self { CommandBinding { name, description: BindingDescription::new(description), trigger, action: None, group: None, id: BindingId::new(), } } /// Materializes a [`CommandBinding`] from a [`BindingLens`], resolving /// any dynamic description against `ctx` so downstream consumers that /// have no `&AppContext` (fuzzy/full-text search indices, accessibility /// labels, render paths that only see an `Appearance`) observe a plain /// string. /// /// This is intentionally the only way to build a `CommandBinding` from /// a lens; taking `&AppContext` by value here forces every cache- /// population site to thread context through, which in turn guarantees /// that a future dynamic description cannot silently go unresolved. /// /// Returns `None` when the source binding has no description. pub fn from_lens(lens: BindingLens<'_>, ctx: &AppContext) -> Option { lens.description.map(|desc| CommandBinding { description: materialize_description(desc, ctx), trigger: trigger_to_keystroke(lens.trigger), action: Some(lens.action.clone()), name: lens.name.to_string(), group: lens.group.and_then(BindingGroup::from_str), id: lens.id, }) } /// Materializes a [`CommandBinding`] from an [`EditableBindingLens`]. /// See [`Self::from_lens`] for why this takes `&AppContext`. pub fn from_editable_lens(lens: EditableBindingLens<'_>, ctx: &AppContext) -> Self { Self { description: materialize_description(lens.description, ctx), trigger: trigger_to_keystroke(lens.trigger), action: Some(lens.action.clone()), name: lens.name.into(), group: lens.group.and_then(BindingGroup::from_str), id: lens.id, } } pub fn placeholder(placeholder: String) -> Self { CommandBinding { name: Default::default(), description: placeholder.into(), trigger: None, action: None, group: None, id: BindingId::new(), } } } fn materialize_description(desc: &BindingDescription, ctx: &AppContext) -> BindingDescription { if desc.has_dynamic_override() { desc.materialized(ctx) } else { desc.clone() } } /// Possible groups a Binding can be part of. The string representation (produced in /// [`BindingGroup::as_str`]) is used as the group identifier within /// [`galaxyui::keymap::FixedBinding`] or [`EditableBinding`]. #[derive(Copy, Clone, Debug, Sequence)] pub enum BindingGroup { Settings, Close, Navigation, WarpAi, Workflow, Notebooks, Folders, KeyboardShortcuts, AutoUpdate, Notifications, EnvVarCollection, Terminal, } impl BindingGroup { /// Returns a string representation of the [`BindingGroup`]. pub fn as_str(&self) -> &'static str { match self { Self::Settings => "settings", Self::WarpAi => "warp_ai", Self::Navigation => "navigation", Self::Workflow => "workflows", Self::Notebooks => "notebooks", Self::Folders => "folders", Self::KeyboardShortcuts => "keyboard_shortcuts", Self::Close => "close", Self::AutoUpdate => "autoupdate", Self::Notifications => "notifications", Self::EnvVarCollection => "env_var_collections", Self::Terminal => "terminal", } } /// Creates a [`BindingGroup`] from a str. Returns `None` if there is no group that corresponds /// to the `str`. fn from_str(str: &'static str) -> Option { all::().find(|&item| item.as_str() == str) } } /// Constructs a keybinding that is the `cmd-key` on Mac or `ctrl-shift-key` otherwise. This is /// useful when constructing a binding that needs to be compliant with the PTY. The typical pattern /// of using `cmdorctrl-XX` to construct a platform agnostic keybinding does not work here because /// `ctrl-XX` would conflict with the PTY because it is reserved as a control character. /// /// Bindings of the form `ctrl-[a-z@[\]^_?]` are reserved as control characters. We don't want to /// create bindings for in-app actions that would conflict with these control characters because we /// would end up preventing the user from sending these control characters to the PTY. To avoid /// this, we follow other terminals and use `ctrl-shift-XX` for in-app bindings if the binding would /// otherwise conflict with the PTY. /// /// ## Panics /// Panics if debug assertions are enabled and a non "A-Z" key was passed in an environment where `ctrl-shift` would be /// used. This is because the passed key needs to modified by the shift character in order to be valid in our UI /// framework and we can't easily produce the shift-modified version of the key ourselves. In this case the recommended /// solution is to to create separate [`Keystroke`]s for the Mac and non-Mac cases. For example: /// ``` /// use galaxyui::keymap::Keystroke; /// use galaxyui::platform::OperatingSystem; /// let keystroke = if OperatingSystem::get().is_mac() { /// Keystroke::parse("cmd-[") /// } else { /// Keystroke::parse("ctrl-shift-{") /// }; /// ``` pub fn cmd_or_ctrl_shift(key: &str) -> String { if OperatingSystem::get().is_mac() { format!("cmd-{key}") } else { let key = if Keystroke::is_valid_special_key(key) { // Valid keys don't need to be uppercase (we don't want to create a binding that looks // like `ctrl-shift-ENTER`). Cow::Borrowed(key) } else { if cfg!(debug_assertions) { let stroke = key.chars().next().expect("Character should exist"); if !stroke.is_ascii_lowercase() { panic!( "Tried to register a ctrl-shift-{key} shortcut which is invalid because the {key} character needs to be modified by the shift character." ); } } // The need to uppercase the key because of the addition of the `shift`. // Keystroke::parse debug asserts if this the modifier is lowercase: // https://github.com/warpdotdev/warp-internal/blob/c225b8cedd94fdba33e957cf1efb99d84768d193/ui/src/keymap.rs#L637/ key.to_ascii_uppercase().into() }; format!("ctrl-shift-{key}") } } /// Returns whether the given [`BindingLens`] is compliant with the PTY. /// A binding is considered PTY compliant if it does not interfere with a control character that /// needs to be sent to the PTY. A binding is considered to be a control character if the only /// modifier set is `ctrl` and the key is one of `a-z@[\]^_?`. pub fn is_binding_pty_compliant(binding: BindingLens) -> IsBindingValid { let trigger = binding.original_trigger.unwrap_or(binding.trigger); let Some(keystroke) = trigger_to_keystroke(trigger) else { return IsBindingValid::Yes; }; if CONTROL_CHARACTER_KEY_REGEX.is_match(keystroke.normalized().as_str()) && !is_pty_non_compliant_binding_allowed(binding.name, &keystroke) { // The binding interferes with a control character so it is not valid. IsBindingValid::No } else { IsBindingValid::Yes } } fn is_pty_non_compliant_binding_allowed(binding_name: &str, keystroke: &Keystroke) -> bool { (OperatingSystem::get().is_mac() && MAC_PTY_NON_COMPLIANT_ACTIONS.contains(binding_name)) || (OperatingSystem::get().is_windows() && WINDOWS_PTY_NON_COMPLIANT_KEYSTROKES.contains(keystroke)) || PTY_NON_COMPLIANT_KEYSTROKES.contains(keystroke) } /// Validates all that bindings are cross-platform by returning [`IsBindingValid::No`] if a `cmd-*` /// binding is used on non-mac platforms. pub fn is_binding_cross_platform(binding: BindingLens) -> IsBindingValid { if OperatingSystem::get().is_mac() { return IsBindingValid::Yes; }; let trigger = binding.original_trigger.unwrap_or(binding.trigger); let Some(keystroke) = trigger_to_keystroke(trigger) else { return IsBindingValid::Yes; }; if keystroke.cmd { IsBindingValid::No } else { IsBindingValid::Yes } } /// Attempts to construct a [`Keystroke`] from the given source string if the current /// [`OperatingSystem`] is mac. Returns `None` if not on Mac or if a [`Keystroke`] was unable to be /// constructed from the source string. fn mac_only_keystroke(source: &str) -> Option { if OperatingSystem::get().is_mac() { Keystroke::parse(source).ok() } else { None } } #[cfg(test)] #[path = "bindings_tests.rs"] mod tests;