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