Files
coop/crates/ui/src/kbd.rs
2025-10-27 08:20:37 +07:00

313 lines
10 KiB
Rust

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<Keystroke> 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<Self> {
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<Self> {
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::<String>()
));
} 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"
);
}
}
}