use gpui::{ div, relative, Action, AsKeystroke, FocusHandle, IntoElement, KeyContext, Keystroke, ParentElement as _, RenderOnce, StyleRefinement, Styled, Window, }; use theme::ActiveTheme; use crate::StyledExt; /// A key binding tag #[derive(IntoElement, Clone, Debug)] pub struct Kbd { style: StyleRefinement, stroke: Keystroke, appearance: bool, } impl From for Kbd { fn from(stroke: Keystroke) -> Self { Self { style: StyleRefinement::default(), stroke, appearance: true, } } } impl Kbd { pub fn new(stroke: Keystroke) -> Self { Self { style: StyleRefinement::default(), stroke, appearance: true, } } /// Set the appearance of the keybinding. pub fn appearance(mut self, appearance: bool) -> Self { self.appearance = appearance; self } /// Return the first keybinding for the given action and context. pub fn binding_for_action( action: &dyn Action, context: Option<&str>, window: &Window, ) -> Option { let key_context = context.and_then(|context| KeyContext::parse(context).ok()); let binding = match key_context { Some(context) => { window.highest_precedence_binding_for_action_in_context(action, context) } None => window.highest_precedence_binding_for_action(action), }?; binding .keystrokes() .first() .map(|key| Self::new(key.as_keystroke().clone())) } /// Return the first keybinding for the given action and focus handle. pub fn binding_for_action_in( action: &dyn Action, focus_handle: &FocusHandle, window: &Window, ) -> Option { let binding = window.highest_precedence_binding_for_action_in(action, focus_handle)?; binding .keystrokes() .first() .map(|key| Self::new(key.as_keystroke().clone())) } /// Return the Platform specific keybinding string by KeyStroke /// /// macOS: https://support.apple.com/en-us/HT201236 /// Windows: https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec pub fn format(key: &Keystroke) -> String { #[cfg(target_os = "macos")] const DIVIDER: &str = ""; #[cfg(not(target_os = "macos"))] const DIVIDER: &str = "+"; let mut parts = vec![]; // The key map order in macOS is: ⌃⌥⇧⌘ // And in Windows is: Ctrl+Alt+Shift+Win if key.modifiers.control { #[cfg(target_os = "macos")] parts.push("⌃"); #[cfg(not(target_os = "macos"))] parts.push("Ctrl"); } if key.modifiers.alt { #[cfg(target_os = "macos")] parts.push("⌥"); #[cfg(not(target_os = "macos"))] parts.push("Alt"); } if key.modifiers.shift { #[cfg(target_os = "macos")] parts.push("⇧"); #[cfg(not(target_os = "macos"))] parts.push("Shift"); } if key.modifiers.platform { #[cfg(target_os = "macos")] parts.push("⌘"); #[cfg(not(target_os = "macos"))] parts.push("Win"); } let mut keys = String::new(); let key_str = key.key.as_str(); match key_str { #[cfg(target_os = "macos")] "ctrl" => keys.push('⌃'), #[cfg(not(target_os = "macos"))] "ctrl" => keys.push_str("Ctrl"), #[cfg(target_os = "macos")] "alt" => keys.push('⌥'), #[cfg(not(target_os = "macos"))] "alt" => keys.push_str("Alt"), #[cfg(target_os = "macos")] "shift" => keys.push('⇧'), #[cfg(not(target_os = "macos"))] "shift" => keys.push_str("Shift"), #[cfg(target_os = "macos")] "cmd" => keys.push('⌘'), #[cfg(not(target_os = "macos"))] "cmd" => keys.push_str("Win"), #[cfg(target_os = "macos")] "space" => keys.push_str("Space"), #[cfg(target_os = "macos")] "backspace" => keys.push('⌫'), #[cfg(not(target_os = "macos"))] "backspace" => keys.push_str("Backspace"), #[cfg(target_os = "macos")] "delete" => keys.push('⌫'), #[cfg(not(target_os = "macos"))] "delete" => keys.push_str("Delete"), #[cfg(target_os = "macos")] "escape" => keys.push('⎋'), #[cfg(not(target_os = "macos"))] "escape" => keys.push_str("Esc"), #[cfg(target_os = "macos")] "enter" => keys.push('⏎'), #[cfg(not(target_os = "macos"))] "enter" => keys.push_str("Enter"), "pagedown" => keys.push_str("Page Down"), "pageup" => keys.push_str("Page Up"), #[cfg(target_os = "macos")] "left" => keys.push('←'), #[cfg(not(target_os = "macos"))] "left" => keys.push_str("Left"), #[cfg(target_os = "macos")] "right" => keys.push('→'), #[cfg(not(target_os = "macos"))] "right" => keys.push_str("Right"), #[cfg(target_os = "macos")] "up" => keys.push('↑'), #[cfg(not(target_os = "macos"))] "up" => keys.push_str("Up"), #[cfg(target_os = "macos")] "down" => keys.push('↓'), #[cfg(not(target_os = "macos"))] "down" => keys.push_str("Down"), _ => { if key_str.len() == 1 { keys.push_str(&key_str.to_uppercase()); } else { let mut chars = key_str.chars(); if let Some(first_char) = chars.next() { keys.push_str(&format!( "{}{}", first_char.to_uppercase(), chars.collect::() )); } else { keys.push_str(key_str); } } } } parts.push(&keys); parts.join(DIVIDER) } } impl Styled for Kbd { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } impl RenderOnce for Kbd { fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement { if !self.appearance { return Self::format(&self.stroke).into_any_element(); } div() .border_1() .border_color(cx.theme().border) .text_color(cx.theme().text_muted) .bg(cx.theme().surface_background) .py_0p5() .px_1() .min_w_5() .text_center() .rounded_sm() .line_height(relative(1.)) .text_xs() .whitespace_normal() .flex_shrink_0() .refine_style(&self.style) .child(Self::format(&self.stroke)) .into_any_element() } } #[cfg(test)] mod tests { #[test] fn test_format() { use gpui::Keystroke; use super::Kbd; if cfg!(target_os = "macos") { assert_eq!(Kbd::format(&Keystroke::parse("cmd-a").unwrap()), "⌘A"); assert_eq!(Kbd::format(&Keystroke::parse("cmd--").unwrap()), "⌘-"); assert_eq!(Kbd::format(&Keystroke::parse("cmd-+").unwrap()), "⌘+"); assert_eq!(Kbd::format(&Keystroke::parse("cmd-enter").unwrap()), "⌘⏎"); assert_eq!( Kbd::format(&Keystroke::parse("secondary-f12").unwrap()), "⌘F12" ); assert_eq!( Kbd::format(&Keystroke::parse("shift-pagedown").unwrap()), "⇧Page Down" ); assert_eq!( Kbd::format(&Keystroke::parse("shift-pageup").unwrap()), "⇧Page Up" ); assert_eq!( Kbd::format(&Keystroke::parse("shift-space").unwrap()), "⇧Space" ); assert_eq!(Kbd::format(&Keystroke::parse("cmd-ctrl-a").unwrap()), "⌃⌘A"); assert_eq!( Kbd::format(&Keystroke::parse("cmd-alt-backspace").unwrap()), "⌥⌘⌫" ); assert_eq!( Kbd::format(&Keystroke::parse("shift-delete").unwrap()), "⇧⌫" ); assert_eq!( Kbd::format(&Keystroke::parse("cmd-ctrl-shift-a").unwrap()), "⌃⇧⌘A" ); assert_eq!( Kbd::format(&Keystroke::parse("cmd-ctrl-shift-alt-a").unwrap()), "⌃⌥⇧⌘A" ); } else { assert_eq!(Kbd::format(&Keystroke::parse("a").unwrap()), "A"); assert_eq!(Kbd::format(&Keystroke::parse("ctrl-a").unwrap()), "Ctrl+A"); assert_eq!( Kbd::format(&Keystroke::parse("shift-space").unwrap()), "Shift+Space" ); assert_eq!( Kbd::format(&Keystroke::parse("ctrl-alt-a").unwrap()), "Ctrl+Alt+A" ); assert_eq!( Kbd::format(&Keystroke::parse("ctrl-alt-shift-a").unwrap()), "Ctrl+Alt+Shift+A" ); assert_eq!( Kbd::format(&Keystroke::parse("ctrl-alt-shift-win-a").unwrap()), "Ctrl+Alt+Shift+Win+A" ); assert_eq!( Kbd::format(&Keystroke::parse("ctrl-shift-backspace").unwrap()), "Ctrl+Shift+Backspace" ); assert_eq!( Kbd::format(&Keystroke::parse("alt-delete").unwrap()), "Alt+Delete" ); assert_eq!( Kbd::format(&Keystroke::parse("alt-tab").unwrap()), "Alt+Tab" ); } } }