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