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    self, TF_FONT_DP, TF_PADDING_X_DP, TextFieldState, TextMeasureConfig, caret_xy_for_byte, 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        if !frame.focus_chain.contains(&fid) {
277            sched.focused = None;
278        }
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 std::collections::{HashMap, HashSet};
418    use winit::application::ApplicationHandler;
419    use winit::dpi::{LogicalPosition, LogicalSize, PhysicalSize};
420    use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
421    use winit::event_loop::EventLoop;
422    use winit::keyboard::{KeyCode, PhysicalKey};
423    use winit::window::{Window, WindowAttributes};
424
425    use crate::a11y::A11yTree;
426    use repose_app::ReposeRuntime;
427
428    struct ReposeActivationHandler {
429        initial_tree: Option<accesskit::TreeUpdate>,
430    }
431
432    impl accesskit::ActivationHandler for ReposeActivationHandler {
433        fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
434            self.initial_tree.take()
435        }
436    }
437
438    struct ReposeDeactivationHandler;
439
440    impl accesskit::DeactivationHandler for ReposeDeactivationHandler {
441        fn deactivate_accessibility(&mut self) {
442            // Nothing to clean up for now
443        }
444    }
445
446    struct App {
447        root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
448        render: RenderContext,
449        window: Option<Arc<Window>>,
450        backend: Option<repose_render_wgpu::WgpuBackend>,
451        rt: ReposeRuntime,
452        inspector: Option<repose_devtools::Inspector>,
453        msaa_samples: u32,
454        max_fps: Option<f32>,
455        present_mode: PresentModePref,
456        window_title: String,
457        window_size: (u32, u32),
458
459        // Files
460        pending_dropped_files: Vec<std::path::PathBuf>,
461        pending_drop_pos_px: Option<(f32, f32)>,
462
463        // External file drag hover (HoveredFile / Cancelled)
464        external_file_drag: bool,
465        hovered_files: Vec<std::path::PathBuf>,
466
467        clipboard: Option<clipawl::Clipboard>,
468        a11y: Box<dyn A11yBridge>,
469
470        accesskit_adapter: Option<Adapter>,
471        a11y_actions: Arc<Mutex<Vec<accesskit::ActionRequest>>>,
472        a11y_tree: A11yTree,
473
474        last_redraw: Instant,
475        pending_redraw: bool,
476
477        // Tracks whether a redraw was requested by app code
478        redraw_requested: Cell<bool>,
479    }
480
481    impl App {
482        fn process_a11y_actions(&mut self) {
483            let mut actions = self.a11y_actions.lock().unwrap();
484            if actions.is_empty() {
485                return;
486            }
487            let pending = actions.drain(..).collect::<Vec<_>>();
488            drop(actions);
489
490            let Some(f) = &self.rt.frame_cache else {
491                return;
492            };
493
494            for req in pending {
495                let target_id = req.target_node.0;
496                match req.action {
497                    accesskit::Action::Click => {
498                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
499                            && let Some(cb) = &hit.on_click
500                        {
501                            cb();
502                            self.request_redraw();
503                        }
504                    }
505                    accesskit::Action::Focus => {
506                        self.rt.sched.focused = Some(target_id);
507                        self.request_redraw();
508                    }
509                    _ => {}
510                }
511            }
512        }
513
514        fn new(
515            root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
516            config: AppConfig,
517        ) -> Self {
518            Self {
519                root,
520                render: RenderContext::new(),
521                window: None,
522                backend: None,
523                rt: ReposeRuntime::new(),
524                inspector: if config.enable_inspector {
525                    Some(repose_devtools::Inspector::new())
526                } else {
527                    None
528                },
529                msaa_samples: config.common.msaa_samples,
530                max_fps: config.common.max_fps,
531                present_mode: config.common.present_mode,
532                window_title: config.window_title,
533                window_size: config.window_size,
534                pending_dropped_files: Vec::new(),
535                pending_drop_pos_px: None,
536
537                external_file_drag: false,
538                hovered_files: Vec::new(),
539
540                clipboard: None,
541                a11y: {
542                    #[cfg(target_os = "linux")]
543                    {
544                        Box::new(LinuxAtspiStub) as Box<dyn A11yBridge>
545                    }
546                    #[cfg(not(target_os = "linux"))]
547                    {
548                        Box::new(NoopA11y) as Box<dyn A11yBridge>
549                    }
550                },
551
552                accesskit_adapter: None,
553                a11y_actions: Arc::new(Mutex::new(Vec::new())),
554                a11y_tree: A11yTree::default(),
555
556                last_redraw: Instant::now(),
557                pending_redraw: false,
558                redraw_requested: Cell::new(false),
559            }
560        }
561
562        fn request_redraw(&self) {
563            self.redraw_requested.set(true);
564            repose_core::request_frame();
565            rc::request_redraw(&self.window);
566        }
567
568        /// Minimum time between CPU-side redraw requests derived from
569        /// `max_fps`. `Duration::ZERO` means uncapped (redraw immediately).
570        fn frame_interval(&self) -> web_time::Duration {
571            match self.max_fps.filter(|f| *f > 0.0) {
572                Some(fps) => {
573                    let secs = (1.0 / fps as f64).clamp(0.0, 1.0);
574                    web_time::Duration::from_secs_f64(secs)
575                }
576                None => web_time::Duration::ZERO,
577            }
578        }
579
580        // Ensure caret is visible after edits/moves (all units in px)
581        fn tf_ensure_caret_visible(st: &mut TextFieldState, is_multiline: bool) {
582            rc::tf_ensure_caret_visible(st, is_multiline);
583        }
584
585        fn paste_from_primary(&self) -> Option<String> {
586            let mut opts = clipawl::ClipboardOptions::default();
587            opts.linux.selection = clipawl::LinuxSelection::Primary;
588            if let Ok(cb) = clipawl::Clipboard::new_with_options(opts) {
589                match pollster::block_on(cb.read()) {
590                    Ok(t) => Some(t),
591                    Err(e) => {
592                        eprintln!("Primary paste error: {}", e);
593                        None
594                    }
595                }
596            } else {
597                None
598            }
599        }
600
601        fn process_render_commands(&mut self) {
602            let Some(backend) = self.backend.as_mut() else {
603                return;
604            };
605            rc::process_render_commands(backend, self.render.drain());
606        }
607
608        fn reset_pointer_state(&mut self) {
609            self.rt.capture_id = None;
610            self.rt.pressed_ids.clear();
611            self.rt.hover_id = None;
612        }
613
614        fn is_textfield(&self, id: u64) -> bool {
615            rc::is_textfield_in_frame(&self.rt.frame_cache, id)
616        }
617
618        fn is_multiline_id(&self, id: u64) -> bool {
619            if let Some(f) = &self.rt.frame_cache {
620                f.hit_regions
621                    .iter()
622                    .find(|h| h.id == id)
623                    .map(|h| h.tf_multiline)
624                    .unwrap_or(false)
625            } else {
626                false
627            }
628        }
629
630        fn hit_by_id(f: &Frame, id: u64) -> Option<&HitRegion> {
631            f.hit_regions.iter().find(|h| h.id == id)
632        }
633
634        fn dp_px(&self, dp: f32) -> f32 {
635            dp_to_px(dp)
636        }
637    }
638
639    impl ApplicationHandler<()> for App {
640        fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
641            self.clipboard = clipawl::Clipboard::new()
642                .map_err(|e| {
643                    eprintln!("clipawl clipboard init failed: {e}");
644                    e
645                })
646                .ok();
647            repose_core::clipboard::set_clipboard_read_fn(Box::new(|| {
648                clipawl::blocking::read().ok()
649            }));
650            // Register for SelectableText (Ctrl+C) - use blocking API directly
651            repose_core::clipboard::set_clipboard_fn(Box::new(move |text| {
652                if let Err(e) = clipawl::blocking::write(text) {
653                    eprintln!("clipboard write error: {e}");
654                }
655            }));
656
657            repose_core::clipboard::set_primary_fn(Box::new(|text| {
658                let mut opts = clipawl::ClipboardOptions::default();
659                opts.linux.selection = clipawl::LinuxSelection::Primary;
660                match clipawl::Clipboard::new_with_options(opts) {
661                    Ok(cb) => {
662                        if let Err(e) = pollster::block_on(cb.write(text)) {
663                            eprintln!("primary selection write error: {e}");
664                        }
665                    }
666                    Err(e) => eprintln!("primary clipboard init error: {e}"),
667                }
668            }));
669
670            if self.window.is_none() {
671                match el.create_window(
672                    WindowAttributes::default()
673                        .with_title(self.window_title.clone())
674                        .with_inner_size(PhysicalSize::new(
675                            self.window_size.0,
676                            self.window_size.1,
677                        ))
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.window.as_ref().map(|w| w.scale_factor() as f32).unwrap_or(1.0);
821                    self.rt.set_viewport_and_scale(size.width, size.height, sf);
822                    if let Some(b) = self.backend.as_mut() {
823                        b.configure_surface(size.width, size.height);
824                    }
825                    if let Some(w) = &self.window {
826                        let sf = w.scale_factor() as f32;
827                        let dp_w = size.width as f32 / sf;
828                        let dp_h = size.height as f32 / sf;
829                        log::info!(
830                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
831                            size.width,
832                            size.height,
833                            sf,
834                            dp_w as i32,
835                            dp_h as i32
836                        );
837                    }
838                    self.request_redraw();
839                }
840
841                WindowEvent::CursorMoved { position, .. } => {
842                    self.rt.pointer_inside = true;
843
844                    if self.external_file_drag {
845                        self.pending_drop_pos_px = Some((position.x as f32, position.y as f32));
846                    }
847
848                    let pos = Vec2 {
849                        x: position.x as f32,
850                        y: position.y as f32,
851                    };
852
853                    // Delegate pointer-move to the host runtime
854                    let result = self.rt.handle_pointer_move(pos);
855
856                    // Inspector hover (platform-specific - devtools inspect)
857                    if let (Some(inspector), Some(f)) =
858                        (&mut self.inspector, &self.rt.frame_cache)
859                        && inspector.hud.inspector_enabled
860                    {
861                        let hit = f.hit_regions.iter().find(|h| {
862                            h.rect.contains(pos)
863                        });
864                        let hover_rect = hit.map(|h| h.rect);
865                        let hover_info = hit.and_then(|h| {
866                            f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
867                                repose_devtools::HoveredInfo {
868                                    id: s.id,
869                                    role: format!("{:?}", s.role),
870                                    label: s.label.clone(),
871                                }
872                            })
873                        });
874                        inspector.hud.set_hovered(hover_rect, hover_info);
875                    }
876
877                    // Cursor icon via winit window
878                    if let Some(win) = &self.window {
879                        if let Some(c) = result.cursor {
880                            win.set_cursor(winit::window::Cursor::Icon(map_cursor(c)));
881                        }
882                    }
883
884                    self.request_redraw();
885                }
886
887                WindowEvent::MouseWheel { delta, .. } => {
888                    let (dx_px, dy_px) = match delta {
889                        MouseScrollDelta::LineDelta(x, y) => {
890                            let unit_px = dp_to_px(60.0);
891                            (-(x * unit_px), -(y * unit_px))
892                        }
893                        MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
894                    };
895                    log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
896
897                    if self.rt.handle_scroll(Vec2 { x: dx_px, y: dy_px }) {
898                        self.request_redraw();
899                    }
900                }
901
902                WindowEvent::MouseInput {
903                    state: ElementState::Pressed,
904                    button: MouseButton::Left,
905                    ..
906                } => {
907                    let pos = Vec2 {
908                        x: self.rt.mouse_pos_px.0,
909                        y: self.rt.mouse_pos_px.1,
910                    };
911
912                    let result = self.rt.handle_pointer_press(pos, PointerButton::Primary);
913
914                    // Platform-specific IME setup for focused textfields
915                    if let Some(fid) = result.focused {
916                        if let Some(win) = &self.window
917                            && let Some(f) = &self.rt.frame_cache
918                            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
919                        {
920                            let sf = win.scale_factor();
921                            rc_web::set_ime_for_textfield(win, true);
922                            win.set_ime_cursor_area(
923                                LogicalPosition::new(
924                                    hit.rect.x as f64 / sf,
925                                    hit.rect.y as f64 / sf,
926                                ),
927                                LogicalSize::new(
928                                    hit.rect.w as f64 / sf,
929                                    hit.rect.h as f64 / sf,
930                                ),
931                            );
932                        }
933                    }
934
935                    // Click outside - no focus result from runtime, drop IME
936                    if result.focused.is_none() && self.rt.ime_preedit {
937                        if let Some(win) = &self.window {
938                            rc_web::set_ime_for_textfield(win, false);
939                        }
940                        self.rt.ime_preedit = false;
941                    }
942
943                    if result.needs_a11y_announce {
944                        self.announce_focus_change();
945                    }
946
947                    // Inspector: click-to-select topmost widget under cursor.
948                    if let Some(inspector) = &mut self.inspector
949                        && inspector.hud.inspector_enabled
950                        && let Some(f) = &self.rt.frame_cache
951                        && let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
952                    {
953                        let info = f
954                            .semantics_nodes
955                            .iter()
956                            .find(|s| s.id == hit.id)
957                            .map(|s| repose_devtools::HoveredInfo {
958                                id: s.id,
959                                role: format!("{:?}", s.role),
960                                label: s.label.clone(),
961                            });
962                        inspector.hud.select_widget(repose_devtools::SelectedWidget {
963                            id: hit.id,
964                            role: info
965                                .as_ref()
966                                .map(|i| i.role.clone())
967                                .unwrap_or_default(),
968                            label: info.as_ref().and_then(|i| i.label.clone()),
969                            bounds: hit.rect,
970                        });
971                    }
972
973                    self.request_redraw();
974                }
975
976                WindowEvent::MouseInput {
977                    state: ElementState::Pressed,
978                    button: MouseButton::Middle,
979                    ..
980                } => {
981                    let Some(f) = &self.rt.frame_cache else {
982                        return;
983                    };
984                    let pos = Vec2 {
985                        x: self.rt.mouse_pos_px.0,
986                        y: self.rt.mouse_pos_px.1,
987                    };
988                    if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos)) {
989                        // Dispatch Tertiary pointer event
990                        if let Some(cb) = &hit.on_pointer_down {
991                            cb(PointerEvent::new(
992                                PointerId(0),
993                                PointerKind::Mouse,
994                                PointerEventKind::Down(PointerButton::Tertiary),
995                                pos,
996                                1.0,
997                                self.rt.modifiers,
998                            ));
999                        }
1000                        // Paste primary selection into textfield
1001                        if self.is_textfield(hit.id) {
1002                            let key = self.tf_key_of(hit.id);
1003                            if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1004                                if let Some(txt) = self.paste_from_primary() {
1005                                    let mut st = state_rc.borrow_mut();
1006                                    st.insert_text_atomic(&txt);
1007                                    self.notify_text_change(hit.id, st.text.clone());
1008                                    if let Some(f) = &self.rt.frame_cache
1009                                        && let Some(h) =
1010                                            f.hit_regions.iter().find(|h| h.id == hit.id)
1011                                    {
1012                                        App::tf_ensure_caret_visible(&mut st, h.tf_multiline);
1013                                    }
1014                                }
1015                            }
1016                        }
1017                    }
1018                    self.request_redraw();
1019                }
1020
1021                WindowEvent::MouseInput {
1022                    state: ElementState::Released,
1023                    button: MouseButton::Left,
1024                    ..
1025                } => {
1026                    let pos = Vec2 {
1027                        x: self.rt.mouse_pos_px.0,
1028                        y: self.rt.mouse_pos_px.1,
1029                    };
1030
1031                    self.rt.handle_pointer_release(pos, PointerButton::Primary);
1032
1033                    // A11y: announce activation when a click fires on release
1034                    if let (Some(f), Some(cid)) = (&self.rt.frame_cache, self.rt.capture_id) {
1035                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
1036                            && hit.rect.contains(pos)
1037                            && hit.on_click.is_some()
1038                            && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid)
1039                        {
1040                            let label = node.label.as_deref().unwrap_or("");
1041                            self.a11y.announce(&format!("Activated {}", label));
1042                        }
1043                    }
1044
1045                    repose_core::request_frame();
1046                }
1047
1048                WindowEvent::MouseInput {
1049                    state: ElementState::Released,
1050                    button: MouseButton::Middle,
1051                    ..
1052                } => {
1053                    if let Some(f) = &self.rt.frame_cache {
1054                        let pos = Vec2 {
1055                            x: self.rt.mouse_pos_px.0,
1056                            y: self.rt.mouse_pos_px.1,
1057                        };
1058                        if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
1059                        {
1060                            if let Some(cb) = &hit.on_pointer_up {
1061                                cb(PointerEvent::new(
1062                                    PointerId(0),
1063                                    PointerKind::Mouse,
1064                                    PointerEventKind::Up(PointerButton::Tertiary),
1065                                    pos,
1066                                    1.0,
1067                                    self.rt.modifiers,
1068                                ));
1069                            }
1070                        }
1071                    }
1072                }
1073
1074                WindowEvent::ModifiersChanged(new_mods) => {
1075                    let state = new_mods.state();
1076                    self.rt.modifiers.shift = state.shift_key();
1077                    self.rt.modifiers.ctrl = state.control_key();
1078                    self.rt.modifiers.alt = state.alt_key();
1079                    self.rt.modifiers.meta = state.super_key();
1080                    self.rt.modifiers.command = if cfg!(target_os = "macos") {
1081                        self.rt.modifiers.meta
1082                    } else {
1083                        self.rt.modifiers.ctrl
1084                    };
1085                }
1086
1087                WindowEvent::KeyboardInput {
1088                    event: key_event, ..
1089                } => {
1090                    // --- Platform-specific shortcuts (before generic dispatch) ---
1091
1092                    // Escape / BrowserBack: cancel DnD, try focus chain, then navigation back
1093                    if key_event.state == ElementState::Pressed && !key_event.repeat {
1094                        match key_event.physical_key {
1095                            PhysicalKey::Code(KeyCode::BrowserBack)
1096                            | PhysicalKey::Code(KeyCode::Escape) => {
1097                                use repose_navigation::back;
1098
1099                                if repose_core::dnd::handle_drag_action(
1100                                    &repose_core::shortcuts::DragAction::Cancel,
1101                                ) {
1102                                    return;
1103                                }
1104
1105                                // Try focus-ancestor dispatch without handle_key's always-true return
1106                                let mapped = rc::map_key(key_event.physical_key);
1107                                if self.dispatch_focus_key_event(&key_event, &mapped) {
1108                                    self.request_redraw();
1109                                    return;
1110                                }
1111
1112                                if !back::handle() {
1113                                    // el.exit();
1114                                }
1115                                return;
1116                            }
1117                            _ => {}
1118                        }
1119                    }
1120
1121                    // Inspector hotkey: Ctrl+Shift+I
1122                    if let Some(inspector) = &mut self.inspector
1123                        && key_event.state == ElementState::Pressed
1124                        && self.rt.modifiers.ctrl
1125                        && self.rt.modifiers.shift
1126                        && key_event.physical_key == PhysicalKey::Code(KeyCode::KeyI)
1127                    {
1128                        inspector.hud.toggle_inspector();
1129                        self.request_redraw();
1130                        return;
1131                    }
1132
1133                    // Text undo/redo (Ctrl+Z / Ctrl+Shift+Z)
1134                    if key_event.state == ElementState::Pressed
1135                        && !key_event.repeat
1136                        && self.rt.modifiers.command
1137                    {
1138                        match key_event.physical_key {
1139                            PhysicalKey::Code(KeyCode::KeyZ) if self.rt.modifiers.shift => {
1140                                if let Some(fid) = self.rt.sched.focused {
1141                                    let key = self.tf_key_of(fid);
1142                                    if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1143                                        let mut st = state_rc.borrow_mut();
1144                                        if st.can_redo() {
1145                                            st.redo();
1146                                            self.notify_text_change(fid, st.text.clone());
1147                                            self.request_redraw();
1148                                            return;
1149                                        }
1150                                    }
1151                                }
1152                            }
1153                            PhysicalKey::Code(KeyCode::KeyZ) => {
1154                                if let Some(fid) = self.rt.sched.focused {
1155                                    let key = self.tf_key_of(fid);
1156                                    if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1157                                        let mut st = state_rc.borrow_mut();
1158                                        if st.can_undo() {
1159                                            st.undo();
1160                                            self.notify_text_change(fid, st.text.clone());
1161                                            self.request_redraw();
1162                                            return;
1163                                        }
1164                                    }
1165                                }
1166                            }
1167                            _ => {}
1168                        }
1169                    }
1170
1171                    // --- Delegate all generic keyboard dispatch to the runtime ---
1172
1173                    let mapped = rc::map_key(key_event.physical_key);
1174                    let ke = winit_key_to_repose(&key_event, &mapped, &self.rt.modifiers);
1175                    let consumed = self.rt.handle_key(&ke);
1176                    if consumed {
1177                        self.request_redraw();
1178                        return;
1179                    }
1180
1181                    // --- Platform-specific text input (winit key_event.text) ---
1182                    // The runtime handles text via Key::Character, but we ALSO try
1183                    // winit's composed `key_event.text` for proper IME-less input
1184                    // on international keyboard layouts.
1185                    if key_event.state == ElementState::Pressed
1186                        && !key_event.repeat
1187                        && !self.rt.ime_preedit
1188                        && !self.rt.modifiers.ctrl
1189                        && !self.rt.modifiers.alt
1190                        && !self.rt.modifiers.meta
1191                        && let Some(raw) = key_event.text.as_deref()
1192                    {
1193                        let text: String = raw
1194                            .chars()
1195                            .filter(|c| !c.is_control() && *c != '\n' && *c != '\r')
1196                            .collect();
1197                        if !text.is_empty()
1198                            && let Some(fid) = self.rt.sched.focused
1199                        {
1200                            let key = self.tf_key_of(fid);
1201                            if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1202                                let mut st = state_rc.borrow_mut();
1203                                st.insert_text(&text);
1204                                self.notify_text_change(fid, st.text.clone());
1205                                if let Some(f) = &self.rt.frame_cache
1206                                    && let Some(hit) =
1207                                        f.hit_regions.iter().find(|h| h.id == fid)
1208                                {
1209                                    App::tf_ensure_caret_visible(&mut st, hit.tf_multiline);
1210                                }
1211                                self.request_redraw();
1212                                return;
1213                            }
1214                        }
1215                    }
1216
1217                    // --- A11y: keyboard activation announcement ---
1218                    if key_event.state == ElementState::Released
1219                        && let Some(active_id) = self.rt.key_pressed_active
1220                    {
1221                        match key_event.physical_key {
1222                            PhysicalKey::Code(KeyCode::Space)
1223                            | PhysicalKey::Code(KeyCode::Enter) => {
1224                                if let Some(f) = &self.rt.frame_cache
1225                                    && let Some(node) =
1226                                        f.semantics_nodes.iter().find(|n| n.id == active_id)
1227                                {
1228                                    let label = node.label.as_deref().unwrap_or("");
1229                                    self.a11y.announce(&format!("Activated {}", label));
1230                                }
1231                            }
1232                            _ => {}
1233                        }
1234                    }
1235                }
1236
1237                WindowEvent::Ime(ime) => {
1238                    if let Some(focused_id) = self.rt.sched.focused {
1239                        let key = self.tf_key_of(focused_id);
1240                        if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1241                            let mut state = state_rc.borrow_mut();
1242                            let on_text_change = self.rt
1243                                .frame_cache
1244                                .as_ref()
1245                                .and_then(|f| f.hit_regions.iter().find(|h| h.id == focused_id))
1246                                .and_then(|h| h.on_text_change.clone());
1247                            let mut notify = |text: String| {
1248                                if let Some(cb) = &on_text_change {
1249                                    cb(text);
1250                                }
1251                            };
1252                            rc_android::handle_ime_event(
1253                                ime,
1254                                &mut state,
1255                                &mut notify,
1256                                &mut self.rt.ime_preedit,
1257                            );
1258                            self.request_redraw();
1259                        }
1260                    }
1261                }
1262
1263                WindowEvent::RedrawRequested => {
1264                    // 1. Check our redraw flag before processing a11y.
1265                    if !self.redraw_requested.replace(false) {
1266                        self.process_a11y_actions();
1267                        self.process_render_commands();
1268                        // Present-only: redraw last cached scene with updated textures
1269                        if let (Some(backend), Some(frame)) =
1270                            (self.backend.as_mut(), self.rt.frame_cache.as_ref())
1271                        {
1272                            let scale = self.window.as_ref().map(|w| w.scale_factor() as f32).unwrap_or(1.0);
1273                            let mut scene = frame.scene.clone();
1274                            if let Some(inspector) = &mut self.inspector {
1275                                inspector.frame(&mut scene);
1276                            }
1277                            backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1278                        }
1279                        log::trace!("RedrawRequested: no frame request, skipping compose");
1280                        return;
1281                    }
1282                    log::trace!("RedrawRequested: frame request pending, composing");
1283
1284                    // 2. Process a11y actions and render commands before compose.
1285                    self.process_a11y_actions();
1286                    self.process_render_commands();
1287
1288                    let Some(win) = self.window.as_ref() else {
1289                        return;
1290                    };
1291                    if self.backend.is_none() {
1292                        return;
1293                    }
1294
1295                    // Advance animations before composition (Compose pattern).
1296                    // Mirrors broadcastFrameClock.sendFrame() before performRecompose().
1297                    repose_core::animation_driver::tick();
1298
1299                    let t0 = Instant::now();
1300                    let scale = win.scale_factor() as f32;
1301                    self.rt.scale = scale;
1302                    let focused = self.rt.sched.focused;
1303
1304                    let output = self.rt.frame(&mut self.root, &self.render);
1305
1306                    // Apply cursor from platform output
1307                    if let Some(cursor) = &output.platform.cursor {
1308                        win.set_cursor(winit::window::Cursor::Icon(map_cursor(*cursor)));
1309                    }
1310
1311                    // Apply IME state based on wants_keyboard
1312                    if !output.wants_keyboard && focused.is_some() && self.rt.sched.focused.is_none() && self.rt.ime_preedit {
1313                        rc_web::set_ime_for_textfield(win, false);
1314                        self.rt.ime_preedit = false;
1315                    }
1316
1317                    let frame = Frame {
1318                        scene: output.scene,
1319                        hit_regions: output.hit_regions,
1320                        semantics_nodes: output.semantics_nodes,
1321                        focus_chain: output.focus_chain,
1322                    };
1323
1324                    let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1325
1326                    // UPDATE ACCESSIBILITY TREE
1327                    if let Some(adapter) = &mut self.accesskit_adapter {
1328                        let win = self.window.as_ref().unwrap();
1329                        let scale = win.scale_factor();
1330                        if let Some(update) =
1331                            self.a11y_tree
1332                                .update(&frame.semantics_nodes, scale, self.rt.sched.focused)
1333                        {
1334                            adapter.update_if_active(|| update);
1335                        }
1336                    }
1337
1338                    // Render
1339                    let mut scene = frame.scene.clone();
1340                    // Update HUD metrics before overlay draws
1341                    if let Some(inspector) = &mut self.inspector {
1342                        let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1343                        let signal_count = self.rt.sched.id_count() as usize;
1344                        let ls = repose_ui::last_layout_stats();
1345                        inspector.hud.metrics = Some(repose_devtools::Metrics {
1346                            build_ms: build_layout_ms,
1347                            layout_ms: ls.layout_time_ms,
1348                            paint_ms: ls.paint_time_ms,
1349                            scene_nodes: scene.nodes.len(),
1350                            widget_count,
1351                            signal_count,
1352                            taffy_created: ls.taffy_created,
1353                            taffy_reused: ls.taffy_reused,
1354                            layout_hits: ls.layout_hits,
1355                            layout_misses: ls.layout_misses,
1356                            paint_cache_hits: ls.paint_cache_hits,
1357                            paint_cache_misses: ls.paint_cache_misses,
1358                            paint_culled: ls.paint_culled,
1359                        });
1360                        inspector.frame(&mut scene);
1361                    }
1362
1363                    // Drag indicator overlay (internal + file drop)
1364                    repose_core::dnd::overlay_drag_indicator(
1365                        &mut scene,
1366                        self.rt.mouse_pos_px,
1367                        self.external_file_drag,
1368                    );
1369
1370                    // Drain upload commands queued during compose (e.g. VideoSink set_image_*)
1371                    // before presenting to avoid 1-frame GPU texture lag.
1372                    self.process_render_commands();
1373
1374                    // Now borrow backend mutably only for the frame() call
1375                    let win = self.window.as_ref().unwrap();
1376                    let scale = win.scale_factor() as f32;
1377                    if let Some(backend) = self.backend.as_mut() {
1378                        backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1379                    }
1380
1381                    // Initialize TextFieldState for any focused TextField that
1382                    // doesn't have one yet (e.g. after FocusRequester::request_focus)
1383                    if let Some(fid) = self.rt.sched.focused {
1384                        if let Some(hit) = frame.hit_regions.iter().find(|h| h.id == fid)
1385                            && let Some(key) = hit.tf_state_key
1386                            && !self.rt.textfield_states.contains_key(&key)
1387                        {
1388                            self.rt.textfield_states
1389                                .entry(key)
1390                                .or_insert_with(|| {
1391                                    Rc::new(RefCell::new(repose_ui::TextFieldState::new()))
1392                                })
1393                                .borrow_mut()
1394                                .reset_caret_blink();
1395                        }
1396                    }
1397
1398                    self.rt.reconcile_hover_from_mouse_pos(&frame);
1399                    repose_core::dnd::set_dnd_frame(Some(frame.clone()));
1400                    self.rt.frame_cache = Some(frame);
1401                    repose_core::dnd::set_dnd_scale(scale);
1402
1403                    self.dispatch_file_drop_now();
1404
1405                    rc::tick_snackbar(self.last_redraw);
1406                    self.last_redraw = Instant::now();
1407                }
1408
1409                _ => {}
1410            }
1411        }
1412
1413        fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1414            // Process cross-thread commands (e.g. tray toggles, deeplinks) before any
1415            // redraw check, so hide/show commands work even when hidden
1416            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1417            if let Some(cb) = ABOUT_TO_WAIT_CALLBACK.lock().unwrap().as_ref() {
1418                cb();
1419            }
1420            process_deeplinks();
1421
1422            // On Wayland, wgpu creates an xdg_surface from the winit window and it shouldn't be recreated with a new id?
1423            // It doesn't take a lot of resources anyway, so let the backend be present.
1424            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1425            if WINDOW_VISIBLE.load(Ordering::Relaxed) && self.backend.is_none() {
1426                if let Some(w) = &self.window {
1427                    log::info!("about_to_wait: recreating GPU backend");
1428                    match repose_render_wgpu::WgpuBackend::new_with_options(
1429                        w.clone(),
1430                        self.msaa_samples,
1431                        self.present_mode,
1432                    ) {
1433                        Ok(b) => self.backend = Some(b),
1434                        Err(e) => log::error!("about_to_wait: failed to recreate backend: {e:?}"),
1435                    }
1436                }
1437            }
1438
1439            let needs_compose = take_frame_request();
1440            let needs_present = take_present_request();
1441
1442            if needs_compose {
1443                self.pending_redraw = true;
1444            }
1445
1446            // Present-only: texture was updated, redraw last cached scene without compose.
1447            if !self.pending_redraw && needs_present && self.rt.frame_cache.is_some() {
1448                let now = Instant::now();
1449                let interval = self.frame_interval();
1450                if now.saturating_duration_since(self.last_redraw) >= interval {
1451                    rc::request_redraw(&self.window);
1452                    self.last_redraw = now;
1453                } else {
1454                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1455                        self.last_redraw + interval,
1456                    ));
1457                }
1458                return;
1459            }
1460
1461            if !self.pending_redraw {
1462                let now = Instant::now();
1463                let idle_cap = web_time::Duration::from_millis(1000);
1464                let deadline = self
1465                    .next_caret_blink_deadline()
1466                    .unwrap_or(now + idle_cap);
1467
1468                if now.saturating_duration_since(self.last_redraw) >= idle_cap || now >= deadline {
1469                    self.redraw_requested.set(true);
1470                    request_frame();
1471                    rc::request_redraw(&self.window);
1472                    self.last_redraw = now;
1473                }
1474                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1475                    Ord::min(deadline, now + idle_cap),
1476                ));
1477                return;
1478            }
1479
1480            let now = Instant::now();
1481            let interval = self.frame_interval();
1482
1483            if now.saturating_duration_since(self.last_redraw) >= interval {
1484                self.pending_redraw = false;
1485                self.redraw_requested.set(true);
1486                rc::request_redraw(&self.window);
1487                self.last_redraw = now;
1488            } else {
1489                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1490                    self.last_redraw + interval,
1491                ));
1492            }
1493        }
1494
1495        fn new_events(
1496            &mut self,
1497            _: &winit::event_loop::ActiveEventLoop,
1498            _: winit::event::StartCause,
1499        ) {
1500        }
1501        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1502            self.pending_redraw = true;
1503        }
1504        fn device_event(
1505            &mut self,
1506            _: &winit::event_loop::ActiveEventLoop,
1507            _: winit::event::DeviceId,
1508            _: winit::event::DeviceEvent,
1509        ) {
1510        }
1511        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1512        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1513        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1514    }
1515
1516    impl App {
1517        /// Dispatch a key event through the focus ancestor chain.
1518        /// Returns true if the event was consumed by a handler.
1519        fn dispatch_focus_key_event(
1520            &self,
1521            key_event: &winit::event::KeyEvent,
1522            mapped_key: &repose_core::input::Key,
1523        ) -> bool {
1524            let Some(f) = &self.rt.frame_cache else {
1525                return false;
1526            };
1527            let Some(focused) = self.rt.sched.focused else {
1528                return false;
1529            };
1530            let utf16 = match mapped_key {
1531                repose_core::input::Key::Character(c) => *c as u16,
1532                _ => 0,
1533            };
1534            let mods = self.rt.modifiers;
1535            let repeat = key_event.repeat;
1536            let ev_type = if key_event.state == ElementState::Pressed {
1537                repose_core::input::KeyEventType::Down
1538            } else {
1539                repose_core::input::KeyEventType::Up
1540            };
1541            let hit_by_id: std::collections::HashMap<u64, &HitRegion> =
1542                f.hit_regions.iter().map(|h| (h.id, h)).collect();
1543            let sem_parent_of: std::collections::HashMap<u64, u64> = f
1544                .semantics_nodes
1545                .iter()
1546                .filter_map(|n| n.parent.map(|p| (n.id, p)))
1547                .collect();
1548            let mut ancestors = Vec::new();
1549            let mut cur = focused;
1550            loop {
1551                ancestors.push(cur);
1552                if let Some(&p) = sem_parent_of.get(&cur) {
1553                    cur = p;
1554                } else {
1555                    break;
1556                }
1557            }
1558            let make_ke = || repose_core::input::KeyEvent {
1559                key: mapped_key.clone(),
1560                modifiers: mods,
1561                is_repeat: repeat,
1562                event_type: ev_type,
1563                utf16_code_point: utf16,
1564            };
1565            // Top-down preview: root → focused
1566            for &id in ancestors.iter().rev() {
1567                if let Some(hit) = hit_by_id.get(&id) {
1568                    if let Some(cb) = &hit.on_preview_key_event {
1569                        if cb(make_ke()) {
1570                            return true;
1571                        }
1572                    }
1573                }
1574            }
1575            // Bottom-up normal: focused → root
1576            for &id in ancestors.iter() {
1577                if let Some(hit) = hit_by_id.get(&id) {
1578                    if let Some(cb) = &hit.on_key_event {
1579                        if cb(make_ke()) {
1580                            return true;
1581                        }
1582                    }
1583                }
1584            }
1585            false
1586        }
1587
1588        fn announce_focus_change(&mut self) {
1589            if let Some(f) = &self.rt.frame_cache {
1590                let focused_node = self.rt
1591                    .sched
1592                    .focused
1593                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1594                self.a11y.focus_changed(focused_node);
1595            }
1596        }
1597
1598        fn notify_text_change(&self, id: u64, text: String) {
1599            if let Some(f) = &self.rt.frame_cache
1600                && let Some(h) = f.hit_regions.iter().find(|h| h.id == id)
1601                && let Some(cb) = &h.on_text_change
1602            {
1603                cb(text);
1604            }
1605        }
1606
1607        fn tf_key_of(&self, visual_id: u64) -> u64 {
1608            rc::tf_key_of_in_frame(&self.rt.frame_cache, visual_id)
1609        }
1610
1611        /// If a text field is focused with a collapsed selection (caret blinking),
1612        /// return the [`Instant`] of the next 500 ms blink edge.
1613        fn next_caret_blink_deadline(&self) -> Option<Instant> {
1614            next_caret_blink_deadline(&self.rt.sched, &self.rt.frame_cache, &self.rt.textfield_states)
1615        }
1616
1617        fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
1618            use repose_core::shortcuts;
1619
1620            if let (Some(f), Some(fid)) = (&self.rt.frame_cache, self.rt.sched.focused)
1621                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
1622                && let Some(cb) = &hit.on_action
1623                && cb(action.clone())
1624            {
1625                return true;
1626            }
1627
1628            if shortcuts::handle(action.clone()) {
1629                return true;
1630            }
1631
1632            // Focus navigation (Tab/arrows)
1633            if let Some(f) = &self.rt.frame_cache {
1634                if let Some(new_id) = repose_core::focus::handle_action(&action, &mut self.rt.sched, f)
1635                {
1636                    if let Some(active) = self.rt.key_pressed_active.take() {
1637                        self.rt.pressed_ids.remove(&active);
1638                    }
1639                    let tf_state_key = f
1640                        .hit_regions
1641                        .iter()
1642                        .find(|h| h.id == new_id)
1643                        .and_then(|h| h.tf_state_key);
1644                    if let Some(key) = tf_state_key {
1645                        self.rt.textfield_states.entry(key).or_insert_with(|| {
1646                            Rc::new(RefCell::new(repose_ui::TextFieldState::new()))
1647                        });
1648                        if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1649                            state_rc.borrow_mut().reset_caret_blink();
1650                        }
1651                    }
1652                    if let Some(win) = &self.window {
1653                        let is_textfield = f.semantics_nodes.iter().any(|n| {
1654                            n.id == new_id && n.role == repose_core::semantics::Role::TextField
1655                        });
1656                        rc_web::set_ime_for_textfield(win, is_textfield);
1657                    }
1658                    self.announce_focus_change();
1659                    return true;
1660                }
1661            }
1662
1663            false
1664        }
1665
1666        fn dispatch_file_drop_now(&mut self) {
1667            let Some(f) = &self.rt.frame_cache else {
1668                self.pending_dropped_files.clear();
1669                self.pending_drop_pos_px = None;
1670                return;
1671            };
1672
1673            if self.pending_dropped_files.is_empty() {
1674                return;
1675            }
1676
1677            let pos_px = self.pending_drop_pos_px.unwrap_or(self.rt.mouse_pos_px);
1678            let pos = Vec2 {
1679                x: pos_px.0,
1680                y: pos_px.1,
1681            };
1682
1683            let mut files = Vec::new();
1684            for p in self.pending_dropped_files.drain(..) {
1685                let name = p
1686                    .file_name()
1687                    .and_then(|s| s.to_str())
1688                    .unwrap_or("file")
1689                    .to_string();
1690                files.push(repose_core::dnd::DroppedFile {
1691                    name,
1692                    path: Some(p),
1693                });
1694            }
1695
1696            let payload: repose_core::dnd::DragPayload =
1697                std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1698
1699            let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1700                self.pending_drop_pos_px = None;
1701                return;
1702            };
1703
1704            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1705                && let Some(cb) = &hit.on_drop
1706            {
1707                let accepted = cb(repose_core::dnd::DropEvent {
1708                    source_id: 0, // external source (OS)
1709                    target_id,
1710                    position: pos,
1711                    modifiers: self.rt.modifiers,
1712                    payload: payload.clone(),
1713                });
1714
1715                if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
1716                {
1717                    let label = node.label.as_deref().unwrap_or("");
1718                    self.a11y.announce(&format!("Dropped files on {}", label));
1719                }
1720            }
1721
1722            self.pending_drop_pos_px = None;
1723            self.request_redraw();
1724        }
1725    }
1726
1727    let event_loop = EventLoop::new()?;
1728    set_event_loop_proxy(event_loop.create_proxy());
1729    let mut app = App::new(Box::new(root), config);
1730    // Install system clock once
1731    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1732    event_loop.run_app(&mut app)?;
1733    Ok(())
1734}
1735
1736// Accessibility bridge stub (Noop by default; logs on Linux for now)
1737/// Bridge from Repose's semantics tree to platform accessibility APIs.
1738///
1739/// Implementations are responsible for:
1740/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
1741/// - Updating focus when `focus_changed` is called.
1742/// - Announcing transient messages (e.g. button activation) via screen readers.
1743pub trait A11yBridge: Send {
1744    /// Publish (or update) the full semantics tree for the current frame.
1745    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1746
1747    /// Notify that the focused node has changed. `None` means focus cleared.
1748    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1749
1750    /// Announce a one‑off message via the platform's accessibility channel.
1751    fn announce(&mut self, msg: &str);
1752}
1753
1754struct NoopA11y;
1755impl A11yBridge for NoopA11y {
1756    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1757        // no-op
1758    }
1759    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1760        if let Some(n) = node {
1761            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1762        } else {
1763            log::info!("A11y focus: None");
1764        }
1765    }
1766    fn announce(&mut self, msg: &str) {
1767        log::info!("A11y announce: {msg}");
1768    }
1769}
1770
1771#[cfg(target_os = "linux")]
1772struct LinuxAtspiStub;
1773#[cfg(target_os = "linux")]
1774impl A11yBridge for LinuxAtspiStub {
1775    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1776        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1777    }
1778    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1779        if let Some(n) = node {
1780            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1781        } else {
1782            log::info!("AT-SPI stub focus: None");
1783        }
1784    }
1785    fn announce(&mut self, msg: &str) {
1786        log::info!("AT-SPI stub announce: {msg}");
1787    }
1788}