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