Skip to main content

repose_platform/
lib.rs

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