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