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