Skip to main content

teksilo_widgets/
keystroke_format.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Platform- and locale-aware formatting for [`KeyStroke`]s.
5//!
6//! - On macOS, the returned string uses the traditional symbol
7//!   modifiers (⌘⇧⌥⌃) and Unicode key glyphs (↑↩⇥ …). These symbols
8//!   are universal, so no locale lookup is needed.
9//! - On Windows / Linux, the modifier labels and named-key labels
10//!   (Enter, Esc, Space, arrows, Home/End, PageUp/Down, …) flow
11//!   through `tr_widget!` (teksilo-i18n's compile-time-checked
12//!   translation macro) so apps that register teksilo-widgets'
13//!   framework locales see "Strg+Eingabe" in German, "Ctrl+Entrée"
14//!   in French, "Ctrl+Enter" in English, etc. Letters, digits,
15//!   character keys and F1..F24 fall through to `Key::Display`:
16//!   those names are universal.
17//!
18//! [`format_keystroke`] gives the whole chord as one label;
19//! [`keystroke_parts`] gives one label per key, for a surface that draws
20//! each key on its own key cap. Both come from the same per-key labels, so
21//! they cannot disagree.
22
23use teksilo_core::event::{Key, Modifiers};
24use teksilo_core::shortcut::KeyStroke;
25#[cfg(not(target_os = "macos"))]
26use teksilo_i18n::tr_widget;
27
28/// Render `keystroke` as the conventional label for the current
29/// platform and active locale.
30pub fn format_keystroke(keystroke: KeyStroke) -> String {
31    let parts = keystroke_parts(keystroke);
32    // macOS writes a chord as one run of glyphs (`⇧⌘Z`); everywhere else
33    // the keys are joined by the locale's separator (`Ctrl+Shift+Z`).
34    #[cfg(target_os = "macos")]
35    {
36        parts.concat()
37    }
38    #[cfg(not(target_os = "macos"))]
39    {
40        parts.join(&tr_widget!(keystroke_separator()).resolve_now())
41    }
42}
43
44/// The labels of the keys in `keystroke`, modifiers first in the
45/// platform's order, the main key last: `["Ctrl", "Shift", "Z"]`, or
46/// `["⇧", "⌘", "Z"]` on macOS.
47///
48/// For a surface that draws one key cap per key. Joining the parts the
49/// platform's way gives exactly [`format_keystroke`].
50pub fn keystroke_parts(keystroke: KeyStroke) -> Vec<String> {
51    let mut parts = modifier_labels(keystroke.modifiers);
52    parts.push(key_label(keystroke.key));
53    parts
54}
55
56/// The labels of the two modifiers a shortcut usually starts with on this
57/// platform: `("Ctrl", "Alt")` on Windows and Linux, `("⌘", "⌥")` on macOS.
58///
59/// For a message telling the user which key to add to a chord.
60pub(crate) fn command_modifier_labels() -> (String, String) {
61    #[cfg(target_os = "macos")]
62    {
63        ("\u{2318}".to_string(), "\u{2325}".to_string())
64    }
65    #[cfg(not(target_os = "macos"))]
66    {
67        (
68            tr_widget!(keystroke_modifier_ctrl()).resolve_now(),
69            tr_widget!(keystroke_modifier_alt()).resolve_now(),
70        )
71    }
72}
73
74#[cfg(target_os = "macos")]
75fn modifier_labels(modifiers: Modifiers) -> Vec<String> {
76    // macOS HIG order: Ctrl(⌃) Option(⌥) Shift(⇧) Command(⌘).
77    // No localization: these symbols are universal.
78    let mut out = Vec::new();
79    if modifiers.ctrl() {
80        out.push("\u{2303}".to_string());
81    }
82    if modifiers.alt() {
83        out.push("\u{2325}".to_string());
84    }
85    if modifiers.shift() {
86        out.push("\u{21E7}".to_string());
87    }
88    if modifiers.super_key() {
89        out.push("\u{2318}".to_string());
90    }
91    out
92}
93
94#[cfg(not(target_os = "macos"))]
95fn modifier_labels(modifiers: Modifiers) -> Vec<String> {
96    // Modifier labels come from framework locale bundles
97    // (`crates/teksilo-widgets/locales/*.ftl`) so apps get "Strg+S" in
98    // German, "Ctrl+S" in English, etc. The macro validates these keys at
99    // compile time.
100    let mut out = Vec::new();
101    if modifiers.ctrl() {
102        out.push(tr_widget!(keystroke_modifier_ctrl()).resolve_now());
103    }
104    if modifiers.shift() {
105        out.push(tr_widget!(keystroke_modifier_shift()).resolve_now());
106    }
107    if modifiers.alt() {
108        out.push(tr_widget!(keystroke_modifier_alt()).resolve_now());
109    }
110    if modifiers.super_key() {
111        out.push(tr_widget!(keystroke_modifier_super()).resolve_now());
112    }
113    out
114}
115
116/// The label of one key, as [`keystroke_parts`] writes it.
117#[cfg(target_os = "macos")]
118pub(crate) fn key_label(key: Key) -> String {
119    match key {
120        Key::ArrowUp => "\u{2191}".to_string(),
121        Key::ArrowDown => "\u{2193}".to_string(),
122        Key::ArrowLeft => "\u{2190}".to_string(),
123        Key::ArrowRight => "\u{2192}".to_string(),
124        Key::Enter => "\u{21A9}".to_string(),
125        Key::Backspace => "\u{232B}".to_string(),
126        Key::Delete => "\u{2326}".to_string(),
127        Key::Escape => "\u{238B}".to_string(),
128        Key::Space => "Space".to_string(),
129        Key::Tab => "\u{21E5}".to_string(),
130        other => other.to_string(),
131    }
132}
133
134/// The label of one key, as [`keystroke_parts`] writes it.
135#[cfg(not(target_os = "macos"))]
136pub(crate) fn key_label(key: Key) -> String {
137    // Named-key labels come from framework locale bundles
138    // (`crates/teksilo-widgets/locales/*.ftl`) so apps see "Entrée"
139    // in French, "Enter" in English, etc. Letters, digits, the
140    // `Character(_)` catch-all and F1..F24 use `Key::Display`:
141    // those names are universal across Latin-script locales.
142    let label = match key {
143        Key::Space => Some(tr_widget!(keystroke_key_space()).resolve_now()),
144        Key::Enter => Some(tr_widget!(keystroke_key_enter()).resolve_now()),
145        Key::Escape => Some(tr_widget!(keystroke_key_escape()).resolve_now()),
146        Key::Tab => Some(tr_widget!(keystroke_key_tab()).resolve_now()),
147        Key::Backspace => Some(tr_widget!(keystroke_key_backspace()).resolve_now()),
148        Key::Delete => Some(tr_widget!(keystroke_key_delete()).resolve_now()),
149        Key::ArrowUp => Some(tr_widget!(keystroke_key_arrow_up()).resolve_now()),
150        Key::ArrowDown => Some(tr_widget!(keystroke_key_arrow_down()).resolve_now()),
151        Key::ArrowLeft => Some(tr_widget!(keystroke_key_arrow_left()).resolve_now()),
152        Key::ArrowRight => Some(tr_widget!(keystroke_key_arrow_right()).resolve_now()),
153        Key::Home => Some(tr_widget!(keystroke_key_home()).resolve_now()),
154        Key::End => Some(tr_widget!(keystroke_key_end()).resolve_now()),
155        Key::PageUp => Some(tr_widget!(keystroke_key_page_up()).resolve_now()),
156        Key::PageDown => Some(tr_widget!(keystroke_key_page_down()).resolve_now()),
157        _ => None,
158    };
159    label.unwrap_or_else(|| key.to_string())
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    #[cfg(not(target_os = "macos"))]
168    fn non_macos_uses_translated_modifiers() {
169        // Default locale is the source language (en-US) unless an
170        // app installs teksilo-widgets' framework locales with a
171        // different active locale. We can only safely check the
172        // English source here.
173        assert_eq!(format_keystroke(KeyStroke::ctrl(Key::S)), "Ctrl+S");
174        assert_eq!(
175            format_keystroke(KeyStroke::ctrl_shift(Key::Z)),
176            "Ctrl+Shift+Z"
177        );
178        assert_eq!(format_keystroke(KeyStroke::alt(Key::F4)), "Alt+F4");
179    }
180
181    #[test]
182    #[cfg(not(target_os = "macos"))]
183    fn non_macos_named_keys_route_through_bundle() {
184        // Named keys flow through `tr_widget!`. Asserting against the
185        // English source confirms the bundle keys exist and the match
186        // arms cover the named-key variants.
187        assert_eq!(format_keystroke(KeyStroke::ctrl(Key::Enter)), "Ctrl+Enter");
188        assert_eq!(
189            format_keystroke(KeyStroke::new(Key::Escape, Modifiers::NONE)),
190            "Esc"
191        );
192        assert_eq!(
193            format_keystroke(KeyStroke::ctrl(Key::ArrowLeft)),
194            "Ctrl+Left"
195        );
196        assert_eq!(
197            format_keystroke(KeyStroke::new(Key::Tab, Modifiers::SHIFT)),
198            "Shift+Tab"
199        );
200    }
201
202    #[test]
203    fn parts_are_the_keys_of_the_formatted_chord() {
204        let ks = KeyStroke::new(Key::Z, Modifiers::CTRL | Modifiers::SHIFT);
205        let parts = keystroke_parts(ks);
206        assert_eq!(parts.len(), 3, "two modifiers, one key: {parts:?}");
207        assert_eq!(parts.last().map(String::as_str), Some("Z"));
208        #[cfg(not(target_os = "macos"))]
209        assert_eq!(parts.join("+"), format_keystroke(ks));
210        #[cfg(target_os = "macos")]
211        assert_eq!(parts.concat(), format_keystroke(ks));
212    }
213
214    #[test]
215    #[cfg(target_os = "macos")]
216    fn macos_uses_symbols() {
217        assert_eq!(
218            format_keystroke(KeyStroke::new(Key::S, Modifiers::SUPER)),
219            "\u{2318}S"
220        );
221        assert_eq!(
222            format_keystroke(KeyStroke::new(Key::Z, Modifiers::SHIFT | Modifiers::SUPER)),
223            "\u{21E7}\u{2318}Z"
224        );
225    }
226}