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
323        frame_pacer: rc::FramePacer,
324        redraw_deferred: bool,
325        occluded: bool,
326        os_focused: bool,
327        ime_output_allowed: bool,
328        ime_state: ImeState,
329
330        // Shared touch-scroll / pinch / swipe gesture state (touchscreens)
331        touch_gestures: rc::TouchGestureState,
332
333        gamepad: Option<Box<dyn gamepad::GamepadBackend>>,
334        sensors: Option<Box<dyn sensor::SensorBackend>>,
335        /// Devices whose samples never routed to a pad, warned once each
336        /// rather than per sample (an IMU reports hundreds a second).
337        sensor_drops: Vec<String>,
338    }
339
340    impl App {
341        fn process_a11y_actions(&mut self) {
342            let mut actions = self.a11y_actions.lock().unwrap_or_else(|e| e.into_inner());
343            if actions.is_empty() {
344                return;
345            }
346            let pending = actions.drain(..).collect::<Vec<_>>();
347            drop(actions);
348
349            let Some(f) = &self.rt.frame_cache else {
350                return;
351            };
352
353            for req in pending {
354                let target_id = req.target_node.0;
355                match req.action {
356                    accesskit::Action::Click => {
357                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
358                            && let Some(cb) = &hit.on_click
359                        {
360                            cb();
361                            self.request_redraw();
362                        }
363                    }
364                    accesskit::Action::Focus => {
365                        // Assistive tech focus should show the focus ring.
366                        let _ = repose_core::request_input_mode(repose_core::InputMode::Keyboard);
367                        self.rt.sched.focused = Some(target_id);
368                        self.request_redraw();
369                    }
370                    _ => {}
371                }
372            }
373        }
374
375        fn new(
376            root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
377            config: AppConfig,
378        ) -> Self {
379            Self {
380                root,
381                render: RenderContext::new(),
382                window: None,
383                backend: None,
384                backend_lifecycle: crate::runner_common::BackendLifecycle::new(),
385                backend_retry_at: None,
386                rt: ReposeRuntime::new(),
387                input_fit: 1.0,
388                inspector: if config.enable_inspector {
389                    Some(repose_devtools::Inspector::new())
390                } else {
391                    None
392                },
393                msaa_samples: config.common.msaa_samples,
394                present_mode: config.common.present_mode,
395                window_title: config.window_title,
396                window_size: config.window_size,
397                fullscreen: config.fullscreen,
398                pending_dropped_files: Vec::new(),
399                pending_drop_pos_px: None,
400
401                external_file_drag: false,
402                hovered_files: Vec::new(),
403
404                a11y: {
405                    #[cfg(target_os = "linux")]
406                    {
407                        Box::new(LinuxAtspiStub) as Box<dyn A11yBridge>
408                    }
409                    #[cfg(not(target_os = "linux"))]
410                    {
411                        Box::new(NoopA11y) as Box<dyn A11yBridge>
412                    }
413                },
414
415                accesskit_adapter: None,
416                a11y_actions: Arc::new(Mutex::new(Vec::new())),
417                a11y_tree: A11yTree::default(),
418                a11y_active: Arc::new(AtomicBool::new(false)),
419                a11y_needs_full: Arc::new(AtomicBool::new(false)),
420
421                frame_pacer: rc::FramePacer::new(config.common.max_fps),
422                redraw_deferred: false,
423                occluded: false,
424                os_focused: true,
425                ime_output_allowed: false,
426                ime_state: ImeState::default(),
427                last_window_theme: None,
428                custom_cursor: None,
429                last_cursor_key: None,
430                touch_gestures: rc::TouchGestureState::default(),
431                gamepad: gamepad::create_backend()
432                    .map(|b| Box::new(b) as Box<dyn gamepad::GamepadBackend>),
433                sensors: sensor::create_backend()
434                    .map(|b| Box::new(b) as Box<dyn sensor::SensorBackend>),
435                sensor_drops: Vec::new(),
436            }
437        }
438
439        fn request_redraw(&self) {
440            repose_core::request_frame();
441        }
442
443        fn ime_allowed_for_frame(&self, frame: &repose_core::runtime::Frame) -> bool {
444            WINDOW_VISIBLE.load(Ordering::Relaxed)
445                && !WINDOW_OCCLUDED.load(Ordering::Relaxed)
446                && self.ime_output_allowed
447                && self.rt.sched.window_focused
448                && self.rt.sched.focused.is_some_and(|id| {
449                    rc::is_editable_textfield_hit(frame, id) || rc::has_ime_handler(frame, id)
450                })
451        }
452
453        fn ime_allowed_for_output(
454            &self,
455            hits: &[repose_core::HitRegion],
456            semantics: &[repose_core::runtime::SemNode],
457            output_allowed: bool,
458        ) -> bool {
459            WINDOW_VISIBLE.load(Ordering::Relaxed)
460                && !WINDOW_OCCLUDED.load(Ordering::Relaxed)
461                && output_allowed
462                && self.rt.sched.window_focused
463                && self.rt.sched.focused.is_some_and(|id| {
464                    rc::editable_textfield_hit(hits, semantics, id).is_some()
465                        || rc::has_ime_handler_hit(hits, id)
466                })
467        }
468
469        fn sync_ime_for_focus(&mut self) {
470            let hit = self.rt.frame_cache.as_ref().and_then(|frame| {
471                (self.ime_allowed_for_frame(frame))
472                    .then(|| {
473                        self.rt.sched.focused.and_then(|id| {
474                            rc::editable_textfield_hit(
475                                &frame.hit_regions,
476                                &frame.semantics_nodes,
477                                id,
478                            )
479                        })
480                    })
481                    .flatten()
482            });
483            let state = match hit {
484                Some(hit) => {
485                    let scale = self.window.as_ref().map(|window| window.scale_factor());
486                    ImeState {
487                        allowed: true,
488                        purpose: hit.keyboard_type.ime_purpose_hint(),
489                        auto_correct: hit.auto_correct.unwrap_or(true),
490                        capitalization: hit.capitalization,
491                        cursor_area: scale.map(|scale| {
492                            (
493                                hit.rect.x as f64 / scale,
494                                hit.rect.y as f64 / scale,
495                                hit.rect.w as f64 / scale,
496                                hit.rect.h as f64 / scale,
497                            )
498                        }),
499                    }
500                }
501                None => ImeState::default(),
502            };
503            self.apply_ime_state(state);
504        }
505
506        fn apply_ime_state(&mut self, state: ImeState) {
507            if self.ime_state == state {
508                return;
509            }
510            let Some(window) = self.window.clone() else {
511                return;
512            };
513            self.ime_state = state;
514            if state.allowed {
515                rc::set_ime_for_textfield_ex(
516                    &window,
517                    true,
518                    state.purpose,
519                    state.auto_correct,
520                    state.capitalization,
521                );
522                if let Some((x, y, w, h)) = state.cursor_area {
523                    window.set_ime_cursor_area(LogicalPosition::new(x, y), LogicalSize::new(w, h));
524                }
525            } else {
526                rc::set_ime_for_textfield(&window, false);
527                self.rt.finish_compositions();
528            }
529        }
530
531        fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
532            if self.rt.dispatch_action(action) {
533                self.sync_ime_for_focus();
534                return true;
535            }
536            false
537        }
538
539        fn paste_from_primary(&self) -> Option<String> {
540            let mut opts = clipawl::ClipboardOptions::default();
541            opts.linux.selection = clipawl::LinuxSelection::Primary;
542            if let Ok(cb) = clipawl::Clipboard::new_with_options(opts) {
543                match pollster::block_on(cb.read()) {
544                    Ok(t) => Some(t),
545                    Err(e) => {
546                        eprintln!("Primary paste error: {}", e);
547                        None
548                    }
549                }
550            } else {
551                None
552            }
553        }
554
555        fn process_render_commands(&mut self) {
556            let Some(backend) = self.backend.as_mut() else {
557                return;
558            };
559            repose_render_wgpu::apply_render_commands(backend, self.render.drain()).log_failures();
560        }
561
562        /// Content hash for the custom-cursor cache: FNV over bytes +
563        /// dims + hotspot. Collision odds are negligible for a 1-entry
564        /// cache that only dedupes identical consecutive frames.
565        #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
566        fn custom_cursor_key(img: &repose_core::CustomCursorImage) -> u64 {
567            let mut h: u64 = 0xcbf29ce484222325;
568            for b in img.rgba.iter().copied().chain(
569                img.size
570                    .iter()
571                    .flat_map(|v| v.to_le_bytes())
572                    .chain(img.hotspot.iter().flat_map(|v| v.to_le_bytes())),
573            ) {
574                h ^= b as u64;
575                h = h.wrapping_mul(0x100000001b3);
576            }
577            h ^= (img.rgba.len() as u64).wrapping_mul(0x9e3779b97f4a7c15);
578            h
579        }
580
581        /// Dedup key for `set_cursor` traffic: content hash for `Custom`,
582        /// discriminant otherwise (egui-winit bitmap path parity).
583        #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
584        fn cursor_key(c: &repose_core::CursorIcon) -> (u8, u64) {
585            match c {
586                repose_core::CursorIcon::Default => (0, 0),
587                repose_core::CursorIcon::Pointer => (1, 0),
588                repose_core::CursorIcon::Text => (2, 0),
589                repose_core::CursorIcon::EwResize => (3, 0),
590                repose_core::CursorIcon::NsResize => (4, 0),
591                repose_core::CursorIcon::NwseResize => (5, 0),
592                repose_core::CursorIcon::NeswResize => (6, 0),
593                repose_core::CursorIcon::Grab => (7, 0),
594                repose_core::CursorIcon::Grabbing => (8, 0),
595                repose_core::CursorIcon::Hidden => (9, 0),
596                repose_core::CursorIcon::Custom(img) => (10, Self::custom_cursor_key(img)),
597            }
598        }
599
600        /// Apply one cursor suggestion to the window (desktop): plain
601        /// icons via `map_cursor`, `Custom` via a cached
602        /// `create_custom_cursor` handle rebuilt only when the pixels
603        /// change (egui-winit bitmap path parity). Invalid art
604        /// (`BadImage`) falls back to the default arrow, never a panic.
605        #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
606        fn apply_cursor_static(
607            win: &std::sync::Arc<winit::window::Window>,
608            cache: &mut Option<(u64, winit::window::CustomCursor)>,
609            last_key: &mut Option<(u8, u64)>,
610            el: Option<&winit::event_loop::ActiveEventLoop>,
611            c: &repose_core::CursorIcon,
612        ) {
613            let key = Self::cursor_key(c);
614            if *last_key == Some(key) {
615                return;
616            }
617            *last_key = Some(key);
618            if let repose_core::CursorIcon::Custom(img) = c {
619                let hash = key.1;
620                let hit = cache
621                    .as_ref()
622                    .filter(|(k, _)| *k == hash)
623                    .map(|(_, h)| h.clone());
624                let custom = match hit {
625                    Some(h) => Some(h),
626                    None => match el {
627                        Some(el) => {
628                            match winit::window::CustomCursor::from_rgba(
629                                img.rgba.to_vec(),
630                                img.size[0],
631                                img.size[1],
632                                img.hotspot[0],
633                                img.hotspot[1],
634                            ) {
635                                Ok(source) => {
636                                    let h = el.create_custom_cursor(source);
637                                    *cache = Some((hash, h.clone()));
638                                    Some(h)
639                                }
640                                Err(e) => {
641                                    log::warn!("invalid custom cursor, falling back: {e:?}");
642                                    *cache = None;
643                                    None
644                                }
645                            }
646                        }
647                        None => cache.as_ref().map(|(_, h)| h.clone()),
648                    },
649                };
650                win.set_cursor_visible(true);
651                match custom {
652                    Some(h) => win.set_cursor(h),
653                    None => win.set_cursor(map_cursor(c)),
654                }
655                return;
656            }
657            *cache = cache.take().and(None);
658            win.set_cursor_visible(!cursor_is_hidden(c));
659            win.set_cursor(map_cursor(c));
660        }
661
662        fn clear_input_state(&mut self) {
663            let active_touches = self
664                .touch_gestures
665                .active_touches()
666                .iter()
667                .map(|(id, pos)| (*id, *pos))
668                .collect::<Vec<_>>();
669            for (touch_id, pos) in active_touches {
670                self.touch_gestures
671                    .touch_ended(&mut self.rt, touch_id, pos, true);
672                self.touch_gestures.contact_up(touch_id);
673            }
674            self.touch_gestures = rc::TouchGestureState::default();
675            self.rt.handle_focus_lost();
676            self.rt.touch_paths.clear();
677            self.rt.scroll_capture_id = None;
678            self.rt.hover_id = None;
679            self.rt.hover_ancestors.clear();
680            self.rt.last_focus = None;
681            self.rt.sched.pointer_pos_px = None;
682            self.rt.ime_preedit = false;
683            self.rt.modifiers = Default::default();
684            self.rt.sched.window_focused = false;
685            self.rt.sched.held_keys.clear();
686            self.rt.sched.mouse_primary = false;
687            self.rt.sched.mouse_secondary = false;
688            self.rt.sched.mouse_middle = false;
689            self.rt.pressed_ids.clear();
690            self.rt.key_pressed_active = None;
691            self.external_file_drag = false;
692            self.hovered_files.clear();
693            self.apply_ime_state(ImeState::default());
694        }
695
696        fn reset_pointer_state(&mut self) {
697            self.rt.capture_id = None;
698            self.rt.pressed_ids.clear();
699            self.rt.hover_id = None;
700        }
701    }
702
703    impl ApplicationHandler<()> for App {
704        fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
705            let _event_scope =
706                repose_app::lifecycle::enter_dispatchers(self.rt.event_dispatchers());
707            self.occluded = false;
708            WINDOW_OCCLUDED.store(false, Ordering::Relaxed);
709            self.rt.sched.window_focused = self.os_focused;
710            let _ = rc::setup_clipboard();
711
712            if self.window.is_none() {
713                let mut attributes = WindowAttributes::default()
714                    .with_title(self.window_title.clone())
715                    .with_inner_size(PhysicalSize::new(self.window_size.0, self.window_size.1))
716                    .with_visible(false);
717                if self.fullscreen {
718                    attributes = attributes
719                        .with_fullscreen(Some(winit::window::Fullscreen::Borderless(None)));
720                }
721                match el.create_window(attributes) {
722                    Ok(win) => {
723                        let w = Arc::new(win);
724
725                        let activation_handler = ReposeActivationHandler {
726                            active: self.a11y_active.clone(),
727                            needs_full: self.a11y_needs_full.clone(),
728                        };
729
730                        let action_handler = ReposeActionHandler {
731                            pending_actions: self.a11y_actions.clone(),
732                        };
733
734                        let deactivation_handler = ReposeDeactivationHandler {
735                            active: self.a11y_active.clone(),
736                        };
737
738                        let adapter = Adapter::with_direct_handlers(
739                            el,
740                            &w,
741                            activation_handler,
742                            action_handler,
743                            deactivation_handler,
744                        );
745
746                        self.accesskit_adapter = Some(adapter);
747
748                        w.set_visible(true);
749
750                        let size = w.inner_size();
751                        let sf = w.scale_factor() as f32;
752                        self.input_fit = rc::sync_viewport(&mut self.rt, &mut None, size, sf);
753
754                        match repose_render_wgpu::WgpuBackend::new_with_options(
755                            w.clone(),
756                            Some(el.owned_display_handle()),
757                            self.msaa_samples,
758                            self.present_mode,
759                        ) {
760                            Ok(mut b) => {
761                                b.set_pixels_per_point(sf);
762                                repose_render_wgpu::offscreen::set_shared_device(
763                                    b.device.clone(),
764                                    b.queue.clone(),
765                                );
766                                self.backend = Some(b);
767                                set_app_window(w.clone());
768                                self.window = Some(w);
769                                self.request_redraw();
770                            }
771                            Err(e) => {
772                                log::error!("Failed to create WGPU backend: {e:?}");
773                                el.exit();
774                            }
775                        }
776                    }
777                    Err(e) => {
778                        log::error!("Failed to create window: {e:?}");
779                        el.exit();
780                    }
781                }
782            }
783            if self.window.is_some() {
784                push_runtime_window_lifecycle(&mut self.rt, WINDOW_VISIBLE.load(Ordering::Relaxed));
785            }
786        }
787
788        fn window_event(
789            &mut self,
790            el: &winit::event_loop::ActiveEventLoop,
791            _id: winit::window::WindowId,
792            event: WindowEvent,
793        ) {
794            let _event_scope =
795                repose_app::lifecycle::enter_dispatchers(self.rt.event_dispatchers());
796            let _dnd_guard = self.rt.dnd_context.enter();
797            // Process AccessKit events first!
798            if let (Some(adapter), Some(window)) = (&mut self.accesskit_adapter, &self.window) {
799                adapter.process_event(window, &event);
800            }
801
802            match event {
803                WindowEvent::CloseRequested => {
804                    if CLOSE_TO_TRAY.load(Ordering::Relaxed) {
805                        // Drop GPU backend before null-buffer unmap.
806                        self.backend = None;
807                        if let Some(w) = &self.window {
808                            w.set_visible(false);
809                        }
810                        WINDOW_VISIBLE.store(false, Ordering::Relaxed);
811                        push_runtime_window_lifecycle(&mut self.rt, false);
812                    } else {
813                        el.exit();
814                    }
815                }
816
817                WindowEvent::Focused(focused) => {
818                    self.os_focused = focused;
819                    let focused = focused && !self.occluded;
820                    self.rt.sched.window_focused = focused;
821                    if !focused {
822                        self.clear_input_state();
823                    } else {
824                        self.sync_ime_for_focus();
825                    }
826                    self.request_redraw();
827                }
828
829                WindowEvent::Occluded(occluded) => {
830                    self.occluded = occluded;
831                    WINDOW_OCCLUDED.store(occluded, Ordering::Relaxed);
832                    if occluded {
833                        self.rt.sched.window_focused = false;
834                        self.clear_input_state();
835                    } else {
836                        self.rt.sched.window_focused = self.os_focused;
837                        if self.os_focused {
838                            self.sync_ime_for_focus();
839                        }
840                    }
841                    push_runtime_window_lifecycle(
842                        &mut self.rt,
843                        WINDOW_VISIBLE.load(Ordering::Relaxed),
844                    );
845                    self.request_redraw();
846                }
847
848                WindowEvent::CursorLeft { .. } => {
849                    self.rt.pointer_inside = false;
850                    self.rt.handle_pointer_cancel();
851                    self.external_file_drag = false;
852                    self.hovered_files.clear();
853                    self.request_redraw();
854                }
855
856                WindowEvent::HoveredFile(path) => {
857                    // Mark external drag active and keep a small bounded list
858                    self.external_file_drag = true;
859                    if self.hovered_files.len() < 32 {
860                        self.hovered_files.push(path);
861                    }
862                    if self.pending_drop_pos_px.is_none() {
863                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
864                    }
865                    self.request_redraw();
866                }
867
868                WindowEvent::HoveredFileCancelled => {
869                    self.external_file_drag = false;
870                    self.hovered_files.clear();
871
872                    // Defensive: cancel any internal capture/drag that might be left stuck
873                    self.reset_pointer_state();
874
875                    self.request_redraw();
876                }
877
878                WindowEvent::DroppedFile(path) => {
879                    // DroppedFile is emitted once per file. Batch them.
880                    self.pending_dropped_files.push(path);
881                    if self.pending_drop_pos_px.is_none() {
882                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
883                    }
884
885                    // Drop ends the external file drag session.
886                    self.external_file_drag = false;
887                    self.hovered_files.clear();
888
889                    self.request_redraw();
890                }
891
892                WindowEvent::Resized(size) => {
893                    let sf = self
894                        .window
895                        .as_ref()
896                        .map(|w| w.scale_factor() as f32)
897                        .unwrap_or(1.0);
898                    self.input_fit = rc::sync_viewport(&mut self.rt, &mut self.backend, size, sf);
899                    if let Some(w) = &self.window {
900                        let sf = w.scale_factor() as f32;
901                        let dp_w = size.width as f32 / sf;
902                        let dp_h = size.height as f32 / sf;
903                        log::info!(
904                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
905                            size.width,
906                            size.height,
907                            sf,
908                            dp_w as i32,
909                            dp_h as i32
910                        );
911                    }
912                    self.request_redraw();
913                }
914
915                WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
916                    let size = self
917                        .window
918                        .as_ref()
919                        .map(|w| w.inner_size())
920                        .unwrap_or_else(|| PhysicalSize::new(0, 0));
921                    self.input_fit = rc::sync_viewport(
922                        &mut self.rt,
923                        &mut self.backend,
924                        size,
925                        scale_factor as f32,
926                    );
927                    self.request_redraw();
928                }
929
930                WindowEvent::CursorMoved { position, .. } => {
931                    self.rt.pointer_inside = true;
932
933                    if self.external_file_drag {
934                        let k = self.input_fit;
935                        self.pending_drop_pos_px =
936                            Some((position.x as f32 * k, position.y as f32 * k));
937                    }
938
939                    let pos = Vec2 {
940                        x: position.x as f32 * self.input_fit,
941                        y: position.y as f32 * self.input_fit,
942                    };
943
944                    // Delegate pointer-move to the host runtime
945                    let result = self.rt.handle_pointer_move(pos);
946
947                    // Inspector hover (platform-specific - devtools inspect)
948                    if let (Some(inspector), Some(f)) = (&mut self.inspector, &self.rt.frame_cache)
949                        && inspector.hud.inspector_enabled
950                    {
951                        let hit = f.hit_regions.iter().find(|h| h.rect.contains(pos));
952                        let hover_rect = hit.map(|h| h.rect);
953                        let hover_info = hit.and_then(|h| {
954                            f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
955                                repose_devtools::HoveredInfo {
956                                    id: s.id,
957                                    role: format!("{:?}", s.role),
958                                    label: s.label.clone(),
959                                }
960                            })
961                        });
962                        inspector.hud.set_hovered(hover_rect, hover_info);
963                    }
964
965                    if let Some(win) = &self.window
966                        && let Some(c) = result.cursor
967                    {
968                        Self::apply_cursor_static(
969                            win,
970                            &mut self.custom_cursor,
971                            &mut self.last_cursor_key,
972                            Some(el),
973                            &c,
974                        );
975                    }
976
977                    self.request_redraw();
978                }
979
980                WindowEvent::MouseWheel { delta, .. } => {
981                    let scale = self.rt.scale;
982                    let k = self.input_fit;
983                    let delta = match delta {
984                        winit::event::MouseScrollDelta::PixelDelta(p) => {
985                            winit::event::MouseScrollDelta::PixelDelta(
986                                winit::dpi::PhysicalPosition::new(p.x * k as f64, p.y * k as f64),
987                            )
988                        }
989                        other => other,
990                    };
991                    if crate::runner_common::on_mouse_wheel(&mut self.rt, delta, scale) {
992                        self.request_redraw();
993                    }
994                }
995
996                WindowEvent::MouseInput { state, button, .. } => {
997                    let pos = Vec2 {
998                        x: self.rt.mouse_pos_px.0,
999                        y: self.rt.mouse_pos_px.1,
1000                    };
1001
1002                    let mapped = match button {
1003                        MouseButton::Left => PointerButton::Primary,
1004                        MouseButton::Right => PointerButton::Secondary,
1005                        MouseButton::Middle => PointerButton::Tertiary,
1006                        // Forward/Back/other buttons are not dispatched by the runtime.
1007                        _ => return,
1008                    };
1009                    match (mapped, state) {
1010                        (PointerButton::Primary, ElementState::Pressed) => {
1011                            self.rt.sched.mouse_primary = true;
1012                        }
1013                        (PointerButton::Primary, ElementState::Released) => {
1014                            self.rt.sched.mouse_primary = false;
1015                        }
1016                        (PointerButton::Secondary, ElementState::Pressed) => {
1017                            self.rt.sched.mouse_secondary = true;
1018                        }
1019                        (PointerButton::Secondary, ElementState::Released) => {
1020                            self.rt.sched.mouse_secondary = false;
1021                        }
1022                        (PointerButton::Tertiary, ElementState::Pressed) => {
1023                            self.rt.sched.mouse_middle = true;
1024                        }
1025                        (PointerButton::Tertiary, ElementState::Released) => {
1026                            self.rt.sched.mouse_middle = false;
1027                        }
1028                    }
1029
1030                    match state {
1031                        ElementState::Pressed => {
1032                            let result = self.rt.handle_pointer_press(pos, mapped);
1033
1034                            self.sync_ime_for_focus();
1035
1036                            if result.focused.is_none() {
1037                                self.rt.ime_preedit = false;
1038                            }
1039
1040                            if result.needs_a11y_announce {
1041                                self.announce_focus_change();
1042                            }
1043
1044                            // Middle-click: paste the primary selection into a textfield.
1045                            if matches!(mapped, PointerButton::Tertiary)
1046                                && let Some(f) = &self.rt.frame_cache
1047                                && let Some(cid) = self.rt.capture_id
1048                                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
1049                                && self.rt.sched.window_focused
1050                                && rc::is_editable_textfield_hit(f, hit.id)
1051                                && let Some(txt) = self.paste_from_primary()
1052                            {
1053                                self.rt.paste_into_focused(&txt);
1054                            }
1055
1056                            // Inspector: click-to-select topmost widget under cursor.
1057                            if matches!(mapped, PointerButton::Primary | PointerButton::Secondary)
1058                                && let Some(inspector) = &mut self.inspector
1059                                && inspector.hud.inspector_enabled
1060                                && let Some(f) = &self.rt.frame_cache
1061                                && let Some(hit) =
1062                                    f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
1063                            {
1064                                let info =
1065                                    f.semantics_nodes.iter().find(|s| s.id == hit.id).map(|s| {
1066                                        repose_devtools::HoveredInfo {
1067                                            id: s.id,
1068                                            role: format!("{:?}", s.role),
1069                                            label: s.label.clone(),
1070                                        }
1071                                    });
1072                                inspector
1073                                    .hud
1074                                    .select_widget(repose_devtools::SelectedWidget {
1075                                        id: hit.id,
1076                                        role: info
1077                                            .as_ref()
1078                                            .map(|i| i.role.clone())
1079                                            .unwrap_or_default(),
1080                                        label: info.as_ref().and_then(|i| i.label.clone()),
1081                                        bounds: hit.rect,
1082                                    });
1083                            }
1084
1085                            self.request_redraw();
1086                        }
1087
1088                        ElementState::Released => {
1089                            let result = self.rt.handle_pointer_release(pos, mapped);
1090
1091                            // A11y: announce activation when a click fires on release.
1092                            // The runtime reports the clicked id before clearing its
1093                            // capture state, so this cannot race with `capture_id = None`.
1094                            if let Some(cid) = result.clicked_id
1095                                && let Some(f) = &self.rt.frame_cache
1096                                && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid)
1097                            {
1098                                let label = node.label.as_deref().unwrap_or("");
1099                                self.a11y.announce(&format!("Activated {}", label));
1100                            }
1101
1102                            self.request_redraw();
1103                        }
1104                    }
1105                }
1106
1107                WindowEvent::Touch(mut t) => {
1108                    let scale = self.rt.scale;
1109                    let k = self.input_fit as f64;
1110                    t.location.x *= k;
1111                    t.location.y *= k;
1112                    let r = crate::runner_common::handle_touch_raw(
1113                        &mut self.rt,
1114                        &mut self.touch_gestures,
1115                        &t,
1116                        scale,
1117                    );
1118                    if matches!(
1119                        t.phase,
1120                        winit::event::TouchPhase::Started | winit::event::TouchPhase::Ended
1121                    ) {
1122                        self.sync_ime_for_focus();
1123                    }
1124                    let mut dirty = r.dirty;
1125                    if let Some((delta_scale, center)) = r.pinch
1126                        && self.dispatch_action(repose_core::shortcuts::Action::Gesture(
1127                            repose_core::shortcuts::Gesture::PinchWithCenter {
1128                                delta_scale,
1129                                center,
1130                            },
1131                        ))
1132                    {
1133                        dirty = true;
1134                    }
1135                    if let Some((delta, center)) = r.pan
1136                        && self.dispatch_action(repose_core::shortcuts::Action::Gesture(
1137                            repose_core::shortcuts::Gesture::Pan { delta, center },
1138                        ))
1139                    {
1140                        dirty = true;
1141                    }
1142                    if let Some((delta_rotation, center)) = r.rotation
1143                        && self.dispatch_action(repose_core::shortcuts::Action::Gesture(
1144                            repose_core::shortcuts::Gesture::Rotate {
1145                                delta_rotation,
1146                                center,
1147                            },
1148                        ))
1149                    {
1150                        dirty = true;
1151                    }
1152                    if let Some(right) = r.swipe_right {
1153                        let g = if right {
1154                            repose_core::shortcuts::Gesture::SwipeRight
1155                        } else {
1156                            repose_core::shortcuts::Gesture::SwipeLeft
1157                        };
1158                        if self.dispatch_action(repose_core::shortcuts::Action::Gesture(g)) {
1159                            dirty = true;
1160                        }
1161                    }
1162                    if dirty {
1163                        self.request_redraw();
1164                    }
1165                }
1166
1167                WindowEvent::ModifiersChanged(new_mods) => {
1168                    crate::runner_common::on_modifiers_changed(&mut self.rt, &new_mods.state());
1169                }
1170
1171                WindowEvent::KeyboardInput {
1172                    event: key_event, ..
1173                } => {
1174                    if crate::runner_common::on_keyboard_input(
1175                        &mut self.rt,
1176                        &key_event,
1177                        &mut self.inspector,
1178                    ) {
1179                        self.request_redraw();
1180                        return;
1181                    }
1182
1183                    if key_event.state == ElementState::Pressed
1184                        && !key_event.repeat
1185                        && (rc::is_back_key(&key_event) || rc::is_escape_key(&key_event))
1186                    {
1187                        if self.rt.overlay.handle_back() {
1188                            self.request_redraw();
1189                            return;
1190                        }
1191                        use repose_navigation::back;
1192                        if back::handle() {
1193                            self.request_redraw();
1194                            return;
1195                        }
1196                        if rc::is_back_key(&key_event) {
1197                            el.exit();
1198                            return;
1199                        }
1200                    }
1201
1202                    // --- A11y: keyboard activation announcement ---
1203                    if key_event.state == ElementState::Released
1204                        && let Some(active_id) = self.rt.key_pressed_active
1205                    {
1206                        match key_event.physical_key {
1207                            PhysicalKey::Code(KeyCode::Space)
1208                            | PhysicalKey::Code(KeyCode::Enter) => {
1209                                if let Some(f) = &self.rt.frame_cache
1210                                    && let Some(node) =
1211                                        f.semantics_nodes.iter().find(|n| n.id == active_id)
1212                                {
1213                                    let label = node.label.as_deref().unwrap_or("");
1214                                    self.a11y.announce(&format!("Activated {}", label));
1215                                }
1216                            }
1217                            _ => {}
1218                        }
1219                    }
1220                }
1221
1222                WindowEvent::Ime(ime) => {
1223                    crate::runner_common::on_ime(&mut self.rt, &ime);
1224                    self.request_redraw();
1225                }
1226
1227                WindowEvent::RedrawRequested => {
1228                    // Allow media (etc.) to queue texture uploads without compose.
1229                    crate::run_pre_redraw(&self.render);
1230
1231                    let now = Instant::now();
1232                    let has_frame = self.rt.frame_cache.is_some();
1233                    if (has_frame || repose_core::frame_clock::peek_frame_request())
1234                        && !self.frame_pacer.due(now)
1235                    {
1236                        self.redraw_deferred = true;
1237                        if let Some(deadline) = self.frame_pacer.deadline(now) {
1238                            el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1239                                deadline,
1240                            ));
1241                        }
1242                        return;
1243                    }
1244                    self.redraw_deferred = false;
1245                    let compose_requested = take_frame_request();
1246
1247                    self.process_a11y_actions();
1248                    self.process_render_commands();
1249
1250                    let Some(win) = self.window.clone() else {
1251                        return;
1252                    };
1253                    if !compose_requested {
1254                        if let (Some(backend), Some(frame)) =
1255                            (self.backend.as_mut(), self.rt.frame_cache.as_ref())
1256                        {
1257                            let scale = win.scale_factor() as f32;
1258                            let inspector_active = self
1259                                .inspector
1260                                .as_ref()
1261                                .is_some_and(|inspector| inspector.hud.inspector_enabled);
1262                            let _dnd_guard = self.rt.dnd_context.enter();
1263                            let drag_active =
1264                                repose_core::dnd::is_dragging() || self.external_file_drag;
1265                            let mut overlay =
1266                                (inspector_active || drag_active).then(|| frame.scene.clone());
1267                            if let Some(scene) = &mut overlay {
1268                                if let Some(inspector) = &mut self.inspector {
1269                                    inspector.frame(scene);
1270                                }
1271                                repose_core::dnd::overlay_drag_indicator(
1272                                    scene,
1273                                    self.rt.mouse_pos_px,
1274                                    self.external_file_drag,
1275                                );
1276                            }
1277                            let _ = take_present_request();
1278                            let presented = backend.frame(
1279                                overlay.as_ref().unwrap_or(&frame.scene),
1280                                GlyphRasterConfig {
1281                                    px: Px(18.0 * scale),
1282                                },
1283                            );
1284                            if presented {
1285                                self.frame_pacer.rendered(Instant::now());
1286                            }
1287                        }
1288                        return;
1289                    }
1290                    log::trace!("RedrawRequested: frame request pending, composing");
1291
1292                    if self.backend.is_none() {
1293                        return;
1294                    }
1295
1296                    // Advance animations before composition (Compose pattern).
1297                    // Mirrors broadcastFrameClock.sendFrame() before performRecompose().
1298                    repose_core::animation_driver::tick();
1299
1300                    let t0 = Instant::now();
1301                    let scale = self.rt.scale;
1302
1303                    let output = self.rt.frame(&mut self.root, &self.render);
1304
1305                    if let Some(cursor) = &output.platform.cursor {
1306                        let win = win.clone();
1307                        Self::apply_cursor_static(
1308                            &win,
1309                            &mut self.custom_cursor,
1310                            &mut self.last_cursor_key,
1311                            Some(el),
1312                            cursor,
1313                        );
1314                    }
1315
1316                    if let Some(fullscreen) = self.rt.take_fullscreen_request()
1317                        && fullscreen != self.fullscreen
1318                    {
1319                        self.fullscreen = fullscreen;
1320                        let _ = win.set_fullscreen(if fullscreen {
1321                            Some(winit::window::Fullscreen::Borderless(None))
1322                        } else {
1323                            None
1324                        });
1325                    }
1326
1327                    if let Some(dark) = output.platform.window_theme_dark
1328                        && self.last_window_theme != Some(dark)
1329                    {
1330                        win.set_theme(Some(if dark {
1331                            winit::window::Theme::Dark
1332                        } else {
1333                            winit::window::Theme::Light
1334                        }));
1335                        self.last_window_theme = Some(dark);
1336                    }
1337
1338                    self.ime_output_allowed = output.platform.ime_allowed;
1339                    let ime_allowed = self.ime_allowed_for_output(
1340                        &output.hit_regions,
1341                        &output.semantics_nodes,
1342                        output.platform.ime_allowed,
1343                    );
1344                    self.apply_ime_state(if ime_allowed {
1345                        ImeState {
1346                            allowed: true,
1347                            purpose: output.platform.ime_purpose,
1348                            auto_correct: output.platform.ime_auto_correct,
1349                            capitalization: output.platform.ime_capitalization,
1350                            cursor_area: output.platform.ime_cursor_area,
1351                        }
1352                    } else {
1353                        ImeState::default()
1354                    });
1355
1356                    let frame = output.into_shared_frame();
1357
1358                    let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1359
1360                    if self.a11y_active.load(Ordering::Acquire) {
1361                        if self.a11y_needs_full.swap(false, Ordering::AcqRel) {
1362                            self.a11y_tree = A11yTree::default();
1363                        }
1364                        if let Some(adapter) = &mut self.accesskit_adapter {
1365                            let scale = win.scale_factor();
1366                            if let Some(update) = self.a11y_tree.update(
1367                                &frame.semantics_nodes,
1368                                scale,
1369                                self.rt.sched.focused,
1370                            ) {
1371                                adapter.update_if_active(|| update);
1372                            }
1373                        }
1374                    }
1375
1376                    self.rt.after_compose_shared(frame.clone(), scale);
1377
1378                    let inspector_active = self
1379                        .inspector
1380                        .as_ref()
1381                        .is_some_and(|inspector| inspector.hud.inspector_enabled);
1382                    let _dnd_guard = self.rt.dnd_context.enter();
1383                    let drag_active = repose_core::dnd::is_dragging() || self.external_file_drag;
1384                    let mut overlay =
1385                        (inspector_active || drag_active).then(|| frame.scene.clone());
1386                    let scene = overlay.as_ref().unwrap_or(&frame.scene);
1387                    if let Some(inspector) = &mut self.inspector {
1388                        let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1389                        let signal_count = self.rt.sched.id_count() as usize;
1390                        let ls = repose_ui::last_layout_stats();
1391                        inspector.hud.metrics = Some(repose_devtools::Metrics {
1392                            build_ms: build_layout_ms,
1393                            layout_ms: ls.layout_time_ms,
1394                            paint_ms: ls.paint_time_ms,
1395                            scene_nodes: scene.nodes.len(),
1396                            widget_count,
1397                            signal_count,
1398                            taffy_created: ls.taffy_created,
1399                            taffy_reused: ls.taffy_reused,
1400                            layout_hits: ls.layout_hits,
1401                            layout_misses: ls.layout_misses,
1402                            paint_cache_hits: ls.paint_cache_hits,
1403                            paint_cache_misses: ls.paint_cache_misses,
1404                            paint_culled: ls.paint_culled,
1405                        });
1406                        if let Some(scene) = &mut overlay {
1407                            inspector.frame(scene);
1408                        }
1409                    }
1410                    if let Some(scene) = &mut overlay {
1411                        repose_core::dnd::overlay_drag_indicator(
1412                            scene,
1413                            self.rt.mouse_pos_px,
1414                            self.external_file_drag,
1415                        );
1416                    }
1417
1418                    // Drain upload commands queued during compose (e.g. VideoSink set_image_*)
1419                    // before presenting to avoid 1-frame GPU texture lag.
1420                    self.process_render_commands();
1421
1422                    let _ = take_present_request();
1423                    let presented = if let Some(backend) = self.backend.as_mut() {
1424                        backend.frame(
1425                            overlay.as_ref().unwrap_or(&frame.scene),
1426                            GlyphRasterConfig {
1427                                px: Px(18.0 * scale),
1428                            },
1429                        )
1430                    } else {
1431                        false
1432                    };
1433
1434                    self.rt.cache_frame(frame);
1435
1436                    self.dispatch_file_drop_now();
1437
1438                    self.rt.tick_overlays();
1439                    if presented {
1440                        self.frame_pacer.rendered(Instant::now());
1441                    }
1442                }
1443
1444                _ => {}
1445            }
1446        }
1447
1448        fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1449            let _event_scope =
1450                repose_app::lifecycle::enter_dispatchers(self.rt.event_dispatchers());
1451            // Process cross-thread commands (e.g. tray toggles, deeplinks) before any
1452            // redraw check, so hide/show commands work even when hidden
1453            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1454            if let Some(cb) = ABOUT_TO_WAIT_CALLBACK
1455                .lock()
1456                .unwrap_or_else(|e| e.into_inner())
1457                .as_ref()
1458            {
1459                let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(cb));
1460                if let Err(e) = res {
1461                    log::error!(
1462                        "ABOUT_TO_WAIT_CALLBACK panicked: {}",
1463                        e.downcast_ref::<String>()
1464                            .map(|s| s.as_str())
1465                            .or_else(|| e.downcast_ref::<&str>().copied())
1466                            .unwrap_or("unknown")
1467                    );
1468                }
1469            }
1470            self.rt.process_deeplinks();
1471            self.rt.process_lifecycle();
1472
1473            if let Some(backend) = &mut self.gamepad {
1474                for ev in backend.poll() {
1475                    self.rt.handle_gamepad(&ev);
1476                }
1477                for (id, low, high, duration_ms) in self.rt.take_rumble_requests() {
1478                    use repose_core::input::GamepadId;
1479                    let gid = GamepadId(id);
1480                    if duration_ms == 0 {
1481                        backend.stop_rumble(gid);
1482                    } else if !backend.set_rumble(gid, low, high, duration_ms) {
1483                        log::warn!("gamepad: rumble not supported for pad {id}");
1484                    }
1485                }
1486            } else {
1487                self.rt.take_rumble_requests();
1488            }
1489
1490            if let Some(backend) = &mut self.sensors {
1491                for reading in backend.poll_sensors() {
1492                    match self.rt.gamepad_id_by_sensor_device(&reading.device) {
1493                        Some(id) => self.rt.handle_sensor_sample(id, reading.sample),
1494                        None if !self.sensor_drops.contains(&reading.device) => {
1495                            self.sensor_drops.push(reading.device.clone());
1496                            log::warn!(
1497                                "sensor: no connected pad named {:?}, dropping samples",
1498                                reading.device
1499                            );
1500                        }
1501                        None => {}
1502                    }
1503                }
1504            }
1505
1506            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1507            if let Some(reason) = self.backend.as_ref().and_then(|b| b.take_device_lost()) {
1508                log::error!("GPU device lost, rebuilding backend: {reason}");
1509                self.backend = None;
1510                self.backend_retry_at = None;
1511                self.backend_lifecycle.begin();
1512                self.rt.frame_cache = None;
1513                self.request_redraw();
1514            }
1515
1516            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1517            if WINDOW_VISIBLE.load(Ordering::Relaxed)
1518                && !WINDOW_OCCLUDED.load(Ordering::Relaxed)
1519                && self.backend.is_none()
1520                && self.backend_lifecycle.state() != BackendState::Unavailable
1521                && let Some(w) = &self.window
1522            {
1523                let now = Instant::now();
1524                if let Some(retry_at) = self.backend_retry_at
1525                    && now < retry_at
1526                {
1527                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(retry_at));
1528                    return;
1529                }
1530                self.backend_retry_at = None;
1531                log::info!("about_to_wait: recreating GPU backend");
1532                match repose_render_wgpu::WgpuBackend::new_with_options(
1533                    w.clone(),
1534                    Some(el.owned_display_handle()),
1535                    self.msaa_samples,
1536                    self.present_mode,
1537                ) {
1538                    Ok(b) => {
1539                        let size = w.inner_size();
1540                        let scale = w.scale_factor() as f32;
1541                        let mut backend = Some(b);
1542                        self.input_fit = rc::sync_viewport(&mut self.rt, &mut backend, size, scale);
1543                        let Some(b) = backend.take() else {
1544                            unreachable!()
1545                        };
1546                        repose_render_wgpu::offscreen::set_shared_device(
1547                            b.device.clone(),
1548                            b.queue.clone(),
1549                        );
1550                        self.backend = Some(b);
1551                        self.backend_lifecycle.ready();
1552                        self.request_redraw();
1553                    }
1554                    Err(e) => match self.backend_lifecycle.failed() {
1555                        BackendRetry::RetryAfter(delay) => {
1556                            self.backend_retry_at = Some(now + delay);
1557                            log::error!(
1558                                "about_to_wait: failed to recreate backend, retrying in \
1559                                 {delay:?}: {e:?}"
1560                            );
1561                        }
1562                        BackendRetry::Unavailable => log::error!(
1563                            "about_to_wait: GPU backend permanently unavailable after {} \
1564                             attempts, giving up: {e:?}",
1565                            MAX_BACKEND_INIT_ATTEMPTS
1566                        ),
1567                        BackendRetry::Ready => unreachable!(),
1568                    },
1569                }
1570            }
1571
1572            if self.redraw_deferred {
1573                let now = Instant::now();
1574                if self.frame_pacer.due(now) {
1575                    self.redraw_deferred = false;
1576                    rc::request_redraw(&self.window);
1577                } else if let Some(deadline) = self.frame_pacer.deadline(now) {
1578                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(deadline));
1579                } else {
1580                    self.redraw_deferred = false;
1581                    rc::request_redraw(&self.window);
1582                }
1583                return;
1584            }
1585
1586            let now = Instant::now();
1587            let idle_cap = web_time::Duration::from_millis(1000);
1588            let idle_deadline = now + idle_cap;
1589            let wakeup_due = self.rt.is_wakeup_due(now);
1590            if wakeup_due
1591                || self.frame_pacer.idle_due(now, idle_cap)
1592                || repose_core::animation_driver::is_active()
1593                || (self.a11y_active.load(Ordering::Acquire)
1594                    && self.a11y_needs_full.load(Ordering::Acquire))
1595            {
1596                request_frame();
1597            }
1598
1599            let wakeup_deadline = self
1600                .rt
1601                .next_wakeup_deadline()
1602                .filter(|deadline| *deadline > now);
1603            if wakeup_due {
1604                rc::request_redraw(&self.window);
1605                return;
1606            }
1607
1608            let compose_requested = repose_core::frame_clock::peek_frame_request();
1609            let present_requested = repose_core::frame_clock::peek_present_request();
1610            if compose_requested {
1611                if self.frame_pacer.due(now) {
1612                    rc::request_redraw(&self.window);
1613                } else if let Some(deadline) = self
1614                    .frame_pacer
1615                    .deadline(now)
1616                    .into_iter()
1617                    .chain(wakeup_deadline)
1618                    .chain(Some(idle_deadline))
1619                    .min()
1620                {
1621                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(deadline));
1622                }
1623                return;
1624            }
1625
1626            if present_requested && self.rt.frame_cache.is_some() {
1627                if self.frame_pacer.due(now) {
1628                    rc::request_redraw(&self.window);
1629                } else if let Some(deadline) = self
1630                    .frame_pacer
1631                    .deadline(now)
1632                    .into_iter()
1633                    .chain(wakeup_deadline)
1634                    .chain(Some(idle_deadline))
1635                    .min()
1636                {
1637                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(deadline));
1638                }
1639                return;
1640            }
1641
1642            let deadline = wakeup_deadline.unwrap_or(idle_deadline);
1643            el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(deadline));
1644        }
1645
1646        fn new_events(
1647            &mut self,
1648            _: &winit::event_loop::ActiveEventLoop,
1649            _: winit::event::StartCause,
1650        ) {
1651        }
1652        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1653            request_frame();
1654        }
1655        fn device_event(
1656            &mut self,
1657            _: &winit::event_loop::ActiveEventLoop,
1658            _: winit::event::DeviceId,
1659            _: winit::event::DeviceEvent,
1660        ) {
1661        }
1662        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {
1663            let _event_scope =
1664                repose_app::lifecycle::enter_dispatchers(self.rt.event_dispatchers());
1665            push_runtime_window_lifecycle(&mut self.rt, false);
1666        }
1667        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {
1668            #[cfg(not(target_arch = "wasm32"))]
1669            rc::flush_clipboard_writes();
1670            repose_core::shutdown_composition();
1671        }
1672        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {
1673            repose_core::runtime::COMPOSER.with(|c| {
1674                let mut c = c.borrow_mut();
1675                let n = c.cursor;
1676                if c.slots.len() > n {
1677                    c.slots.truncate(n);
1678                }
1679                if c.slot_callers.len() > n {
1680                    c.slot_callers.truncate(n);
1681                }
1682            });
1683            repose_core::scope_cache::gc_dead_scopes();
1684        }
1685    }
1686
1687    impl App {
1688        fn announce_focus_change(&mut self) {
1689            if let Some(f) = &self.rt.frame_cache {
1690                let focused_node = self
1691                    .rt
1692                    .sched
1693                    .focused
1694                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1695                self.a11y.focus_changed(focused_node);
1696            }
1697        }
1698
1699        fn dispatch_file_drop_now(&mut self) {
1700            let Some(f) = &self.rt.frame_cache else {
1701                self.pending_dropped_files.clear();
1702                self.pending_drop_pos_px = None;
1703                return;
1704            };
1705
1706            if self.pending_dropped_files.is_empty() {
1707                return;
1708            }
1709
1710            let pos_px = self.pending_drop_pos_px.unwrap_or(self.rt.mouse_pos_px);
1711            let pos = Vec2 {
1712                x: pos_px.0,
1713                y: pos_px.1,
1714            };
1715
1716            let mut files = Vec::new();
1717            for p in self.pending_dropped_files.drain(..) {
1718                let name = p
1719                    .file_name()
1720                    .and_then(|s| s.to_str())
1721                    .unwrap_or("file")
1722                    .to_string();
1723                files.push(repose_core::dnd::DroppedFile {
1724                    name,
1725                    path: Some(p),
1726                });
1727            }
1728
1729            let payload: repose_core::dnd::DragPayload =
1730                std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1731
1732            let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1733                self.pending_drop_pos_px = None;
1734                return;
1735            };
1736
1737            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1738                && let Some(cb) = &hit.on_drop
1739            {
1740                let accepted = cb(repose_core::dnd::DropEvent {
1741                    source_id: 0, // external source (OS)
1742                    target_id,
1743                    position: pos,
1744                    modifiers: self.rt.modifiers,
1745                    payload: payload.clone(),
1746                });
1747
1748                if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
1749                {
1750                    let label = node.label.as_deref().unwrap_or("");
1751                    self.a11y.announce(&format!("Dropped files on {}", label));
1752                }
1753            }
1754
1755            self.pending_drop_pos_px = None;
1756            self.request_redraw();
1757        }
1758    }
1759
1760    let event_loop = EventLoop::new()?;
1761    set_event_loop_proxy(event_loop.create_proxy());
1762    let mut app = App::new(Box::new(root), config);
1763    // Install system clock once
1764    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1765    event_loop.run_app(&mut app)?;
1766    Ok(())
1767}
1768
1769// Accessibility bridge stub (Noop by default; logs on Linux for now)
1770/// Bridge from Repose's semantics tree to platform accessibility APIs.
1771///
1772/// Implementations are responsible for:
1773/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
1774/// - Updating focus when `focus_changed` is called.
1775/// - Announcing transient messages (e.g. button activation) via screen readers.
1776pub trait A11yBridge: Send {
1777    /// Publish (or update) the full semantics tree for the current frame.
1778    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1779
1780    /// Notify that the focused node has changed. `None` means focus cleared.
1781    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1782
1783    /// Announce a one‑off message via the platform's accessibility channel.
1784    fn announce(&mut self, msg: &str);
1785}
1786
1787#[cfg_attr(target_os = "linux", allow(dead_code))]
1788struct NoopA11y;
1789impl A11yBridge for NoopA11y {
1790    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1791        // no-op
1792    }
1793    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1794        if let Some(n) = node {
1795            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1796        } else {
1797            log::info!("A11y focus: None");
1798        }
1799    }
1800    fn announce(&mut self, msg: &str) {
1801        log::info!("A11y announce: {msg}");
1802    }
1803}
1804
1805#[cfg(target_os = "linux")]
1806struct LinuxAtspiStub;
1807#[cfg(target_os = "linux")]
1808impl A11yBridge for LinuxAtspiStub {
1809    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1810        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1811    }
1812    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1813        if let Some(n) = node {
1814            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1815        } else {
1816            log::info!("AT-SPI stub focus: None");
1817        }
1818    }
1819    fn announce(&mut self, msg: &str) {
1820        log::info!("AT-SPI stub announce: {msg}");
1821    }
1822}