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