use gpui::{App, IntoElement, RenderOnce, SharedString, Window, div, prelude::*, px};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use crate::foundation::{ActiveTheme, Ident, StyledExt};
use crate::strings::{ActiveStrings, StringKey, Strings};
#[derive(Debug, Clone, IntoElement)]
pub struct Kbd {
keystroke: SharedString,
ident: Option<Ident>,
}
impl Kbd {
pub fn new(keystroke: impl Into<SharedString>) -> Self {
Self {
keystroke: keystroke.into(),
ident: None,
}
}
pub fn id(mut self, ident: impl Into<Ident>) -> Self {
self.ident = Some(ident.into());
self
}
pub fn caps(&self, cx: &App) -> Vec<SharedString> {
caps(
self.keystroke.as_ref(),
cfg!(target_os = "macos"),
cx.strings(),
)
}
}
impl RenderOnce for Kbd {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let published = self.ident.as_ref().map(|ident| {
NodeSpec::new(ident.semantic_id(), Role::Text).text(self.keystroke.clone())
});
let element =
div()
.row()
.gap(px(theme.spacing.xs / 2.0))
.children(self.caps(cx).into_iter().map(|cap| {
div()
.h(px(theme
.control
.get(gpui_kit_theme::ControlSize::Sm)
.height))
.min_w(px(theme
.control
.get(gpui_kit_theme::ControlSize::Sm)
.height))
.px(px(theme.spacing.xs))
.flex()
.items_center()
.justify_center()
.radius(&theme, gpui_kit_theme::Radius::Small)
.bg(theme.colors.hover)
.font_family(theme.typography.mono.clone())
.text_size(px(theme.typography.caption.size))
.text_color(theme.colors.text_muted)
.child(cap)
}));
match published {
Some(spec) => element.semantic_in(cx, spec).into_any_element(),
None => element.into_any_element(),
}
}
}
pub fn caps(keystroke: &str, macos: bool, strings: &Strings) -> Vec<SharedString> {
let mut modifiers = String::new();
let mut caps: Vec<SharedString> = Vec::new();
let parts: Vec<&str> = keystroke
.split('-')
.filter(|part| !part.is_empty())
.collect();
let Some((key, modifier_parts)) = parts.split_last() else {
return Vec::new();
};
for modifier in modifier_parts {
let label = modifier_label(modifier, macos, strings);
if macos {
modifiers.push_str(&label);
} else {
caps.push(label.into());
}
}
let key = key_label(key, macos);
if macos {
modifiers.push_str(&key);
vec![modifiers.into()]
} else {
caps.push(key.into());
caps
}
}
fn modifier_label(modifier: &str, macos: bool, strings: &Strings) -> String {
match (modifier, macos) {
("cmd" | "super" | "win", true) => "⌘".into(),
("cmd" | "super" | "win", false) => strings.text(StringKey::KbdSuper).to_string(),
("ctrl" | "control", true) => "⌃".into(),
("ctrl" | "control", false) => strings.text(StringKey::KbdControl).to_string(),
("alt" | "option", true) => "⌥".into(),
("alt" | "option", false) => strings.text(StringKey::KbdAlt).to_string(),
("shift", true) => "⇧".into(),
("shift", false) => strings.text(StringKey::KbdShift).to_string(),
(other, _) => capitalize(other),
}
}
fn key_label(key: &str, macos: bool) -> String {
match (key, macos) {
("enter", true) => "⏎".into(),
("escape", true) => "esc".into(),
("backspace", true) => "⌫".into(),
("delete", true) => "⌦".into(),
("tab", true) => "⇥".into(),
("up", _) => "↑".into(),
("down", _) => "↓".into(),
("left", _) => "←".into(),
("right", _) => "→".into(),
("space", _) => "␣".into(),
(other, _) if other.chars().count() == 1 => other.to_uppercase(),
(other, _) => capitalize(other),
}
}
fn capitalize(value: &str) -> String {
let mut characters = value.chars();
match characters.next() {
Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn macos_composes_modifiers_into_one_cap() {
assert_eq!(
caps("cmd-shift-p", true, &Strings::new()),
vec![SharedString::from("⌘⇧P")]
);
}
#[test]
fn other_platforms_spell_each_modifier_out() {
assert_eq!(
caps("ctrl-shift-p", false, &Strings::new()),
vec![
SharedString::from("Ctrl"),
SharedString::from("Shift"),
SharedString::from("P")
]
);
}
#[test]
fn named_keys_use_their_symbols_where_the_platform_expects_them() {
assert_eq!(
caps("enter", true, &Strings::new()),
vec![SharedString::from("⏎")]
);
assert_eq!(
caps("enter", false, &Strings::new()),
vec![SharedString::from("Enter")]
);
assert_eq!(
caps("up", false, &Strings::new()),
vec![SharedString::from("↑")]
);
}
#[test]
fn an_empty_keystroke_draws_nothing() {
assert!(caps("", true, &Strings::new()).is_empty());
}
}