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