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                        log::trace!("RedrawRequested: no frame request, skipping compose");
1067                        return;
1068                    }
1069                    log::trace!("RedrawRequested: frame request pending, composing");
1070
1071                    // 2. Process a11y actions and render commands before compose.
1072                    self.process_a11y_actions();
1073                    self.process_render_commands();
1074
1075                    let Some(win) = self.window.as_ref() else {
1076                        return;
1077                    };
1078                    if self.backend.is_none() {
1079                        return;
1080                    }
1081
1082                    // Advance animations before composition (Compose pattern).
1083                    // Mirrors broadcastFrameClock.sendFrame() before performRecompose().
1084                    repose_core::animation_driver::tick();
1085
1086                    let t0 = Instant::now();
1087                    let scale = win.scale_factor() as f32;
1088                    self.rt.scale = scale;
1089                    let focused = self.rt.sched.focused;
1090
1091                    let output = self.rt.frame(&mut self.root, &self.render);
1092
1093                    // Apply cursor from platform output
1094                    if let Some(cursor) = &output.platform.cursor {
1095                        win.set_cursor(winit::window::Cursor::Icon(map_cursor(*cursor)));
1096                    }
1097
1098                    // Apply IME state based on wants_keyboard
1099                    if !output.wants_keyboard && focused.is_some() && self.rt.sched.focused.is_none() && self.rt.ime_preedit {
1100                        rc_web::set_ime_for_textfield(win, false);
1101                        self.rt.ime_preedit = false;
1102                    }
1103
1104                    let frame = Frame {
1105                        scene: output.scene,
1106                        hit_regions: output.hit_regions,
1107                        semantics_nodes: output.semantics_nodes,
1108                        focus_chain: output.focus_chain,
1109                    };
1110
1111                    let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1112
1113                    // UPDATE ACCESSIBILITY TREE
1114                    if let Some(adapter) = &mut self.accesskit_adapter {
1115                        let win = self.window.as_ref().unwrap();
1116                        let scale = win.scale_factor();
1117                        if let Some(update) =
1118                            self.a11y_tree
1119                                .update(&frame.semantics_nodes, scale, self.rt.sched.focused)
1120                        {
1121                            adapter.update_if_active(|| update);
1122                        }
1123                    }
1124
1125                    // Render
1126                    let mut scene = frame.scene.clone();
1127                    // Update HUD metrics before overlay draws
1128                    let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1129                    let signal_count = self.rt.sched.id_count() as usize;
1130                    self.inspector.hud.metrics = Some(repose_devtools::Metrics {
1131                        build_ms: build_layout_ms,
1132                        layout_ms: build_layout_ms * 0.5,
1133                        scene_nodes: scene.nodes.len(),
1134                        widget_count,
1135                        signal_count,
1136                    });
1137                    self.inspector.frame(&mut scene);
1138
1139                    // Drag indicator overlay (internal + file drop)
1140                    repose_core::dnd::overlay_drag_indicator(
1141                        &mut scene,
1142                        self.rt.mouse_pos_px,
1143                        self.external_file_drag,
1144                    );
1145
1146                    // Now borrow backend mutably only for the frame() call
1147                    let win = self.window.as_ref().unwrap();
1148                    let scale = win.scale_factor() as f32;
1149                    if let Some(backend) = self.backend.as_mut() {
1150                        backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1151                    }
1152
1153                    // Initialize TextFieldState for any focused TextField that
1154                    // doesn't have one yet (e.g. after FocusRequester::request_focus)
1155                    if let Some(fid) = self.rt.sched.focused {
1156                        if let Some(hit) = frame.hit_regions.iter().find(|h| h.id == fid)
1157                            && let Some(key) = hit.tf_state_key
1158                            && !self.rt.textfield_states.contains_key(&key)
1159                        {
1160                            self.rt.textfield_states
1161                                .entry(key)
1162                                .or_insert_with(|| {
1163                                    Rc::new(RefCell::new(repose_ui::TextFieldState::new()))
1164                                })
1165                                .borrow_mut()
1166                                .reset_caret_blink();
1167                        }
1168                    }
1169
1170                    repose_core::dnd::set_dnd_frame(Some(frame.clone()));
1171                    self.rt.frame_cache = Some(frame);
1172                    repose_core::dnd::set_dnd_scale(scale);
1173
1174                    self.dispatch_file_drop_now();
1175
1176                    rc::tick_snackbar(self.last_redraw);
1177                    self.last_redraw = Instant::now();
1178                }
1179
1180                _ => {}
1181            }
1182        }
1183
1184        fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1185            // Process cross-thread commands (e.g. tray toggles, deeplinks) before any
1186            // redraw check, so hide/show commands work even when hidden
1187            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1188            if let Some(cb) = ABOUT_TO_WAIT_CALLBACK.lock().unwrap().as_ref() {
1189                cb();
1190            }
1191            process_deeplinks();
1192
1193            // On Wayland, wgpu creates an xdg_surface from the winit window and it shouldn't be recreated with a new id?
1194            // It doesn't take a lot of resources anyway, so let the backend be present.
1195            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1196            if WINDOW_VISIBLE.load(Ordering::Relaxed) && self.backend.is_none() {
1197                if let Some(w) = &self.window {
1198                    log::info!("about_to_wait: recreating GPU backend");
1199                    match repose_render_wgpu::WgpuBackend::new(w.clone()) {
1200                        Ok(b) => self.backend = Some(b),
1201                        Err(e) => log::error!("about_to_wait: failed to recreate backend: {e:?}"),
1202                    }
1203                }
1204            }
1205
1206            if take_frame_request() {
1207                self.pending_redraw = true;
1208            }
1209            if !self.pending_redraw {
1210                let now = Instant::now();
1211                let idle_cap = web_time::Duration::from_millis(1000);
1212                let deadline = self
1213                    .next_caret_blink_deadline()
1214                    .unwrap_or(now + idle_cap);
1215
1216                if now.saturating_duration_since(self.last_redraw) >= idle_cap || now >= deadline {
1217                    self.redraw_requested.set(true);
1218                    request_frame();
1219                    rc::request_redraw(&self.window);
1220                    self.last_redraw = now;
1221                }
1222                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1223                    Ord::min(deadline, now + idle_cap),
1224                ));
1225                return;
1226            }
1227
1228            let now = Instant::now();
1229            let interval = web_time::Duration::from_millis(16);
1230
1231            if now.saturating_duration_since(self.last_redraw) >= interval {
1232                self.pending_redraw = false;
1233                self.redraw_requested.set(true);
1234                rc::request_redraw(&self.window);
1235                self.last_redraw = now;
1236            } else {
1237                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1238                    self.last_redraw + interval,
1239                ));
1240            }
1241        }
1242
1243        fn new_events(
1244            &mut self,
1245            _: &winit::event_loop::ActiveEventLoop,
1246            _: winit::event::StartCause,
1247        ) {
1248        }
1249        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1250            self.pending_redraw = true;
1251        }
1252        fn device_event(
1253            &mut self,
1254            _: &winit::event_loop::ActiveEventLoop,
1255            _: winit::event::DeviceId,
1256            _: winit::event::DeviceEvent,
1257        ) {
1258        }
1259        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1260        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1261        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1262    }
1263
1264    impl App {
1265        /// Dispatch a key event through the focus ancestor chain.
1266        /// Returns true if the event was consumed by a handler.
1267        fn dispatch_focus_key_event(
1268            &self,
1269            key_event: &winit::event::KeyEvent,
1270            mapped_key: &repose_core::input::Key,
1271        ) -> bool {
1272            let Some(f) = &self.rt.frame_cache else {
1273                return false;
1274            };
1275            let Some(focused) = self.rt.sched.focused else {
1276                return false;
1277            };
1278            let utf16 = match mapped_key {
1279                repose_core::input::Key::Character(c) => *c as u16,
1280                _ => 0,
1281            };
1282            let mods = self.rt.modifiers;
1283            let repeat = key_event.repeat;
1284            let ev_type = if key_event.state == ElementState::Pressed {
1285                repose_core::input::KeyEventType::Down
1286            } else {
1287                repose_core::input::KeyEventType::Up
1288            };
1289            let hit_by_id: std::collections::HashMap<u64, &HitRegion> =
1290                f.hit_regions.iter().map(|h| (h.id, h)).collect();
1291            let sem_parent_of: std::collections::HashMap<u64, u64> = f
1292                .semantics_nodes
1293                .iter()
1294                .filter_map(|n| n.parent.map(|p| (n.id, p)))
1295                .collect();
1296            let mut ancestors = Vec::new();
1297            let mut cur = focused;
1298            loop {
1299                ancestors.push(cur);
1300                if let Some(&p) = sem_parent_of.get(&cur) {
1301                    cur = p;
1302                } else {
1303                    break;
1304                }
1305            }
1306            let make_ke = || repose_core::input::KeyEvent {
1307                key: mapped_key.clone(),
1308                modifiers: mods,
1309                is_repeat: repeat,
1310                event_type: ev_type,
1311                utf16_code_point: utf16,
1312            };
1313            // Top-down preview: root → focused
1314            for &id in ancestors.iter().rev() {
1315                if let Some(hit) = hit_by_id.get(&id) {
1316                    if let Some(cb) = &hit.on_preview_key_event {
1317                        if cb(make_ke()) {
1318                            return true;
1319                        }
1320                    }
1321                }
1322            }
1323            // Bottom-up normal: focused → root
1324            for &id in ancestors.iter() {
1325                if let Some(hit) = hit_by_id.get(&id) {
1326                    if let Some(cb) = &hit.on_key_event {
1327                        if cb(make_ke()) {
1328                            return true;
1329                        }
1330                    }
1331                }
1332            }
1333            false
1334        }
1335
1336        fn announce_focus_change(&mut self) {
1337            if let Some(f) = &self.rt.frame_cache {
1338                let focused_node = self.rt
1339                    .sched
1340                    .focused
1341                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1342                self.a11y.focus_changed(focused_node);
1343            }
1344        }
1345
1346        fn notify_text_change(&self, id: u64, text: String) {
1347            if let Some(f) = &self.rt.frame_cache
1348                && let Some(h) = f.hit_regions.iter().find(|h| h.id == id)
1349                && let Some(cb) = &h.on_text_change
1350            {
1351                cb(text);
1352            }
1353        }
1354
1355        fn tf_key_of(&self, visual_id: u64) -> u64 {
1356            rc::tf_key_of_in_frame(&self.rt.frame_cache, visual_id)
1357        }
1358
1359        /// If a text field is focused with a collapsed selection (caret blinking),
1360        /// return the [`Instant`] of the next 500 ms blink edge.
1361        fn next_caret_blink_deadline(&self) -> Option<Instant> {
1362            next_caret_blink_deadline(&self.rt.sched, &self.rt.frame_cache, &self.rt.textfield_states)
1363        }
1364
1365        fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
1366            use repose_core::shortcuts;
1367
1368            if let (Some(f), Some(fid)) = (&self.rt.frame_cache, self.rt.sched.focused)
1369                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
1370                && let Some(cb) = &hit.on_action
1371                && cb(action.clone())
1372            {
1373                return true;
1374            }
1375
1376            if shortcuts::handle(action.clone()) {
1377                return true;
1378            }
1379
1380            // Focus navigation (Tab/arrows)
1381            if let Some(f) = &self.rt.frame_cache {
1382                if let Some(new_id) = repose_core::focus::handle_action(&action, &mut self.rt.sched, f)
1383                {
1384                    if let Some(active) = self.rt.key_pressed_active.take() {
1385                        self.rt.pressed_ids.remove(&active);
1386                    }
1387                    let tf_state_key = f
1388                        .hit_regions
1389                        .iter()
1390                        .find(|h| h.id == new_id)
1391                        .and_then(|h| h.tf_state_key);
1392                    if let Some(key) = tf_state_key {
1393                        self.rt.textfield_states.entry(key).or_insert_with(|| {
1394                            Rc::new(RefCell::new(repose_ui::TextFieldState::new()))
1395                        });
1396                        if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1397                            state_rc.borrow_mut().reset_caret_blink();
1398                        }
1399                    }
1400                    if let Some(win) = &self.window {
1401                        let is_textfield = f.semantics_nodes.iter().any(|n| {
1402                            n.id == new_id && n.role == repose_core::semantics::Role::TextField
1403                        });
1404                        rc_web::set_ime_for_textfield(win, is_textfield);
1405                    }
1406                    self.announce_focus_change();
1407                    return true;
1408                }
1409            }
1410
1411            false
1412        }
1413
1414        fn dispatch_file_drop_now(&mut self) {
1415            let Some(f) = &self.rt.frame_cache else {
1416                self.pending_dropped_files.clear();
1417                self.pending_drop_pos_px = None;
1418                return;
1419            };
1420
1421            if self.pending_dropped_files.is_empty() {
1422                return;
1423            }
1424
1425            let pos_px = self.pending_drop_pos_px.unwrap_or(self.rt.mouse_pos_px);
1426            let pos = Vec2 {
1427                x: pos_px.0,
1428                y: pos_px.1,
1429            };
1430
1431            let mut files = Vec::new();
1432            for p in self.pending_dropped_files.drain(..) {
1433                let name = p
1434                    .file_name()
1435                    .and_then(|s| s.to_str())
1436                    .unwrap_or("file")
1437                    .to_string();
1438                files.push(repose_core::dnd::DroppedFile {
1439                    name,
1440                    path: Some(p),
1441                });
1442            }
1443
1444            let payload: repose_core::dnd::DragPayload =
1445                std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1446
1447            let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1448                self.pending_drop_pos_px = None;
1449                return;
1450            };
1451
1452            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1453                && let Some(cb) = &hit.on_drop
1454            {
1455                let accepted = cb(repose_core::dnd::DropEvent {
1456                    source_id: 0, // external source (OS)
1457                    target_id,
1458                    position: pos,
1459                    modifiers: self.rt.modifiers,
1460                    payload: payload.clone(),
1461                });
1462
1463                if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
1464                {
1465                    let label = node.label.as_deref().unwrap_or("");
1466                    self.a11y.announce(&format!("Dropped files on {}", label));
1467                }
1468            }
1469
1470            self.pending_drop_pos_px = None;
1471            self.request_redraw();
1472        }
1473    }
1474
1475    let event_loop = EventLoop::new()?;
1476    set_event_loop_proxy(event_loop.create_proxy());
1477    let mut app = App::new(Box::new(root));
1478    // Install system clock once
1479    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1480    event_loop.run_app(&mut app)?;
1481    Ok(())
1482}
1483
1484// Accessibility bridge stub (Noop by default; logs on Linux for now)
1485/// Bridge from Repose's semantics tree to platform accessibility APIs.
1486///
1487/// Implementations are responsible for:
1488/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
1489/// - Updating focus when `focus_changed` is called.
1490/// - Announcing transient messages (e.g. button activation) via screen readers.
1491pub trait A11yBridge: Send {
1492    /// Publish (or update) the full semantics tree for the current frame.
1493    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1494
1495    /// Notify that the focused node has changed. `None` means focus cleared.
1496    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1497
1498    /// Announce a one‑off message via the platform's accessibility channel.
1499    fn announce(&mut self, msg: &str);
1500}
1501
1502struct NoopA11y;
1503impl A11yBridge for NoopA11y {
1504    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1505        // no-op
1506    }
1507    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1508        if let Some(n) = node {
1509            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1510        } else {
1511            log::info!("A11y focus: None");
1512        }
1513    }
1514    fn announce(&mut self, msg: &str) {
1515        log::info!("A11y announce: {msg}");
1516    }
1517}
1518
1519#[cfg(target_os = "linux")]
1520struct LinuxAtspiStub;
1521#[cfg(target_os = "linux")]
1522impl A11yBridge for LinuxAtspiStub {
1523    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1524        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1525    }
1526    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1527        if let Some(n) = node {
1528            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1529        } else {
1530            log::info!("AT-SPI stub focus: None");
1531        }
1532    }
1533    fn announce(&mut self, msg: &str) {
1534        log::info!("AT-SPI stub announce: {msg}");
1535    }
1536}