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        _ => None,
222    }
223}
224
225/// Translate a winit MouseWheel event to a WidgetEvent::Scroll.
226pub fn translate_mouse_wheel(
227    delta: winit::event::MouseScrollDelta,
228    _phase: winit::event::TouchPhase,
229    state: &TranslationState,
230) -> Option<WidgetEvent> {
231    // Winit uses "natural" sign: positive y = scroll up (content moves down).
232    // Teksilo's ScrollDelta uses positive y = increase scroll offset (content moves up).
233    // Negate both axes to match.
234    //
235    // Winit reports raw notch counts (typically 1.0 per wheel notch). Multiply
236    // by a platform factor so each notch scrolls a comfortable number of lines.
237    // 3 lines per notch matches the Windows/GTK default.
238    const LINES_PER_NOTCH: f32 = 3.0;
239    let scroll_delta = match delta {
240        winit::event::MouseScrollDelta::LineDelta(x, y) => ScrollDelta::Lines {
241            x: -x * LINES_PER_NOTCH,
242            y: -y * LINES_PER_NOTCH,
243        },
244        winit::event::MouseScrollDelta::PixelDelta(pos) => ScrollDelta::Pixels {
245            x: -(pos.x / state.scale_factor) as f32,
246            y: -(pos.y / state.scale_factor) as f32,
247        },
248    };
249    Some(WidgetEvent::Scroll {
250        delta: scroll_delta,
251        modifiers: state.current_modifiers,
252    })
253}
254
255// --- Desktop trackpad gesture passthrough ---
256// On desktop, most gestures arrive as already-recognized events from the OS
257// trackpad driver. These functions translate winit's high-level gesture events
258// into Teksilo GestureEvents, which can be dispatched as WidgetEvent::Gesture.
259
260/// Translate a winit PinchGesture into a Teksilo gesture event.
261/// Returns PinchStarted on Started phase, PinchChanged on Changed, PinchEnded on Ended.
262pub fn translate_pinch_gesture(
263    delta: f64,
264    phase: winit::event::TouchPhase,
265    state: &TranslationState,
266) -> Option<WidgetEvent> {
267    let center = state.cursor_position.unwrap_or(Point::ZERO);
268    let gesture = match phase {
269        winit::event::TouchPhase::Started => GestureEvent::PinchStarted { center },
270        winit::event::TouchPhase::Moved => GestureEvent::PinchChanged {
271            center,
272            scale: 1.0 + delta as f32,
273            rotation: 0.0,
274        },
275        winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => {
276            GestureEvent::PinchEnded
277        }
278    };
279    Some(WidgetEvent::Gesture { gesture })
280}
281
282/// Translate a winit RotationGesture into a PinchChanged with rotation.
283/// Rotation gestures are folded into the pinch gesture model since they
284/// typically co-occur with pinch on trackpads.
285pub fn translate_rotation_gesture(
286    delta_degrees: f32,
287    phase: winit::event::TouchPhase,
288    state: &TranslationState,
289) -> Option<WidgetEvent> {
290    let center = state.cursor_position.unwrap_or(Point::ZERO);
291    let gesture = match phase {
292        winit::event::TouchPhase::Started => GestureEvent::PinchStarted { center },
293        winit::event::TouchPhase::Moved => GestureEvent::PinchChanged {
294            center,
295            scale: 1.0,
296            rotation: delta_degrees,
297        },
298        winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => {
299            GestureEvent::PinchEnded
300        }
301    };
302    Some(WidgetEvent::Gesture { gesture })
303}
304
305/// Translate a winit DoubleTapGesture (trackpad smart magnification).
306///
307/// Synthetic OS-driven double-tap: there's no underlying mouse button
308/// or modifier set the OS hands us, so we attribute it to
309/// `PointerButton::Primary` with no modifiers. Apps that need richer
310/// trackpad-gesture metadata should match on `WidgetEvent::Gesture`
311/// directly rather than hooking `on_double_tap`.
312pub fn translate_double_tap_gesture(state: &TranslationState) -> Option<WidgetEvent> {
313    let position = state.cursor_position.unwrap_or(Point::ZERO);
314    Some(WidgetEvent::Gesture {
315        gesture: GestureEvent::DoubleTap(TapEvent::new(
316            position,
317            PointerButton::Primary,
318            state.current_modifiers,
319        )),
320    })
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn cursor_moved_to_pointer_move() {
329        let mut state = TranslationState::new();
330        let event = translate_cursor_moved(100.0, 50.0, &mut state).unwrap();
331        if let WidgetEvent::PointerMove { position } = event {
332            assert_eq!(position.x, 100.0);
333            assert_eq!(position.y, 50.0);
334        } else {
335            panic!("Expected PointerMove");
336        }
337    }
338
339    #[test]
340    fn scale_factor_divides_physical_coords() {
341        let mut state = TranslationState::new();
342        state.set_scale_factor(2.0);
343        let event = translate_cursor_moved(200.0, 100.0, &mut state).unwrap();
344        if let WidgetEvent::PointerMove { position } = event {
345            assert_eq!(position.x, 100.0);
346            assert_eq!(position.y, 50.0);
347        } else {
348            panic!("Expected PointerMove");
349        }
350    }
351
352    #[test]
353    fn mouse_button_translation() {
354        assert_eq!(
355            translate_mouse_button(winit::event::MouseButton::Left),
356            Some(PointerButton::Primary)
357        );
358        assert_eq!(
359            translate_mouse_button(winit::event::MouseButton::Right),
360            Some(PointerButton::Secondary)
361        );
362    }
363
364    #[test]
365    fn mouse_press_to_pointer_down() {
366        let mut state = TranslationState::new();
367        translate_cursor_moved(50.0, 25.0, &mut state);
368        let event = translate_mouse_input(
369            winit::event::ElementState::Pressed,
370            winit::event::MouseButton::Left,
371            &state,
372        )
373        .unwrap();
374        assert!(matches!(
375            event,
376            WidgetEvent::PointerDown {
377                button: PointerButton::Primary,
378                ..
379            }
380        ));
381    }
382
383    #[test]
384    fn key_translation() {
385        let key = winit::keyboard::Key::Named(winit::keyboard::NamedKey::Space);
386        assert_eq!(translate_key(&key), Some(Key::Space));
387    }
388
389    #[test]
390    fn pinch_gesture_started() {
391        let mut state = TranslationState::new();
392        translate_cursor_moved(100.0, 50.0, &mut state);
393        let event =
394            translate_pinch_gesture(0.0, winit::event::TouchPhase::Started, &state).unwrap();
395        assert!(matches!(
396            event,
397            WidgetEvent::Gesture {
398                gesture: GestureEvent::PinchStarted { .. }
399            }
400        ));
401    }
402
403    #[test]
404    fn pinch_gesture_changed() {
405        let mut state = TranslationState::new();
406        translate_cursor_moved(100.0, 50.0, &mut state);
407        let event = translate_pinch_gesture(0.5, winit::event::TouchPhase::Moved, &state).unwrap();
408        if let WidgetEvent::Gesture {
409            gesture: GestureEvent::PinchChanged { scale, .. },
410        } = event
411        {
412            assert!((scale - 1.5).abs() < 0.001);
413        } else {
414            panic!("Expected PinchChanged");
415        }
416    }
417
418    #[test]
419    fn pinch_gesture_ended() {
420        let state = TranslationState::new();
421        let event = translate_pinch_gesture(0.0, winit::event::TouchPhase::Ended, &state).unwrap();
422        assert!(matches!(
423            event,
424            WidgetEvent::Gesture {
425                gesture: GestureEvent::PinchEnded
426            }
427        ));
428    }
429
430    #[test]
431    fn rotation_gesture_translates() {
432        let mut state = TranslationState::new();
433        translate_cursor_moved(50.0, 50.0, &mut state);
434        let event =
435            translate_rotation_gesture(15.0, winit::event::TouchPhase::Moved, &state).unwrap();
436        if let WidgetEvent::Gesture {
437            gesture: GestureEvent::PinchChanged {
438                rotation, scale, ..
439            },
440        } = event
441        {
442            assert!((rotation - 15.0).abs() < 0.001);
443            assert!((scale - 1.0).abs() < 0.001);
444        } else {
445            panic!("Expected PinchChanged with rotation");
446        }
447    }
448
449    #[test]
450    fn double_tap_gesture_translates() {
451        let mut state = TranslationState::new();
452        translate_cursor_moved(75.0, 25.0, &mut state);
453        let event = translate_double_tap_gesture(&state).unwrap();
454        if let WidgetEvent::Gesture {
455            gesture: GestureEvent::DoubleTap(tap_event),
456        } = event
457        {
458            assert_eq!(tap_event.position.x, 75.0);
459            assert_eq!(tap_event.position.y, 25.0);
460            assert_eq!(tap_event.button, PointerButton::Primary);
461        } else {
462            panic!("Expected DoubleTap");
463        }
464    }
465
466    #[test]
467    fn ime_preedit_to_composition_preserves_cursor_bytes() {
468        let evt = translate_ime(winit::event::Ime::Preedit("a".to_string(), Some((1, 1)))).unwrap();
469        assert!(matches!(
470            evt,
471            WidgetEvent::ImeComposition { ref text, cursor: Some(ref r) }
472                if text == "a" && *r == (1..1)
473        ));
474    }
475
476    #[test]
477    fn ime_preedit_hide_cursor_maps_to_none() {
478        let evt = translate_ime(winit::event::Ime::Preedit(String::new(), None)).unwrap();
479        assert!(matches!(
480            evt,
481            WidgetEvent::ImeComposition { ref text, cursor: None } if text.is_empty()
482        ));
483    }
484
485    #[test]
486    fn ime_commit_translates() {
487        let evt = translate_ime(winit::event::Ime::Commit("你".to_string())).unwrap();
488        assert!(matches!(evt, WidgetEvent::ImeCommit { ref text } if text == "你"));
489    }
490
491    #[test]
492    fn ime_enabled_disabled_produce_no_event() {
493        assert!(translate_ime(winit::event::Ime::Enabled).is_none());
494        assert!(translate_ime(winit::event::Ime::Disabled).is_none());
495    }
496}