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