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