Skip to main content

repose_platform/
lib.rs

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