Skip to main content

repose_platform/
runner_common.rs

1//! Shared runner helpers extracted from duplicated desktop/web/android `App` impls.
2
3use web_time::Duration;
4
5use repose_app::{ReposeRuntime, TouchGestureState};
6use repose_core::Vec2;
7use repose_core::input::PointerButton;
8use repose_core::shortcuts::{Action, Gesture};
9use winit::event::{ElementState, MouseScrollDelta, Touch};
10use winit::keyboard::{KeyCode, PhysicalKey};
11
12use crate::common::{map_key, winit_key_to_repose};
13
14/// Adapter creation is retried a few times because a platform may still be
15/// bringing up its GPU process. A capability problem (no supported backend,
16/// blocklisted driver) never resolves, so stop after this many tries.
17pub const MAX_BACKEND_INIT_ATTEMPTS: u32 = 3;
18
19const BASE_BACKEND_RETRY_DELAY: Duration = Duration::from_millis(250);
20
21#[derive(Clone, Copy, PartialEq, Eq, Debug)]
22pub enum BackendState {
23    Pending,
24    Ready,
25    Failed,
26    /// Nothing left to try: the canvas/window stays blank until reload.
27    Unavailable,
28}
29
30pub enum BackendRetry {
31    Ready,
32    RetryAfter(Duration),
33    Unavailable,
34}
35
36/// Retry policy for GPU backend construction, shared by every runner so the
37/// attempt cap and backoff curve stay identical across platforms. Callers keep
38/// their own clock and absolute retry deadline, since `Instant` differs on
39/// wasm.
40#[derive(Default)]
41pub struct BackendLifecycle {
42    state: Option<BackendState>,
43    attempts: u32,
44}
45
46impl BackendLifecycle {
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    pub fn state(&self) -> BackendState {
52        self.state.unwrap_or(BackendState::Pending)
53    }
54
55    /// Mark an in-flight construction so a stale result cannot look ready.
56    pub fn begin(&mut self) {
57        self.state = Some(BackendState::Pending);
58    }
59
60    pub fn ready(&mut self) {
61        self.state = Some(BackendState::Ready);
62        self.attempts = 0;
63    }
64
65    /// Record a failure and say what to do next.
66    pub fn failed(&mut self) -> BackendRetry {
67        self.attempts += 1;
68        if self.attempts >= MAX_BACKEND_INIT_ATTEMPTS {
69            self.state = Some(BackendState::Unavailable);
70            BackendRetry::Unavailable
71        } else {
72            self.state = Some(BackendState::Failed);
73            BackendRetry::RetryAfter(BASE_BACKEND_RETRY_DELAY * (1 << (self.attempts - 1).min(4)))
74        }
75    }
76}
77
78/// Update `Modifiers` from winit state - shared.
79pub fn on_modifiers_changed(rt: &mut ReposeRuntime, state: &winit::keyboard::ModifiersState) {
80    crate::common::update_modifiers(&mut rt.modifiers, state);
81}
82
83/// CursorMoved helper - returns cursor + whether inspector hover updated.
84pub fn on_cursor_moved(
85    rt: &mut ReposeRuntime,
86    pos: Vec2,
87    inspector: &mut Option<repose_devtools::Inspector>,
88) -> Option<repose_core::CursorIcon> {
89    let result = rt.handle_pointer_move(pos);
90    if let (Some(inspector), Some(f)) = (inspector, &rt.frame_cache)
91        && inspector.hud.inspector_enabled
92    {
93        let hit = f.hit_regions.iter().find(|h| h.rect.contains(pos));
94        let hover_rect = hit.map(|h| h.rect);
95        let hover_info = hit.and_then(|h| {
96            f.semantics_nodes
97                .iter()
98                .find(|s| s.id == h.id)
99                .map(|s| repose_devtools::HoveredInfo {
100                    id: s.id,
101                    role: format!("{:?}", s.role),
102                    label: s.label.clone(),
103                })
104        });
105        inspector.hud.set_hovered(hover_rect, hover_info);
106    }
107    result.cursor
108}
109
110/// MouseWheel helper - converts delta to px and dispatches.
111pub fn on_mouse_wheel(rt: &mut ReposeRuntime, delta: MouseScrollDelta, scale: f32) -> bool {
112    let (dx_px, dy_px) = match delta {
113        MouseScrollDelta::LineDelta(x, y) => {
114            let _ = scale;
115            let unit_px = repose_core::Dp(60.0).to_px().0;
116            (-(x * unit_px), -(y * unit_px))
117        }
118        MouseScrollDelta::PixelDelta(p) => (-(p.x as f32), -(p.y as f32)),
119    };
120    rt.handle_scroll(Vec2 { x: dx_px, y: dy_px })
121}
122
123/// Map winit MouseButton -> PointerButton.
124pub fn map_mouse_button(btn: winit::event::MouseButton) -> Option<PointerButton> {
125    match btn {
126        winit::event::MouseButton::Left => Some(PointerButton::Primary),
127        winit::event::MouseButton::Right => Some(PointerButton::Secondary),
128        winit::event::MouseButton::Middle => Some(PointerButton::Tertiary),
129        _ => None,
130    }
131}
132
133/// Raw touch result without dispatch - caller handles `dispatch_action` to avoid double-borrow.
134pub struct TouchResult {
135    pub dirty: bool,
136    pub pinch: Option<(f32, Vec2)>,
137    pub pan: Option<(Vec2, Vec2)>,
138    pub rotation: Option<(f32, Vec2)>,
139    pub swipe_right: Option<bool>,
140    /// Press result of a tap dispatched on `Ended`. `None` when no press ran
141    /// (scroll/pinch release); `Some` carries the press's focused id, with
142    /// `None` inside meaning the tap explicitly defocused.
143    pub press: Option<Option<u64>>,
144}
145
146pub fn handle_touch_raw(
147    rt: &mut ReposeRuntime,
148    touch_gestures: &mut TouchGestureState,
149    t: &Touch,
150    scale: f32,
151) -> TouchResult {
152    let pos_px = (t.location.x as f32, t.location.y as f32);
153    let tid = t.id;
154    match t.phase {
155        winit::event::TouchPhase::Started => {
156            // Sync before the gesture layer: `touch_started` reads
157            // the table for multi-finger detection (`len() >= 2`
158            // cancels the pending primary instead of dispatching a
159            // press), so the second finger must already be present.
160            touch_gestures.contact_down(tid, pos_px);
161            touch_gestures.touch_started(rt, tid, pos_px);
162            sync_touch_points(rt, touch_gestures);
163            TouchResult {
164                dirty: true,
165                pinch: None,
166                pan: None,
167                rotation: None,
168                swipe_right: None,
169                press: None,
170            }
171        }
172        winit::event::TouchPhase::Moved => {
173            touch_gestures.contact_down(tid, pos_px);
174            let (dirty, pinch, pan, rotation) = touch_gestures.touch_moved(rt, tid, pos_px, scale);
175            sync_touch_points(rt, touch_gestures);
176            TouchResult {
177                dirty,
178                pinch,
179                pan,
180                rotation,
181                swipe_right: None,
182                press: None,
183            }
184        }
185        winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => {
186            let cancelled = t.phase == winit::event::TouchPhase::Cancelled;
187            // Sync before the gesture layer: `touch_ended` derives
188            // tap-vs-multi from the table (`was_multi`), so the lifted
189            // finger must still be present.
190            let ended = touch_gestures.touch_ended(rt, tid, pos_px, cancelled);
191            touch_gestures.contact_up(tid);
192            sync_touch_points(rt, touch_gestures);
193            TouchResult {
194                dirty: false,
195                pinch: None,
196                pan: None,
197                rotation: None,
198                swipe_right: ended.swipe_right,
199                press: ended.press,
200            }
201        }
202    }
203}
204
205/// Publish the live contact table into `sched.touch_points` (single
206/// sync point: every runner funnels raw touch through
207/// `handle_touch_raw`). Games snapshot it each frame, so held fingers
208/// stay visible across frames (unlike the press edge, which fires
209/// once and is gone). GML `device_mouse_*` parity: stable per-finger
210/// ids, physical-px positions sampled every tick while held.
211pub fn sync_touch_points(rt: &mut ReposeRuntime, touch_gestures: &TouchGestureState) {
212    rt.sched.touch_points.clear();
213    rt.sched.touch_points.extend(
214        touch_gestures
215            .active_touches()
216            .iter()
217            .map(|(id, (x, y))| (*id, *x, *y)),
218    );
219}
220
221/// HACK: Legacy wrapper that dispatches gestures inline (use `handle_touch_raw` when caller
222/// needs to avoid double-borrow of `self` containing `rt`).
223pub fn on_touch(
224    rt: &mut ReposeRuntime,
225    touch_gestures: &mut TouchGestureState,
226    t: &Touch,
227    scale: f32,
228    mut dispatch: impl FnMut(Action) -> bool,
229) -> bool {
230    let r = handle_touch_raw(rt, touch_gestures, t, scale);
231    let mut dirty = r.dirty;
232    if let Some((delta_scale, center)) = r.pinch
233        && dispatch(Action::Gesture(Gesture::PinchWithCenter {
234            delta_scale,
235            center,
236        }))
237    {
238        dirty = true;
239    }
240    if let Some((delta, center)) = r.pan
241        && dispatch(Action::Gesture(Gesture::Pan { delta, center }))
242    {
243        dirty = true;
244    }
245    if let Some((delta_rotation, center)) = r.rotation
246        && dispatch(Action::Gesture(Gesture::Rotate {
247            delta_rotation,
248            center,
249        }))
250    {
251        dirty = true;
252    }
253    if let Some(right) = r.swipe_right {
254        let g = if right {
255            Gesture::SwipeRight
256        } else {
257            Gesture::SwipeLeft
258        };
259        if dispatch(Action::Gesture(g)) {
260            dirty = true;
261        }
262    }
263    dirty
264}
265
266/// Touch handler that also syncs IME for focused textfields on native runners.
267/// The web backend has no editor bridge, so its touch path only dispatches input.
268pub fn on_touch_with_ime(
269    rt: &mut ReposeRuntime,
270    touch_gestures: &mut TouchGestureState,
271    t: &Touch,
272    scale: f32,
273    _window: &winit::window::Window,
274    dispatch: impl FnMut(Action) -> bool,
275) -> bool {
276    let pos_px = (t.location.x as f32, t.location.y as f32);
277    let tid = t.id;
278    if t.phase == winit::event::TouchPhase::Started {
279        touch_gestures.contact_down(tid, pos_px);
280        let focused = touch_gestures.touch_started(rt, tid, pos_px);
281        sync_touch_points(rt, touch_gestures);
282        #[cfg(not(target_arch = "wasm32"))]
283        if let Some(fid) = focused {
284            let editable = rt
285                .frame_cache
286                .as_ref()
287                .is_some_and(|frame| crate::common::is_editable_textfield_hit(frame, fid));
288            if rt.sched.window_focused && editable {
289                let (purpose, ac, cap) = rt.focused_keyboard_hints();
290                crate::common::set_ime_for_textfield_ex(_window, true, purpose, ac, cap);
291            } else {
292                crate::common::set_ime_for_textfield(_window, false);
293            }
294        } else {
295            crate::common::set_ime_for_textfield(_window, false);
296        }
297        #[cfg(target_arch = "wasm32")]
298        let _ = focused;
299        return true;
300    }
301    on_touch(rt, touch_gestures, t, scale, dispatch)
302}
303
304/// Shared inspector toggle + runtime dispatch.
305/// Reports the raw physical transition to the polled `held_keys` set
306/// first (games read held keys GML-style without depending on focus),
307/// then dispatches the focus-routed key event as before.
308/// Returns true if event consumed / needs redraw.
309pub fn on_keyboard_input(
310    rt: &mut ReposeRuntime,
311    key_event: &winit::event::KeyEvent,
312    inspector: &mut Option<repose_devtools::Inspector>,
313) -> bool {
314    on_key_parts(
315        rt,
316        key_event.physical_key,
317        key_event.state == ElementState::Pressed,
318        key_event.repeat,
319        key_event.text.as_deref(),
320        inspector,
321    )
322}
323
324/// Same dispatch as [`on_keyboard_input`], for runners that cannot hand over a
325/// [`winit::event::KeyEvent`] (it has no public constructor): synthetic keys
326/// and keys forwarded from a DOM element.
327pub fn on_key_parts(
328    rt: &mut ReposeRuntime,
329    physical_key: PhysicalKey,
330    pressed: bool,
331    repeat: bool,
332    text: Option<&str>,
333    inspector: &mut Option<repose_devtools::Inspector>,
334) -> bool {
335    let key_event = KeyParts {
336        physical_key,
337        pressed,
338        repeat,
339    };
340    rt.set_physical_key(
341        &physical_key_name(key_event.physical_key),
342        key_event.pressed,
343    );
344    // Mirror into the scheduler's polled snapshot too: games read
345    // `sched.held_keys` + mouse levels directly (no runtime handle),
346    // so the platform publishes hardware levels where composition can
347    // see them. Focus dispatch below is unchanged.
348    {
349        let key = map_physical_key(key_event.physical_key);
350        let sched = &mut rt.sched;
351        if key_event.pressed {
352            sched.held_keys.insert(key);
353        } else {
354            sched.held_keys.remove(&key);
355        }
356    }
357    if key_event.pressed
358        && !key_event.repeat
359        && rt.modifiers.ctrl
360        && rt.modifiers.shift
361        && key_event.physical_key == PhysicalKey::Code(KeyCode::KeyI)
362        && let Some(inspector) = inspector
363    {
364        inspector.hud.toggle_inspector();
365        return true;
366    }
367    let mapped = if crate::common::is_back_key_physical(key_event.physical_key) {
368        repose_core::input::Key::Escape
369    } else {
370        map_key(key_event.physical_key, &rt.modifiers)
371    };
372    let ke = winit_key_to_repose(
373        key_event.physical_key,
374        key_event.pressed,
375        key_event.repeat,
376        &mapped,
377        &rt.modifiers,
378    );
379    rt.handle_key_with_text(&ke, text)
380}
381
382struct KeyParts {
383    physical_key: PhysicalKey,
384    pressed: bool,
385    repeat: bool,
386}
387
388/// Stable debug name for a physical key (`KeyW`, `Digit1`, `Space`,
389/// ...), matching `KeyCode`'s variant names. Layout-independent:
390/// AZERTY/QWERTZ report the same physical names, so games bind by
391/// position the way GML's `ord("W")` binds the US position.
392pub fn physical_key_name(key: PhysicalKey) -> String {
393    match key {
394        PhysicalKey::Code(code) => format!("{code:?}"),
395        PhysicalKey::Unidentified(native) => format!("Unidentified({native:?})"),
396    }
397}
398
399/// Typed physical position for a winit key (`None` wraps to
400/// [`repose_core::input::PhysicalKey::Unidentified`], never `None`:
401/// every physical press has a position). Backs both the `KeyEvent`
402/// field and the `Scheduler::held_keys` snapshot.
403pub fn map_physical_key(key: PhysicalKey) -> repose_core::input::PhysicalKey {
404    repose_core::input::PhysicalKey::from_name(&physical_key_name(key))
405}
406
407/// Ime dispatch helper.
408pub fn on_ime(rt: &mut ReposeRuntime, ime: &winit::event::Ime) {
409    use winit::event::Ime;
410    let ev = match ime {
411        Ime::Enabled => repose_core::input::ImeEvent::Start,
412        Ime::Preedit(text, cursor) => repose_core::input::ImeEvent::Update {
413            text: text.clone(),
414            cursor: cursor.map(|(a, b)| (a, b)),
415        },
416        Ime::Commit(text) => repose_core::input::ImeEvent::Commit(text.clone()),
417        Ime::Disabled => repose_core::input::ImeEvent::Cancel,
418    };
419    rt.handle_ime(&ev);
420}