Skip to main content

teksilo_platform/
event_translation.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use teksilo_canvas::Point;
5use teksilo_core::event::{Key, Modifiers, PointerButton, ScrollDelta, WidgetEvent};
6use teksilo_core::gesture::{GestureEvent, TapEvent};
7
8/// State tracked during event translation.
9pub struct TranslationState {
10    scale_factor: f64,
11    cursor_position: Option<Point>,
12    current_modifiers: Modifiers,
13}
14
15impl TranslationState {
16    pub fn new() -> Self {
17        Self {
18            scale_factor: 1.0,
19            cursor_position: None,
20            current_modifiers: Modifiers::NONE,
21        }
22    }
23
24    pub fn set_scale_factor(&mut self, factor: f64) {
25        self.scale_factor = factor;
26    }
27
28    pub fn scale_factor(&self) -> f64 {
29        self.scale_factor
30    }
31
32    pub fn cursor_position(&self) -> Option<Point> {
33        self.cursor_position
34    }
35
36    pub fn set_modifiers(&mut self, modifiers: Modifiers) {
37        self.current_modifiers = modifiers;
38    }
39}
40
41impl Default for TranslationState {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47/// Translate a winit CursorMoved event to a WidgetEvent::PointerMove.
48pub fn translate_cursor_moved(
49    physical_x: f64,
50    physical_y: f64,
51    state: &mut TranslationState,
52) -> Option<WidgetEvent> {
53    let logical_x = (physical_x / state.scale_factor) as f32;
54    let logical_y = (physical_y / state.scale_factor) as f32;
55    let position = Point::new(logical_x, logical_y);
56    state.cursor_position = Some(position);
57    Some(WidgetEvent::PointerMove { position })
58}
59
60/// Translate a winit `Ime` event into a teksilo-core `WidgetEvent`.
61///
62/// - `Preedit(text, cursor)` → `ImeComposition`. The `cursor` byte indices
63///   `(begin, end)` index into the preedit `text` and are preserved as a
64///   `Range`. `None` (hide-cursor) and empty `text` (winit's synthetic
65///   clear, emitted right before `Commit`) flow through faithfully.
66/// - `Commit(text)` → `ImeCommit`.
67/// - `Enabled` / `Disabled` are OS acknowledgements (enablement is driven
68///   by the focused node's descriptor) and produce no tree event.
69pub fn translate_ime(ime: winit::event::Ime) -> Option<WidgetEvent> {
70    match ime {
71        winit::event::Ime::Preedit(text, cursor) => Some(WidgetEvent::ImeComposition {
72            text,
73            cursor: cursor.map(|(begin, end)| begin..end),
74        }),
75        winit::event::Ime::Commit(text) => Some(WidgetEvent::ImeCommit { text }),
76        winit::event::Ime::Enabled | winit::event::Ime::Disabled => None,
77    }
78}
79
80/// Translate a winit mouse button to a teksilo-core PointerButton.
81pub fn translate_mouse_button(button: winit::event::MouseButton) -> Option<PointerButton> {
82    match button {
83        winit::event::MouseButton::Left => Some(PointerButton::Primary),
84        winit::event::MouseButton::Right => Some(PointerButton::Secondary),
85        winit::event::MouseButton::Middle => Some(PointerButton::Middle),
86        winit::event::MouseButton::Back => Some(PointerButton::Back),
87        winit::event::MouseButton::Forward => Some(PointerButton::Forward),
88        // MouseButton::Other(_) — vendor-specific extra buttons we don't
89        // currently surface. Returning None drops the event.
90        _ => None,
91    }
92}
93
94/// Translate a winit ElementState + MouseButton to PointerDown/Up.
95pub fn translate_mouse_input(
96    button_state: winit::event::ElementState,
97    button: winit::event::MouseButton,
98    state: &TranslationState,
99) -> Option<WidgetEvent> {
100    let pointer_button = translate_mouse_button(button)?;
101    let position = state.cursor_position.unwrap_or(Point::ZERO);
102    match button_state {
103        winit::event::ElementState::Pressed => Some(WidgetEvent::PointerDown {
104            position,
105            button: pointer_button,
106            modifiers: state.current_modifiers,
107        }),
108        winit::event::ElementState::Released => Some(WidgetEvent::PointerUp {
109            position,
110            button: pointer_button,
111            modifiers: state.current_modifiers,
112        }),
113    }
114}
115
116/// Translate winit keyboard modifiers to teksilo-core Modifiers.
117pub fn translate_modifiers(mods: winit::keyboard::ModifiersState) -> Modifiers {
118    let mut result = Modifiers::NONE;
119    if mods.control_key() {
120        result = result | Modifiers::CTRL;
121    }
122    if mods.shift_key() {
123        result = result | Modifiers::SHIFT;
124    }
125    if mods.alt_key() {
126        result = result | Modifiers::ALT;
127    }
128    if mods.super_key() {
129        result = result | Modifiers::SUPER;
130    }
131    result
132}
133
134/// Translate a winit logical key to a teksilo-core Key.
135pub fn translate_key(key: &winit::keyboard::Key) -> Option<Key> {
136    match key {
137        winit::keyboard::Key::Named(named) => translate_named_key(*named),
138        winit::keyboard::Key::Character(c) => {
139            let ch = c.chars().next()?;
140            match ch.to_ascii_uppercase() {
141                'A' => Some(Key::A),
142                'B' => Some(Key::B),
143                'C' => Some(Key::C),
144                'D' => Some(Key::D),
145                'E' => Some(Key::E),
146                'F' => Some(Key::F),
147                'G' => Some(Key::G),
148                'H' => Some(Key::H),
149                'I' => Some(Key::I),
150                'J' => Some(Key::J),
151                'K' => Some(Key::K),
152                'L' => Some(Key::L),
153                'M' => Some(Key::M),
154                'N' => Some(Key::N),
155                'O' => Some(Key::O),
156                'P' => Some(Key::P),
157                'Q' => Some(Key::Q),
158                'R' => Some(Key::R),
159                'S' => Some(Key::S),
160                'T' => Some(Key::T),
161                'U' => Some(Key::U),
162                'V' => Some(Key::V),
163                'W' => Some(Key::W),
164                'X' => Some(Key::X),
165                'Y' => Some(Key::Y),
166                'Z' => Some(Key::Z),
167                _ => Some(Key::Character(ch)),
168            }
169        }
170        _ => None,
171    }
172}
173
174fn translate_named_key(key: winit::keyboard::NamedKey) -> Option<Key> {
175    use winit::keyboard::NamedKey;
176    match key {
177        NamedKey::Space => Some(Key::Space),
178        NamedKey::Enter => Some(Key::Enter),
179        NamedKey::Escape => Some(Key::Escape),
180        NamedKey::Tab => Some(Key::Tab),
181        NamedKey::Backspace => Some(Key::Backspace),
182        NamedKey::Delete => Some(Key::Delete),
183        NamedKey::Insert => Some(Key::Insert),
184        NamedKey::ArrowUp => Some(Key::ArrowUp),
185        NamedKey::ArrowDown => Some(Key::ArrowDown),
186        NamedKey::ArrowLeft => Some(Key::ArrowLeft),
187        NamedKey::ArrowRight => Some(Key::ArrowRight),
188        NamedKey::Home => Some(Key::Home),
189        NamedKey::End => Some(Key::End),
190        NamedKey::PageUp => Some(Key::PageUp),
191        NamedKey::PageDown => Some(Key::PageDown),
192        NamedKey::F1 => Some(Key::F1),
193        NamedKey::F2 => Some(Key::F2),
194        NamedKey::F3 => Some(Key::F3),
195        NamedKey::F4 => Some(Key::F4),
196        NamedKey::F5 => Some(Key::F5),
197        NamedKey::F6 => Some(Key::F6),
198        NamedKey::F7 => Some(Key::F7),
199        NamedKey::F8 => Some(Key::F8),
200        NamedKey::F9 => Some(Key::F9),
201        NamedKey::F10 => Some(Key::F10),
202        NamedKey::F11 => Some(Key::F11),
203        NamedKey::F12 => Some(Key::F12),
204        NamedKey::F13 => Some(Key::F13),
205        NamedKey::F14 => Some(Key::F14),
206        NamedKey::F15 => Some(Key::F15),
207        NamedKey::F16 => Some(Key::F16),
208        NamedKey::F17 => Some(Key::F17),
209        NamedKey::F18 => Some(Key::F18),
210        NamedKey::F19 => Some(Key::F19),
211        NamedKey::F20 => Some(Key::F20),
212        NamedKey::F21 => Some(Key::F21),
213        NamedKey::F22 => Some(Key::F22),
214        NamedKey::F23 => Some(Key::F23),
215        NamedKey::F24 => Some(Key::F24),
216        // Caps Lock arrives as a discrete press/release. winit's
217        // `ModifiersState` carries no lock state, so the window manager
218        // tracks the active state itself on the key-down edge (drives
219        // `WindowState::caps_lock` for the password-field warning).
220        NamedKey::CapsLock => Some(Key::CapsLock),
221        // Windows `VK_APPS`, X11/Wayland `keysyms::Menu`. macOS produces this
222        // zero times, which is why the dispatcher also reserves a chord.
223        NamedKey::ContextMenu => Some(Key::ContextMenu),
224        _ => None,
225    }
226}
227
228/// Translate a winit MouseWheel event to a WidgetEvent::Scroll.
229pub fn translate_mouse_wheel(
230    delta: winit::event::MouseScrollDelta,
231    _phase: winit::event::TouchPhase,
232    state: &TranslationState,
233) -> Option<WidgetEvent> {
234    // Winit uses "natural" sign: positive y = scroll up (content moves down).
235    // Teksilo's ScrollDelta uses positive y = increase scroll offset (content moves up).
236    // Negate both axes to match.
237    //
238    // Winit reports raw notch counts (typically 1.0 per wheel notch). Multiply
239    // by a platform factor so each notch scrolls a comfortable number of lines.
240    // 3 lines per notch matches the Windows/GTK default.
241    const LINES_PER_NOTCH: f32 = 3.0;
242    let scroll_delta = match delta {
243        winit::event::MouseScrollDelta::LineDelta(x, y) => ScrollDelta::Lines {
244            x: -x * LINES_PER_NOTCH,
245            y: -y * LINES_PER_NOTCH,
246        },
247        winit::event::MouseScrollDelta::PixelDelta(pos) => ScrollDelta::Pixels {
248            x: -(pos.x / state.scale_factor) as f32,
249            y: -(pos.y / state.scale_factor) as f32,
250        },
251    };
252    Some(WidgetEvent::Scroll {
253        delta: scroll_delta,
254        modifiers: state.current_modifiers,
255    })
256}
257
258// --- Desktop trackpad gesture passthrough ---
259// On desktop, most gestures arrive as already-recognized events from the OS
260// trackpad driver. These functions translate winit's high-level gesture events
261// into Teksilo GestureEvents, which can be dispatched as WidgetEvent::Gesture.
262
263/// Translate a winit PinchGesture into a Teksilo gesture event.
264/// Returns PinchStarted on Started phase, PinchChanged on Changed, PinchEnded on Ended.
265pub fn translate_pinch_gesture(
266    delta: f64,
267    phase: winit::event::TouchPhase,
268    state: &TranslationState,
269) -> Option<WidgetEvent> {
270    let center = state.cursor_position.unwrap_or(Point::ZERO);
271    let gesture = match phase {
272        winit::event::TouchPhase::Started => GestureEvent::PinchStarted { center },
273        winit::event::TouchPhase::Moved => GestureEvent::PinchChanged {
274            center,
275            scale: 1.0 + delta as f32,
276            rotation: 0.0,
277        },
278        winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => {
279            GestureEvent::PinchEnded
280        }
281    };
282    Some(WidgetEvent::Gesture { gesture })
283}
284
285/// Translate a winit RotationGesture into a PinchChanged with rotation.
286/// Rotation gestures are folded into the pinch gesture model since they
287/// typically co-occur with pinch on trackpads.
288pub fn translate_rotation_gesture(
289    delta_degrees: f32,
290    phase: winit::event::TouchPhase,
291    state: &TranslationState,
292) -> Option<WidgetEvent> {
293    let center = state.cursor_position.unwrap_or(Point::ZERO);
294    let gesture = match phase {
295        winit::event::TouchPhase::Started => GestureEvent::PinchStarted { center },
296        winit::event::TouchPhase::Moved => GestureEvent::PinchChanged {
297            center,
298            scale: 1.0,
299            rotation: delta_degrees,
300        },
301        winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => {
302            GestureEvent::PinchEnded
303        }
304    };
305    Some(WidgetEvent::Gesture { gesture })
306}
307
308/// Translate a winit DoubleTapGesture (trackpad smart magnification).
309///
310/// Synthetic OS-driven double-tap: there's no underlying mouse button
311/// or modifier set the OS hands us, so we attribute it to
312/// `PointerButton::Primary` with no modifiers. Apps that need richer
313/// trackpad-gesture metadata should match on `WidgetEvent::Gesture`
314/// directly rather than hooking `on_double_tap`.
315pub fn translate_double_tap_gesture(state: &TranslationState) -> Option<WidgetEvent> {
316    let position = state.cursor_position.unwrap_or(Point::ZERO);
317    Some(WidgetEvent::Gesture {
318        gesture: GestureEvent::DoubleTap(TapEvent::new(
319            position,
320            PointerButton::Primary,
321            state.current_modifiers,
322        )),
323    })
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn cursor_moved_to_pointer_move() {
332        let mut state = TranslationState::new();
333        let event = translate_cursor_moved(100.0, 50.0, &mut state).unwrap();
334        if let WidgetEvent::PointerMove { position } = event {
335            assert_eq!(position.x, 100.0);
336            assert_eq!(position.y, 50.0);
337        } else {
338            panic!("Expected PointerMove");
339        }
340    }
341
342    #[test]
343    fn scale_factor_divides_physical_coords() {
344        let mut state = TranslationState::new();
345        state.set_scale_factor(2.0);
346        let event = translate_cursor_moved(200.0, 100.0, &mut state).unwrap();
347        if let WidgetEvent::PointerMove { position } = event {
348            assert_eq!(position.x, 100.0);
349            assert_eq!(position.y, 50.0);
350        } else {
351            panic!("Expected PointerMove");
352        }
353    }
354
355    #[test]
356    fn mouse_button_translation() {
357        assert_eq!(
358            translate_mouse_button(winit::event::MouseButton::Left),
359            Some(PointerButton::Primary)
360        );
361        assert_eq!(
362            translate_mouse_button(winit::event::MouseButton::Right),
363            Some(PointerButton::Secondary)
364        );
365    }
366
367    #[test]
368    fn mouse_press_to_pointer_down() {
369        let mut state = TranslationState::new();
370        translate_cursor_moved(50.0, 25.0, &mut state);
371        let event = translate_mouse_input(
372            winit::event::ElementState::Pressed,
373            winit::event::MouseButton::Left,
374            &state,
375        )
376        .unwrap();
377        assert!(matches!(
378            event,
379            WidgetEvent::PointerDown {
380                button: PointerButton::Primary,
381                ..
382            }
383        ));
384    }
385
386    #[test]
387    fn key_translation() {
388        let key = winit::keyboard::Key::Named(winit::keyboard::NamedKey::Space);
389        assert_eq!(translate_key(&key), Some(Key::Space));
390    }
391
392    #[test]
393    fn pinch_gesture_started() {
394        let mut state = TranslationState::new();
395        translate_cursor_moved(100.0, 50.0, &mut state);
396        let event =
397            translate_pinch_gesture(0.0, winit::event::TouchPhase::Started, &state).unwrap();
398        assert!(matches!(
399            event,
400            WidgetEvent::Gesture {
401                gesture: GestureEvent::PinchStarted { .. }
402            }
403        ));
404    }
405
406    #[test]
407    fn pinch_gesture_changed() {
408        let mut state = TranslationState::new();
409        translate_cursor_moved(100.0, 50.0, &mut state);
410        let event = translate_pinch_gesture(0.5, winit::event::TouchPhase::Moved, &state).unwrap();
411        if let WidgetEvent::Gesture {
412            gesture: GestureEvent::PinchChanged { scale, .. },
413        } = event
414        {
415            assert!((scale - 1.5).abs() < 0.001);
416        } else {
417            panic!("Expected PinchChanged");
418        }
419    }
420
421    #[test]
422    fn pinch_gesture_ended() {
423        let state = TranslationState::new();
424        let event = translate_pinch_gesture(0.0, winit::event::TouchPhase::Ended, &state).unwrap();
425        assert!(matches!(
426            event,
427            WidgetEvent::Gesture {
428                gesture: GestureEvent::PinchEnded
429            }
430        ));
431    }
432
433    #[test]
434    fn rotation_gesture_translates() {
435        let mut state = TranslationState::new();
436        translate_cursor_moved(50.0, 50.0, &mut state);
437        let event =
438            translate_rotation_gesture(15.0, winit::event::TouchPhase::Moved, &state).unwrap();
439        if let WidgetEvent::Gesture {
440            gesture: GestureEvent::PinchChanged {
441                rotation, scale, ..
442            },
443        } = event
444        {
445            assert!((rotation - 15.0).abs() < 0.001);
446            assert!((scale - 1.0).abs() < 0.001);
447        } else {
448            panic!("Expected PinchChanged with rotation");
449        }
450    }
451
452    #[test]
453    fn double_tap_gesture_translates() {
454        let mut state = TranslationState::new();
455        translate_cursor_moved(75.0, 25.0, &mut state);
456        let event = translate_double_tap_gesture(&state).unwrap();
457        if let WidgetEvent::Gesture {
458            gesture: GestureEvent::DoubleTap(tap_event),
459        } = event
460        {
461            assert_eq!(tap_event.position.x, 75.0);
462            assert_eq!(tap_event.position.y, 25.0);
463            assert_eq!(tap_event.button, PointerButton::Primary);
464        } else {
465            panic!("Expected DoubleTap");
466        }
467    }
468
469    #[test]
470    fn ime_preedit_to_composition_preserves_cursor_bytes() {
471        let evt = translate_ime(winit::event::Ime::Preedit("a".to_string(), Some((1, 1)))).unwrap();
472        assert!(matches!(
473            evt,
474            WidgetEvent::ImeComposition { ref text, cursor: Some(ref r) }
475                if text == "a" && *r == (1..1)
476        ));
477    }
478
479    #[test]
480    fn ime_preedit_hide_cursor_maps_to_none() {
481        let evt = translate_ime(winit::event::Ime::Preedit(String::new(), None)).unwrap();
482        assert!(matches!(
483            evt,
484            WidgetEvent::ImeComposition { ref text, cursor: None } if text.is_empty()
485        ));
486    }
487
488    #[test]
489    fn ime_commit_translates() {
490        let evt = translate_ime(winit::event::Ime::Commit("你".to_string())).unwrap();
491        assert!(matches!(evt, WidgetEvent::ImeCommit { ref text } if text == "你"));
492    }
493
494    #[test]
495    fn ime_enabled_disabled_produce_no_event() {
496        assert!(translate_ime(winit::event::Ime::Enabled).is_none());
497        assert!(translate_ime(winit::event::Ime::Disabled).is_none());
498    }
499}