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