Skip to main content

retroglyph_window/winit/
translate.rs

1//! winit-event -> retroglyph-event converters.
2//!
3//! Pure functions, unit-testable without a window.
4
5use retroglyph_core::event::{
6    Event, KeyCode, KeyEvent, KeyEventKind, KeyLocation, KeyModifiers, ModifierKey, MouseButton,
7    PhysicalPos,
8};
9
10/// Maps a winit logical [`Key`](winit::keyboard::Key) plus modifiers to a [`KeyCode`].
11///
12/// Split out from [`translate_key`] so this (the actual key-identity logic) is unit-testable
13/// directly: `winit::event::KeyEvent` (the type `translate_key` takes) has a private
14/// platform-specific field in the pinned winit version, so it can't be constructed in test code,
15/// but `winit::keyboard::Key`/`NamedKey` are plain public enums a test can build directly.
16fn key_code_from_logical(key: &winit::keyboard::Key, modifiers: KeyModifiers) -> Option<KeyCode> {
17    use winit::keyboard::{Key, NamedKey};
18
19    Some(match key {
20        Key::Named(NamedKey::Enter) => KeyCode::Enter,
21        Key::Named(NamedKey::Escape) => KeyCode::Escape,
22        Key::Named(NamedKey::Backspace) => KeyCode::Backspace,
23        Key::Named(NamedKey::Delete) => KeyCode::Delete,
24        Key::Named(NamedKey::Insert) => KeyCode::Insert,
25        // winit has no distinct "Shift+Tab" key value: `Tab` is reported with `modifiers.shift()`
26        // set instead. Normalize that to `KeyCode::BackTab` here (rather than making every
27        // consumer separately check `code == Tab && modifiers.contains(SHIFT)`) so the same
28        // "Shift+Tab" gesture always arrives as one canonical code, matching the crossterm
29        // backend's legacy `ESC[Z` -> `BackTab` behavior.
30        Key::Named(NamedKey::Tab) if modifiers.contains(KeyModifiers::SHIFT) => KeyCode::BackTab,
31        Key::Named(NamedKey::Tab) => KeyCode::Tab,
32        // winit 0.30 still reports the spacebar as `NamedKey::Space` (a later winit version is
33        // expected to switch to `Key::Character(" ")` per the UI Events spec, but that hasn't
34        // shipped in the pinned 0.30 line): without this arm, every Space press silently falls
35        // through to `_ => return None` and is dropped.
36        Key::Named(NamedKey::Space) => KeyCode::Char(' '),
37        Key::Named(NamedKey::ArrowUp) => KeyCode::Up,
38        Key::Named(NamedKey::ArrowDown) => KeyCode::Down,
39        Key::Named(NamedKey::ArrowLeft) => KeyCode::Left,
40        Key::Named(NamedKey::ArrowRight) => KeyCode::Right,
41        Key::Named(NamedKey::Home) => KeyCode::Home,
42        Key::Named(NamedKey::End) => KeyCode::End,
43        Key::Named(NamedKey::PageUp) => KeyCode::PageUp,
44        Key::Named(NamedKey::PageDown) => KeyCode::PageDown,
45        Key::Named(NamedKey::F1) => KeyCode::F(1),
46        Key::Named(NamedKey::F2) => KeyCode::F(2),
47        Key::Named(NamedKey::F3) => KeyCode::F(3),
48        Key::Named(NamedKey::F4) => KeyCode::F(4),
49        Key::Named(NamedKey::F5) => KeyCode::F(5),
50        Key::Named(NamedKey::F6) => KeyCode::F(6),
51        Key::Named(NamedKey::F7) => KeyCode::F(7),
52        Key::Named(NamedKey::F8) => KeyCode::F(8),
53        Key::Named(NamedKey::F9) => KeyCode::F(9),
54        Key::Named(NamedKey::F10) => KeyCode::F(10),
55        Key::Named(NamedKey::F11) => KeyCode::F(11),
56        Key::Named(NamedKey::F12) => KeyCode::F(12),
57        // Bare modifier presses. Side (left/right) is not carried here: it comes from winit's
58        // own `KeyLocation` on the surrounding event, consulted once in `translate_key` via
59        // `translate_key_location` rather than re-derived per key.
60        Key::Named(NamedKey::Shift) => KeyCode::Modifier(ModifierKey::Shift),
61        Key::Named(NamedKey::Control) => KeyCode::Modifier(ModifierKey::Control),
62        Key::Named(NamedKey::Alt) => KeyCode::Modifier(ModifierKey::Alt),
63        Key::Named(NamedKey::Super) => KeyCode::Modifier(ModifierKey::Super),
64        Key::Named(NamedKey::CapsLock) => KeyCode::CapsLock,
65        Key::Named(NamedKey::ScrollLock) => KeyCode::ScrollLock,
66        Key::Named(NamedKey::NumLock) => KeyCode::NumLock,
67        Key::Named(NamedKey::PrintScreen) => KeyCode::PrintScreen,
68        Key::Named(NamedKey::Pause) => KeyCode::Pause,
69        Key::Named(NamedKey::ContextMenu) => KeyCode::Menu,
70        Key::Character(s) => KeyCode::Char(s.chars().next()?),
71        _ => return None,
72    })
73}
74
75/// Translates a winit [`Ime`](winit::event::Ime) event into an [`Event`].
76///
77/// Only [`Ime::Commit`](winit::event::Ime) carries a complete, atomic block of text: the same
78/// shape as the crossterm backend's `Event::Paste` (see its handling of `crossterm::event::Event
79/// ::Paste` in `crates/crossterm/src/lib.rs`), so a commit is mapped to [`Event::Paste`] rather
80/// than adding a new `Event` variant: `Event` is `#[non_exhaustive]`, so a new variant would be
81/// backward-compatible for exhaustive-matching consumers (per issue #267), but there is no need
82/// for a new one when an existing variant already fits the shape of the data. `Ime::Enabled`,
83/// `Ime::Preedit` (in-progress composition, not yet committed), and `Ime::Disabled` have no
84/// existing-`Event` equivalent and are dropped: an app that wants live preedit
85/// rendering is out of scope for this landable-sized change (see issue #296). An empty commit
86/// (`Ime::Commit(String::new())`) is also dropped: winit can send an empty commit as part of
87/// clearing composition state, and forwarding it would deliver a spurious empty paste.
88#[must_use]
89pub fn translate_ime(ime: winit::event::Ime) -> Option<Event> {
90    match ime {
91        winit::event::Ime::Commit(text) if !text.is_empty() => Some(Event::Paste(text)),
92        _ => None,
93    }
94}
95
96/// Translates a winit key event into an [`Event`].
97///
98/// Reports [`KeyEventKind::Press`], [`KeyEventKind::Repeat`] (winit's `repeat` flag), and
99/// [`KeyEventKind::Release`]. Returns `None` only for keys we don't map.
100#[must_use]
101#[allow(clippy::needless_pass_by_value)]
102pub fn translate_key(input: winit::event::KeyEvent, modifiers: KeyModifiers) -> Option<Event> {
103    let kind = translate_key_event_kind(input.state, input.repeat);
104    let code = key_code_from_logical(&input.logical_key, modifiers)?;
105    let location = translate_key_location(input.location);
106    Some(Event::Key(KeyEvent::with_location(
107        code, modifiers, kind, location,
108    )))
109}
110
111/// Maps winit's [`KeyLocation`](winit::keyboard::KeyLocation) 1:1 onto our [`KeyLocation`].
112#[must_use]
113pub const fn translate_key_location(location: winit::keyboard::KeyLocation) -> KeyLocation {
114    use winit::keyboard::KeyLocation as WL;
115    match location {
116        WL::Standard => KeyLocation::Standard,
117        WL::Left => KeyLocation::Left,
118        WL::Right => KeyLocation::Right,
119        WL::Numpad => KeyLocation::Numpad,
120    }
121}
122
123/// Converts a raw f64 cursor position to a [`PhysicalPos`].
124///
125/// `f64.max(0.0) as u32`: the `.max(0.0)` clamp makes sign loss intentional. Truncation of the
126/// fractional part is also intentional: pixel coordinates are always integers.
127#[must_use]
128#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
129pub const fn translate_physical_pos(x: f64, y: f64) -> PhysicalPos {
130    PhysicalPos {
131        x: x.max(0.0) as u32,
132        y: y.max(0.0) as u32,
133    }
134}
135
136/// Translates a winit [`winit::event::MouseButton`] into our [`MouseButton`].
137///
138/// Returns `None` for side buttons and other unrecognized buttons.
139#[must_use]
140pub const fn translate_mouse_button(button: winit::event::MouseButton) -> Option<MouseButton> {
141    match button {
142        winit::event::MouseButton::Left => Some(MouseButton::Left),
143        winit::event::MouseButton::Right => Some(MouseButton::Right),
144        winit::event::MouseButton::Middle => Some(MouseButton::Middle),
145        _ => None,
146    }
147}
148
149/// Maps a winit key `state`/`repeat` pair to a [`KeyEventKind`].
150#[must_use]
151pub const fn translate_key_event_kind(
152    state: winit::event::ElementState,
153    repeat: bool,
154) -> KeyEventKind {
155    use winit::event::ElementState;
156    match (state, repeat) {
157        (ElementState::Pressed, false) => KeyEventKind::Press,
158        (ElementState::Pressed, true) => KeyEventKind::Repeat,
159        (ElementState::Released, _) => KeyEventKind::Release,
160    }
161}
162
163/// Translates winit modifier state into our [`KeyModifiers`].
164#[must_use]
165pub fn translate_modifiers(state: winit::keyboard::ModifiersState) -> KeyModifiers {
166    KeyModifiers::from_parts(
167        state.shift_key(),
168        state.control_key(),
169        state.alt_key(),
170        state.super_key(),
171    )
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    // ── key_code_from_logical ─────────────────────────────────────────────────
179
180    #[test]
181    fn space_maps_to_char_space() {
182        // Regression test: winit 0.30 reports the spacebar as `NamedKey::Space`, not
183        // `Key::Character(" ")`: without a dedicated arm this silently mapped to `None` and
184        // every Space press was dropped.
185        let key = winit::keyboard::Key::Named(winit::keyboard::NamedKey::Space);
186        assert_eq!(
187            key_code_from_logical(&key, KeyModifiers::NONE),
188            Some(KeyCode::Char(' '))
189        );
190    }
191
192    #[test]
193    fn shift_tab_normalizes_to_backtab() {
194        // Regression test: winit has no distinct "Shift+Tab" key value: it reports `Tab` with
195        // the shift modifier set instead, which has to be normalized to `KeyCode::BackTab` here
196        // (matching the crossterm backend's legacy `ESC[Z` -> `BackTab` behavior) or every
197        // consumer of the event stream sees indistinguishable plain-Tab and Shift+Tab presses.
198        let key = winit::keyboard::Key::Named(winit::keyboard::NamedKey::Tab);
199        assert_eq!(
200            key_code_from_logical(&key, KeyModifiers::SHIFT),
201            Some(KeyCode::BackTab)
202        );
203    }
204
205    #[test]
206    fn plain_tab_is_unaffected() {
207        let key = winit::keyboard::Key::Named(winit::keyboard::NamedKey::Tab);
208        assert_eq!(
209            key_code_from_logical(&key, KeyModifiers::NONE),
210            Some(KeyCode::Tab)
211        );
212    }
213
214    #[test]
215    fn shift_modifier_on_non_tab_keys_is_unaffected() {
216        let key = winit::keyboard::Key::Character("a".into());
217        assert_eq!(
218            key_code_from_logical(&key, KeyModifiers::SHIFT),
219            Some(KeyCode::Char('a'))
220        );
221    }
222
223    #[test]
224    fn left_shift_alone_maps_to_modifier_shift_with_left_location() {
225        // `translate_key` itself can't be constructed directly in tests (see this module's doc
226        // comment on `key_code_from_logical`), so the round trip is exercised as its two parts:
227        // the key-identity mapping here, and `translate_key_location` below.
228        let key = winit::keyboard::Key::Named(winit::keyboard::NamedKey::Shift);
229        assert_eq!(
230            key_code_from_logical(&key, KeyModifiers::SHIFT),
231            Some(KeyCode::Modifier(ModifierKey::Shift))
232        );
233        assert_eq!(
234            translate_key_location(winit::keyboard::KeyLocation::Left),
235            KeyLocation::Left
236        );
237    }
238
239    #[test]
240    fn caps_lock_maps_straight_through() {
241        let key = winit::keyboard::Key::Named(winit::keyboard::NamedKey::CapsLock);
242        assert_eq!(
243            key_code_from_logical(&key, KeyModifiers::NONE),
244            Some(KeyCode::CapsLock)
245        );
246    }
247
248    #[test]
249    fn unmapped_key_returns_none() {
250        let key = winit::keyboard::Key::Named(winit::keyboard::NamedKey::AudioVolumeUp);
251        assert_eq!(key_code_from_logical(&key, KeyModifiers::NONE), None);
252    }
253
254    // ── translate_ime ─────────────────────────────────────────────────────────
255
256    #[test]
257    fn ime_commit_maps_to_paste_event() {
258        let ime = winit::event::Ime::Commit("hello".to_string());
259        assert_eq!(translate_ime(ime), Some(Event::Paste("hello".to_string())));
260    }
261
262    #[test]
263    fn ime_empty_commit_produces_no_event() {
264        // winit can send an empty commit while clearing composition state; forwarding it would
265        // deliver a spurious empty paste.
266        let ime = winit::event::Ime::Commit(String::new());
267        assert_eq!(translate_ime(ime), None);
268    }
269
270    #[test]
271    fn ime_enabled_produces_no_event() {
272        assert_eq!(translate_ime(winit::event::Ime::Enabled), None);
273    }
274
275    #[test]
276    fn ime_disabled_produces_no_event() {
277        assert_eq!(translate_ime(winit::event::Ime::Disabled), None);
278    }
279
280    #[test]
281    fn ime_preedit_produces_no_event() {
282        // In-progress composition text (not yet committed) has no `Event` equivalent; only a
283        // completed `Commit` is forwarded.
284        let ime = winit::event::Ime::Preedit("nihon".to_string(), Some((0, 5)));
285        assert_eq!(translate_ime(ime), None);
286    }
287
288    // ── translate_modifiers ──────────────────────────────────────────────────
289
290    #[test]
291    fn translate_modifiers_none() {
292        let state = winit::keyboard::ModifiersState::empty();
293        assert_eq!(translate_modifiers(state), KeyModifiers::NONE);
294    }
295
296    #[test]
297    fn translate_modifiers_super_only() {
298        let state = winit::keyboard::ModifiersState::SUPER;
299        let mods = translate_modifiers(state);
300        assert!(mods.contains(KeyModifiers::SUPER));
301        assert!(!mods.contains(KeyModifiers::SHIFT));
302        assert!(!mods.contains(KeyModifiers::CONTROL));
303        assert!(!mods.contains(KeyModifiers::ALT));
304    }
305
306    #[test]
307    fn translate_modifiers_super_without_super_key() {
308        let state = winit::keyboard::ModifiersState::SHIFT;
309        let mods = translate_modifiers(state);
310        assert!(!mods.contains(KeyModifiers::SUPER));
311    }
312
313    #[test]
314    fn translate_modifiers_super_combined_with_shift() {
315        let state = winit::keyboard::ModifiersState::SUPER | winit::keyboard::ModifiersState::SHIFT;
316        let mods = translate_modifiers(state);
317        assert!(mods.contains(KeyModifiers::SUPER));
318        assert!(mods.contains(KeyModifiers::SHIFT));
319        assert!(!mods.contains(KeyModifiers::CONTROL));
320        assert!(!mods.contains(KeyModifiers::ALT));
321    }
322
323    #[test]
324    fn translate_modifiers_all_together() {
325        let state = winit::keyboard::ModifiersState::SHIFT
326            | winit::keyboard::ModifiersState::CONTROL
327            | winit::keyboard::ModifiersState::ALT
328            | winit::keyboard::ModifiersState::SUPER;
329        let mods = translate_modifiers(state);
330        assert!(mods.contains(KeyModifiers::SHIFT));
331        assert!(mods.contains(KeyModifiers::CONTROL));
332        assert!(mods.contains(KeyModifiers::ALT));
333        assert!(mods.contains(KeyModifiers::SUPER));
334    }
335
336    // ── key_event_kind ────────────────────────────────────────────────────────
337
338    #[test]
339    fn key_event_kind_press_repeat_release() {
340        use winit::event::ElementState;
341        assert_eq!(
342            translate_key_event_kind(ElementState::Pressed, false),
343            KeyEventKind::Press
344        );
345        assert_eq!(
346            translate_key_event_kind(ElementState::Pressed, true),
347            KeyEventKind::Repeat
348        );
349        assert_eq!(
350            translate_key_event_kind(ElementState::Released, false),
351            KeyEventKind::Release
352        );
353        // A release is a release regardless of the repeat flag.
354        assert_eq!(
355            translate_key_event_kind(ElementState::Released, true),
356            KeyEventKind::Release
357        );
358    }
359
360    // ── translate_key_location ────────────────────────────────────────────────
361
362    #[test]
363    fn translate_key_location_maps_all_variants() {
364        use winit::keyboard::KeyLocation as WL;
365        assert_eq!(translate_key_location(WL::Standard), KeyLocation::Standard);
366        assert_eq!(translate_key_location(WL::Left), KeyLocation::Left);
367        assert_eq!(translate_key_location(WL::Right), KeyLocation::Right);
368        assert_eq!(translate_key_location(WL::Numpad), KeyLocation::Numpad);
369    }
370
371    // ── translate_mouse_button ────────────────────────────────────────────────
372
373    #[test]
374    fn translate_mouse_button_left() {
375        assert_eq!(
376            translate_mouse_button(winit::event::MouseButton::Left),
377            Some(MouseButton::Left)
378        );
379    }
380
381    #[test]
382    fn translate_mouse_button_right() {
383        assert_eq!(
384            translate_mouse_button(winit::event::MouseButton::Right),
385            Some(MouseButton::Right)
386        );
387    }
388
389    #[test]
390    fn translate_mouse_button_middle() {
391        assert_eq!(
392            translate_mouse_button(winit::event::MouseButton::Middle),
393            Some(MouseButton::Middle)
394        );
395    }
396
397    #[test]
398    fn translate_mouse_button_other_is_none() {
399        assert_eq!(
400            translate_mouse_button(winit::event::MouseButton::Back),
401            None
402        );
403        assert_eq!(
404            translate_mouse_button(winit::event::MouseButton::Forward),
405            None
406        );
407        assert_eq!(
408            translate_mouse_button(winit::event::MouseButton::Other(7)),
409            None
410        );
411    }
412}