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(mut b) => {
651                                b.set_pixels_per_point(sf);
652                                repose_render_wgpu::offscreen::set_shared_device(
653                                    b.device.clone(),
654                                    b.queue.clone(),
655                                );
656                                self.backend = Some(b);
657                                set_app_window(w.clone());
658                                self.window = Some(w);
659                                self.request_redraw();
660                            }
661                            Err(e) => {
662                                log::error!("Failed to create WGPU backend: {e:?}");
663                                el.exit();
664                            }
665                        }
666                    }
667                    Err(e) => {
668                        log::error!("Failed to create window: {e:?}");
669                        el.exit();
670                    }
671                }
672            }
673        }
674
675        fn window_event(
676            &mut self,
677            el: &winit::event_loop::ActiveEventLoop,
678            _id: winit::window::WindowId,
679            event: WindowEvent,
680        ) {
681            // Process AccessKit events first!
682            if let Some(adapter) = &mut self.accesskit_adapter {
683                adapter.process_event(self.window.as_ref().unwrap(), &event);
684            }
685
686            match event {
687                WindowEvent::CloseRequested => {
688                    if CLOSE_TO_TRAY.load(Ordering::Relaxed) {
689                        // Drop GPU backend before null-buffer unmap.
690                        self.backend = None;
691                        if let Some(w) = &self.window {
692                            w.set_visible(false);
693                        }
694                        WINDOW_VISIBLE.store(false, Ordering::Relaxed);
695                    } else {
696                        el.exit();
697                    }
698                }
699
700                WindowEvent::Focused(false) => {
701                    // Delegate all common focus-lost cleanup to the runtime
702                    self.rt.handle_focus_lost();
703
704                    // Platform-specific cleanup
705                    self.external_file_drag = false;
706                    self.hovered_files.clear();
707
708                    if let Some(w) = &self.window {
709                        rc_web::set_ime_for_textfield(w, false);
710                    }
711
712                    self.request_redraw();
713                }
714
715                WindowEvent::CursorLeft { .. } => {
716                    self.rt.pointer_inside = false;
717                    self.rt.clear_hover();
718                    self.external_file_drag = false;
719                    self.hovered_files.clear();
720                    self.request_redraw();
721                }
722
723                WindowEvent::HoveredFile(path) => {
724                    // Mark external drag active and keep a small bounded list
725                    self.external_file_drag = true;
726                    if self.hovered_files.len() < 32 {
727                        self.hovered_files.push(path);
728                    }
729                    if self.pending_drop_pos_px.is_none() {
730                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
731                    }
732                    self.request_redraw();
733                }
734
735                WindowEvent::HoveredFileCancelled => {
736                    self.external_file_drag = false;
737                    self.hovered_files.clear();
738
739                    // Defensive: cancel any internal capture/drag that might be left stuck
740                    self.reset_pointer_state();
741
742                    self.request_redraw();
743                }
744
745                WindowEvent::DroppedFile(path) => {
746                    // DroppedFile is emitted once per file. Batch them.
747                    self.pending_dropped_files.push(path);
748                    if self.pending_drop_pos_px.is_none() {
749                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
750                    }
751
752                    // Drop ends the external file drag session.
753                    self.external_file_drag = false;
754                    self.hovered_files.clear();
755
756                    self.request_redraw();
757                }
758
759                WindowEvent::Resized(size) => {
760                    let sf = self
761                        .window
762                        .as_ref()
763                        .map(|w| w.scale_factor() as f32)
764                        .unwrap_or(1.0);
765                    self.rt.set_viewport_and_scale(size.width, size.height, sf);
766                    if let Some(b) = self.backend.as_mut() {
767                        b.configure_surface(size.width, size.height);
768                        b.set_pixels_per_point(sf);
769                    }
770                    if let Some(w) = &self.window {
771                        let sf = w.scale_factor() as f32;
772                        let dp_w = size.width as f32 / sf;
773                        let dp_h = size.height as f32 / sf;
774                        log::info!(
775                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
776                            size.width,
777                            size.height,
778                            sf,
779                            dp_w as i32,
780                            dp_h as i32
781                        );
782                    }
783                    self.request_redraw();
784                }
785
786                WindowEvent::CursorMoved { position, .. } => {
787                    self.rt.pointer_inside = true;
788
789                    if self.external_file_drag {
790                        self.pending_drop_pos_px = Some((position.x as f32, position.y as f32));
791                    }
792
793                    let pos = Vec2 {
794                        x: position.x as f32,
795                        y: position.y as f32,
796                    };
797
798                    // Delegate pointer-move to the host runtime
799                    let result = self.rt.handle_pointer_move(pos);
800
801                    // Inspector hover (platform-specific - devtools inspect)
802                    if let (Some(inspector), Some(f)) = (&mut self.inspector, &self.rt.frame_cache)
803                        && inspector.hud.inspector_enabled
804                    {
805                        let hit = f.hit_regions.iter().find(|h| h.rect.contains(pos));
806                        let hover_rect = hit.map(|h| h.rect);
807                        let hover_info = hit.and_then(|h| {
808                            f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
809                                repose_devtools::HoveredInfo {
810                                    id: s.id,
811                                    role: format!("{:?}", s.role),
812                                    label: s.label.clone(),
813                                }
814                            })
815                        });
816                        inspector.hud.set_hovered(hover_rect, hover_info);
817                    }
818
819                    // Cursor icon via winit window
820                    if let Some(win) = &self.window
821                        && let Some(c) = result.cursor
822                    {
823                        win.set_cursor(winit::window::Cursor::Icon(map_cursor(c)));
824                    }
825
826                    self.request_redraw();
827                }
828
829                WindowEvent::MouseWheel { delta, .. } => {
830                    let (dx_px, dy_px) = match delta {
831                        MouseScrollDelta::LineDelta(x, y) => {
832                            let unit_px = dp_to_px(60.0);
833                            (-(x * unit_px), -(y * unit_px))
834                        }
835                        MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
836                    };
837                    log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
838
839                    if self.rt.handle_scroll(Vec2 { x: dx_px, y: dy_px }) {
840                        self.request_redraw();
841                    }
842                }
843
844                WindowEvent::MouseInput { state, button, .. } => {
845                    let pos = Vec2 {
846                        x: self.rt.mouse_pos_px.0,
847                        y: self.rt.mouse_pos_px.1,
848                    };
849
850                    let mapped = match button {
851                        MouseButton::Left => PointerButton::Primary,
852                        MouseButton::Right => PointerButton::Secondary,
853                        MouseButton::Middle => PointerButton::Tertiary,
854                        // Forward/Back/other buttons are not dispatched by the runtime.
855                        _ => return,
856                    };
857
858                    match state {
859                        ElementState::Pressed => {
860                            let result = self.rt.handle_pointer_press(pos, mapped);
861
862                            // Platform-specific IME setup for focused textfields
863                            if let Some(fid) = result.focused
864                                && let Some(win) = &self.window
865                                && let Some(f) = &self.rt.frame_cache
866                                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
867                            {
868                                let sf = win.scale_factor();
869                                rc_web::set_ime_for_textfield_ex(
870                                    win,
871                                    true,
872                                    hit.keyboard_type.ime_purpose_hint(),
873                                    hit.auto_correct.unwrap_or(true),
874                                    hit.capitalization,
875                                );
876                                win.set_ime_cursor_area(
877                                    LogicalPosition::new(
878                                        hit.rect.x as f64 / sf,
879                                        hit.rect.y as f64 / sf,
880                                    ),
881                                    LogicalSize::new(
882                                        hit.rect.w as f64 / sf,
883                                        hit.rect.h as f64 / sf,
884                                    ),
885                                );
886                            }
887
888                            // Click outside - no focus result from runtime, drop IME
889                            if result.focused.is_none() && self.rt.ime_preedit {
890                                if let Some(win) = &self.window {
891                                    rc_web::set_ime_for_textfield(win, false);
892                                }
893                                self.rt.ime_preedit = false;
894                            }
895
896                            if result.needs_a11y_announce {
897                                self.announce_focus_change();
898                            }
899
900                            // Middle-click: paste the primary selection into a textfield.
901                            if matches!(mapped, PointerButton::Tertiary)
902                                && let Some(f) = &self.rt.frame_cache
903                                && let Some(cid) = self.rt.capture_id
904                                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
905                                && self.rt.is_textfield(hit.id)
906                                && let Some(txt) = self.paste_from_primary()
907                            {
908                                self.rt.paste_into_focused(&txt);
909                            }
910
911                            // Inspector: click-to-select topmost widget under cursor.
912                            if matches!(mapped, PointerButton::Primary | PointerButton::Secondary)
913                                && let Some(inspector) = &mut self.inspector
914                                && inspector.hud.inspector_enabled
915                                && let Some(f) = &self.rt.frame_cache
916                                && let Some(hit) =
917                                    f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
918                            {
919                                let info =
920                                    f.semantics_nodes.iter().find(|s| s.id == hit.id).map(|s| {
921                                        repose_devtools::HoveredInfo {
922                                            id: s.id,
923                                            role: format!("{:?}", s.role),
924                                            label: s.label.clone(),
925                                        }
926                                    });
927                                inspector
928                                    .hud
929                                    .select_widget(repose_devtools::SelectedWidget {
930                                        id: hit.id,
931                                        role: info
932                                            .as_ref()
933                                            .map(|i| i.role.clone())
934                                            .unwrap_or_default(),
935                                        label: info.as_ref().and_then(|i| i.label.clone()),
936                                        bounds: hit.rect,
937                                    });
938                            }
939
940                            self.request_redraw();
941                        }
942
943                        ElementState::Released => {
944                            let result = self.rt.handle_pointer_release(pos, mapped);
945
946                            // A11y: announce activation when a click fires on release.
947                            // The runtime reports the clicked id before clearing its
948                            // capture state, so this cannot race with `capture_id = None`.
949                            if let Some(cid) = result.clicked_id
950                                && let Some(f) = &self.rt.frame_cache
951                                && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid)
952                            {
953                                let label = node.label.as_deref().unwrap_or("");
954                                self.a11y.announce(&format!("Activated {}", label));
955                            }
956
957                            self.request_redraw();
958                        }
959                    }
960                }
961
962                WindowEvent::Touch(t) => {
963                    let pos_px = (t.location.x as f32, t.location.y as f32);
964                    let tid = t.id;
965                    let scale = self
966                        .window
967                        .as_ref()
968                        .map(|w| w.scale_factor() as f32)
969                        .unwrap_or(1.0);
970
971                    match t.phase {
972                        winit::event::TouchPhase::Started => {
973                            self.touch_gestures.touch_started(&mut self.rt, tid, pos_px);
974                            self.request_redraw();
975                        }
976
977                        winit::event::TouchPhase::Moved => {
978                            let (mut dirty, pinch, pan) =
979                                self.touch_gestures
980                                    .touch_moved(&mut self.rt, tid, pos_px, scale);
981
982                            if let Some((delta_scale, center)) = pinch
983                                && self.dispatch_action(repose_core::shortcuts::Action::Gesture(
984                                    repose_core::shortcuts::Gesture::PinchWithCenter {
985                                        delta_scale,
986                                        center,
987                                    },
988                                ))
989                            {
990                                dirty = true;
991                            }
992                            if let Some(delta) = pan
993                                && self.dispatch_action(repose_core::shortcuts::Action::Gesture(
994                                    repose_core::shortcuts::Gesture::Pan { delta },
995                                ))
996                            {
997                                dirty = true;
998                            }
999
1000                            if dirty {
1001                                self.request_redraw();
1002                            }
1003                        }
1004
1005                        winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => {
1006                            let cancelled = t.phase == winit::event::TouchPhase::Cancelled;
1007                            let swipe_right = self.touch_gestures.touch_ended(
1008                                &mut self.rt,
1009                                tid,
1010                                pos_px,
1011                                cancelled,
1012                            );
1013
1014                            use repose_core::shortcuts::{Action, Gesture};
1015                            let mut dirty = false;
1016                            if let Some(right) = swipe_right {
1017                                let g = if right {
1018                                    Gesture::SwipeRight
1019                                } else {
1020                                    Gesture::SwipeLeft
1021                                };
1022                                if self.dispatch_action(Action::Gesture(g)) {
1023                                    dirty = true;
1024                                }
1025                            }
1026
1027                            if dirty {
1028                                self.request_redraw();
1029                            }
1030                        }
1031                    }
1032                }
1033
1034                WindowEvent::ModifiersChanged(new_mods) => {
1035                    let state = new_mods.state();
1036                    self.rt.modifiers.shift = state.shift_key();
1037                    self.rt.modifiers.ctrl = state.control_key();
1038                    self.rt.modifiers.alt = state.alt_key();
1039                    self.rt.modifiers.meta = state.super_key();
1040                    self.rt.modifiers.command = if cfg!(target_os = "macos") {
1041                        self.rt.modifiers.meta
1042                    } else {
1043                        self.rt.modifiers.ctrl
1044                    };
1045                }
1046
1047                WindowEvent::KeyboardInput {
1048                    event: key_event, ..
1049                } => {
1050                    // Inspector hotkey: Ctrl+Shift+I
1051                    if key_event.state == ElementState::Pressed
1052                        && !key_event.repeat
1053                        && self.rt.modifiers.ctrl
1054                        && self.rt.modifiers.shift
1055                        && key_event.physical_key == PhysicalKey::Code(KeyCode::KeyI)
1056                        && let Some(inspector) = &mut self.inspector
1057                    {
1058                        inspector.hud.toggle_inspector();
1059                        self.request_redraw();
1060                        return;
1061                    }
1062
1063                    // --- Delegate all generic keyboard dispatch to the runtime ---
1064                    // (focus-chain dispatch, shortcuts, single-char input, and
1065                    // winit's composed `key_event.text` for international layouts).
1066                    let mapped = rc::map_key(key_event.physical_key, &self.rt.modifiers);
1067                    let ke = winit_key_to_repose(&key_event, &mapped, &self.rt.modifiers);
1068                    if self.rt.handle_key_with_text(&ke, key_event.text.as_deref()) {
1069                        self.request_redraw();
1070                        return;
1071                    }
1072
1073                    // Escape / BrowserBack: when the runtime didn't cancel a
1074                    // drag / dispatch focus, fall back to navigation back.
1075                    if key_event.state == ElementState::Pressed && !key_event.repeat {
1076                        match key_event.physical_key {
1077                            PhysicalKey::Code(KeyCode::BrowserBack)
1078                            | PhysicalKey::Code(KeyCode::Escape) => {
1079                                use repose_navigation::back;
1080                                if !back::handle() {
1081                                    // el.exit();
1082                                }
1083                                return;
1084                            }
1085                            _ => {}
1086                        }
1087                    }
1088
1089                    // --- A11y: keyboard activation announcement ---
1090                    if key_event.state == ElementState::Released
1091                        && let Some(active_id) = self.rt.key_pressed_active
1092                    {
1093                        match key_event.physical_key {
1094                            PhysicalKey::Code(KeyCode::Space)
1095                            | PhysicalKey::Code(KeyCode::Enter) => {
1096                                if let Some(f) = &self.rt.frame_cache
1097                                    && let Some(node) =
1098                                        f.semantics_nodes.iter().find(|n| n.id == active_id)
1099                                {
1100                                    let label = node.label.as_deref().unwrap_or("");
1101                                    self.a11y.announce(&format!("Activated {}", label));
1102                                }
1103                            }
1104                            _ => {}
1105                        }
1106                    }
1107                }
1108
1109                WindowEvent::Ime(ime) => {
1110                    // Translate winit IME events into runtime events; the
1111                    // runtime owns the composition state + notify callbacks.
1112                    let ime_event = match ime {
1113                        winit::event::Ime::Enabled => repose_core::input::ImeEvent::Start,
1114                        winit::event::Ime::Preedit(text, cursor) => {
1115                            repose_core::input::ImeEvent::Update { text, cursor }
1116                        }
1117                        winit::event::Ime::Commit(text) => {
1118                            repose_core::input::ImeEvent::Commit(text)
1119                        }
1120                        winit::event::Ime::Disabled => repose_core::input::ImeEvent::Cancel,
1121                    };
1122                    self.rt.handle_ime(&ime_event);
1123                    self.request_redraw();
1124                }
1125
1126                WindowEvent::RedrawRequested => {
1127                    // Allow media (etc.) to queue texture uploads without compose.
1128                    crate::run_pre_redraw(&self.render);
1129
1130                    // 1. Check our redraw flag before processing a11y.
1131                    if !self.redraw_requested.replace(false) {
1132                        self.process_a11y_actions();
1133                        self.process_render_commands();
1134                        // Present-only: redraw last cached scene with updated textures
1135                        if let (Some(backend), Some(frame)) =
1136                            (self.backend.as_mut(), self.rt.frame_cache.as_ref())
1137                        {
1138                            let scale = self
1139                                .window
1140                                .as_ref()
1141                                .map(|w| w.scale_factor() as f32)
1142                                .unwrap_or(1.0);
1143                            let mut scene = frame.scene.clone();
1144                            if let Some(inspector) = &mut self.inspector {
1145                                inspector.frame(&mut scene);
1146                            }
1147                            backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1148                        }
1149                        log::trace!("RedrawRequested: no frame request, skipping compose");
1150                        return;
1151                    }
1152                    log::trace!("RedrawRequested: frame request pending, composing");
1153
1154                    // 2. Process a11y actions and render commands before compose.
1155                    self.process_a11y_actions();
1156                    self.process_render_commands();
1157
1158                    let Some(win) = self.window.as_ref() else {
1159                        return;
1160                    };
1161                    if self.backend.is_none() {
1162                        return;
1163                    }
1164
1165                    // Advance animations before composition (Compose pattern).
1166                    // Mirrors broadcastFrameClock.sendFrame() before performRecompose().
1167                    repose_core::animation_driver::tick();
1168
1169                    let t0 = Instant::now();
1170                    let scale = win.scale_factor() as f32;
1171                    self.rt.scale = scale;
1172                    let focused = self.rt.sched.focused;
1173
1174                    let output = self.rt.frame(&mut self.root, &self.render);
1175
1176                    if let Some(cursor) = &output.platform.cursor {
1177                        win.set_cursor(winit::window::Cursor::Icon(map_cursor(*cursor)));
1178                    }
1179
1180                    // Sync OS window chrome (titlebar) to the app theme, deduped.
1181                    if let Some(dark) = output.platform.window_theme_dark
1182                        && self.last_window_theme != Some(dark)
1183                    {
1184                        win.set_theme(Some(if dark {
1185                            winit::window::Theme::Dark
1186                        } else {
1187                            winit::window::Theme::Light
1188                        }));
1189                        self.last_window_theme = Some(dark);
1190                    }
1191
1192                    // Apply IME keyboard hints
1193                    if output.platform.ime_allowed {
1194                        rc_web::set_ime_for_textfield_ex(
1195                            win,
1196                            true,
1197                            output.platform.ime_purpose,
1198                            output.platform.ime_auto_correct,
1199                            output.platform.ime_capitalization,
1200                        );
1201                        if let Some((x, y, w, h)) = output.platform.ime_cursor_area {
1202                            win.set_ime_cursor_area(
1203                                LogicalPosition::new(x, y),
1204                                LogicalSize::new(w, h),
1205                            );
1206                        }
1207                    } else if self.rt.ime_preedit {
1208                        rc_web::set_ime_for_textfield_ex(
1209                            win,
1210                            false,
1211                            repose_core::ImePurposeHint::Normal,
1212                            true,
1213                            repose_core::KeyboardCapitalization::Unspecified,
1214                        );
1215                        self.rt.ime_preedit = false;
1216                    }
1217
1218                    // Apply IME state based on wants_keyboard
1219                    if !output.wants_keyboard
1220                        && focused.is_some()
1221                        && self.rt.sched.focused.is_none()
1222                        && self.rt.ime_preedit
1223                    {
1224                        rc_web::set_ime_for_textfield(win, false);
1225                        self.rt.ime_preedit = false;
1226                    }
1227
1228                    let frame = output.into_frame();
1229
1230                    let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1231
1232                    // UPDATE ACCESSIBILITY TREE
1233                    if let Some(adapter) = &mut self.accesskit_adapter {
1234                        let win = self.window.as_ref().unwrap();
1235                        let scale = win.scale_factor();
1236                        if let Some(update) = self.a11y_tree.update(
1237                            &frame.semantics_nodes,
1238                            scale,
1239                            self.rt.sched.focused,
1240                        ) {
1241                            adapter.update_if_active(|| update);
1242                        }
1243                    }
1244
1245                    // Render
1246                    let mut scene = frame.scene.clone();
1247                    // Update HUD metrics before overlay draws
1248                    if let Some(inspector) = &mut self.inspector {
1249                        let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1250                        let signal_count = self.rt.sched.id_count() as usize;
1251                        let ls = repose_ui::last_layout_stats();
1252                        inspector.hud.metrics = Some(repose_devtools::Metrics {
1253                            build_ms: build_layout_ms,
1254                            layout_ms: ls.layout_time_ms,
1255                            paint_ms: ls.paint_time_ms,
1256                            scene_nodes: scene.nodes.len(),
1257                            widget_count,
1258                            signal_count,
1259                            taffy_created: ls.taffy_created,
1260                            taffy_reused: ls.taffy_reused,
1261                            layout_hits: ls.layout_hits,
1262                            layout_misses: ls.layout_misses,
1263                            paint_cache_hits: ls.paint_cache_hits,
1264                            paint_cache_misses: ls.paint_cache_misses,
1265                            paint_culled: ls.paint_culled,
1266                        });
1267                        inspector.frame(&mut scene);
1268                    }
1269
1270                    // Drag indicator overlay (internal + file drop)
1271                    repose_core::dnd::overlay_drag_indicator(
1272                        &mut scene,
1273                        self.rt.mouse_pos_px,
1274                        self.external_file_drag,
1275                    );
1276
1277                    // Drain upload commands queued during compose (e.g. VideoSink set_image_*)
1278                    // before presenting to avoid 1-frame GPU texture lag.
1279                    self.process_render_commands();
1280
1281                    // Now borrow backend mutably only for the frame() call
1282                    let win = self.window.as_ref().unwrap();
1283                    let scale = win.scale_factor() as f32;
1284                    if let Some(backend) = self.backend.as_mut() {
1285                        backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1286                    }
1287
1288                    // Initialize TextFieldState for any focused TextField that
1289                    // doesn't have one yet (e.g. after FocusRequester::request_focus),
1290                    // reconcile hover, and publish the DnD frame/scale.
1291                    self.rt.after_compose(&frame, scale);
1292
1293                    // NOTE: hover was already reconciled inside `compose()`.
1294                    // `cache_frame` rebuilds the retained hover-leave map.
1295                    self.rt.cache_frame(frame);
1296
1297                    self.dispatch_file_drop_now();
1298
1299                    self.rt.tick_overlays(self.last_redraw);
1300                    self.last_redraw = Instant::now();
1301                }
1302
1303                _ => {}
1304            }
1305        }
1306
1307        fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1308            // Process cross-thread commands (e.g. tray toggles, deeplinks) before any
1309            // redraw check, so hide/show commands work even when hidden
1310            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1311            if let Some(cb) = ABOUT_TO_WAIT_CALLBACK.lock().unwrap().as_ref() {
1312                cb();
1313            }
1314            process_deeplinks();
1315
1316            // On Wayland, wgpu creates an xdg_surface from the winit window and it shouldn't be recreated with a new id?
1317            // It doesn't take a lot of resources anyway, so let the backend be present.
1318            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1319            if WINDOW_VISIBLE.load(Ordering::Relaxed)
1320                && self.backend.is_none()
1321                && let Some(w) = &self.window
1322            {
1323                log::info!("about_to_wait: recreating GPU backend");
1324                match repose_render_wgpu::WgpuBackend::new_with_options(
1325                    w.clone(),
1326                    self.msaa_samples,
1327                    self.present_mode,
1328                ) {
1329                    Ok(b) => {
1330                        repose_render_wgpu::offscreen::set_shared_device(
1331                            b.device.clone(),
1332                            b.queue.clone(),
1333                        );
1334                        self.backend = Some(b)
1335                    }
1336                    Err(e) => log::error!("about_to_wait: failed to recreate backend: {e:?}"),
1337                }
1338            }
1339
1340            let needs_compose = take_frame_request();
1341            let needs_present = take_present_request();
1342
1343            if needs_compose {
1344                self.pending_redraw = true;
1345            }
1346
1347            // Present-only: texture was updated, redraw last cached scene without compose.
1348            if !self.pending_redraw && needs_present && self.rt.frame_cache.is_some() {
1349                let now = Instant::now();
1350                let interval = self.frame_interval();
1351                if now.saturating_duration_since(self.last_redraw) >= interval {
1352                    rc::request_redraw(&self.window);
1353                    self.last_redraw = now;
1354                } else {
1355                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1356                        self.last_redraw + interval,
1357                    ));
1358                }
1359                return;
1360            }
1361
1362            if !self.pending_redraw {
1363                let now = Instant::now();
1364                let idle_cap = web_time::Duration::from_millis(1000);
1365                let deadline = self.rt.next_frame_deadline(now, idle_cap);
1366
1367                if now.saturating_duration_since(self.last_redraw) >= idle_cap
1368                    || self.rt.is_wakeup_due(now)
1369                {
1370                    self.redraw_requested.set(true);
1371                    request_frame();
1372                    rc::request_redraw(&self.window);
1373                    self.last_redraw = now;
1374                }
1375                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(Ord::min(
1376                    deadline,
1377                    now + idle_cap,
1378                )));
1379                return;
1380            }
1381
1382            let now = Instant::now();
1383            let interval = self.frame_interval();
1384
1385            if now.saturating_duration_since(self.last_redraw) >= interval {
1386                self.pending_redraw = false;
1387                self.redraw_requested.set(true);
1388                rc::request_redraw(&self.window);
1389                self.last_redraw = now;
1390            } else {
1391                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1392                    self.last_redraw + interval,
1393                ));
1394            }
1395        }
1396
1397        fn new_events(
1398            &mut self,
1399            _: &winit::event_loop::ActiveEventLoop,
1400            _: winit::event::StartCause,
1401        ) {
1402        }
1403        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1404            self.pending_redraw = true;
1405        }
1406        fn device_event(
1407            &mut self,
1408            _: &winit::event_loop::ActiveEventLoop,
1409            _: winit::event::DeviceId,
1410            _: winit::event::DeviceEvent,
1411        ) {
1412        }
1413        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1414        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1415        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1416    }
1417
1418    impl App {
1419        fn announce_focus_change(&mut self) {
1420            if let Some(f) = &self.rt.frame_cache {
1421                let focused_node = self
1422                    .rt
1423                    .sched
1424                    .focused
1425                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1426                self.a11y.focus_changed(focused_node);
1427            }
1428        }
1429
1430        fn dispatch_file_drop_now(&mut self) {
1431            let Some(f) = &self.rt.frame_cache else {
1432                self.pending_dropped_files.clear();
1433                self.pending_drop_pos_px = None;
1434                return;
1435            };
1436
1437            if self.pending_dropped_files.is_empty() {
1438                return;
1439            }
1440
1441            let pos_px = self.pending_drop_pos_px.unwrap_or(self.rt.mouse_pos_px);
1442            let pos = Vec2 {
1443                x: pos_px.0,
1444                y: pos_px.1,
1445            };
1446
1447            let mut files = Vec::new();
1448            for p in self.pending_dropped_files.drain(..) {
1449                let name = p
1450                    .file_name()
1451                    .and_then(|s| s.to_str())
1452                    .unwrap_or("file")
1453                    .to_string();
1454                files.push(repose_core::dnd::DroppedFile {
1455                    name,
1456                    path: Some(p),
1457                });
1458            }
1459
1460            let payload: repose_core::dnd::DragPayload =
1461                std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1462
1463            let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1464                self.pending_drop_pos_px = None;
1465                return;
1466            };
1467
1468            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1469                && let Some(cb) = &hit.on_drop
1470            {
1471                let accepted = cb(repose_core::dnd::DropEvent {
1472                    source_id: 0, // external source (OS)
1473                    target_id,
1474                    position: pos,
1475                    modifiers: self.rt.modifiers,
1476                    payload: payload.clone(),
1477                });
1478
1479                if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
1480                {
1481                    let label = node.label.as_deref().unwrap_or("");
1482                    self.a11y.announce(&format!("Dropped files on {}", label));
1483                }
1484            }
1485
1486            self.pending_drop_pos_px = None;
1487            self.request_redraw();
1488        }
1489    }
1490
1491    let event_loop = EventLoop::new()?;
1492    set_event_loop_proxy(event_loop.create_proxy());
1493    let mut app = App::new(Box::new(root), config);
1494    // Install system clock once
1495    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1496    event_loop.run_app(&mut app)?;
1497    Ok(())
1498}
1499
1500// Accessibility bridge stub (Noop by default; logs on Linux for now)
1501/// Bridge from Repose's semantics tree to platform accessibility APIs.
1502///
1503/// Implementations are responsible for:
1504/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
1505/// - Updating focus when `focus_changed` is called.
1506/// - Announcing transient messages (e.g. button activation) via screen readers.
1507pub trait A11yBridge: Send {
1508    /// Publish (or update) the full semantics tree for the current frame.
1509    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1510
1511    /// Notify that the focused node has changed. `None` means focus cleared.
1512    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1513
1514    /// Announce a one‑off message via the platform's accessibility channel.
1515    fn announce(&mut self, msg: &str);
1516}
1517
1518#[cfg_attr(target_os = "linux", allow(dead_code))]
1519struct NoopA11y;
1520impl A11yBridge for NoopA11y {
1521    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1522        // no-op
1523    }
1524    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1525        if let Some(n) = node {
1526            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1527        } else {
1528            log::info!("A11y focus: None");
1529        }
1530    }
1531    fn announce(&mut self, msg: &str) {
1532        log::info!("A11y announce: {msg}");
1533    }
1534}
1535
1536#[cfg(target_os = "linux")]
1537struct LinuxAtspiStub;
1538#[cfg(target_os = "linux")]
1539impl A11yBridge for LinuxAtspiStub {
1540    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1541        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1542    }
1543    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1544        if let Some(n) = node {
1545            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1546        } else {
1547            log::info!("AT-SPI stub focus: None");
1548        }
1549    }
1550    fn announce(&mut self, msg: &str) {
1551        log::info!("AT-SPI stub announce: {msg}");
1552    }
1553}