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 — no locale lookup 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..F12 fall through to `Key::Display`
16//!   — those names are universal.
17//!
18//! Apps that want full control can bypass this function entirely —
19//! the settings widget simply calls [`format_keystroke`] at render
20//! time, so overriding the visible label means substituting a
21//! different formatter at the caller.
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 mut out = String::new();
32    write_modifiers(&mut out, keystroke.modifiers);
33    write_key(&mut out, keystroke.key);
34    out
35}
36
37#[cfg(target_os = "macos")]
38fn write_modifiers(out: &mut String, modifiers: Modifiers) {
39    // macOS HIG order: Ctrl(⌃) Option(⌥) Shift(⇧) Command(⌘).
40    // No localization — these symbols are universal.
41    if modifiers.ctrl() {
42        out.push('\u{2303}');
43    }
44    if modifiers.alt() {
45        out.push('\u{2325}');
46    }
47    if modifiers.shift() {
48        out.push('\u{21E7}');
49    }
50    if modifiers.super_key() {
51        out.push('\u{2318}');
52    }
53}
54
55#[cfg(not(target_os = "macos"))]
56fn write_modifiers(out: &mut String, modifiers: Modifiers) {
57    // Modifier labels + separator come from framework locale bundles
58    // (`crates/teksilo-widgets/locales/*.ftl`) so apps get "Strg+S" in
59    // German, "Ctrl+S" in English, etc. Resolved once per call via
60    // `tr_widget!` — the macro validates these keys at compile time.
61    let sep = tr_widget!(keystroke_separator()).resolve_now();
62    if modifiers.ctrl() {
63        out.push_str(&tr_widget!(keystroke_modifier_ctrl()).resolve_now());
64        out.push_str(&sep);
65    }
66    if modifiers.shift() {
67        out.push_str(&tr_widget!(keystroke_modifier_shift()).resolve_now());
68        out.push_str(&sep);
69    }
70    if modifiers.alt() {
71        out.push_str(&tr_widget!(keystroke_modifier_alt()).resolve_now());
72        out.push_str(&sep);
73    }
74    if modifiers.super_key() {
75        out.push_str(&tr_widget!(keystroke_modifier_super()).resolve_now());
76        out.push_str(&sep);
77    }
78}
79
80#[cfg(target_os = "macos")]
81fn write_key(out: &mut String, key: Key) {
82    match key {
83        Key::ArrowUp => out.push('\u{2191}'),
84        Key::ArrowDown => out.push('\u{2193}'),
85        Key::ArrowLeft => out.push('\u{2190}'),
86        Key::ArrowRight => out.push('\u{2192}'),
87        Key::Enter => out.push('\u{21A9}'),
88        Key::Backspace => out.push('\u{232B}'),
89        Key::Delete => out.push('\u{2326}'),
90        Key::Escape => out.push('\u{238B}'),
91        Key::Space => out.push_str("Space"),
92        Key::Tab => out.push('\u{21E5}'),
93        other => out.push_str(&other.to_string()),
94    }
95}
96
97#[cfg(not(target_os = "macos"))]
98fn write_key(out: &mut String, key: Key) {
99    // Named-key labels come from framework locale bundles
100    // (`crates/teksilo-widgets/locales/*.ftl`) so apps see "Entrée"
101    // in French, "Enter" in English, etc. Letters, digits, the
102    // `Character(_)` catch-all and F1..F12 use `Key::Display` —
103    // those names are universal across Latin-script locales.
104    let label = match key {
105        Key::Space => Some(tr_widget!(keystroke_key_space()).resolve_now()),
106        Key::Enter => Some(tr_widget!(keystroke_key_enter()).resolve_now()),
107        Key::Escape => Some(tr_widget!(keystroke_key_escape()).resolve_now()),
108        Key::Tab => Some(tr_widget!(keystroke_key_tab()).resolve_now()),
109        Key::Backspace => Some(tr_widget!(keystroke_key_backspace()).resolve_now()),
110        Key::Delete => Some(tr_widget!(keystroke_key_delete()).resolve_now()),
111        Key::ArrowUp => Some(tr_widget!(keystroke_key_arrow_up()).resolve_now()),
112        Key::ArrowDown => Some(tr_widget!(keystroke_key_arrow_down()).resolve_now()),
113        Key::ArrowLeft => Some(tr_widget!(keystroke_key_arrow_left()).resolve_now()),
114        Key::ArrowRight => Some(tr_widget!(keystroke_key_arrow_right()).resolve_now()),
115        Key::Home => Some(tr_widget!(keystroke_key_home()).resolve_now()),
116        Key::End => Some(tr_widget!(keystroke_key_end()).resolve_now()),
117        Key::PageUp => Some(tr_widget!(keystroke_key_page_up()).resolve_now()),
118        Key::PageDown => Some(tr_widget!(keystroke_key_page_down()).resolve_now()),
119        _ => None,
120    };
121    match label {
122        Some(s) => out.push_str(&s),
123        None => out.push_str(&key.to_string()),
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    #[cfg(not(target_os = "macos"))]
133    fn non_macos_uses_translated_modifiers() {
134        // Default locale is the source language (en-US) unless an
135        // app installs teksilo-widgets' framework locales with a
136        // different active locale. We can only safely check the
137        // English source here.
138        assert_eq!(format_keystroke(KeyStroke::ctrl(Key::S)), "Ctrl+S");
139        assert_eq!(
140            format_keystroke(KeyStroke::ctrl_shift(Key::Z)),
141            "Ctrl+Shift+Z"
142        );
143        assert_eq!(format_keystroke(KeyStroke::alt(Key::F4)), "Alt+F4");
144    }
145
146    #[test]
147    #[cfg(not(target_os = "macos"))]
148    fn non_macos_named_keys_route_through_bundle() {
149        // Named keys flow through `tr_widget!`. Asserting against the
150        // English source confirms the bundle keys exist and the match
151        // arms cover the named-key variants.
152        assert_eq!(format_keystroke(KeyStroke::ctrl(Key::Enter)), "Ctrl+Enter");
153        assert_eq!(
154            format_keystroke(KeyStroke::new(Key::Escape, Modifiers::NONE)),
155            "Esc"
156        );
157        assert_eq!(
158            format_keystroke(KeyStroke::ctrl(Key::ArrowLeft)),
159            "Ctrl+Left"
160        );
161        assert_eq!(
162            format_keystroke(KeyStroke::new(Key::Tab, Modifiers::SHIFT)),
163            "Shift+Tab"
164        );
165    }
166
167    #[test]
168    #[cfg(target_os = "macos")]
169    fn macos_uses_symbols() {
170        assert_eq!(
171            format_keystroke(KeyStroke::new(Key::S, Modifiers::SUPER)),
172            "\u{2318}S"
173        );
174        assert_eq!(
175            format_keystroke(KeyStroke::new(Key::Z, Modifiers::SHIFT | Modifiers::SUPER)),
176            "\u{21E7}\u{2318}Z"
177        );
178    }
179}