Skip to main content

repose_platform/
lib.rs

1//! Platform runners
2#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
3use accesskit_winit::Adapter;
4use repose_core::a11y::ReposeActionHandler;
5use repose_core::*;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex};
8use web_time::Instant;
9
10#[cfg(target_os = "android")]
11pub mod android;
12
13#[cfg(target_arch = "wasm32")]
14pub mod web;
15#[cfg(target_arch = "wasm32")]
16mod web_text_agent;
17
18mod common;
19pub mod gamepad;
20pub mod render;
21pub mod runner_common;
22pub mod sensor;
23pub mod window_v2;
24
25use common as rc;
26
27#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
28use crate::runner_common::{BackendRetry, BackendState, MAX_BACKEND_INIT_ATTEMPTS};
29
30pub use render::{ImageHandleGuard, RenderCommand, RenderContext};
31
32#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
33use winit::window::Window;
34
35#[cfg(not(target_arch = "wasm32"))]
36use std::sync::OnceLock;
37
38#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
39static APP_WINDOW: OnceLock<Arc<Window>> = OnceLock::new();
40
41#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
42static WINDOW_VISIBLE: AtomicBool = AtomicBool::new(true);
43
44#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
45static WINDOW_OCCLUDED: AtomicBool = AtomicBool::new(false);
46
47#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
48static CLOSE_TO_TRAY: AtomicBool = AtomicBool::new(false);
49
50#[cfg(not(target_arch = "wasm32"))]
51static EVENT_LOOP_PROXY: OnceLock<winit::event_loop::EventLoopProxy<()>> = OnceLock::new();
52
53/// Optional callback invoked on every AboutToWait, regardless of redraw state.
54#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
55static ABOUT_TO_WAIT_CALLBACK: Mutex<Option<Box<dyn Fn() + Send>>> = Mutex::new(None);
56
57pub use repose_app::lifecycle::{
58    AppLifecycle, add_deeplink_listener, add_lifecycle_listener, current_lifecycle,
59    process_deeplinks, process_lifecycle, remove_deeplink_listener, remove_lifecycle_listener,
60    run_pre_redraw, set_on_deeplink, set_on_lifecycle, set_pre_redraw,
61};
62
63/// Queue a lifecycle transition and wake the UI loop. Thin wrapper over `repose_app`.
64/// Window focus alone does not push here: hosts push `Foreground`/`Background`
65/// on real visibility transitions (Android resumed/suspended, desktop
66/// occluded/visible, web visibilitychange), and `process_lifecycle` drains them.
67#[cfg(target_os = "android")]
68pub(crate) fn push_lifecycle(state: AppLifecycle) {
69    repose_app::lifecycle::push_lifecycle(state);
70    #[cfg(not(target_arch = "wasm32"))]
71    wake_event_loop();
72}
73
74/// Non-Android hosts and tests: queue a lifecycle transition and wake the loop
75/// when a proxy exists. Prefer pushing only on real visibility transitions.
76#[cfg(not(target_os = "android"))]
77pub fn push_lifecycle(state: AppLifecycle) {
78    repose_app::lifecycle::push_lifecycle(state);
79    #[cfg(not(target_arch = "wasm32"))]
80    wake_event_loop();
81}
82
83#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
84fn push_runtime_window_lifecycle(rt: &mut repose_app::ReposeRuntime, visible: bool) {
85    let foreground = visible && !WINDOW_OCCLUDED.load(Ordering::Relaxed);
86    let state = if foreground {
87        AppLifecycle::Foreground
88    } else {
89        AppLifecycle::Background
90    };
91    if rt.current_lifecycle() != Some(state) {
92        rt.push_lifecycle(state);
93    }
94}
95
96/// Desktop visibility policy: hidden or occluded windows report `Background`,
97/// visible windows report `Foreground`.
98#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
99pub fn push_window_lifecycle(visible: bool) {
100    let foreground = visible && !WINDOW_OCCLUDED.load(Ordering::Relaxed);
101    let state = if foreground {
102        AppLifecycle::Foreground
103    } else {
104        AppLifecycle::Background
105    };
106    if current_lifecycle() != Some(state) {
107        push_lifecycle(state);
108    }
109}
110
111/// Push a deeplink payload and wake the UI loop. Thin wrapper over `repose_app`.
112pub fn push_deeplink(data: Vec<u8>) {
113    repose_app::lifecycle::push_deeplink(data);
114    #[cfg(not(target_arch = "wasm32"))]
115    if let Some(proxy) = EVENT_LOOP_PROXY.get() {
116        let _ = proxy.send_event(());
117    }
118}
119
120/// Store the application window handle (called once during app setup).
121#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
122pub fn set_app_window(window: Arc<Window>) {
123    let _ = APP_WINDOW.set(window);
124}
125
126/// Store the event loop proxy so tray commands / deeplinks can wake the event loop.
127#[cfg(not(target_arch = "wasm32"))]
128pub fn set_event_loop_proxy(proxy: winit::event_loop::EventLoopProxy<()>) {
129    let _ = EVENT_LOOP_PROXY.set(proxy);
130}
131
132/// Register a callback invoked on every AboutToWait (used for draining tray commands).
133#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
134pub fn set_about_to_wait_callback(cb: Box<dyn Fn() + Send>) {
135    *ABOUT_TO_WAIT_CALLBACK
136        .lock()
137        .unwrap_or_else(|e| e.into_inner()) = Some(cb);
138}
139
140/// Wake the winit event loop from another thread (e.g. tray's GTK thread, JNI callback).
141#[cfg(not(target_arch = "wasm32"))]
142pub fn wake_event_loop() {
143    if let Some(proxy) = EVENT_LOOP_PROXY.get() {
144        let _ = proxy.send_event(());
145    }
146}
147
148/// Show the application window.
149///
150/// On Wayland, unminimizing might not be supported by the protocol?
151#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
152pub fn show_app_window() {
153    WINDOW_VISIBLE.store(true, Ordering::Relaxed);
154    if let Some(w) = APP_WINDOW.get() {
155        log::info!("show_app_window: calling set_visible(true)");
156        w.set_visible(true);
157        #[allow(deprecated)]
158        w.focus_window();
159    }
160    push_window_lifecycle(true);
161    repose_core::frame_clock::request_frame();
162    wake_event_loop();
163}
164
165#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
166pub fn hide_app_window() {
167    WINDOW_VISIBLE.store(false, Ordering::Relaxed);
168    if let Some(w) = APP_WINDOW.get() {
169        log::info!("hide_app_window: calling set_visible(false)");
170        w.set_visible(false);
171    }
172    push_window_lifecycle(false);
173    repose_core::frame_clock::request_frame();
174    wake_event_loop();
175}
176
177/// Returns whether the application window is currently visible.
178#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
179pub fn window_is_visible() -> bool {
180    WINDOW_VISIBLE.load(Ordering::Relaxed)
181}
182
183/// The close button hides the window (via ``set_visible(false)``) instead of
184/// closing. The tray "Quit" action still exits the process regardless.
185#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
186pub fn set_close_to_tray(enabled: bool) {
187    CLOSE_TO_TRAY.store(enabled, Ordering::Relaxed);
188}
189
190/// Helper: ensure caret visibility - now in `repose_ui::textfield::tf_ensure_visible_in_rect`.
191pub use repose_ui::textfield::tf_ensure_visible_in_rect;
192
193#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
194use common::{cursor_is_hidden, map_cursor};
195
196pub use repose_app::{AndroidOptions, AppConfig, ReposeOptions};
197
198/// Run a desktop app with default [`AppConfig`].
199///
200/// Deprecated: use [`run_desktop_app_with_config`] with
201/// `AppConfig::default()` instead. This may be removed in a future release.
202#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
203#[deprecated(
204    note = "use run_desktop_app_with_config(root, AppConfig) instead; this may be removed in a future release"
205)]
206pub fn run_desktop_app(
207    root: impl FnMut(&mut Scheduler, &RenderContext) -> View + 'static,
208) -> anyhow::Result<()> {
209    run_desktop_app_with_config(root, AppConfig::default())
210}
211
212/// Default sink so a game sees the engine's own diagnostics without wiring a
213/// logger. A logger the host already installed wins.
214pub fn install_default_logger() {
215    let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
216        .try_init();
217}
218
219/// Run a desktop app with the given [`AppConfig`].
220#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
221pub fn run_desktop_app_with_config(
222    root: impl FnMut(&mut Scheduler, &RenderContext) -> View + 'static,
223    config: AppConfig,
224) -> anyhow::Result<()> {
225    install_default_logger();
226    use winit::application::ApplicationHandler;
227    use winit::dpi::{LogicalPosition, LogicalSize, PhysicalSize};
228    use winit::event::{ElementState, MouseButton, WindowEvent};
229    use winit::event_loop::EventLoop;
230    use winit::keyboard::{KeyCode, PhysicalKey};
231    use winit::window::{Window, WindowAttributes};
232
233    use repose_app::ReposeRuntime;
234    use repose_core::a11y::A11yTree;
235
236    struct ReposeActivationHandler {
237        active: Arc<AtomicBool>,
238        needs_full: Arc<AtomicBool>,
239    }
240
241    impl accesskit::ActivationHandler for ReposeActivationHandler {
242        fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
243            self.active.store(true, Ordering::Release);
244            self.needs_full.store(true, Ordering::Release);
245            wake_event_loop();
246            Some(A11yTree::initial_tree())
247        }
248    }
249
250    struct ReposeDeactivationHandler {
251        active: Arc<AtomicBool>,
252    }
253
254    impl accesskit::DeactivationHandler for ReposeDeactivationHandler {
255        fn deactivate_accessibility(&mut self) {
256            self.active.store(false, Ordering::Release);
257        }
258    }
259
260    #[derive(Clone, Copy, PartialEq)]
261    struct ImeState {
262        allowed: bool,
263        purpose: repose_core::ImePurposeHint,
264        auto_correct: bool,
265        capitalization: repose_core::KeyboardCapitalization,
266        cursor_area: Option<(f64, f64, f64, f64)>,
267    }
268
269    impl Default for ImeState {
270        fn default() -> Self {
271            Self {
272                allowed: false,
273                purpose: repose_core::ImePurposeHint::Normal,
274                auto_correct: true,
275                capitalization: repose_core::KeyboardCapitalization::Unspecified,
276                cursor_area: None,
277            }
278        }
279    }
280
281    struct App {
282        root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
283        render: RenderContext,
284        window: Option<Arc<Window>>,
285        backend: Option<repose_render_wgpu::WgpuBackend>,
286        backend_lifecycle: crate::runner_common::BackendLifecycle,
287        backend_retry_at: Option<Instant>,
288        rt: ReposeRuntime,
289        /// Ratio from host device pixels to layout pixels, from
290        /// `sync_viewport`. 1.0 unless device texture limits clamped the
291        /// framebuffer below the window, but it must be applied to every
292        /// incoming position or hit testing drifts off the drawn output.
293        input_fit: f32,
294        inspector: Option<repose_devtools::Inspector>,
295        msaa_samples: u32,
296        present_mode: PresentModePref,
297        window_title: String,
298        window_size: (u32, u32),
299        fullscreen: bool,
300
301        // Files
302        pending_dropped_files: Vec<std::path::PathBuf>,
303        pending_drop_pos_px: Option<(f32, f32)>,
304
305        // External file drag hover (HoveredFile / Cancelled)
306        external_file_drag: bool,
307        hovered_files: Vec<std::path::PathBuf>,
308
309        a11y: Box<dyn A11yBridge>,
310
311        accesskit_adapter: Option<Adapter>,
312        a11y_actions: Arc<Mutex<Vec<accesskit::ActionRequest>>>,
313        a11y_tree: A11yTree,
314        a11y_active: Arc<AtomicBool>,
315        a11y_needs_full: Arc<AtomicBool>,
316
317        // Last applied OS window theme (dark/light) to avoid spamming set_theme.
318        last_window_theme: Option<bool>,
319
320        custom_cursor: Option<(u64, winit::window::CustomCursor)>,
321        last_cursor_key: Option<(u8, u64)>,
322        /// Last declared pointer confinement, `None` until applied.
323        /// Reset when the window is created so the mode lands on the
324        /// new window too.
325        cursor_grab: Option<repose_core::CursorGrabMode>,
326
327        frame_pacer: rc::FramePacer,
328        redraw_deferred: bool,
329        occluded: bool,
330        os_focused: bool,
331        ime_output_allowed: bool,
332        ime_state: ImeState,
333
334        // Shared touch-scroll / pinch / swipe gesture state (touchscreens)
335        touch_gestures: rc::TouchGestureState,
336
337        gamepad: Option<Box<dyn gamepad::GamepadBackend>>,
338        sensors: Option<Box<dyn sensor::SensorBackend>>,
339        /// Devices whose samples never routed to a pad, warned once each
340        /// rather than per sample (an IMU reports hundreds a second).
341        sensor_drops: Vec<String>,
342    }
343
344    impl App {
345        fn process_a11y_actions(&mut self) {
346            let mut actions = self.a11y_actions.lock().unwrap_or_else(|e| e.into_inner());
347            if actions.is_empty() {
348                return;
349            }
350            let pending = actions.drain(..).collect::<Vec<_>>();
351            drop(actions);
352
353            let Some(f) = &self.rt.frame_cache else {
354                return;
355            };
356
357            for req in pending {
358                let target_id = req.target_node.0;
359                match req.action {
360                    accesskit::Action::Click => {
361                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
362                            && let Some(cb) = &hit.on_click
363                        {
364                            cb();
365                            self.request_redraw();
366                        }
367                    }
368                    accesskit::Action::Focus => {
369                        // Assistive tech focus should show the focus ring.
370                        let _ = repose_core::request_input_mode(repose_core::InputMode::Keyboard);
371                        self.rt.sched.focused = Some(target_id);
372                        self.request_redraw();
373                    }
374                    accesskit::Action::ScrollUp
375                    | accesskit::Action::ScrollDown
376                    | accesskit::Action::ScrollLeft
377                    | accesskit::Action::ScrollRight => {
378                        let delta = match req.action {
379                            accesskit::Action::ScrollUp => repose_core::Vec2 { x: 0.0, y: -200.0 },
380                            accesskit::Action::ScrollDown => repose_core::Vec2 { x: 0.0, y: 200.0 },
381                            accesskit::Action::ScrollLeft => {
382                                repose_core::Vec2 { x: -200.0, y: 0.0 }
383                            }
384                            _ => repose_core::Vec2 { x: 200.0, y: 0.0 },
385                        };
386                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id) {
387                            if let Some(scroll) = &hit.on_scroll_with_source {
388                                scroll(delta, repose_core::input::ScrollSource::Wheel);
389                                self.request_redraw();
390                            } else if let Some(scroll) = &hit.on_scroll {
391                                scroll(delta);
392                                self.request_redraw();
393                            }
394                        }
395                    }
396                    accesskit::Action::SetValue => {
397                        let value = match &req.data {
398                            Some(accesskit::ActionData::NumericValue(v)) => Some(*v as f32),
399                            Some(accesskit::ActionData::Value(v)) => v.parse::<f32>().ok(),
400                            _ => None,
401                        };
402                        if let Some(value) = value
403                            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
404                            && let Some(cb) = &hit.on_set_progress
405                        {
406                            cb(value);
407                            self.request_redraw();
408                        }
409                    }
410                    accesskit::Action::Increment | accesskit::Action::Decrement => {
411                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
412                            && let Some(cb) = &hit.on_set_progress
413                        {
414                            let dir = if matches!(req.action, accesskit::Action::Increment) {
415                                1.0
416                            } else {
417                                -1.0
418                            };
419                            let value = f
420                                .semantics_nodes
421                                .iter()
422                                .find(|n| n.id == target_id)
423                                .and_then(|n| n.progress)
424                                .map(|p| {
425                                    p.current + dir * p.step.unwrap_or((p.max - p.min) / 20.0)
426                                });
427                            if let Some(value) = value {
428                                cb(value);
429                                self.request_redraw();
430                            }
431                        }
432                    }
433                    accesskit::Action::ReplaceSelectedText => {
434                        if let Some(accesskit::ActionData::Value(text)) = &req.data
435                            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
436                            && let Some(cb) = &hit.on_text_change
437                        {
438                            cb(text.to_string());
439                            self.request_redraw();
440                        }
441                    }
442                    accesskit::Action::Expand => {
443                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
444                            && let Some(cb) = &hit.on_expand
445                        {
446                            cb();
447                            self.request_redraw();
448                        }
449                    }
450                    accesskit::Action::Collapse => {
451                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
452                            && let Some(cb) = &hit.on_collapse
453                        {
454                            cb();
455                            self.request_redraw();
456                        }
457                    }
458                    _ => {}
459                }
460            }
461        }
462
463        fn new(
464            root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
465            config: AppConfig,
466        ) -> Self {
467            Self {
468                root,
469                render: RenderContext::new(),
470                window: None,
471                backend: None,
472                backend_lifecycle: crate::runner_common::BackendLifecycle::new(),
473                backend_retry_at: None,
474                rt: ReposeRuntime::new(),
475                input_fit: 1.0,
476                inspector: if config.enable_inspector {
477                    Some(repose_devtools::Inspector::new())
478                } else {
479                    None
480                },
481                msaa_samples: config.common.msaa_samples,
482                present_mode: config.common.present_mode,
483                window_title: config.window_title,
484                window_size: config.window_size,
485                fullscreen: config.fullscreen,
486                pending_dropped_files: Vec::new(),
487                pending_drop_pos_px: None,
488
489                external_file_drag: false,
490                hovered_files: Vec::new(),
491
492                a11y: {
493                    #[cfg(target_os = "linux")]
494                    {
495                        Box::new(LinuxAtspiStub) as Box<dyn A11yBridge>
496                    }
497                    #[cfg(not(target_os = "linux"))]
498                    {
499                        Box::new(NoopA11y) as Box<dyn A11yBridge>
500                    }
501                },
502
503                accesskit_adapter: None,
504                a11y_actions: Arc::new(Mutex::new(Vec::new())),
505                a11y_tree: A11yTree::default(),
506                a11y_active: Arc::new(AtomicBool::new(false)),
507                a11y_needs_full: Arc::new(AtomicBool::new(false)),
508
509                frame_pacer: rc::FramePacer::new(config.common.max_fps),
510                redraw_deferred: false,
511                occluded: false,
512                os_focused: true,
513                ime_output_allowed: false,
514                ime_state: ImeState::default(),
515                last_window_theme: None,
516                custom_cursor: None,
517                last_cursor_key: None,
518                cursor_grab: None,
519                touch_gestures: rc::TouchGestureState::default(),
520                gamepad: gamepad::create_backend()
521                    .map(|b| Box::new(b) as Box<dyn gamepad::GamepadBackend>),
522                sensors: sensor::create_backend()
523                    .map(|b| Box::new(b) as Box<dyn sensor::SensorBackend>),
524                sensor_drops: Vec::new(),
525            }
526        }
527
528        fn request_redraw(&self) {
529            repose_core::request_frame();
530        }
531
532        fn ime_allowed_for_frame(&self, frame: &repose_core::runtime::Frame) -> bool {
533            WINDOW_VISIBLE.load(Ordering::Relaxed)
534                && !WINDOW_OCCLUDED.load(Ordering::Relaxed)
535                && self.ime_output_allowed
536                && self.rt.sched.window_focused
537                && self.rt.sched.focused.is_some_and(|id| {
538                    rc::is_editable_textfield_hit(frame, id) || rc::has_ime_handler(frame, id)
539                })
540        }
541
542        fn ime_allowed_for_output(
543            &self,
544            hits: &[repose_core::HitRegion],
545            semantics: &[repose_core::runtime::SemNode],
546            output_allowed: bool,
547        ) -> bool {
548            WINDOW_VISIBLE.load(Ordering::Relaxed)
549                && !WINDOW_OCCLUDED.load(Ordering::Relaxed)
550                && output_allowed
551                && self.rt.sched.window_focused
552                && self.rt.sched.focused.is_some_and(|id| {
553                    rc::editable_textfield_hit(hits, semantics, id).is_some()
554                        || rc::has_ime_handler_hit(hits, id)
555                })
556        }
557
558        fn sync_ime_for_focus(&mut self) {
559            let hit = self.rt.frame_cache.as_ref().and_then(|frame| {
560                (self.ime_allowed_for_frame(frame))
561                    .then(|| {
562                        self.rt.sched.focused.and_then(|id| {
563                            rc::editable_textfield_hit(
564                                &frame.hit_regions,
565                                &frame.semantics_nodes,
566                                id,
567                            )
568                        })
569                    })
570                    .flatten()
571            });
572            let state = match hit {
573                Some(hit) => {
574                    let scale = self.window.as_ref().map(|window| window.scale_factor());
575                    ImeState {
576                        allowed: true,
577                        purpose: hit.keyboard_type.ime_purpose_hint(),
578                        auto_correct: hit.auto_correct.unwrap_or(true),
579                        capitalization: hit.capitalization,
580                        cursor_area: scale.map(|scale| {
581                            (
582                                hit.rect.x as f64 / scale,
583                                hit.rect.y as f64 / scale,
584                                hit.rect.w as f64 / scale,
585                                hit.rect.h as f64 / scale,
586                            )
587                        }),
588                    }
589                }
590                None => ImeState::default(),
591            };
592            self.apply_ime_state(state);
593        }
594
595        fn apply_ime_state(&mut self, state: ImeState) {
596            if self.ime_state == state {
597                return;
598            }
599            let Some(window) = self.window.clone() else {
600                return;
601            };
602            self.ime_state = state;
603            if state.allowed {
604                rc::set_ime_for_textfield_ex(
605                    &window,
606                    true,
607                    state.purpose,
608                    state.auto_correct,
609                    state.capitalization,
610                );
611                if let Some((x, y, w, h)) = state.cursor_area {
612                    window.set_ime_cursor_area(LogicalPosition::new(x, y), LogicalSize::new(w, h));
613                }
614            } else {
615                rc::set_ime_for_textfield(&window, false);
616                self.rt.finish_compositions();
617            }
618        }
619
620        fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
621            if self.rt.dispatch_action(action) {
622                self.sync_ime_for_focus();
623                return true;
624            }
625            false
626        }
627
628        fn paste_from_primary(&self) -> Option<String> {
629            let mut opts = clipawl::ClipboardOptions::default();
630            opts.linux.selection = clipawl::LinuxSelection::Primary;
631            if let Ok(cb) = clipawl::Clipboard::new_with_options(opts) {
632                match pollster::block_on(cb.read()) {
633                    Ok(t) => Some(t),
634                    Err(e) => {
635                        eprintln!("Primary paste error: {}", e);
636                        None
637                    }
638                }
639            } else {
640                None
641            }
642        }
643
644        fn process_render_commands(&mut self) {
645            let Some(backend) = self.backend.as_mut() else {
646                return;
647            };
648            repose_render_wgpu::apply_render_commands(backend, self.render.drain()).log_failures();
649        }
650
651        /// Content hash for the custom-cursor cache: FNV over bytes +
652        /// dims + hotspot. Collision odds are negligible for a 1-entry
653        /// cache that only dedupes identical consecutive frames.
654        #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
655        fn custom_cursor_key(img: &repose_core::CustomCursorImage) -> u64 {
656            let mut h: u64 = 0xcbf29ce484222325;
657            for b in img.rgba.iter().copied().chain(
658                img.size
659                    .iter()
660                    .flat_map(|v| v.to_le_bytes())
661                    .chain(img.hotspot.iter().flat_map(|v| v.to_le_bytes())),
662            ) {
663                h ^= b as u64;
664                h = h.wrapping_mul(0x100000001b3);
665            }
666            h ^= (img.rgba.len() as u64).wrapping_mul(0x9e3779b97f4a7c15);
667            h
668        }
669
670        /// Dedup key for `set_cursor` traffic: content hash for `Custom`,
671        /// discriminant otherwise (egui-winit bitmap path parity).
672        #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
673        fn cursor_key(c: &repose_core::CursorIcon) -> (u8, u64) {
674            match c {
675                repose_core::CursorIcon::Default => (0, 0),
676                repose_core::CursorIcon::Pointer => (1, 0),
677                repose_core::CursorIcon::Text => (2, 0),
678                repose_core::CursorIcon::EwResize => (3, 0),
679                repose_core::CursorIcon::NsResize => (4, 0),
680                repose_core::CursorIcon::NwseResize => (5, 0),
681                repose_core::CursorIcon::NeswResize => (6, 0),
682                repose_core::CursorIcon::Grab => (7, 0),
683                repose_core::CursorIcon::Grabbing => (8, 0),
684                repose_core::CursorIcon::Hidden => (9, 0),
685                repose_core::CursorIcon::Custom(img) => (10, Self::custom_cursor_key(img)),
686            }
687        }
688
689        /// Apply one cursor suggestion to the window (desktop): plain
690        /// icons via `map_cursor`, `Custom` via a cached
691        /// `create_custom_cursor` handle rebuilt only when the pixels
692        /// change (egui-winit bitmap path parity). Invalid art
693        /// (`BadImage`) falls back to the default arrow, never a panic.
694        #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
695        fn apply_cursor_static(
696            win: &std::sync::Arc<winit::window::Window>,
697            cache: &mut Option<(u64, winit::window::CustomCursor)>,
698            last_key: &mut Option<(u8, u64)>,
699            el: Option<&winit::event_loop::ActiveEventLoop>,
700            c: &repose_core::CursorIcon,
701        ) {
702            let key = Self::cursor_key(c);
703            if *last_key == Some(key) {
704                return;
705            }
706            *last_key = Some(key);
707            if let repose_core::CursorIcon::Custom(img) = c {
708                let hash = key.1;
709                let hit = cache
710                    .as_ref()
711                    .filter(|(k, _)| *k == hash)
712                    .map(|(_, h)| h.clone());
713                let custom = match hit {
714                    Some(h) => Some(h),
715                    None => match el {
716                        Some(el) => {
717                            match winit::window::CustomCursor::from_rgba(
718                                img.rgba.to_vec(),
719                                img.size[0],
720                                img.size[1],
721                                img.hotspot[0],
722                                img.hotspot[1],
723                            ) {
724                                Ok(source) => {
725                                    let h = el.create_custom_cursor(source);
726                                    *cache = Some((hash, h.clone()));
727                                    Some(h)
728                                }
729                                Err(e) => {
730                                    log::warn!("invalid custom cursor, falling back: {e:?}");
731                                    *cache = None;
732                                    None
733                                }
734                            }
735                        }
736                        None => cache.as_ref().map(|(_, h)| h.clone()),
737                    },
738                };
739                win.set_cursor_visible(true);
740                match custom {
741                    Some(h) => win.set_cursor(h),
742                    None => win.set_cursor(map_cursor(c)),
743                }
744                return;
745            }
746            *cache = cache.take().and(None);
747            win.set_cursor_visible(!cursor_is_hidden(c));
748            win.set_cursor(map_cursor(c));
749        }
750
751        /// Apply the app's declared pointer confinement to the window,
752        /// with the same fallback ladder `bevy_winit` uses: `Locked`
753        /// falls back to `Confined` where the platform has no lock
754        /// (X11), and `Confined` falls back to free where it has no
755        /// confinement (macOS). Failures are logged, not fatal - a
756        /// window without the protocol still plays, it just does not
757        /// confine.
758        fn apply_cursor_grab(win: &winit::window::Window, mode: repose_core::CursorGrabMode) {
759            use repose_core::CursorGrabMode as Repose;
760            use winit::window::CursorGrabMode as Winit;
761            let result = match mode {
762                Repose::None => win.set_cursor_grab(Winit::None),
763                Repose::Confined => win
764                    .set_cursor_grab(Winit::Confined)
765                    .or_else(|_| win.set_cursor_grab(Winit::None)),
766                Repose::Locked => win
767                    .set_cursor_grab(Winit::Locked)
768                    .or_else(|_| win.set_cursor_grab(Winit::Confined)),
769            };
770            if let Err(e) = result {
771                log::warn!("cannot grab cursor ({mode:?}): {e:?}");
772            }
773        }
774
775        fn clear_input_state(&mut self) {
776            let active_touches = self
777                .touch_gestures
778                .active_touches()
779                .iter()
780                .map(|(id, pos)| (*id, *pos))
781                .collect::<Vec<_>>();
782            for (touch_id, pos) in active_touches {
783                self.touch_gestures
784                    .touch_ended(&mut self.rt, touch_id, pos, true);
785                self.touch_gestures.contact_up(touch_id);
786            }
787            self.touch_gestures = rc::TouchGestureState::default();
788            self.rt.handle_focus_lost();
789            self.rt.touch_paths.clear();
790            self.rt.scroll_capture_id = None;
791            self.rt.hover_id = None;
792            self.rt.hover_ancestors.clear();
793            self.rt.last_focus = None;
794            self.rt.sched.pointer_pos_px = None;
795            self.rt.ime_preedit = false;
796            self.rt.modifiers = Default::default();
797            self.rt.sched.window_focused = false;
798            self.rt.sched.held_keys.clear();
799            self.rt.sched.mouse_primary = false;
800            self.rt.sched.mouse_secondary = false;
801            self.rt.sched.mouse_middle = false;
802            self.rt.pressed_ids.clear();
803            self.rt.key_pressed_active = None;
804            self.external_file_drag = false;
805            self.hovered_files.clear();
806            self.apply_ime_state(ImeState::default());
807        }
808
809        fn reset_pointer_state(&mut self) {
810            self.rt.capture_id = None;
811            self.rt.pressed_ids.clear();
812            self.rt.hover_id = None;
813        }
814    }
815
816    impl ApplicationHandler<()> for App {
817        fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
818            let _event_scope =
819                repose_app::lifecycle::enter_dispatchers(self.rt.event_dispatchers());
820            self.occluded = false;
821            WINDOW_OCCLUDED.store(false, Ordering::Relaxed);
822            self.rt.sched.window_focused = self.os_focused;
823            let _ = rc::setup_clipboard();
824
825            if self.window.is_none() {
826                let mut attributes = WindowAttributes::default()
827                    .with_title(self.window_title.clone())
828                    .with_inner_size(PhysicalSize::new(self.window_size.0, self.window_size.1))
829                    .with_visible(false);
830                if self.fullscreen {
831                    attributes = attributes
832                        .with_fullscreen(Some(winit::window::Fullscreen::Borderless(None)));
833                }
834                match el.create_window(attributes) {
835                    Ok(win) => {
836                        let w = Arc::new(win);
837
838                        let activation_handler = ReposeActivationHandler {
839                            active: self.a11y_active.clone(),
840                            needs_full: self.a11y_needs_full.clone(),
841                        };
842
843                        let action_handler = ReposeActionHandler {
844                            pending_actions: self.a11y_actions.clone(),
845                        };
846
847                        let deactivation_handler = ReposeDeactivationHandler {
848                            active: self.a11y_active.clone(),
849                        };
850
851                        let adapter = Adapter::with_direct_handlers(
852                            el,
853                            &w,
854                            activation_handler,
855                            action_handler,
856                            deactivation_handler,
857                        );
858
859                        self.accesskit_adapter = Some(adapter);
860
861                        w.set_visible(true);
862
863                        let size = w.inner_size();
864                        let sf = w.scale_factor() as f32;
865                        self.input_fit = rc::sync_viewport(&mut self.rt, &mut None, size, sf);
866
867                        match repose_render_wgpu::WgpuBackend::new_with_options(
868                            w.clone(),
869                            Some(el.owned_display_handle()),
870                            self.msaa_samples,
871                            self.present_mode,
872                        ) {
873                            Ok(mut b) => {
874                                b.set_pixels_per_point(sf);
875                                repose_render_wgpu::offscreen::set_shared_device(
876                                    b.device.clone(),
877                                    b.queue.clone(),
878                                );
879                                self.backend = Some(b);
880                                set_app_window(w.clone());
881                                self.window = Some(w);
882                                // Window-level state is per-window: a
883                                // recreated window starts ungrabbed, so
884                                // force the declared mode to be applied
885                                // again on the next frame.
886                                self.cursor_grab = None;
887                                self.request_redraw();
888                            }
889                            Err(e) => {
890                                log::error!("Failed to create WGPU backend: {e:?}");
891                                el.exit();
892                            }
893                        }
894                    }
895                    Err(e) => {
896                        log::error!("Failed to create window: {e:?}");
897                        el.exit();
898                    }
899                }
900            }
901            if self.window.is_some() {
902                push_runtime_window_lifecycle(&mut self.rt, WINDOW_VISIBLE.load(Ordering::Relaxed));
903            }
904        }
905
906        fn window_event(
907            &mut self,
908            el: &winit::event_loop::ActiveEventLoop,
909            _id: winit::window::WindowId,
910            event: WindowEvent,
911        ) {
912            let _event_scope =
913                repose_app::lifecycle::enter_dispatchers(self.rt.event_dispatchers());
914            let _dnd_guard = self.rt.dnd_context.enter();
915            // Process AccessKit events first!
916            if let (Some(adapter), Some(window)) = (&mut self.accesskit_adapter, &self.window) {
917                adapter.process_event(window, &event);
918            }
919
920            match event {
921                WindowEvent::CloseRequested => {
922                    if CLOSE_TO_TRAY.load(Ordering::Relaxed) {
923                        // Drop GPU backend before null-buffer unmap.
924                        self.backend = None;
925                        if let Some(w) = &self.window {
926                            w.set_visible(false);
927                        }
928                        WINDOW_VISIBLE.store(false, Ordering::Relaxed);
929                        push_runtime_window_lifecycle(&mut self.rt, false);
930                    } else {
931                        el.exit();
932                    }
933                }
934
935                WindowEvent::Focused(focused) => {
936                    self.os_focused = focused;
937                    let focused = focused && !self.occluded;
938                    self.rt.sched.window_focused = focused;
939                    if !focused {
940                        self.clear_input_state();
941                    } else {
942                        self.sync_ime_for_focus();
943                    }
944                    self.request_redraw();
945                }
946
947                WindowEvent::Occluded(occluded) => {
948                    self.occluded = occluded;
949                    WINDOW_OCCLUDED.store(occluded, Ordering::Relaxed);
950                    if occluded {
951                        self.rt.sched.window_focused = false;
952                        self.clear_input_state();
953                    } else {
954                        self.rt.sched.window_focused = self.os_focused;
955                        if self.os_focused {
956                            self.sync_ime_for_focus();
957                        }
958                    }
959                    push_runtime_window_lifecycle(
960                        &mut self.rt,
961                        WINDOW_VISIBLE.load(Ordering::Relaxed),
962                    );
963                    self.request_redraw();
964                }
965
966                WindowEvent::CursorLeft { .. } => {
967                    self.rt.pointer_inside = false;
968                    self.rt.handle_pointer_cancel();
969                    self.external_file_drag = false;
970                    self.hovered_files.clear();
971                    self.request_redraw();
972                }
973
974                WindowEvent::HoveredFile(path) => {
975                    // Mark external drag active and keep a small bounded list
976                    self.external_file_drag = true;
977                    if self.hovered_files.len() < 32 {
978                        self.hovered_files.push(path);
979                    }
980                    if self.pending_drop_pos_px.is_none() {
981                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
982                    }
983                    self.request_redraw();
984                }
985
986                WindowEvent::HoveredFileCancelled => {
987                    self.external_file_drag = false;
988                    self.hovered_files.clear();
989
990                    // Defensive: cancel any internal capture/drag that might be left stuck
991                    self.reset_pointer_state();
992
993                    self.request_redraw();
994                }
995
996                WindowEvent::DroppedFile(path) => {
997                    // DroppedFile is emitted once per file. Batch them.
998                    self.pending_dropped_files.push(path);
999                    if self.pending_drop_pos_px.is_none() {
1000                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
1001                    }
1002
1003                    // Drop ends the external file drag session.
1004                    self.external_file_drag = false;
1005                    self.hovered_files.clear();
1006
1007                    self.request_redraw();
1008                }
1009
1010                WindowEvent::Resized(size) => {
1011                    let sf = self
1012                        .window
1013                        .as_ref()
1014                        .map(|w| w.scale_factor() as f32)
1015                        .unwrap_or(1.0);
1016                    self.input_fit = rc::sync_viewport(&mut self.rt, &mut self.backend, size, sf);
1017                    if let Some(w) = &self.window {
1018                        let sf = w.scale_factor() as f32;
1019                        let dp_w = size.width as f32 / sf;
1020                        let dp_h = size.height as f32 / sf;
1021                        log::info!(
1022                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
1023                            size.width,
1024                            size.height,
1025                            sf,
1026                            dp_w as i32,
1027                            dp_h as i32
1028                        );
1029                    }
1030                    self.request_redraw();
1031                }
1032
1033                WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
1034                    let size = self
1035                        .window
1036                        .as_ref()
1037                        .map(|w| w.inner_size())
1038                        .unwrap_or_else(|| PhysicalSize::new(0, 0));
1039                    self.input_fit = rc::sync_viewport(
1040                        &mut self.rt,
1041                        &mut self.backend,
1042                        size,
1043                        scale_factor as f32,
1044                    );
1045                    self.request_redraw();
1046                }
1047
1048                WindowEvent::CursorMoved { position, .. } => {
1049                    self.rt.pointer_inside = true;
1050
1051                    if self.external_file_drag {
1052                        let k = self.input_fit;
1053                        self.pending_drop_pos_px =
1054                            Some((position.x as f32 * k, position.y as f32 * k));
1055                    }
1056
1057                    let pos = Vec2 {
1058                        x: position.x as f32 * self.input_fit,
1059                        y: position.y as f32 * self.input_fit,
1060                    };
1061
1062                    // Delegate pointer-move to the host runtime
1063                    let result = self.rt.handle_pointer_move(pos);
1064
1065                    // Inspector hover (platform-specific - devtools inspect)
1066                    if let (Some(inspector), Some(f)) = (&mut self.inspector, &self.rt.frame_cache)
1067                        && inspector.hud.inspector_enabled
1068                    {
1069                        let hit = f.hit_regions.iter().find(|h| h.rect.contains(pos));
1070                        let hover_rect = hit.map(|h| h.rect);
1071                        let hover_info = hit.and_then(|h| {
1072                            f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
1073                                repose_devtools::HoveredInfo {
1074                                    id: s.id,
1075                                    role: format!("{:?}", s.role),
1076                                    label: s.label.clone(),
1077                                }
1078                            })
1079                        });
1080                        inspector.hud.set_hovered(hover_rect, hover_info);
1081                    }
1082
1083                    if let Some(win) = &self.window
1084                        && let Some(c) = result.cursor
1085                    {
1086                        Self::apply_cursor_static(
1087                            win,
1088                            &mut self.custom_cursor,
1089                            &mut self.last_cursor_key,
1090                            Some(el),
1091                            &c,
1092                        );
1093                    }
1094
1095                    self.request_redraw();
1096                }
1097
1098                WindowEvent::MouseWheel { delta, .. } => {
1099                    let scale = self.rt.scale;
1100                    let k = self.input_fit;
1101                    let delta = match delta {
1102                        winit::event::MouseScrollDelta::PixelDelta(p) => {
1103                            winit::event::MouseScrollDelta::PixelDelta(
1104                                winit::dpi::PhysicalPosition::new(p.x * k as f64, p.y * k as f64),
1105                            )
1106                        }
1107                        other => other,
1108                    };
1109                    if crate::runner_common::on_mouse_wheel(&mut self.rt, delta, scale) {
1110                        self.request_redraw();
1111                    }
1112                }
1113
1114                WindowEvent::MouseInput { state, button, .. } => {
1115                    let pos = Vec2 {
1116                        x: self.rt.mouse_pos_px.0,
1117                        y: self.rt.mouse_pos_px.1,
1118                    };
1119
1120                    let mapped = match button {
1121                        MouseButton::Left => PointerButton::Primary,
1122                        MouseButton::Right => PointerButton::Secondary,
1123                        MouseButton::Middle => PointerButton::Tertiary,
1124                        // Forward/Back/other buttons are not dispatched by the runtime.
1125                        _ => return,
1126                    };
1127                    match (mapped, state) {
1128                        (PointerButton::Primary, ElementState::Pressed) => {
1129                            self.rt.sched.mouse_primary = true;
1130                        }
1131                        (PointerButton::Primary, ElementState::Released) => {
1132                            self.rt.sched.mouse_primary = false;
1133                        }
1134                        (PointerButton::Secondary, ElementState::Pressed) => {
1135                            self.rt.sched.mouse_secondary = true;
1136                        }
1137                        (PointerButton::Secondary, ElementState::Released) => {
1138                            self.rt.sched.mouse_secondary = false;
1139                        }
1140                        (PointerButton::Tertiary, ElementState::Pressed) => {
1141                            self.rt.sched.mouse_middle = true;
1142                        }
1143                        (PointerButton::Tertiary, ElementState::Released) => {
1144                            self.rt.sched.mouse_middle = false;
1145                        }
1146                    }
1147
1148                    match state {
1149                        ElementState::Pressed => {
1150                            let result = self.rt.handle_pointer_press(pos, mapped);
1151
1152                            self.sync_ime_for_focus();
1153
1154                            if result.focused.is_none() {
1155                                self.rt.ime_preedit = false;
1156                            }
1157
1158                            if result.needs_a11y_announce {
1159                                self.announce_focus_change();
1160                            }
1161
1162                            // Middle-click: paste the primary selection into a textfield.
1163                            if matches!(mapped, PointerButton::Tertiary)
1164                                && let Some(f) = &self.rt.frame_cache
1165                                && let Some(cid) = self.rt.capture_id
1166                                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
1167                                && self.rt.sched.window_focused
1168                                && rc::is_editable_textfield_hit(f, hit.id)
1169                                && let Some(txt) = self.paste_from_primary()
1170                            {
1171                                self.rt.paste_into_focused(&txt);
1172                            }
1173
1174                            // Inspector: click-to-select topmost widget under cursor.
1175                            if matches!(mapped, PointerButton::Primary | PointerButton::Secondary)
1176                                && let Some(inspector) = &mut self.inspector
1177                                && inspector.hud.inspector_enabled
1178                                && let Some(f) = &self.rt.frame_cache
1179                                && let Some(hit) =
1180                                    f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
1181                            {
1182                                let info =
1183                                    f.semantics_nodes.iter().find(|s| s.id == hit.id).map(|s| {
1184                                        repose_devtools::HoveredInfo {
1185                                            id: s.id,
1186                                            role: format!("{:?}", s.role),
1187                                            label: s.label.clone(),
1188                                        }
1189                                    });
1190                                inspector
1191                                    .hud
1192                                    .select_widget(repose_devtools::SelectedWidget {
1193                                        id: hit.id,
1194                                        role: info
1195                                            .as_ref()
1196                                            .map(|i| i.role.clone())
1197                                            .unwrap_or_default(),
1198                                        label: info.as_ref().and_then(|i| i.label.clone()),
1199                                        bounds: hit.rect,
1200                                    });
1201                            }
1202
1203                            self.request_redraw();
1204                        }
1205
1206                        ElementState::Released => {
1207                            let result = self.rt.handle_pointer_release(pos, mapped);
1208
1209                            // A11y: announce activation when a click fires on release.
1210                            // The runtime reports the clicked id before clearing its
1211                            // capture state, so this cannot race with `capture_id = None`.
1212                            if let Some(cid) = result.clicked_id
1213                                && let Some(f) = &self.rt.frame_cache
1214                                && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid)
1215                            {
1216                                let label = node.label.as_deref().unwrap_or("");
1217                                self.a11y.announce(&format!("Activated {}", label));
1218                            }
1219
1220                            self.request_redraw();
1221                        }
1222                    }
1223                }
1224
1225                WindowEvent::Touch(mut t) => {
1226                    let scale = self.rt.scale;
1227                    let k = self.input_fit as f64;
1228                    t.location.x *= k;
1229                    t.location.y *= k;
1230                    let r = crate::runner_common::handle_touch_raw(
1231                        &mut self.rt,
1232                        &mut self.touch_gestures,
1233                        &t,
1234                        scale,
1235                    );
1236                    if matches!(
1237                        t.phase,
1238                        winit::event::TouchPhase::Started | winit::event::TouchPhase::Ended
1239                    ) {
1240                        self.sync_ime_for_focus();
1241                    }
1242                    let mut dirty = r.dirty;
1243                    if let Some((delta_scale, center)) = r.pinch
1244                        && self.dispatch_action(repose_core::shortcuts::Action::Gesture(
1245                            repose_core::shortcuts::Gesture::PinchWithCenter {
1246                                delta_scale,
1247                                center,
1248                            },
1249                        ))
1250                    {
1251                        dirty = true;
1252                    }
1253                    if let Some((delta, center)) = r.pan
1254                        && self.dispatch_action(repose_core::shortcuts::Action::Gesture(
1255                            repose_core::shortcuts::Gesture::Pan { delta, center },
1256                        ))
1257                    {
1258                        dirty = true;
1259                    }
1260                    if let Some((delta_rotation, center)) = r.rotation
1261                        && self.dispatch_action(repose_core::shortcuts::Action::Gesture(
1262                            repose_core::shortcuts::Gesture::Rotate {
1263                                delta_rotation,
1264                                center,
1265                            },
1266                        ))
1267                    {
1268                        dirty = true;
1269                    }
1270                    if let Some(right) = r.swipe_right {
1271                        let g = if right {
1272                            repose_core::shortcuts::Gesture::SwipeRight
1273                        } else {
1274                            repose_core::shortcuts::Gesture::SwipeLeft
1275                        };
1276                        if self.dispatch_action(repose_core::shortcuts::Action::Gesture(g)) {
1277                            dirty = true;
1278                        }
1279                    }
1280                    if dirty {
1281                        self.request_redraw();
1282                    }
1283                }
1284
1285                WindowEvent::ModifiersChanged(new_mods) => {
1286                    crate::runner_common::on_modifiers_changed(&mut self.rt, &new_mods.state());
1287                }
1288
1289                WindowEvent::KeyboardInput {
1290                    event: key_event, ..
1291                } => {
1292                    if crate::runner_common::on_keyboard_input(
1293                        &mut self.rt,
1294                        &key_event,
1295                        &mut self.inspector,
1296                    ) {
1297                        self.request_redraw();
1298                        return;
1299                    }
1300
1301                    if key_event.state == ElementState::Pressed
1302                        && !key_event.repeat
1303                        && (rc::is_back_key(&key_event) || rc::is_escape_key(&key_event))
1304                    {
1305                        if self.rt.overlay.handle_back() {
1306                            self.request_redraw();
1307                            return;
1308                        }
1309                        use repose_navigation::back;
1310                        if back::handle() {
1311                            self.request_redraw();
1312                            return;
1313                        }
1314                        if rc::is_back_key(&key_event) {
1315                            el.exit();
1316                            return;
1317                        }
1318                    }
1319
1320                    // --- A11y: keyboard activation announcement ---
1321                    if key_event.state == ElementState::Released
1322                        && let Some(active_id) = self.rt.key_pressed_active
1323                    {
1324                        match key_event.physical_key {
1325                            PhysicalKey::Code(KeyCode::Space)
1326                            | PhysicalKey::Code(KeyCode::Enter) => {
1327                                if let Some(f) = &self.rt.frame_cache
1328                                    && let Some(node) =
1329                                        f.semantics_nodes.iter().find(|n| n.id == active_id)
1330                                {
1331                                    let label = node.label.as_deref().unwrap_or("");
1332                                    self.a11y.announce(&format!("Activated {}", label));
1333                                }
1334                            }
1335                            _ => {}
1336                        }
1337                    }
1338                }
1339
1340                WindowEvent::Ime(ime) => {
1341                    crate::runner_common::on_ime(&mut self.rt, &ime);
1342                    self.request_redraw();
1343                }
1344
1345                WindowEvent::RedrawRequested => {
1346                    // Allow media (etc.) to queue texture uploads without compose.
1347                    crate::run_pre_redraw(&self.render);
1348
1349                    let now = Instant::now();
1350                    let has_frame = self.rt.frame_cache.is_some();
1351                    if (has_frame || repose_core::frame_clock::peek_frame_request())
1352                        && !self.frame_pacer.due(now)
1353                    {
1354                        self.redraw_deferred = true;
1355                        if let Some(deadline) = self.frame_pacer.deadline(now) {
1356                            el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1357                                deadline,
1358                            ));
1359                        }
1360                        return;
1361                    }
1362                    self.redraw_deferred = false;
1363                    let compose_requested = take_frame_request();
1364
1365                    self.process_a11y_actions();
1366                    self.process_render_commands();
1367
1368                    let Some(win) = self.window.clone() else {
1369                        return;
1370                    };
1371                    if !compose_requested {
1372                        if let (Some(backend), Some(frame)) =
1373                            (self.backend.as_mut(), self.rt.frame_cache.as_ref())
1374                        {
1375                            let scale = win.scale_factor() as f32;
1376                            let inspector_active = self
1377                                .inspector
1378                                .as_ref()
1379                                .is_some_and(|inspector| inspector.hud.inspector_enabled);
1380                            let _dnd_guard = self.rt.dnd_context.enter();
1381                            let drag_active =
1382                                repose_core::dnd::is_dragging() || self.external_file_drag;
1383                            let mut overlay =
1384                                (inspector_active || drag_active).then(|| frame.scene.clone());
1385                            if let Some(scene) = &mut overlay {
1386                                if let Some(inspector) = &mut self.inspector {
1387                                    inspector.frame(scene);
1388                                }
1389                                repose_core::dnd::overlay_drag_indicator(
1390                                    scene,
1391                                    self.rt.mouse_pos_px,
1392                                    self.external_file_drag,
1393                                );
1394                            }
1395                            let _ = take_present_request();
1396                            let presented = backend.frame(
1397                                overlay.as_ref().unwrap_or(&frame.scene),
1398                                GlyphRasterConfig {
1399                                    px: Px(18.0 * scale),
1400                                },
1401                            );
1402                            if presented {
1403                                self.frame_pacer.rendered(Instant::now());
1404                            }
1405                        }
1406                        return;
1407                    }
1408                    log::trace!("RedrawRequested: frame request pending, composing");
1409
1410                    if self.backend.is_none() {
1411                        return;
1412                    }
1413
1414                    // Advance animations before composition (Compose pattern).
1415                    // Mirrors broadcastFrameClock.sendFrame() before performRecompose().
1416                    repose_core::animation_driver::tick();
1417
1418                    let t0 = Instant::now();
1419                    let scale = self.rt.scale;
1420
1421                    let output = self.rt.frame(&mut self.root, &self.render);
1422
1423                    let declared_grab = output.platform.cursor_grab;
1424                    if self.cursor_grab != Some(declared_grab) {
1425                        self.cursor_grab = Some(declared_grab);
1426                        Self::apply_cursor_grab(&win, declared_grab);
1427                    }
1428
1429                    if let Some(cursor) = &output.platform.cursor {
1430                        let win = win.clone();
1431                        Self::apply_cursor_static(
1432                            &win,
1433                            &mut self.custom_cursor,
1434                            &mut self.last_cursor_key,
1435                            Some(el),
1436                            cursor,
1437                        );
1438                    }
1439
1440                    if let Some(fullscreen) = self.rt.take_fullscreen_request()
1441                        && fullscreen != self.fullscreen
1442                    {
1443                        self.fullscreen = fullscreen;
1444                        let _ = win.set_fullscreen(if fullscreen {
1445                            Some(winit::window::Fullscreen::Borderless(None))
1446                        } else {
1447                            None
1448                        });
1449                    }
1450
1451                    if let Some(dark) = output.platform.window_theme_dark
1452                        && self.last_window_theme != Some(dark)
1453                    {
1454                        win.set_theme(Some(if dark {
1455                            winit::window::Theme::Dark
1456                        } else {
1457                            winit::window::Theme::Light
1458                        }));
1459                        self.last_window_theme = Some(dark);
1460                    }
1461
1462                    self.ime_output_allowed = output.platform.ime_allowed;
1463                    let ime_allowed = self.ime_allowed_for_output(
1464                        &output.hit_regions,
1465                        &output.semantics_nodes,
1466                        output.platform.ime_allowed,
1467                    );
1468                    self.apply_ime_state(if ime_allowed {
1469                        ImeState {
1470                            allowed: true,
1471                            purpose: output.platform.ime_purpose,
1472                            auto_correct: output.platform.ime_auto_correct,
1473                            capitalization: output.platform.ime_capitalization,
1474                            cursor_area: output.platform.ime_cursor_area,
1475                        }
1476                    } else {
1477                        ImeState::default()
1478                    });
1479
1480                    let frame = output.into_shared_frame();
1481
1482                    let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1483
1484                    if self.a11y_active.load(Ordering::Acquire) {
1485                        if self.a11y_needs_full.swap(false, Ordering::AcqRel) {
1486                            self.a11y_tree = A11yTree::default();
1487                        }
1488                        if let Some(adapter) = &mut self.accesskit_adapter {
1489                            let scale = win.scale_factor();
1490                            if let Some(update) = self.a11y_tree.update(
1491                                &frame.semantics_nodes,
1492                                scale,
1493                                self.rt.sched.focused,
1494                            ) {
1495                                adapter.update_if_active(|| update);
1496                            }
1497                        }
1498                    }
1499
1500                    self.rt.after_compose_shared(frame.clone(), scale);
1501
1502                    let inspector_active = self
1503                        .inspector
1504                        .as_ref()
1505                        .is_some_and(|inspector| inspector.hud.inspector_enabled);
1506                    let _dnd_guard = self.rt.dnd_context.enter();
1507                    let drag_active = repose_core::dnd::is_dragging() || self.external_file_drag;
1508                    let mut overlay =
1509                        (inspector_active || drag_active).then(|| frame.scene.clone());
1510                    let scene = overlay.as_ref().unwrap_or(&frame.scene);
1511                    if let Some(inspector) = &mut self.inspector {
1512                        let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1513                        let signal_count = self.rt.sched.id_count() as usize;
1514                        let ls = repose_ui::last_layout_stats();
1515                        inspector.hud.metrics = Some(repose_devtools::Metrics {
1516                            build_ms: build_layout_ms,
1517                            layout_ms: ls.layout_time_ms,
1518                            paint_ms: ls.paint_time_ms,
1519                            scene_nodes: scene.nodes.len(),
1520                            widget_count,
1521                            signal_count,
1522                            taffy_created: ls.taffy_created,
1523                            taffy_reused: ls.taffy_reused,
1524                            layout_hits: ls.layout_hits,
1525                            layout_misses: ls.layout_misses,
1526                            paint_cache_hits: ls.paint_cache_hits,
1527                            paint_cache_misses: ls.paint_cache_misses,
1528                            paint_culled: ls.paint_culled,
1529                        });
1530                        if let Some(scene) = &mut overlay {
1531                            inspector.frame(scene);
1532                        }
1533                    }
1534                    if let Some(scene) = &mut overlay {
1535                        repose_core::dnd::overlay_drag_indicator(
1536                            scene,
1537                            self.rt.mouse_pos_px,
1538                            self.external_file_drag,
1539                        );
1540                    }
1541
1542                    // Drain upload commands queued during compose (e.g. VideoSink set_image_*)
1543                    // before presenting to avoid 1-frame GPU texture lag.
1544                    self.process_render_commands();
1545
1546                    let _ = take_present_request();
1547                    let presented = if let Some(backend) = self.backend.as_mut() {
1548                        backend.frame(
1549                            overlay.as_ref().unwrap_or(&frame.scene),
1550                            GlyphRasterConfig {
1551                                px: Px(18.0 * scale),
1552                            },
1553                        )
1554                    } else {
1555                        false
1556                    };
1557
1558                    self.rt.cache_frame(frame);
1559
1560                    self.dispatch_file_drop_now();
1561
1562                    self.rt.tick_overlays();
1563                    if presented {
1564                        self.frame_pacer.rendered(Instant::now());
1565                    }
1566                }
1567
1568                _ => {}
1569            }
1570        }
1571
1572        fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1573            let _event_scope =
1574                repose_app::lifecycle::enter_dispatchers(self.rt.event_dispatchers());
1575            // Process cross-thread commands (e.g. tray toggles, deeplinks) before any
1576            // redraw check, so hide/show commands work even when hidden
1577            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1578            if let Some(cb) = ABOUT_TO_WAIT_CALLBACK
1579                .lock()
1580                .unwrap_or_else(|e| e.into_inner())
1581                .as_ref()
1582            {
1583                let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(cb));
1584                if let Err(e) = res {
1585                    log::error!(
1586                        "ABOUT_TO_WAIT_CALLBACK panicked: {}",
1587                        e.downcast_ref::<String>()
1588                            .map(|s| s.as_str())
1589                            .or_else(|| e.downcast_ref::<&str>().copied())
1590                            .unwrap_or("unknown")
1591                    );
1592                }
1593            }
1594            self.rt.process_deeplinks();
1595            self.rt.process_lifecycle();
1596
1597            if let Some(backend) = &mut self.gamepad {
1598                for ev in backend.poll() {
1599                    self.rt.handle_gamepad(&ev);
1600                }
1601                for (id, low, high, duration_ms) in self.rt.take_rumble_requests() {
1602                    use repose_core::input::GamepadId;
1603                    let gid = GamepadId(id);
1604                    if duration_ms == 0 {
1605                        backend.stop_rumble(gid);
1606                    } else if !backend.set_rumble(gid, low, high, duration_ms) {
1607                        log::warn!("gamepad: rumble not supported for pad {id}");
1608                    }
1609                }
1610            } else {
1611                self.rt.take_rumble_requests();
1612            }
1613
1614            if let Some(backend) = &mut self.sensors {
1615                for reading in backend.poll_sensors() {
1616                    match self.rt.gamepad_id_by_sensor_device(&reading.device) {
1617                        Some(id) => self.rt.handle_sensor_sample(id, reading.sample),
1618                        None if !self.sensor_drops.contains(&reading.device) => {
1619                            self.sensor_drops.push(reading.device.clone());
1620                            log::warn!(
1621                                "sensor: no connected pad named {:?}, dropping samples",
1622                                reading.device
1623                            );
1624                        }
1625                        None => {}
1626                    }
1627                }
1628            }
1629
1630            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1631            if let Some(reason) = self.backend.as_ref().and_then(|b| b.take_device_lost()) {
1632                log::error!("GPU device lost, rebuilding backend: {reason}");
1633                self.backend = None;
1634                self.backend_retry_at = None;
1635                self.backend_lifecycle.begin();
1636                self.rt.frame_cache = None;
1637                self.request_redraw();
1638            }
1639
1640            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1641            if WINDOW_VISIBLE.load(Ordering::Relaxed)
1642                && !WINDOW_OCCLUDED.load(Ordering::Relaxed)
1643                && self.backend.is_none()
1644                && self.backend_lifecycle.state() != BackendState::Unavailable
1645                && let Some(w) = &self.window
1646            {
1647                let now = Instant::now();
1648                if let Some(retry_at) = self.backend_retry_at
1649                    && now < retry_at
1650                {
1651                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(retry_at));
1652                    return;
1653                }
1654                self.backend_retry_at = None;
1655                log::info!("about_to_wait: recreating GPU backend");
1656                match repose_render_wgpu::WgpuBackend::new_with_options(
1657                    w.clone(),
1658                    Some(el.owned_display_handle()),
1659                    self.msaa_samples,
1660                    self.present_mode,
1661                ) {
1662                    Ok(b) => {
1663                        let size = w.inner_size();
1664                        let scale = w.scale_factor() as f32;
1665                        let mut backend = Some(b);
1666                        self.input_fit = rc::sync_viewport(&mut self.rt, &mut backend, size, scale);
1667                        let Some(b) = backend.take() else {
1668                            unreachable!()
1669                        };
1670                        repose_render_wgpu::offscreen::set_shared_device(
1671                            b.device.clone(),
1672                            b.queue.clone(),
1673                        );
1674                        self.backend = Some(b);
1675                        self.backend_lifecycle.ready();
1676                        self.request_redraw();
1677                    }
1678                    Err(e) => match self.backend_lifecycle.failed() {
1679                        BackendRetry::RetryAfter(delay) => {
1680                            self.backend_retry_at = Some(now + delay);
1681                            log::error!(
1682                                "about_to_wait: failed to recreate backend, retrying in \
1683                                 {delay:?}: {e:?}"
1684                            );
1685                        }
1686                        BackendRetry::Unavailable => log::error!(
1687                            "about_to_wait: GPU backend permanently unavailable after {} \
1688                             attempts, giving up: {e:?}",
1689                            MAX_BACKEND_INIT_ATTEMPTS
1690                        ),
1691                        BackendRetry::Ready => unreachable!(),
1692                    },
1693                }
1694            }
1695
1696            if self.redraw_deferred {
1697                let now = Instant::now();
1698                if self.frame_pacer.due(now) {
1699                    self.redraw_deferred = false;
1700                    rc::request_redraw(&self.window);
1701                } else if let Some(deadline) = self.frame_pacer.deadline(now) {
1702                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(deadline));
1703                } else {
1704                    self.redraw_deferred = false;
1705                    rc::request_redraw(&self.window);
1706                }
1707                return;
1708            }
1709
1710            let now = Instant::now();
1711            let idle_cap = web_time::Duration::from_millis(1000);
1712            let idle_deadline = now + idle_cap;
1713            let wakeup_due = self.rt.is_wakeup_due(now);
1714            if wakeup_due
1715                || self.frame_pacer.idle_due(now, idle_cap)
1716                || repose_core::animation_driver::is_active()
1717                || (self.a11y_active.load(Ordering::Acquire)
1718                    && self.a11y_needs_full.load(Ordering::Acquire))
1719            {
1720                request_frame();
1721            }
1722
1723            let wakeup_deadline = self
1724                .rt
1725                .next_wakeup_deadline()
1726                .filter(|deadline| *deadline > now);
1727            if wakeup_due {
1728                rc::request_redraw(&self.window);
1729                return;
1730            }
1731
1732            let compose_requested = repose_core::frame_clock::peek_frame_request();
1733            let present_requested = repose_core::frame_clock::peek_present_request();
1734            if compose_requested {
1735                if self.frame_pacer.due(now) {
1736                    rc::request_redraw(&self.window);
1737                } else if let Some(deadline) = self
1738                    .frame_pacer
1739                    .deadline(now)
1740                    .into_iter()
1741                    .chain(wakeup_deadline)
1742                    .chain(Some(idle_deadline))
1743                    .min()
1744                {
1745                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(deadline));
1746                }
1747                return;
1748            }
1749
1750            if present_requested && self.rt.frame_cache.is_some() {
1751                if self.frame_pacer.due(now) {
1752                    rc::request_redraw(&self.window);
1753                } else if let Some(deadline) = self
1754                    .frame_pacer
1755                    .deadline(now)
1756                    .into_iter()
1757                    .chain(wakeup_deadline)
1758                    .chain(Some(idle_deadline))
1759                    .min()
1760                {
1761                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(deadline));
1762                }
1763                return;
1764            }
1765
1766            let deadline = wakeup_deadline.unwrap_or(idle_deadline);
1767            el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(deadline));
1768        }
1769
1770        fn new_events(
1771            &mut self,
1772            _: &winit::event_loop::ActiveEventLoop,
1773            _: winit::event::StartCause,
1774        ) {
1775        }
1776        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1777            request_frame();
1778        }
1779        fn device_event(
1780            &mut self,
1781            _: &winit::event_loop::ActiveEventLoop,
1782            _: winit::event::DeviceId,
1783            _: winit::event::DeviceEvent,
1784        ) {
1785        }
1786        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {
1787            let _event_scope =
1788                repose_app::lifecycle::enter_dispatchers(self.rt.event_dispatchers());
1789            push_runtime_window_lifecycle(&mut self.rt, false);
1790        }
1791        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {
1792            #[cfg(not(target_arch = "wasm32"))]
1793            rc::flush_clipboard_writes();
1794            repose_core::shutdown_composition();
1795        }
1796        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {
1797            repose_core::runtime::COMPOSER.with(|c| {
1798                let mut c = c.borrow_mut();
1799                let n = c.cursor;
1800                if c.slots.len() > n {
1801                    c.slots.truncate(n);
1802                }
1803                if c.slot_callers.len() > n {
1804                    c.slot_callers.truncate(n);
1805                }
1806            });
1807            repose_core::scope_cache::gc_dead_scopes();
1808        }
1809    }
1810
1811    impl App {
1812        fn announce_focus_change(&mut self) {
1813            if let Some(f) = &self.rt.frame_cache {
1814                let focused_node = self
1815                    .rt
1816                    .sched
1817                    .focused
1818                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1819                self.a11y.focus_changed(focused_node);
1820            }
1821        }
1822
1823        fn dispatch_file_drop_now(&mut self) {
1824            let Some(f) = &self.rt.frame_cache else {
1825                self.pending_dropped_files.clear();
1826                self.pending_drop_pos_px = None;
1827                return;
1828            };
1829
1830            if self.pending_dropped_files.is_empty() {
1831                return;
1832            }
1833
1834            let pos_px = self.pending_drop_pos_px.unwrap_or(self.rt.mouse_pos_px);
1835            let pos = Vec2 {
1836                x: pos_px.0,
1837                y: pos_px.1,
1838            };
1839
1840            let mut files = Vec::new();
1841            for p in self.pending_dropped_files.drain(..) {
1842                let name = p
1843                    .file_name()
1844                    .and_then(|s| s.to_str())
1845                    .unwrap_or("file")
1846                    .to_string();
1847                files.push(repose_core::dnd::DroppedFile {
1848                    name,
1849                    path: Some(p),
1850                });
1851            }
1852
1853            let payload: repose_core::dnd::DragPayload =
1854                std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1855
1856            let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1857                self.pending_drop_pos_px = None;
1858                return;
1859            };
1860
1861            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1862                && let Some(cb) = &hit.on_drop
1863            {
1864                let accepted = cb(repose_core::dnd::DropEvent {
1865                    source_id: 0, // external source (OS)
1866                    target_id,
1867                    position: pos,
1868                    modifiers: self.rt.modifiers,
1869                    payload: payload.clone(),
1870                });
1871
1872                if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
1873                {
1874                    let label = node.label.as_deref().unwrap_or("");
1875                    self.a11y.announce(&format!("Dropped files on {}", label));
1876                }
1877            }
1878
1879            self.pending_drop_pos_px = None;
1880            self.request_redraw();
1881        }
1882    }
1883
1884    let event_loop = EventLoop::new()?;
1885    set_event_loop_proxy(event_loop.create_proxy());
1886    let mut app = App::new(Box::new(root), config);
1887    // Install system clock once
1888    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1889    event_loop.run_app(&mut app)?;
1890    Ok(())
1891}
1892
1893// Accessibility bridge stub (Noop by default; logs on Linux for now)
1894/// Bridge from Repose's semantics tree to platform accessibility APIs.
1895///
1896/// Implementations are responsible for:
1897/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
1898/// - Updating focus when `focus_changed` is called.
1899/// - Announcing transient messages (e.g. button activation) via screen readers.
1900pub trait A11yBridge: Send {
1901    /// Publish (or update) the full semantics tree for the current frame.
1902    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1903
1904    /// Notify that the focused node has changed. `None` means focus cleared.
1905    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1906
1907    /// Announce a one‑off message via the platform's accessibility channel.
1908    fn announce(&mut self, msg: &str);
1909}
1910
1911#[cfg_attr(target_os = "linux", allow(dead_code))]
1912struct NoopA11y;
1913impl A11yBridge for NoopA11y {
1914    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1915        // no-op
1916    }
1917    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1918        if let Some(n) = node {
1919            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1920        } else {
1921            log::info!("A11y focus: None");
1922        }
1923    }
1924    fn announce(&mut self, msg: &str) {
1925        log::info!("A11y announce: {msg}");
1926    }
1927}
1928
1929#[cfg(target_os = "linux")]
1930struct LinuxAtspiStub;
1931#[cfg(target_os = "linux")]
1932impl A11yBridge for LinuxAtspiStub {
1933    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1934        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1935    }
1936    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1937        if let Some(n) = node {
1938            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1939        } else {
1940            log::info!("AT-SPI stub focus: None");
1941        }
1942    }
1943    fn announce(&mut self, msg: &str) {
1944        log::info!("AT-SPI stub announce: {msg}");
1945    }
1946}