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 applied OS window theme (dark/light) to avoid spamming set_theme.
394        last_window_theme: Option<bool>,
395
396        last_redraw: Instant,
397        pending_redraw: bool,
398
399        // Tracks whether a redraw was requested by app code
400        redraw_requested: Cell<bool>,
401
402        // Shared touch-scroll / pinch / swipe gesture state (touchscreens)
403        touch_gestures: rc::TouchGestureState,
404    }
405
406    impl App {
407        fn process_a11y_actions(&mut self) {
408            let mut actions = self.a11y_actions.lock().unwrap();
409            if actions.is_empty() {
410                return;
411            }
412            let pending = actions.drain(..).collect::<Vec<_>>();
413            drop(actions);
414
415            let Some(f) = &self.rt.frame_cache else {
416                return;
417            };
418
419            for req in pending {
420                let target_id = req.target_node.0;
421                match req.action {
422                    accesskit::Action::Click => {
423                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
424                            && let Some(cb) = &hit.on_click
425                        {
426                            cb();
427                            self.request_redraw();
428                        }
429                    }
430                    accesskit::Action::Focus => {
431                        // Assistive tech focus should show the focus ring.
432                        let _ = repose_core::request_input_mode(repose_core::InputMode::Keyboard);
433                        self.rt.sched.focused = Some(target_id);
434                        self.request_redraw();
435                    }
436                    _ => {}
437                }
438            }
439        }
440
441        fn new(
442            root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
443            config: AppConfig,
444        ) -> Self {
445            Self {
446                root,
447                render: RenderContext::new(),
448                window: None,
449                backend: None,
450                rt: ReposeRuntime::new(),
451                inspector: if config.enable_inspector {
452                    Some(repose_devtools::Inspector::new())
453                } else {
454                    None
455                },
456                msaa_samples: config.common.msaa_samples,
457                max_fps: config.common.max_fps,
458                present_mode: config.common.present_mode,
459                window_title: config.window_title,
460                window_size: config.window_size,
461                pending_dropped_files: Vec::new(),
462                pending_drop_pos_px: None,
463
464                external_file_drag: false,
465                hovered_files: Vec::new(),
466
467                clipboard: None,
468                a11y: {
469                    #[cfg(target_os = "linux")]
470                    {
471                        Box::new(LinuxAtspiStub) as Box<dyn A11yBridge>
472                    }
473                    #[cfg(not(target_os = "linux"))]
474                    {
475                        Box::new(NoopA11y) as Box<dyn A11yBridge>
476                    }
477                },
478
479                accesskit_adapter: None,
480                a11y_actions: Arc::new(Mutex::new(Vec::new())),
481                a11y_tree: A11yTree::default(),
482
483                last_redraw: Instant::now(),
484                pending_redraw: false,
485                last_window_theme: None,
486                redraw_requested: Cell::new(false),
487                touch_gestures: rc::TouchGestureState::default(),
488            }
489        }
490
491        fn request_redraw(&self) {
492            self.redraw_requested.set(true);
493            repose_core::request_frame();
494            rc::request_redraw(&self.window);
495        }
496
497        fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
498            if self.rt.dispatch_action(action) {
499                if let Some(win) = &self.window {
500                    rc_web::set_ime_for_textfield(
501                        win,
502                        self.rt
503                            .sched
504                            .focused
505                            .map_or(false, |id| self.rt.is_textfield(id)),
506                    );
507                }
508                return true;
509            }
510            false
511        }
512
513        /// Minimum time between CPU-side redraw requests derived from
514        /// `max_fps`. `Duration::ZERO` means uncapped (redraw immediately).
515        fn frame_interval(&self) -> web_time::Duration {
516            match self.max_fps.filter(|f| *f > 0.0) {
517                Some(fps) => {
518                    let secs = (1.0 / fps as f64).clamp(0.0, 1.0);
519                    web_time::Duration::from_secs_f64(secs)
520                }
521                None => web_time::Duration::ZERO,
522            }
523        }
524
525        fn paste_from_primary(&self) -> Option<String> {
526            let mut opts = clipawl::ClipboardOptions::default();
527            opts.linux.selection = clipawl::LinuxSelection::Primary;
528            if let Ok(cb) = clipawl::Clipboard::new_with_options(opts) {
529                match pollster::block_on(cb.read()) {
530                    Ok(t) => Some(t),
531                    Err(e) => {
532                        eprintln!("Primary paste error: {}", e);
533                        None
534                    }
535                }
536            } else {
537                None
538            }
539        }
540
541        fn process_render_commands(&mut self) {
542            let Some(backend) = self.backend.as_mut() else {
543                return;
544            };
545            repose_render_wgpu::apply_render_commands(backend, self.render.drain());
546        }
547
548        fn reset_pointer_state(&mut self) {
549            self.rt.capture_id = None;
550            self.rt.pressed_ids.clear();
551            self.rt.hover_id = None;
552        }
553    }
554
555    impl ApplicationHandler<()> for App {
556        fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
557            self.clipboard = clipawl::Clipboard::new()
558                .map_err(|e| {
559                    eprintln!("clipawl clipboard init failed: {e}");
560                    e
561                })
562                .ok();
563            repose_core::clipboard::set_clipboard_read_fn(Box::new(|| {
564                clipawl::blocking::read().ok()
565            }));
566            // Register for SelectableText (Ctrl+C) - use blocking API directly
567            repose_core::clipboard::set_clipboard_fn(Box::new(move |text| {
568                if let Err(e) = clipawl::blocking::write(text) {
569                    eprintln!("clipboard write error: {e}");
570                }
571            }));
572
573            repose_core::clipboard::set_primary_fn(Box::new(|text| {
574                let mut opts = clipawl::ClipboardOptions::default();
575                opts.linux.selection = clipawl::LinuxSelection::Primary;
576                match clipawl::Clipboard::new_with_options(opts) {
577                    Ok(cb) => {
578                        if let Err(e) = pollster::block_on(cb.write(text)) {
579                            eprintln!("primary selection write error: {e}");
580                        }
581                    }
582                    Err(e) => eprintln!("primary clipboard init error: {e}"),
583                }
584            }));
585
586            if self.window.is_none() {
587                match el.create_window(
588                    WindowAttributes::default()
589                        .with_title(self.window_title.clone())
590                        .with_inner_size(PhysicalSize::new(self.window_size.0, self.window_size.1))
591                        .with_visible(false),
592                ) {
593                    Ok(win) => {
594                        let w = Arc::new(win);
595
596                        let activation_handler = ReposeActivationHandler {
597                            initial_tree: Some(A11yTree::initial_tree()),
598                        };
599
600                        let action_handler = ReposeActionHandler {
601                            pending_actions: self.a11y_actions.clone(),
602                        };
603
604                        let deactivation_handler = ReposeDeactivationHandler;
605
606                        let adapter = Adapter::with_direct_handlers(
607                            el,
608                            &w,
609                            activation_handler,
610                            action_handler,
611                            deactivation_handler,
612                        );
613
614                        self.accesskit_adapter = Some(adapter);
615
616                        w.set_visible(true);
617
618                        let size = w.inner_size();
619                        let sf = w.scale_factor() as f32;
620                        self.rt.set_viewport_and_scale(size.width, size.height, sf);
621
622                        match repose_render_wgpu::WgpuBackend::new_with_options(
623                            w.clone(),
624                            self.msaa_samples,
625                            self.present_mode,
626                        ) {
627                            Ok(b) => {
628                                self.backend = Some(b);
629                                set_app_window(w.clone());
630                                self.window = Some(w);
631                                self.request_redraw();
632                            }
633                            Err(e) => {
634                                log::error!("Failed to create WGPU backend: {e:?}");
635                                el.exit();
636                            }
637                        }
638                    }
639                    Err(e) => {
640                        log::error!("Failed to create window: {e:?}");
641                        el.exit();
642                    }
643                }
644            }
645        }
646
647        fn window_event(
648            &mut self,
649            el: &winit::event_loop::ActiveEventLoop,
650            _id: winit::window::WindowId,
651            event: WindowEvent,
652        ) {
653            // Process AccessKit events first!
654            if let Some(adapter) = &mut self.accesskit_adapter {
655                adapter.process_event(self.window.as_ref().unwrap(), &event);
656            }
657
658            match event {
659                WindowEvent::CloseRequested => {
660                    if CLOSE_TO_TRAY.load(Ordering::Relaxed) {
661                        // Drop GPU backend before null-buffer unmap.
662                        self.backend = None;
663                        if let Some(w) = &self.window {
664                            w.set_visible(false);
665                        }
666                        WINDOW_VISIBLE.store(false, Ordering::Relaxed);
667                    } else {
668                        el.exit();
669                    }
670                }
671
672                WindowEvent::Focused(false) => {
673                    // Delegate all common focus-lost cleanup to the runtime
674                    self.rt.handle_focus_lost();
675
676                    // Platform-specific cleanup
677                    self.external_file_drag = false;
678                    self.hovered_files.clear();
679
680                    if let Some(w) = &self.window {
681                        rc_web::set_ime_for_textfield(w, false);
682                    }
683
684                    self.request_redraw();
685                }
686
687                WindowEvent::CursorLeft { .. } => {
688                    self.rt.pointer_inside = false;
689                    self.rt.clear_hover();
690                    self.external_file_drag = false;
691                    self.hovered_files.clear();
692                    self.request_redraw();
693                }
694
695                WindowEvent::HoveredFile(path) => {
696                    // Mark external drag active and keep a small bounded list
697                    self.external_file_drag = true;
698                    if self.hovered_files.len() < 32 {
699                        self.hovered_files.push(path);
700                    }
701                    // Update drop position (best effort)
702                    if self.pending_drop_pos_px.is_none() {
703                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
704                    }
705                    self.request_redraw();
706                }
707
708                WindowEvent::HoveredFileCancelled => {
709                    self.external_file_drag = false;
710                    self.hovered_files.clear();
711
712                    // Defensive: cancel any internal capture/drag that might be left stuck
713                    self.reset_pointer_state();
714
715                    self.request_redraw();
716                }
717
718                WindowEvent::DroppedFile(path) => {
719                    // DroppedFile is emitted once per file. Batch them.
720                    self.pending_dropped_files.push(path);
721                    if self.pending_drop_pos_px.is_none() {
722                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
723                    }
724
725                    // Drop ends the external file drag session.
726                    self.external_file_drag = false;
727                    self.hovered_files.clear();
728
729                    self.request_redraw();
730                }
731
732                WindowEvent::Resized(size) => {
733                    let sf = self
734                        .window
735                        .as_ref()
736                        .map(|w| w.scale_factor() as f32)
737                        .unwrap_or(1.0);
738                    self.rt.set_viewport_and_scale(size.width, size.height, sf);
739                    if let Some(b) = self.backend.as_mut() {
740                        b.configure_surface(size.width, size.height);
741                    }
742                    if let Some(w) = &self.window {
743                        let sf = w.scale_factor() as f32;
744                        let dp_w = size.width as f32 / sf;
745                        let dp_h = size.height as f32 / sf;
746                        log::info!(
747                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
748                            size.width,
749                            size.height,
750                            sf,
751                            dp_w as i32,
752                            dp_h as i32
753                        );
754                    }
755                    self.request_redraw();
756                }
757
758                WindowEvent::CursorMoved { position, .. } => {
759                    self.rt.pointer_inside = true;
760
761                    if self.external_file_drag {
762                        self.pending_drop_pos_px = Some((position.x as f32, position.y as f32));
763                    }
764
765                    let pos = Vec2 {
766                        x: position.x as f32,
767                        y: position.y as f32,
768                    };
769
770                    // Delegate pointer-move to the host runtime
771                    let result = self.rt.handle_pointer_move(pos);
772
773                    // Inspector hover (platform-specific - devtools inspect)
774                    if let (Some(inspector), Some(f)) = (&mut self.inspector, &self.rt.frame_cache)
775                        && inspector.hud.inspector_enabled
776                    {
777                        let hit = f.hit_regions.iter().find(|h| h.rect.contains(pos));
778                        let hover_rect = hit.map(|h| h.rect);
779                        let hover_info = hit.and_then(|h| {
780                            f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
781                                repose_devtools::HoveredInfo {
782                                    id: s.id,
783                                    role: format!("{:?}", s.role),
784                                    label: s.label.clone(),
785                                }
786                            })
787                        });
788                        inspector.hud.set_hovered(hover_rect, hover_info);
789                    }
790
791                    // Cursor icon via winit window
792                    if let Some(win) = &self.window
793                        && let Some(c) = result.cursor
794                    {
795                        win.set_cursor(winit::window::Cursor::Icon(map_cursor(c)));
796                    }
797
798                    self.request_redraw();
799                }
800
801                WindowEvent::MouseWheel { delta, .. } => {
802                    let (dx_px, dy_px) = match delta {
803                        MouseScrollDelta::LineDelta(x, y) => {
804                            let unit_px = dp_to_px(60.0);
805                            (-(x * unit_px), -(y * unit_px))
806                        }
807                        MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
808                    };
809                    log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
810
811                    if self.rt.handle_scroll(Vec2 { x: dx_px, y: dy_px }) {
812                        self.request_redraw();
813                    }
814                }
815
816                WindowEvent::MouseInput { state, button, .. } => {
817                    let pos = Vec2 {
818                        x: self.rt.mouse_pos_px.0,
819                        y: self.rt.mouse_pos_px.1,
820                    };
821
822                    let mapped = match button {
823                        MouseButton::Left => PointerButton::Primary,
824                        MouseButton::Right => PointerButton::Secondary,
825                        MouseButton::Middle => PointerButton::Tertiary,
826                        // Forward/Back/other buttons are not dispatched by the runtime.
827                        _ => return,
828                    };
829
830                    match state {
831                        ElementState::Pressed => {
832                            let result = self.rt.handle_pointer_press(pos, mapped);
833
834                            // Platform-specific IME setup for focused textfields
835                            if let Some(fid) = result.focused
836                                && let Some(win) = &self.window
837                                && let Some(f) = &self.rt.frame_cache
838                                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
839                            {
840                                let sf = win.scale_factor();
841                                rc_web::set_ime_for_textfield_ex(
842                                    win,
843                                    true,
844                                    hit.keyboard_type.ime_purpose_hint(),
845                                    hit.auto_correct.unwrap_or(true),
846                                    hit.capitalization,
847                                );
848                                win.set_ime_cursor_area(
849                                    LogicalPosition::new(
850                                        hit.rect.x as f64 / sf,
851                                        hit.rect.y as f64 / sf,
852                                    ),
853                                    LogicalSize::new(
854                                        hit.rect.w as f64 / sf,
855                                        hit.rect.h as f64 / sf,
856                                    ),
857                                );
858                            }
859
860                            // Click outside - no focus result from runtime, drop IME
861                            if result.focused.is_none() && self.rt.ime_preedit {
862                                if let Some(win) = &self.window {
863                                    rc_web::set_ime_for_textfield(win, false);
864                                }
865                                self.rt.ime_preedit = false;
866                            }
867
868                            if result.needs_a11y_announce {
869                                self.announce_focus_change();
870                            }
871
872                            // Middle-click: paste the primary selection into a textfield.
873                            if matches!(mapped, PointerButton::Tertiary)
874                                && let Some(f) = &self.rt.frame_cache
875                                && let Some(cid) = self.rt.capture_id
876                                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
877                                && self.rt.is_textfield(hit.id)
878                                && let Some(txt) = self.paste_from_primary()
879                            {
880                                self.rt.paste_into_focused(&txt);
881                            }
882
883                            // Inspector: click-to-select topmost widget under cursor.
884                            if matches!(mapped, PointerButton::Primary | PointerButton::Secondary)
885                                && let Some(inspector) = &mut self.inspector
886                                && inspector.hud.inspector_enabled
887                                && let Some(f) = &self.rt.frame_cache
888                                && let Some(hit) =
889                                    f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
890                            {
891                                let info =
892                                    f.semantics_nodes.iter().find(|s| s.id == hit.id).map(|s| {
893                                        repose_devtools::HoveredInfo {
894                                            id: s.id,
895                                            role: format!("{:?}", s.role),
896                                            label: s.label.clone(),
897                                        }
898                                    });
899                                inspector
900                                    .hud
901                                    .select_widget(repose_devtools::SelectedWidget {
902                                        id: hit.id,
903                                        role: info
904                                            .as_ref()
905                                            .map(|i| i.role.clone())
906                                            .unwrap_or_default(),
907                                        label: info.as_ref().and_then(|i| i.label.clone()),
908                                        bounds: hit.rect,
909                                    });
910                            }
911
912                            self.request_redraw();
913                        }
914
915                        ElementState::Released => {
916                            let result = self.rt.handle_pointer_release(pos, mapped);
917
918                            // A11y: announce activation when a click fires on release.
919                            // The runtime reports the clicked id before clearing its
920                            // capture state, so this cannot race with `capture_id = None`.
921                            if let Some(cid) = result.clicked_id
922                                && let Some(f) = &self.rt.frame_cache
923                                && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid)
924                            {
925                                let label = node.label.as_deref().unwrap_or("");
926                                self.a11y.announce(&format!("Activated {}", label));
927                            }
928
929                            self.request_redraw();
930                        }
931                    }
932                }
933
934                WindowEvent::Touch(t) => {
935                    let pos_px = (t.location.x as f32, t.location.y as f32);
936                    let tid = t.id;
937                    let scale = self
938                        .window
939                        .as_ref()
940                        .map(|w| w.scale_factor() as f32)
941                        .unwrap_or(1.0);
942
943                    match t.phase {
944                        winit::event::TouchPhase::Started => {
945                            self.touch_gestures.touch_started(&mut self.rt, tid, pos_px);
946                            self.request_redraw();
947                        }
948
949                        winit::event::TouchPhase::Moved => {
950                            let (mut dirty, pinch, pan) = self
951                                .touch_gestures
952                                .touch_moved(&mut self.rt, tid, pos_px, scale);
953
954                            if let Some((delta_scale, center)) = pinch
955                                && self.dispatch_action(
956                                    repose_core::shortcuts::Action::Gesture(
957                                        repose_core::shortcuts::Gesture::PinchWithCenter {
958                                            delta_scale,
959                                            center,
960                                        },
961                                    ),
962                                )
963                            {
964                                dirty = true;
965                            }
966                            if let Some(delta) = pan
967                                && self.dispatch_action(
968                                    repose_core::shortcuts::Action::Gesture(
969                                        repose_core::shortcuts::Gesture::Pan { delta },
970                                    ),
971                                )
972                            {
973                                dirty = true;
974                            }
975
976                            if dirty {
977                                self.request_redraw();
978                            }
979                        }
980
981                        winit::event::TouchPhase::Ended
982                        | winit::event::TouchPhase::Cancelled => {
983                            let cancelled = t.phase == winit::event::TouchPhase::Cancelled;
984                            let swipe_right =
985                                self.touch_gestures
986                                    .touch_ended(&mut self.rt, tid, pos_px, cancelled);
987
988                            use repose_core::shortcuts::{Action, Gesture};
989                            let mut dirty = false;
990                            if let Some(right) = swipe_right {
991                                let g = if right {
992                                    Gesture::SwipeRight
993                                } else {
994                                    Gesture::SwipeLeft
995                                };
996                                if self.dispatch_action(Action::Gesture(g)) {
997                                    dirty = true;
998                                }
999                            }
1000
1001                            if dirty {
1002                                self.request_redraw();
1003                            }
1004                        }
1005                    }
1006                }
1007
1008                WindowEvent::ModifiersChanged(new_mods) => {
1009                    let state = new_mods.state();
1010                    self.rt.modifiers.shift = state.shift_key();
1011                    self.rt.modifiers.ctrl = state.control_key();
1012                    self.rt.modifiers.alt = state.alt_key();
1013                    self.rt.modifiers.meta = state.super_key();
1014                    self.rt.modifiers.command = if cfg!(target_os = "macos") {
1015                        self.rt.modifiers.meta
1016                    } else {
1017                        self.rt.modifiers.ctrl
1018                    };
1019                }
1020
1021                WindowEvent::KeyboardInput {
1022                    event: key_event, ..
1023                } => {
1024                    // Inspector hotkey: Ctrl+Shift+I
1025                    if key_event.state == ElementState::Pressed
1026                        && !key_event.repeat
1027                        && self.rt.modifiers.ctrl
1028                        && self.rt.modifiers.shift
1029                        && key_event.physical_key == PhysicalKey::Code(KeyCode::KeyI)
1030                        && let Some(inspector) = &mut self.inspector
1031                    {
1032                        inspector.hud.toggle_inspector();
1033                        self.request_redraw();
1034                        return;
1035                    }
1036
1037                    // --- Delegate all generic keyboard dispatch to the runtime ---
1038                    // (focus-chain dispatch, shortcuts, single-char input, and
1039                    // winit's composed `key_event.text` for international layouts).
1040                    let mapped = rc::map_key(key_event.physical_key, &self.rt.modifiers);
1041                    let ke = winit_key_to_repose(&key_event, &mapped, &self.rt.modifiers);
1042                    if self.rt.handle_key_with_text(&ke, key_event.text.as_deref()) {
1043                        self.request_redraw();
1044                        return;
1045                    }
1046
1047                    // Escape / BrowserBack: when the runtime didn't cancel a
1048                    // drag / dispatch focus, fall back to navigation back.
1049                    if key_event.state == ElementState::Pressed && !key_event.repeat {
1050                        match key_event.physical_key {
1051                            PhysicalKey::Code(KeyCode::BrowserBack)
1052                            | PhysicalKey::Code(KeyCode::Escape) => {
1053                                use repose_navigation::back;
1054                                if !back::handle() {
1055                                    // el.exit();
1056                                }
1057                                return;
1058                            }
1059                            _ => {}
1060                        }
1061                    }
1062
1063                    // --- A11y: keyboard activation announcement ---
1064                    if key_event.state == ElementState::Released
1065                        && let Some(active_id) = self.rt.key_pressed_active
1066                    {
1067                        match key_event.physical_key {
1068                            PhysicalKey::Code(KeyCode::Space)
1069                            | PhysicalKey::Code(KeyCode::Enter) => {
1070                                if let Some(f) = &self.rt.frame_cache
1071                                    && let Some(node) =
1072                                        f.semantics_nodes.iter().find(|n| n.id == active_id)
1073                                {
1074                                    let label = node.label.as_deref().unwrap_or("");
1075                                    self.a11y.announce(&format!("Activated {}", label));
1076                                }
1077                            }
1078                            _ => {}
1079                        }
1080                    }
1081                }
1082
1083                WindowEvent::Ime(ime) => {
1084                    // Translate winit IME events into runtime events; the
1085                    // runtime owns the composition state + notify callbacks.
1086                    let ime_event = match ime {
1087                        winit::event::Ime::Enabled => repose_core::input::ImeEvent::Start,
1088                        winit::event::Ime::Preedit(text, cursor) => {
1089                            repose_core::input::ImeEvent::Update { text, cursor }
1090                        }
1091                        winit::event::Ime::Commit(text) => {
1092                            repose_core::input::ImeEvent::Commit(text)
1093                        }
1094                        winit::event::Ime::Disabled => repose_core::input::ImeEvent::Cancel,
1095                    };
1096                    self.rt.handle_ime(&ime_event);
1097                    self.request_redraw();
1098                }
1099
1100                WindowEvent::RedrawRequested => {
1101                    // 1. Check our redraw flag before processing a11y.
1102                    if !self.redraw_requested.replace(false) {
1103                        self.process_a11y_actions();
1104                        self.process_render_commands();
1105                        // Present-only: redraw last cached scene with updated textures
1106                        if let (Some(backend), Some(frame)) =
1107                            (self.backend.as_mut(), self.rt.frame_cache.as_ref())
1108                        {
1109                            let scale = self
1110                                .window
1111                                .as_ref()
1112                                .map(|w| w.scale_factor() as f32)
1113                                .unwrap_or(1.0);
1114                            let mut scene = frame.scene.clone();
1115                            if let Some(inspector) = &mut self.inspector {
1116                                inspector.frame(&mut scene);
1117                            }
1118                            backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1119                        }
1120                        log::trace!("RedrawRequested: no frame request, skipping compose");
1121                        return;
1122                    }
1123                    log::trace!("RedrawRequested: frame request pending, composing");
1124
1125                    // 2. Process a11y actions and render commands before compose.
1126                    self.process_a11y_actions();
1127                    self.process_render_commands();
1128
1129                    let Some(win) = self.window.as_ref() else {
1130                        return;
1131                    };
1132                    if self.backend.is_none() {
1133                        return;
1134                    }
1135
1136                    // Advance animations before composition (Compose pattern).
1137                    // Mirrors broadcastFrameClock.sendFrame() before performRecompose().
1138                    repose_core::animation_driver::tick();
1139
1140                    let t0 = Instant::now();
1141                    let scale = win.scale_factor() as f32;
1142                    self.rt.scale = scale;
1143                    let focused = self.rt.sched.focused;
1144
1145                    let output = self.rt.frame(&mut self.root, &self.render);
1146
1147                    // Apply cursor from platform output
1148                    if let Some(cursor) = &output.platform.cursor {
1149                        win.set_cursor(winit::window::Cursor::Icon(map_cursor(*cursor)));
1150                    }
1151
1152                    // Sync OS window chrome (titlebar) to the app theme, deduped.
1153                    if let Some(dark) = output.platform.window_theme_dark
1154                        && self.last_window_theme != Some(dark)
1155                    {
1156                        win.set_theme(Some(if dark {
1157                            winit::window::Theme::Dark
1158                        } else {
1159                            winit::window::Theme::Light
1160                        }));
1161                        self.last_window_theme = Some(dark);
1162                    }
1163
1164                    // Apply IME keyboard hints
1165                    if output.platform.ime_allowed {
1166                        rc_web::set_ime_for_textfield_ex(
1167                            win,
1168                            true,
1169                            output.platform.ime_purpose,
1170                            output.platform.ime_auto_correct,
1171                            output.platform.ime_capitalization,
1172                        );
1173                        if let Some((x, y, w, h)) = output.platform.ime_cursor_area {
1174                            win.set_ime_cursor_area(
1175                                LogicalPosition::new(x, y),
1176                                LogicalSize::new(w, h),
1177                            );
1178                        }
1179                    } else if self.rt.ime_preedit {
1180                        rc_web::set_ime_for_textfield_ex(
1181                            win,
1182                            false,
1183                            repose_core::ImePurposeHint::Normal,
1184                            true,
1185                            repose_core::KeyboardCapitalization::Unspecified,
1186                        );
1187                        self.rt.ime_preedit = false;
1188                    }
1189
1190                    // Apply IME state based on wants_keyboard
1191                    if !output.wants_keyboard
1192                        && focused.is_some()
1193                        && self.rt.sched.focused.is_none()
1194                        && self.rt.ime_preedit
1195                    {
1196                        rc_web::set_ime_for_textfield(win, false);
1197                        self.rt.ime_preedit = false;
1198                    }
1199
1200                    let frame = output.into_frame();
1201
1202                    let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1203
1204                    // UPDATE ACCESSIBILITY TREE
1205                    if let Some(adapter) = &mut self.accesskit_adapter {
1206                        let win = self.window.as_ref().unwrap();
1207                        let scale = win.scale_factor();
1208                        if let Some(update) = self.a11y_tree.update(
1209                            &frame.semantics_nodes,
1210                            scale,
1211                            self.rt.sched.focused,
1212                        ) {
1213                            adapter.update_if_active(|| update);
1214                        }
1215                    }
1216
1217                    // Render
1218                    let mut scene = frame.scene.clone();
1219                    // Update HUD metrics before overlay draws
1220                    if let Some(inspector) = &mut self.inspector {
1221                        let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1222                        let signal_count = self.rt.sched.id_count() as usize;
1223                        let ls = repose_ui::last_layout_stats();
1224                        inspector.hud.metrics = Some(repose_devtools::Metrics {
1225                            build_ms: build_layout_ms,
1226                            layout_ms: ls.layout_time_ms,
1227                            paint_ms: ls.paint_time_ms,
1228                            scene_nodes: scene.nodes.len(),
1229                            widget_count,
1230                            signal_count,
1231                            taffy_created: ls.taffy_created,
1232                            taffy_reused: ls.taffy_reused,
1233                            layout_hits: ls.layout_hits,
1234                            layout_misses: ls.layout_misses,
1235                            paint_cache_hits: ls.paint_cache_hits,
1236                            paint_cache_misses: ls.paint_cache_misses,
1237                            paint_culled: ls.paint_culled,
1238                        });
1239                        inspector.frame(&mut scene);
1240                    }
1241
1242                    // Drag indicator overlay (internal + file drop)
1243                    repose_core::dnd::overlay_drag_indicator(
1244                        &mut scene,
1245                        self.rt.mouse_pos_px,
1246                        self.external_file_drag,
1247                    );
1248
1249                    // Drain upload commands queued during compose (e.g. VideoSink set_image_*)
1250                    // before presenting to avoid 1-frame GPU texture lag.
1251                    self.process_render_commands();
1252
1253                    // Now borrow backend mutably only for the frame() call
1254                    let win = self.window.as_ref().unwrap();
1255                    let scale = win.scale_factor() as f32;
1256                    if let Some(backend) = self.backend.as_mut() {
1257                        backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1258                    }
1259
1260                    // Initialize TextFieldState for any focused TextField that
1261                    // doesn't have one yet (e.g. after FocusRequester::request_focus),
1262                    // reconcile hover, and publish the DnD frame/scale.
1263                    self.rt.after_compose(&frame, scale);
1264
1265                    // NOTE: hover was already reconciled inside `compose()`.
1266                    // `cache_frame` rebuilds the retained hover-leave map.
1267                    self.rt.cache_frame(frame);
1268
1269                    self.dispatch_file_drop_now();
1270
1271                    self.rt.tick_overlays(self.last_redraw);
1272                    self.last_redraw = Instant::now();
1273                }
1274
1275                _ => {}
1276            }
1277        }
1278
1279        fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1280            // Process cross-thread commands (e.g. tray toggles, deeplinks) before any
1281            // redraw check, so hide/show commands work even when hidden
1282            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1283            if let Some(cb) = ABOUT_TO_WAIT_CALLBACK.lock().unwrap().as_ref() {
1284                cb();
1285            }
1286            process_deeplinks();
1287
1288            // On Wayland, wgpu creates an xdg_surface from the winit window and it shouldn't be recreated with a new id?
1289            // It doesn't take a lot of resources anyway, so let the backend be present.
1290            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1291            if WINDOW_VISIBLE.load(Ordering::Relaxed)
1292                && self.backend.is_none()
1293                && let Some(w) = &self.window
1294            {
1295                log::info!("about_to_wait: recreating GPU backend");
1296                match repose_render_wgpu::WgpuBackend::new_with_options(
1297                    w.clone(),
1298                    self.msaa_samples,
1299                    self.present_mode,
1300                ) {
1301                    Ok(b) => self.backend = Some(b),
1302                    Err(e) => log::error!("about_to_wait: failed to recreate backend: {e:?}"),
1303                }
1304            }
1305
1306            let needs_compose = take_frame_request();
1307            let needs_present = take_present_request();
1308
1309            if needs_compose {
1310                self.pending_redraw = true;
1311            }
1312
1313            // Present-only: texture was updated, redraw last cached scene without compose.
1314            if !self.pending_redraw && needs_present && self.rt.frame_cache.is_some() {
1315                let now = Instant::now();
1316                let interval = self.frame_interval();
1317                if now.saturating_duration_since(self.last_redraw) >= interval {
1318                    rc::request_redraw(&self.window);
1319                    self.last_redraw = now;
1320                } else {
1321                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1322                        self.last_redraw + interval,
1323                    ));
1324                }
1325                return;
1326            }
1327
1328            if !self.pending_redraw {
1329                let now = Instant::now();
1330                let idle_cap = web_time::Duration::from_millis(1000);
1331                let deadline = self
1332                    .rt
1333                    .next_caret_blink_deadline()
1334                    .unwrap_or(now + idle_cap);
1335
1336                if now.saturating_duration_since(self.last_redraw) >= idle_cap || now >= deadline {
1337                    self.redraw_requested.set(true);
1338                    request_frame();
1339                    rc::request_redraw(&self.window);
1340                    self.last_redraw = now;
1341                }
1342                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(Ord::min(
1343                    deadline,
1344                    now + idle_cap,
1345                )));
1346                return;
1347            }
1348
1349            let now = Instant::now();
1350            let interval = self.frame_interval();
1351
1352            if now.saturating_duration_since(self.last_redraw) >= interval {
1353                self.pending_redraw = false;
1354                self.redraw_requested.set(true);
1355                rc::request_redraw(&self.window);
1356                self.last_redraw = now;
1357            } else {
1358                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1359                    self.last_redraw + interval,
1360                ));
1361            }
1362        }
1363
1364        fn new_events(
1365            &mut self,
1366            _: &winit::event_loop::ActiveEventLoop,
1367            _: winit::event::StartCause,
1368        ) {
1369        }
1370        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1371            self.pending_redraw = true;
1372        }
1373        fn device_event(
1374            &mut self,
1375            _: &winit::event_loop::ActiveEventLoop,
1376            _: winit::event::DeviceId,
1377            _: winit::event::DeviceEvent,
1378        ) {
1379        }
1380        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1381        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1382        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1383    }
1384
1385    impl App {
1386        fn announce_focus_change(&mut self) {
1387            if let Some(f) = &self.rt.frame_cache {
1388                let focused_node = self
1389                    .rt
1390                    .sched
1391                    .focused
1392                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1393                self.a11y.focus_changed(focused_node);
1394            }
1395        }
1396
1397        fn dispatch_file_drop_now(&mut self) {
1398            let Some(f) = &self.rt.frame_cache else {
1399                self.pending_dropped_files.clear();
1400                self.pending_drop_pos_px = None;
1401                return;
1402            };
1403
1404            if self.pending_dropped_files.is_empty() {
1405                return;
1406            }
1407
1408            let pos_px = self.pending_drop_pos_px.unwrap_or(self.rt.mouse_pos_px);
1409            let pos = Vec2 {
1410                x: pos_px.0,
1411                y: pos_px.1,
1412            };
1413
1414            let mut files = Vec::new();
1415            for p in self.pending_dropped_files.drain(..) {
1416                let name = p
1417                    .file_name()
1418                    .and_then(|s| s.to_str())
1419                    .unwrap_or("file")
1420                    .to_string();
1421                files.push(repose_core::dnd::DroppedFile {
1422                    name,
1423                    path: Some(p),
1424                });
1425            }
1426
1427            let payload: repose_core::dnd::DragPayload =
1428                std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1429
1430            let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1431                self.pending_drop_pos_px = None;
1432                return;
1433            };
1434
1435            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1436                && let Some(cb) = &hit.on_drop
1437            {
1438                let accepted = cb(repose_core::dnd::DropEvent {
1439                    source_id: 0, // external source (OS)
1440                    target_id,
1441                    position: pos,
1442                    modifiers: self.rt.modifiers,
1443                    payload: payload.clone(),
1444                });
1445
1446                if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
1447                {
1448                    let label = node.label.as_deref().unwrap_or("");
1449                    self.a11y.announce(&format!("Dropped files on {}", label));
1450                }
1451            }
1452
1453            self.pending_drop_pos_px = None;
1454            self.request_redraw();
1455        }
1456    }
1457
1458    let event_loop = EventLoop::new()?;
1459    set_event_loop_proxy(event_loop.create_proxy());
1460    let mut app = App::new(Box::new(root), config);
1461    // Install system clock once
1462    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1463    event_loop.run_app(&mut app)?;
1464    Ok(())
1465}
1466
1467// Accessibility bridge stub (Noop by default; logs on Linux for now)
1468/// Bridge from Repose's semantics tree to platform accessibility APIs.
1469///
1470/// Implementations are responsible for:
1471/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
1472/// - Updating focus when `focus_changed` is called.
1473/// - Announcing transient messages (e.g. button activation) via screen readers.
1474pub trait A11yBridge: Send {
1475    /// Publish (or update) the full semantics tree for the current frame.
1476    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1477
1478    /// Notify that the focused node has changed. `None` means focus cleared.
1479    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1480
1481    /// Announce a one‑off message via the platform's accessibility channel.
1482    fn announce(&mut self, msg: &str);
1483}
1484
1485#[cfg_attr(target_os = "linux", allow(dead_code))]
1486struct NoopA11y;
1487impl A11yBridge for NoopA11y {
1488    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1489        // no-op
1490    }
1491    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1492        if let Some(n) = node {
1493            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1494        } else {
1495            log::info!("A11y focus: None");
1496        }
1497    }
1498    fn announce(&mut self, msg: &str) {
1499        log::info!("A11y announce: {msg}");
1500    }
1501}
1502
1503#[cfg(target_os = "linux")]
1504struct LinuxAtspiStub;
1505#[cfg(target_os = "linux")]
1506impl A11yBridge for LinuxAtspiStub {
1507    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1508        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1509    }
1510    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1511        if let Some(n) = node {
1512            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1513        } else {
1514            log::info!("AT-SPI stub focus: None");
1515        }
1516    }
1517    fn announce(&mut self, msg: &str) {
1518        log::info!("AT-SPI stub announce: {msg}");
1519    }
1520}