Skip to main content

repose_platform/
lib.rs

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