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