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, 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
224/// Helper: ensure caret visibility for a TextFieldState inside a given rect (px).
225pub fn tf_ensure_visible_in_rect(state: &mut repose_ui::TextFieldState, inner_rect: Rect) {
226    let font_px = dp_to_px(TF_FONT_DP) * repose_core::locals::text_scale().0;
227    let m = measure_text(&state.text, font_px, None, 400, 0);
228    let caret_x_px = m.positions.get(state.caret_index()).copied().unwrap_or(0.0);
229    state.ensure_caret_visible(
230        caret_x_px,
231        inner_rect.w - 2.0 * dp_to_px(TF_PADDING_X_DP),
232        dp_to_px(2.0),
233    );
234}
235
236#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
237fn map_cursor(c: repose_core::CursorIcon) -> winit::window::CursorIcon {
238    use winit::window::CursorIcon as W;
239    match c {
240        repose_core::CursorIcon::Default => W::Default,
241        repose_core::CursorIcon::Pointer => W::Pointer,
242        repose_core::CursorIcon::Text => W::Text,
243        repose_core::CursorIcon::EwResize => W::EwResize,
244        repose_core::CursorIcon::NsResize => W::NsResize,
245        repose_core::CursorIcon::Grab => W::Grab,
246        repose_core::CursorIcon::Grabbing => W::Grabbing,
247    }
248}
249
250#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
251pub fn run_desktop_app(
252    root: impl FnMut(&mut Scheduler, &RenderContext) -> View + 'static,
253) -> anyhow::Result<()> {
254    use std::collections::{HashMap, HashSet};
255    use winit::application::ApplicationHandler;
256    use winit::dpi::{LogicalPosition, LogicalSize, PhysicalSize};
257    use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
258    use winit::event_loop::EventLoop;
259    use winit::keyboard::{KeyCode, PhysicalKey};
260    use winit::window::{Window, WindowAttributes};
261
262    use crate::a11y::A11yTree;
263
264    struct ReposeActivationHandler {
265        initial_tree: Option<accesskit::TreeUpdate>,
266    }
267
268    impl accesskit::ActivationHandler for ReposeActivationHandler {
269        fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
270            self.initial_tree.take()
271        }
272    }
273
274    struct ReposeDeactivationHandler;
275
276    impl accesskit::DeactivationHandler for ReposeDeactivationHandler {
277        fn deactivate_accessibility(&mut self) {
278            // Nothing to clean up for now
279        }
280    }
281
282    struct App {
283        root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
284        render: RenderContext,
285        window: Option<Arc<Window>>,
286        backend: Option<repose_render_wgpu::WgpuBackend>,
287        sched: Scheduler,
288        inspector: repose_devtools::Inspector,
289        frame_cache: Option<Frame>,
290        mouse_pos_px: (f32, f32),
291        modifiers: Modifiers,
292        textfield_states: HashMap<u64, Rc<RefCell<TextFieldState>>>,
293        ime_preedit: bool,
294        hover_id: Option<u64>,
295        capture_id: Option<u64>,
296        pressed_ids: HashSet<u64>,
297
298        // Files
299        pending_dropped_files: Vec<std::path::PathBuf>,
300        pending_drop_pos_px: Option<(f32, f32)>,
301
302        // External file drag hover (HoveredFile / Cancelled)
303        external_file_drag: bool,
304        hovered_files: Vec<std::path::PathBuf>,
305
306        key_pressed_active: Option<u64>,
307        clipboard: Option<clipawl::Clipboard>,
308        a11y: Box<dyn A11yBridge>,
309        last_focus: Option<u64>,
310
311        accesskit_adapter: Option<Adapter>,
312        a11y_actions: Arc<Mutex<Vec<accesskit::ActionRequest>>>,
313        a11y_tree: A11yTree,
314
315        last_redraw: Instant,
316        pending_redraw: bool,
317
318        // Tracks whether a redraw was requested by app code
319        redraw_requested: Cell<bool>,
320    }
321
322    impl App {
323        fn process_a11y_actions(&mut self) {
324            let mut actions = self.a11y_actions.lock().unwrap();
325            if actions.is_empty() {
326                return;
327            }
328            let pending = actions.drain(..).collect::<Vec<_>>();
329            drop(actions);
330
331            let Some(f) = &self.frame_cache else {
332                return;
333            };
334
335            for req in pending {
336                let target_id = req.target_node.0;
337                match req.action {
338                    accesskit::Action::Click => {
339                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
340                            && let Some(cb) = &hit.on_click
341                        {
342                            cb();
343                            self.request_redraw();
344                        }
345                    }
346                    accesskit::Action::Focus => {
347                        self.sched.focused = Some(target_id);
348                        self.request_redraw();
349                    }
350                    _ => {}
351                }
352            }
353        }
354
355        fn new(root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>) -> Self {
356            Self {
357                root,
358                render: RenderContext::new(),
359                window: None,
360                backend: None,
361                sched: Scheduler::new(),
362                inspector: repose_devtools::Inspector::new(),
363                frame_cache: None,
364                mouse_pos_px: (0.0, 0.0),
365                modifiers: Modifiers::default(),
366                textfield_states: HashMap::new(),
367                ime_preedit: false,
368                hover_id: None,
369                capture_id: None,
370                pressed_ids: HashSet::new(),
371                pending_dropped_files: Vec::new(),
372                pending_drop_pos_px: None,
373
374                external_file_drag: false,
375                hovered_files: Vec::new(),
376
377                key_pressed_active: None,
378                clipboard: None,
379                a11y: {
380                    #[cfg(target_os = "linux")]
381                    {
382                        Box::new(LinuxAtspiStub) as Box<dyn A11yBridge>
383                    }
384                    #[cfg(not(target_os = "linux"))]
385                    {
386                        Box::new(NoopA11y) as Box<dyn A11yBridge>
387                    }
388                },
389                last_focus: None,
390
391                accesskit_adapter: None,
392                a11y_actions: Arc::new(Mutex::new(Vec::new())),
393                a11y_tree: A11yTree::default(),
394
395                last_redraw: Instant::now(),
396                pending_redraw: false,
397                redraw_requested: Cell::new(false),
398            }
399        }
400
401        fn request_redraw(&self) {
402            self.redraw_requested.set(true);
403            repose_core::request_frame();
404            rc::request_redraw(&self.window);
405        }
406
407        // Ensure caret is visible after edits/moves (all units in px)
408        fn tf_ensure_caret_visible(st: &mut TextFieldState, is_multiline: bool) {
409            rc::tf_ensure_caret_visible(st, is_multiline);
410        }
411
412        fn paste_from_primary(&self) -> Option<String> {
413            let opts = clipawl::ClipboardOptions {
414                linux: clipawl::LinuxOptions {
415                    selection: clipawl::LinuxSelection::Primary,
416                    ..Default::default()
417                },
418            };
419            if let Ok(cb) = clipawl::Clipboard::new_with_options(opts) {
420                match pollster::block_on(cb.read()) {
421                    Ok(t) => Some(t),
422                    Err(e) => {
423                        eprintln!("Primary paste error: {}", e);
424                        None
425                    }
426                }
427            } else {
428                None
429            }
430        }
431
432        fn process_render_commands(&mut self) {
433            let Some(backend) = self.backend.as_mut() else {
434                return;
435            };
436            rc::process_render_commands(backend, self.render.drain());
437        }
438
439        fn reset_pointer_state(&mut self) {
440            self.capture_id = None;
441            self.pressed_ids.clear();
442            self.hover_id = None;
443        }
444
445        fn is_textfield(&self, id: u64) -> bool {
446            rc::is_textfield_in_frame(&self.frame_cache, id)
447        }
448
449        fn is_multiline_id(&self, id: u64) -> bool {
450            if let Some(f) = &self.frame_cache {
451                f.hit_regions
452                    .iter()
453                    .find(|h| h.id == id)
454                    .map(|h| h.tf_multiline)
455                    .unwrap_or(false)
456            } else {
457                false
458            }
459        }
460
461        fn hit_by_id(f: &Frame, id: u64) -> Option<&HitRegion> {
462            f.hit_regions.iter().find(|h| h.id == id)
463        }
464
465        fn padding_px(&self) -> f32 {
466            dp_to_px(TF_PADDING_X_DP)
467        }
468
469        fn dp_px(&self, dp: f32) -> f32 {
470            dp_to_px(dp)
471        }
472    }
473
474    impl ApplicationHandler<()> for App {
475        fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
476            self.clipboard = clipawl::Clipboard::new()
477                .map_err(|e| {
478                    eprintln!("clipawl clipboard init failed: {e}");
479                    e
480                })
481                .ok();
482            repose_core::clipboard::set_clipboard_read_fn(Box::new(|| {
483                clipawl::blocking::read().ok()
484            }));
485            // Register for SelectableText (Ctrl+C) - use blocking API directly
486            repose_core::clipboard::set_clipboard_fn(Box::new(move |text| {
487                if let Err(e) = clipawl::blocking::write(text) {
488                    eprintln!("clipboard write error: {e}");
489                }
490            }));
491
492            repose_core::clipboard::set_primary_fn(Box::new(|text| {
493                let opts = clipawl::ClipboardOptions {
494                    linux: clipawl::LinuxOptions {
495                        selection: clipawl::LinuxSelection::Primary,
496                        ..Default::default()
497                    },
498                };
499                match clipawl::Clipboard::new_with_options(opts) {
500                    Ok(cb) => {
501                        if let Err(e) = pollster::block_on(cb.write(text)) {
502                            eprintln!("primary selection write error: {e}");
503                        }
504                    }
505                    Err(e) => eprintln!("primary clipboard init error: {e}"),
506                }
507            }));
508
509            if self.window.is_none() {
510                match el.create_window(
511                    WindowAttributes::default()
512                        .with_title("Repose")
513                        .with_inner_size(PhysicalSize::new(1280, 800))
514                        .with_visible(false),
515                ) {
516                    Ok(win) => {
517                        let w = Arc::new(win);
518
519                        let activation_handler = ReposeActivationHandler {
520                            initial_tree: Some(A11yTree::initial_tree()),
521                        };
522
523                        let action_handler = ReposeActionHandler {
524                            pending_actions: self.a11y_actions.clone(),
525                        };
526
527                        let deactivation_handler = ReposeDeactivationHandler;
528
529                        let adapter = Adapter::with_direct_handlers(
530                            el,
531                            &w,
532                            activation_handler,
533                            action_handler,
534                            deactivation_handler,
535                        );
536
537                        self.accesskit_adapter = Some(adapter);
538
539                        w.set_visible(true);
540
541                        let size = w.inner_size();
542                        self.sched.size = (size.width, size.height);
543
544                        match repose_render_wgpu::WgpuBackend::new(w.clone()) {
545                            Ok(b) => {
546                                self.backend = Some(b);
547                                set_app_window(w.clone());
548                                self.window = Some(w);
549                                self.request_redraw();
550                            }
551                            Err(e) => {
552                                log::error!("Failed to create WGPU backend: {e:?}");
553                                el.exit();
554                            }
555                        }
556                    }
557                    Err(e) => {
558                        log::error!("Failed to create window: {e:?}");
559                        el.exit();
560                    }
561                }
562            }
563        }
564
565        fn window_event(
566            &mut self,
567            el: &winit::event_loop::ActiveEventLoop,
568            _id: winit::window::WindowId,
569            event: WindowEvent,
570        ) {
571            // Process AccessKit events first!
572            if let Some(adapter) = &mut self.accesskit_adapter {
573                adapter.process_event(self.window.as_ref().unwrap(), &event);
574            }
575
576            match event {
577                WindowEvent::CloseRequested => {
578                    if CLOSE_TO_TRAY.load(Ordering::Relaxed) {
579                        // Drop GPU backend before null-buffer unmap.
580                        self.backend = None;
581                        if let Some(w) = &self.window {
582                            w.set_visible(false);
583                        }
584                        WINDOW_VISIBLE.store(false, Ordering::Relaxed);
585                    } else {
586                        el.exit();
587                    }
588                }
589
590                WindowEvent::Focused(false) => {
591                    // Cancel any active drag operation
592                    repose_core::dnd::handle_drag_action(
593                        &repose_core::shortcuts::DragAction::Cancel,
594                    );
595
596                    // Emit interaction Cancel for the captured hit region
597                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
598                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
599                            if let Some(cb) = &hit.on_pointer_cancel {
600                                let pos = repose_core::Vec2 {
601                                    x: self.mouse_pos_px.0,
602                                    y: self.mouse_pos_px.1,
603                                };
604                                let pe = PointerEvent::new(
605                                    PointerId(0),
606                                    PointerKind::Mouse,
607                                    PointerEventKind::Cancel,
608                                    pos,
609                                    1.0,
610                                    self.modifiers,
611                                );
612                                cb(pe);
613                            }
614                        }
615                    }
616
617                    // Defensive reset: Wayland/KDE can "eat" releases during DnD.
618                    self.external_file_drag = false;
619                    self.hovered_files.clear();
620                    self.reset_pointer_state();
621
622                    if let Some(w) = &self.window {
623                        rc_web::set_ime_for_textfield(w, false);
624                    }
625                    self.ime_preedit = false;
626
627                    self.request_redraw();
628                }
629
630                WindowEvent::HoveredFile(path) => {
631                    // Mark external drag active and keep a small bounded list
632                    self.external_file_drag = true;
633                    if self.hovered_files.len() < 32 {
634                        self.hovered_files.push(path);
635                    }
636                    // Update drop position (best effort)
637                    if self.pending_drop_pos_px.is_none() {
638                        self.pending_drop_pos_px = Some(self.mouse_pos_px);
639                    }
640                    self.request_redraw();
641                }
642
643                WindowEvent::HoveredFileCancelled => {
644                    self.external_file_drag = false;
645                    self.hovered_files.clear();
646
647                    // Defensive: cancel any internal capture/drag that might be left stuck
648                    self.reset_pointer_state();
649
650                    self.request_redraw();
651                }
652
653                WindowEvent::DroppedFile(path) => {
654                    // DroppedFile is emitted once per file. Batch them.
655                    self.pending_dropped_files.push(path);
656                    if self.pending_drop_pos_px.is_none() {
657                        self.pending_drop_pos_px = Some(self.mouse_pos_px);
658                    }
659
660                    // Drop ends the external file drag session.
661                    self.external_file_drag = false;
662                    self.hovered_files.clear();
663
664                    self.request_redraw();
665                }
666
667                WindowEvent::Resized(size) => {
668                    self.sched.size = (size.width, size.height);
669                    if let Some(b) = self.backend.as_mut() {
670                        b.configure_surface(size.width, size.height);
671                    }
672                    if let Some(w) = &self.window {
673                        let sf = w.scale_factor() as f32;
674                        let dp_w = size.width as f32 / sf;
675                        let dp_h = size.height as f32 / sf;
676                        log::info!(
677                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
678                            size.width,
679                            size.height,
680                            sf,
681                            dp_w as i32,
682                            dp_h as i32
683                        );
684                    }
685                    self.request_redraw();
686                }
687
688                WindowEvent::CursorMoved { position, .. } => {
689                    self.mouse_pos_px = (position.x as f32, position.y as f32);
690
691                    if self.external_file_drag {
692                        self.pending_drop_pos_px = Some(self.mouse_pos_px);
693                    }
694
695                    let pos = Vec2 {
696                        x: self.mouse_pos_px.0,
697                        y: self.mouse_pos_px.1,
698                    };
699
700                    if repose_core::dnd::handle_drag_action(
701                        &repose_core::shortcuts::DragAction::Move {
702                            position: pos,
703                            modifiers: self.modifiers,
704                        },
705                    ) {
706                        self.request_redraw();
707                        return;
708                    }
709
710                    // Inspector hover
711                    if self.inspector.hud.inspector_enabled
712                        && let Some(f) = &self.frame_cache
713                    {
714                        let hit = f.hit_regions.iter().find(|h| {
715                            h.rect.contains(Vec2 {
716                                x: self.mouse_pos_px.0,
717                                y: self.mouse_pos_px.1,
718                            })
719                        });
720                        let hover_rect = hit.map(|h| h.rect);
721                        let hover_info = hit.and_then(|h| {
722                            f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
723                                repose_devtools::HoveredInfo {
724                                    id: s.id,
725                                    role: format!("{:?}", s.role),
726                                    label: s.label.clone(),
727                                }
728                            })
729                        });
730                        self.inspector.hud.set_hovered(hover_rect, hover_info);
731                        self.request_redraw();
732                    }
733
734                    // TextField/TextArea drag selection (if captured)
735                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id)
736                        && self.is_textfield(cid)
737                        && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
738                    {
739                        let key = self.tf_key_of(cid);
740                        if let Some(state_rc) = self.textfield_states.get(&key) {
741                            let mut st = state_rc.borrow_mut();
742
743                            let pad_x = dp_to_px(TF_PADDING_X_DP);
744                            let inner_x = hit.rect.x + pad_x;
745                            let inner_y = hit.rect.y + dp_to_px(8.0);
746                            let inner_w = (hit.rect.w - 2.0 * pad_x).max(1.0);
747                            let inner_h = (hit.rect.h - dp_to_px(16.0)).max(1.0);
748
749                            st.set_inner_width(inner_w);
750                            st.set_inner_height(inner_h);
751
752                            let content_x =
753                                (self.mouse_pos_px.0 - inner_x + st.scroll_offset).max(0.0);
754                            let content_y =
755                                (self.mouse_pos_px.1 - inner_y + st.scroll_offset_y).max(0.0);
756
757                            let font_px =
758                                dp_to_px(TF_FONT_DP) * repose_core::locals::text_scale().0;
759
760                            let idx = if hit.tf_multiline {
761                                rc::index_for_xy_bytes_vt(
762                                    &st, font_px, inner_w, content_x, content_y,
763                                )
764                            } else {
765                                rc::index_for_x_bytes_vt(&st, font_px, content_x)
766                            };
767
768                            st.drag_to(idx);
769
770                            // Ensure caret visible
771                            if hit.tf_multiline {
772                                let (cx, cy, _) =
773                                    caret_xy_for_byte(&st.text, font_px, inner_w, st.caret_index());
774                                st.ensure_caret_visible_xy(cx, cy, inner_w, inner_h, dp_to_px(2.0));
775                            } else {
776                                let m = measure_text(&st.text, font_px, None, 400, 0);
777                                let cx = m.positions.get(st.caret_index()).copied().unwrap_or(0.0);
778                                st.ensure_caret_visible(cx, inner_w, dp_to_px(2.0));
779                            }
780
781                            self.request_redraw();
782                        }
783                    }
784
785                    // Pointer routing: hover + move/capture
786                    if let Some(f) = &self.frame_cache {
787                        // Determine topmost hit
788                        let pos = Vec2 {
789                            x: self.mouse_pos_px.0,
790                            y: self.mouse_pos_px.1,
791                        };
792                        let top = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos));
793
794                        // Update cursor icon based on hit
795                        if let Some(win) = &self.window {
796                            let c = top
797                                .and_then(|h| h.cursor)
798                                .unwrap_or(repose_core::CursorIcon::Default);
799                            win.set_cursor(winit::window::Cursor::Icon(map_cursor(c)));
800                        }
801
802                        let new_hover = top.map(|h| h.id);
803
804                        // Enter/Leave
805                        if new_hover != self.hover_id {
806                            if let Some(prev_id) = self.hover_id
807                                && let Some(prev) = f.hit_regions.iter().find(|h| h.id == prev_id)
808                                && let Some(cb) = &prev.on_pointer_leave
809                            {
810                                let pe = PointerEvent::new(
811                                    PointerId(0),
812                                    PointerKind::Mouse,
813                                    PointerEventKind::Leave,
814                                    pos,
815                                    1.0,
816                                    self.modifiers,
817                                );
818                                cb(pe);
819                            }
820                            if let Some(h) = top
821                                && let Some(cb) = &h.on_pointer_enter
822                            {
823                                let pe = PointerEvent::new(
824                                    PointerId(0),
825                                    PointerKind::Mouse,
826                                    PointerEventKind::Enter,
827                                    pos,
828                                    1.0,
829                                    self.modifiers,
830                                );
831                                cb(pe);
832                            }
833                            self.hover_id = new_hover;
834                            self.request_redraw();
835                        }
836
837                        // Build PointerEvent
838                        let pe = PointerEvent::new(
839                            PointerId(0),
840                            PointerKind::Mouse,
841                            PointerEventKind::Move,
842                            pos,
843                            1.0,
844                            self.modifiers,
845                        );
846
847                        // Move delivery (captured first)
848                        if let Some(cid) = self.capture_id {
849                            if let Some(h) = f.hit_regions.iter().find(|h| h.id == cid)
850                                && let Some(cb) = &h.on_pointer_move
851                            {
852                                cb(pe.clone());
853                            }
854                        } else if let Some(h) = &top
855                            && let Some(cb) = &h.on_pointer_move
856                        {
857                            cb(pe);
858                        }
859                    }
860                }
861
862                WindowEvent::MouseWheel { delta, .. } => {
863                    // Convert line deltas (logical) to px; pixel delta is already px
864                    let (dx_px, dy_px) = match delta {
865                        MouseScrollDelta::LineDelta(x, y) => {
866                            let unit_px = dp_to_px(60.0);
867                            (-(x * unit_px), -(y * unit_px))
868                        }
869                        MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
870                    };
871                    log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
872
873                    if let Some(f) = &self.frame_cache {
874                        let pos = Vec2 {
875                            x: self.mouse_pos_px.0,
876                            y: self.mouse_pos_px.1,
877                        };
878
879                        if rc::dispatch_scroll(f, pos, Vec2 { x: dx_px, y: dy_px }, None).0 {
880                            self.request_redraw();
881                        }
882                    }
883                }
884
885                WindowEvent::MouseInput {
886                    state: ElementState::Pressed,
887                    button: MouseButton::Left,
888                    ..
889                } => {
890                    let mut need_announce = false;
891                    if let Some(f) = &self.frame_cache {
892                        let pos = Vec2 {
893                            x: self.mouse_pos_px.0,
894                            y: self.mouse_pos_px.1,
895                        };
896                        if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
897                        {
898                            repose_core::dnd::handle_drag_action(
899                                &repose_core::shortcuts::DragAction::Press {
900                                    position: Vec2 {
901                                        x: self.mouse_pos_px.0,
902                                        y: self.mouse_pos_px.1,
903                                    },
904                                    capture_id: hit.id,
905                                    kind: repose_core::input::PointerKind::Mouse,
906                                    modifiers: self.modifiers,
907                                },
908                            );
909
910                            // Capture starts on press
911                            self.capture_id = Some(hit.id);
912
913                            // Text input caret placement + begin drag selection
914                            if self.is_textfield(hit.id) {
915                                let key = self.tf_key_of(hit.id);
916                                self.textfield_states.entry(key).or_insert_with(|| {
917                                    Rc::new(RefCell::new(TextFieldState::new()))
918                                });
919                                if let Some(st_rc) = self.textfield_states.get(&key) {
920                                    let mut st = st_rc.borrow_mut();
921                                    let pad = self.padding_px();
922                                    let inner_x = hit.rect.x + pad;
923                                    let inner_y = hit.rect.y + self.dp_px(8.0);
924                                    let content_x =
925                                        (self.mouse_pos_px.0 - inner_x + st.scroll_offset).max(0.0);
926                                    let content_y = (self.mouse_pos_px.1 - inner_y
927                                        + st.scroll_offset_y)
928                                        .max(0.0);
929                                    let font_px = self.dp_px(TF_FONT_DP)
930                                        * repose_core::locals::text_scale().0;
931
932                                    let idx = if hit.tf_multiline {
933                                        rc::index_for_xy_bytes_vt(
934                                            &st,
935                                            font_px,
936                                            hit.rect.w - 2.0 * pad,
937                                            content_x,
938                                            content_y,
939                                        )
940                                    } else {
941                                        rc::index_for_x_bytes_vt(&st, font_px, content_x)
942                                    };
943
944                                    st.begin_drag(idx, self.modifiers.shift);
945
946                                    // Ensure caret visible
947                                    let caret_idx = st.caret_index();
948                                    let iw = st.inner_width;
949                                    let ih = st.inner_height;
950                                    let wrap_w = hit.rect.w - 2.0 * pad;
951                                    if hit.tf_multiline {
952                                        let (cx, cy, _) = textfield::caret_xy_for_byte(
953                                            &st.text, font_px, wrap_w, caret_idx,
954                                        );
955                                        st.ensure_caret_visible_xy(cx, cy, iw, ih, self.dp_px(2.0));
956                                    } else {
957                                        let m = measure_text(&st.text, font_px, None, 400, 0);
958                                        let cx = m.positions.get(caret_idx).copied().unwrap_or(0.0);
959                                        st.ensure_caret_visible(cx, iw, self.dp_px(2.0));
960                                    }
961                                }
962                            }
963                            // Pressed visual for mouse
964                            self.pressed_ids.insert(hit.id);
965                            // Repaint for pressed state
966                            self.request_redraw();
967
968                            // Focus & IME first for focusables (so state exists)
969                            if hit.focusable {
970                                self.sched.focused = Some(hit.id);
971                                need_announce = true;
972                                let key = self.tf_key_of(hit.id);
973                                self.textfield_states.entry(key).or_insert_with(|| {
974                                    Rc::new(RefCell::new(TextFieldState::new()))
975                                });
976                                if let Some(win) = &self.window {
977                                    let sf = win.scale_factor();
978                                    rc_web::set_ime_for_textfield(win, true);
979                                    win.set_ime_cursor_area(
980                                        LogicalPosition::new(
981                                            hit.rect.x as f64 / sf,
982                                            hit.rect.y as f64 / sf,
983                                        ),
984                                        LogicalSize::new(
985                                            hit.rect.w as f64 / sf,
986                                            hit.rect.h as f64 / sf,
987                                        ),
988                                    );
989                                }
990                            }
991
992                            // PointerDown callback (legacy)
993                            if let Some(cb) = &hit.on_pointer_down {
994                                let pe = PointerEvent::new(
995                                    PointerId(0),
996                                    PointerKind::Mouse,
997                                    PointerEventKind::Down(PointerButton::Primary),
998                                    pos,
999                                    1.0,
1000                                    self.modifiers,
1001                                );
1002                                cb(pe);
1003                            }
1004
1005                            if need_announce {
1006                                self.announce_focus_change();
1007                            }
1008
1009                            self.request_redraw();
1010                        } else {
1011                            // Click outside: drop focus/IME
1012                            if self.ime_preedit {
1013                                if let Some(win) = &self.window {
1014                                    rc_web::set_ime_for_textfield(win, false);
1015                                }
1016                                self.ime_preedit = false;
1017                            }
1018                            self.sched.focused = None;
1019                            self.request_redraw();
1020                        }
1021                    }
1022                }
1023
1024                WindowEvent::MouseInput {
1025                    state: ElementState::Pressed,
1026                    button: MouseButton::Middle,
1027                    ..
1028                } => {
1029                    let Some(f) = &self.frame_cache else {
1030                        return;
1031                    };
1032                    let pos = Vec2 {
1033                        x: self.mouse_pos_px.0,
1034                        y: self.mouse_pos_px.1,
1035                    };
1036                    if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos)) {
1037                        // Dispatch Tertiary pointer event
1038                        if let Some(cb) = &hit.on_pointer_down {
1039                            cb(PointerEvent::new(
1040                                PointerId(0),
1041                                PointerKind::Mouse,
1042                                PointerEventKind::Down(PointerButton::Tertiary),
1043                                pos,
1044                                1.0,
1045                                self.modifiers,
1046                            ));
1047                        }
1048                        // Paste primary selection into textfield
1049                        if self.is_textfield(hit.id) {
1050                            let key = self.tf_key_of(hit.id);
1051                            if let Some(state_rc) = self.textfield_states.get(&key) {
1052                                if let Some(txt) = self.paste_from_primary() {
1053                                    let mut st = state_rc.borrow_mut();
1054                                    st.insert_text_atomic(&txt);
1055                                    self.notify_text_change(hit.id, st.text.clone());
1056                                    if let Some(f) = &self.frame_cache
1057                                        && let Some(h) =
1058                                            f.hit_regions.iter().find(|h| h.id == hit.id)
1059                                    {
1060                                        App::tf_ensure_caret_visible(&mut st, h.tf_multiline);
1061                                    }
1062                                }
1063                            }
1064                        }
1065                    }
1066                    self.request_redraw();
1067                }
1068
1069                WindowEvent::MouseInput {
1070                    state: ElementState::Released,
1071                    button: MouseButton::Left,
1072                    ..
1073                } => {
1074                    let pos = Vec2 {
1075                        x: self.mouse_pos_px.0,
1076                        y: self.mouse_pos_px.1,
1077                    };
1078
1079                    if repose_core::dnd::handle_drag_action(
1080                        &repose_core::shortcuts::DragAction::Release {
1081                            position: pos,
1082                            modifiers: self.modifiers,
1083                        },
1084                    ) {
1085                        self.capture_id = None;
1086                        self.pressed_ids.clear();
1087                        repose_core::request_frame();
1088                        return;
1089                    }
1090
1091                    if let Some(cid) = self.capture_id {
1092                        self.pressed_ids.remove(&cid);
1093                        self.request_redraw();
1094                    }
1095
1096                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id)
1097                        && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
1098                        && let Some(cb) = &hit.on_pointer_up
1099                    {
1100                        let pos = Vec2 {
1101                            x: self.mouse_pos_px.0,
1102                            y: self.mouse_pos_px.1,
1103                        };
1104                        let pe = PointerEvent::new(
1105                            PointerId(0),
1106                            PointerKind::Mouse,
1107                            PointerEventKind::Up(PointerButton::Primary),
1108                            pos,
1109                            1.0,
1110                            self.modifiers,
1111                        );
1112                        cb(pe);
1113                    }
1114
1115                    // Click on release if pointer is still over the captured hit region
1116                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
1117                        let pos = Vec2 {
1118                            x: self.mouse_pos_px.0,
1119                            y: self.mouse_pos_px.1,
1120                        };
1121                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
1122                            && hit.rect.contains(pos)
1123                            && let Some(cb) = &hit.on_click
1124                        {
1125                            cb();
1126                            // A11y: announce activation (mouse)
1127                            if let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid) {
1128                                let label = node.label.as_deref().unwrap_or("");
1129                                self.a11y.announce(&format!("Activated {}", label));
1130                            }
1131                        }
1132                    }
1133                    // TextField drag end
1134                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id)
1135                        && let Some(_sem) = f
1136                            .semantics_nodes
1137                            .iter()
1138                            .find(|n| n.id == cid && n.role == Role::TextField)
1139                    {
1140                        let key = self.tf_key_of(cid);
1141                        if let Some(state_rc) = self.textfield_states.get(&key) {
1142                            state_rc.borrow_mut().end_drag();
1143                        }
1144                    }
1145
1146                    self.capture_id = None;
1147
1148                    repose_core::request_frame();
1149                }
1150
1151                WindowEvent::MouseInput {
1152                    state: ElementState::Released,
1153                    button: MouseButton::Middle,
1154                    ..
1155                } => {
1156                    if let Some(f) = &self.frame_cache {
1157                        let pos = Vec2 {
1158                            x: self.mouse_pos_px.0,
1159                            y: self.mouse_pos_px.1,
1160                        };
1161                        if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
1162                        {
1163                            if let Some(cb) = &hit.on_pointer_up {
1164                                cb(PointerEvent::new(
1165                                    PointerId(0),
1166                                    PointerKind::Mouse,
1167                                    PointerEventKind::Up(PointerButton::Tertiary),
1168                                    pos,
1169                                    1.0,
1170                                    self.modifiers,
1171                                ));
1172                            }
1173                        }
1174                    }
1175                }
1176
1177                WindowEvent::ModifiersChanged(new_mods) => {
1178                    rc::update_modifiers(&mut self.modifiers, &new_mods.state());
1179                }
1180
1181                WindowEvent::KeyboardInput {
1182                    event: key_event, ..
1183                } => {
1184                    let mapped_key = rc::map_key(key_event.physical_key);
1185
1186                    if key_event.state == ElementState::Pressed && !key_event.repeat {
1187                        match key_event.physical_key {
1188                            PhysicalKey::Code(KeyCode::BrowserBack)
1189                            | PhysicalKey::Code(KeyCode::Escape) => {
1190                                use repose_navigation::back;
1191
1192                                if repose_core::dnd::handle_drag_action(
1193                                    &repose_core::shortcuts::DragAction::Cancel,
1194                                ) {
1195                                    return;
1196                                }
1197
1198                                // Try dispatching Escape through the focus chain first
1199                                // (allows dialog on_key_event to intercept Escape)
1200                                if self.dispatch_focus_key_event(&key_event, &mapped_key) {
1201                                    self.request_redraw();
1202                                    return;
1203                                }
1204
1205                                if !back::handle() {
1206                                    // el.exit();
1207                                }
1208                                return;
1209                            }
1210                            _ => {}
1211                        }
1212                    }
1213
1214                    // Dispatch key event through focus ancestor chain (Compose-compatible)
1215                    let utf16 = match mapped_key {
1216                        repose_core::input::Key::Character(c) => c as u16,
1217                        _ => 0,
1218                    };
1219                    let mods = self.modifiers;
1220                    let repeat = key_event.repeat;
1221                    let ev_type = if key_event.state == ElementState::Pressed {
1222                        repose_core::input::KeyEventType::Down
1223                    } else {
1224                        repose_core::input::KeyEventType::Up
1225                    };
1226                    let consumed = self
1227                        .frame_cache
1228                        .as_ref()
1229                        .and_then(|f| {
1230                            let focused = self.sched.focused.or_else(|| {
1231                                f.semantics_nodes
1232                                    .iter()
1233                                    .find(|n| n.parent.is_none())
1234                                    .map(|n| n.id)
1235                            })?;
1236                            let sem_parent_of: std::collections::HashMap<u64, u64> = f
1237                                .semantics_nodes
1238                                .iter()
1239                                .filter_map(|n| n.parent.map(|p| (n.id, p)))
1240                                .collect();
1241                            let hit_by_id: std::collections::HashMap<u64, &HitRegion> =
1242                                f.hit_regions.iter().map(|h| (h.id, h)).collect();
1243                            let mut ancestors = Vec::new();
1244                            let mut cur = focused;
1245                            loop {
1246                                ancestors.push(cur);
1247                                if let Some(&p) = sem_parent_of.get(&cur) {
1248                                    cur = p;
1249                                } else {
1250                                    break;
1251                                }
1252                            }
1253                            let make_ke = || repose_core::input::KeyEvent {
1254                                key: mapped_key.clone(),
1255                                modifiers: mods,
1256                                is_repeat: repeat,
1257                                event_type: ev_type,
1258                                utf16_code_point: utf16,
1259                            };
1260                            // Top-down preview: root → focused
1261                            for &id in ancestors.iter().rev() {
1262                                if let Some(hit) = hit_by_id.get(&id) {
1263                                    if let Some(cb) = &hit.on_preview_key_event {
1264                                        if cb(make_ke()) {
1265                                            return Some(true);
1266                                        }
1267                                    }
1268                                }
1269                            }
1270                            // Bottom-up normal: focused → root
1271                            for &id in ancestors.iter() {
1272                                if let Some(hit) = hit_by_id.get(&id) {
1273                                    if let Some(cb) = &hit.on_key_event {
1274                                        if cb(make_ke()) {
1275                                            return Some(true);
1276                                        }
1277                                    }
1278                                }
1279                            }
1280                            None
1281                        })
1282                        .unwrap_or(false);
1283                    if consumed {
1284                        self.request_redraw();
1285                        return;
1286                    }
1287
1288                    if key_event.state == ElementState::Pressed
1289                        && let Some(action) = repose_core::shortcuts::resolve_action(
1290                            repose_core::shortcuts::KeyChord::new(mapped_key, self.modifiers),
1291                        )
1292                        && self.dispatch_action(action)
1293                    {
1294                        self.request_redraw();
1295                        return;
1296                    }
1297
1298                    if let Some(fid) = self.sched.focused {
1299                        // If focused is NOT a TextField, allow Space/Enter activation
1300                        let is_textfield = if let Some(f) = &self.frame_cache {
1301                            f.semantics_nodes
1302                                .iter()
1303                                .any(|n| n.id == fid && n.role == Role::TextField)
1304                        } else {
1305                            false
1306                        };
1307
1308                        if !is_textfield {
1309                            match key_event.physical_key {
1310                                PhysicalKey::Code(KeyCode::Space)
1311                                | PhysicalKey::Code(KeyCode::Enter) => {
1312                                    if key_event.state == ElementState::Pressed && !key_event.repeat
1313                                    {
1314                                        self.pressed_ids.insert(fid);
1315                                        self.key_pressed_active = Some(fid);
1316                                        self.request_redraw();
1317                                        return;
1318                                    }
1319                                }
1320                                _ => {}
1321                            }
1322                        }
1323                    }
1324
1325                    // Keyboard activation for focused TextField submit on Enter
1326                    // For multiline: Ctrl+Enter or Cmd+Enter submits, plain Enter inserts newline
1327                    // For single-line: Enter submits
1328                    if key_event.state == ElementState::Pressed
1329                        && !key_event.repeat
1330                        && let PhysicalKey::Code(KeyCode::Enter) = key_event.physical_key
1331                        && let Some(focused_id) = self.sched.focused
1332                        && let Some(f) = &self.frame_cache
1333                        && let Some(hit) = f.hit_regions.iter().find(|h| h.id == focused_id)
1334                    {
1335                        let is_multiline = hit.tf_multiline;
1336                        let should_submit = if is_multiline {
1337                            // Multiline: Ctrl+Enter or Cmd+Enter submits
1338                            self.modifiers.ctrl || self.modifiers.meta
1339                        } else {
1340                            // Single-line: Enter always submits
1341                            true
1342                        };
1343
1344                        if should_submit {
1345                            if let Some(on_submit) = &hit.on_text_submit {
1346                                let key = self.tf_key_of(focused_id);
1347                                if let Some(state) = self.textfield_states.get(&key) {
1348                                    let text = state.borrow().text.clone();
1349                                    on_submit(text);
1350                                    self.request_redraw();
1351                                    return;
1352                                }
1353                            }
1354                        } else {
1355                            // Multiline with plain Enter: insert newline
1356                            let key = self.tf_key_of(focused_id);
1357                            if let Some(state_rc) = self.textfield_states.get(&key) {
1358                                let mut st = state_rc.borrow_mut();
1359                                st.insert_text("\n");
1360                                let new_text = st.text.clone();
1361                                self.notify_text_change(focused_id, new_text);
1362                                App::tf_ensure_caret_visible(&mut st, hit.tf_multiline);
1363                                self.request_redraw();
1364                                return;
1365                            }
1366                        }
1367                    }
1368
1369                    if key_event.state == ElementState::Pressed {
1370                        // Inspector hotkey: Ctrl+Shift+I
1371                        if self.modifiers.ctrl
1372                            && self.modifiers.shift
1373                            && let PhysicalKey::Code(KeyCode::KeyI) = key_event.physical_key
1374                        {
1375                            self.inspector.hud.toggle_inspector();
1376                            self.request_redraw();
1377                            return;
1378                        }
1379
1380                        // TextField navigation/edit
1381                        if let Some(focused_id) = self.sched.focused {
1382                            let key = self.tf_key_of(focused_id);
1383                            if let Some(state_rc) = self.textfield_states.get(&key) {
1384                                let mut state = state_rc.borrow_mut();
1385                                match key_event.physical_key {
1386                                    PhysicalKey::Code(KeyCode::Backspace) => {
1387                                        state.delete_backward();
1388                                        let new_text = state.text.clone();
1389                                        self.notify_text_change(focused_id, new_text);
1390                                        App::tf_ensure_caret_visible(
1391                                            &mut state,
1392                                            self.is_multiline_id(focused_id),
1393                                        );
1394                                        self.request_redraw();
1395                                    }
1396                                    PhysicalKey::Code(KeyCode::Delete) => {
1397                                        state.delete_forward();
1398                                        let new_text = state.text.clone();
1399                                        self.notify_text_change(focused_id, new_text);
1400                                        App::tf_ensure_caret_visible(
1401                                            &mut state,
1402                                            self.is_multiline_id(focused_id),
1403                                        );
1404                                        self.request_redraw();
1405                                    }
1406                                    PhysicalKey::Code(KeyCode::ArrowLeft) => {
1407                                        state.move_cursor(-1, self.modifiers.shift);
1408                                        state.preferred_x_px = None; // Reset preferred x on horizontal movement
1409                                        App::tf_ensure_caret_visible(
1410                                            &mut state,
1411                                            self.is_multiline_id(focused_id),
1412                                        );
1413                                        self.request_redraw();
1414                                    }
1415                                    PhysicalKey::Code(KeyCode::ArrowRight) => {
1416                                        state.move_cursor(1, self.modifiers.shift);
1417                                        state.preferred_x_px = None; // Reset preferred x on horizontal movement
1418                                        App::tf_ensure_caret_visible(
1419                                            &mut state,
1420                                            self.is_multiline_id(focused_id),
1421                                        );
1422                                        self.request_redraw();
1423                                    }
1424                                    PhysicalKey::Code(KeyCode::ArrowUp) => {
1425                                        if self.is_multiline_id(focused_id)
1426                                            && let Some(f) = &self.frame_cache
1427                                            && let Some(hit) =
1428                                                f.hit_regions.iter().find(|h| h.id == focused_id)
1429                                        {
1430                                            let font_px = dp_to_px(TF_FONT_DP);
1431                                            let pad = self.padding_px();
1432                                            let wrap_w = hit.rect.w - 2.0 * pad;
1433                                            let cur = state.caret_index();
1434                                            let (new_pos, px) =
1435                                                repose_ui::textfield::move_caret_vertical(
1436                                                    &state.text,
1437                                                    font_px,
1438                                                    wrap_w,
1439                                                    cur,
1440                                                    -1,
1441                                                    state.preferred_x_px,
1442                                                );
1443                                            if self.modifiers.shift {
1444                                                state.selection.end = new_pos;
1445                                            } else {
1446                                                state.selection = new_pos..new_pos;
1447                                            }
1448                                            state.preferred_x_px = Some(px);
1449                                            // Use multiline-aware caret visibility
1450                                            let (cx, cy, _) = caret_xy_for_byte(
1451                                                &state.text,
1452                                                font_px,
1453                                                wrap_w,
1454                                                state.caret_index(),
1455                                            );
1456                                            let iw = state.inner_width;
1457                                            let ih = state.inner_height;
1458                                            state.ensure_caret_visible_xy(
1459                                                cx,
1460                                                cy,
1461                                                iw,
1462                                                ih,
1463                                                self.dp_px(2.0),
1464                                            );
1465                                            self.request_redraw();
1466                                        }
1467                                    }
1468                                    PhysicalKey::Code(KeyCode::ArrowDown) => {
1469                                        if self.is_multiline_id(focused_id)
1470                                            && let Some(f) = &self.frame_cache
1471                                            && let Some(hit) =
1472                                                f.hit_regions.iter().find(|h| h.id == focused_id)
1473                                        {
1474                                            let font_px = dp_to_px(TF_FONT_DP);
1475                                            let pad = self.padding_px();
1476                                            let wrap_w = hit.rect.w - 2.0 * pad;
1477                                            let cur = state.caret_index();
1478                                            let (new_pos, px) =
1479                                                repose_ui::textfield::move_caret_vertical(
1480                                                    &state.text,
1481                                                    font_px,
1482                                                    wrap_w,
1483                                                    cur,
1484                                                    1,
1485                                                    state.preferred_x_px,
1486                                                );
1487                                            if self.modifiers.shift {
1488                                                state.selection.end = new_pos;
1489                                            } else {
1490                                                state.selection = new_pos..new_pos;
1491                                            }
1492                                            state.preferred_x_px = Some(px);
1493                                            // Use multiline-aware caret visibility
1494                                            let (cx, cy, _) = caret_xy_for_byte(
1495                                                &state.text,
1496                                                font_px,
1497                                                wrap_w,
1498                                                state.caret_index(),
1499                                            );
1500                                            let iw = state.inner_width;
1501                                            let ih = state.inner_height;
1502                                            state.ensure_caret_visible_xy(
1503                                                cx,
1504                                                cy,
1505                                                iw,
1506                                                ih,
1507                                                self.dp_px(2.0),
1508                                            );
1509                                            self.request_redraw();
1510                                        }
1511                                    }
1512                                    PhysicalKey::Code(KeyCode::Home) => {
1513                                        state.selection = 0..0;
1514                                        App::tf_ensure_caret_visible(
1515                                            &mut state,
1516                                            self.is_multiline_id(focused_id),
1517                                        );
1518                                        self.request_redraw();
1519                                    }
1520                                    PhysicalKey::Code(KeyCode::End) => {
1521                                        {
1522                                            let end = state.text.len();
1523                                            state.selection = end..end;
1524                                        }
1525                                        App::tf_ensure_caret_visible(
1526                                            &mut state,
1527                                            self.is_multiline_id(focused_id),
1528                                        );
1529                                        self.request_redraw();
1530                                    }
1531                                    _ => {}
1532                                }
1533                            }
1534                        }
1535
1536                        // Plain text input when IME is not active
1537                        if !self.ime_preedit
1538                            && !self.modifiers.ctrl
1539                            && !self.modifiers.alt
1540                            && !self.modifiers.meta
1541                            && let Some(raw) = key_event.text.as_deref()
1542                        {
1543                            let text: String = raw
1544                                .chars()
1545                                .filter(|c| !c.is_control() && *c != '\n' && *c != '\r')
1546                                .collect();
1547                            if !text.is_empty()
1548                                && let Some(fid) = self.sched.focused
1549                            {
1550                                let key = self.tf_key_of(fid);
1551                                if let Some(state_rc) = self.textfield_states.get(&key) {
1552                                    let mut st = state_rc.borrow_mut();
1553                                    st.insert_text(&text);
1554                                    self.notify_text_change(fid, st.text.clone());
1555                                    if let Some(f) = &self.frame_cache
1556                                        && let Some(hit) =
1557                                            f.hit_regions.iter().find(|h| h.id == fid)
1558                                    {
1559                                        App::tf_ensure_caret_visible(&mut st, hit.tf_multiline);
1560                                    }
1561                                    self.request_redraw();
1562                                }
1563                            }
1564                        }
1565                    } else if key_event.state == ElementState::Released {
1566                        // Finish keyboard activation on release (Space/Enter)
1567                        if let Some(active_id) = self.key_pressed_active {
1568                            match key_event.physical_key {
1569                                PhysicalKey::Code(KeyCode::Space)
1570                                | PhysicalKey::Code(KeyCode::Enter) => {
1571                                    self.pressed_ids.remove(&active_id);
1572                                    self.key_pressed_active = None;
1573
1574                                    if let Some(f) = &self.frame_cache
1575                                        && let Some(hit) =
1576                                            f.hit_regions.iter().find(|h| h.id == active_id)
1577                                    {
1578                                        if let Some(cb) = &hit.on_click {
1579                                            cb();
1580                                        } else if let Some(cb) = &hit.on_pointer_down {
1581                                            let pe = PointerEvent::new(
1582                                                PointerId(0),
1583                                                PointerKind::Mouse,
1584                                                PointerEventKind::Down(PointerButton::Primary),
1585                                                Vec2 { x: 0.0, y: 0.0 },
1586                                                1.0,
1587                                                self.modifiers,
1588                                            );
1589                                            cb(pe);
1590                                        }
1591                                        if let Some(node) =
1592                                            f.semantics_nodes.iter().find(|n| n.id == active_id)
1593                                        {
1594                                            let label = node.label.as_deref().unwrap_or("");
1595                                            self.a11y.announce(&format!("Activated {}", label));
1596                                        }
1597                                    }
1598                                    self.request_redraw();
1599                                }
1600                                _ => {}
1601                            }
1602                        }
1603                    }
1604                }
1605
1606                WindowEvent::Ime(ime) => {
1607                    if let Some(focused_id) = self.sched.focused {
1608                        let key = self.tf_key_of(focused_id);
1609                        if let Some(state_rc) = self.textfield_states.get(&key)
1610                            && let Some(f) = &self.frame_cache
1611                            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == focused_id)
1612                        {
1613                            let mut state = state_rc.borrow_mut();
1614                            let hit_rect = hit.rect;
1615                            let on_text_change = hit.on_text_change.clone();
1616                            let mut notify = |text: String| {
1617                                if let Some(cb) = &on_text_change {
1618                                    cb(text);
1619                                }
1620                            };
1621                            rc_android::handle_ime_event(
1622                                ime,
1623                                &mut state,
1624                                hit_rect,
1625                                &mut notify,
1626                                &mut self.ime_preedit,
1627                            );
1628                            self.request_redraw();
1629                        }
1630                    }
1631                }
1632
1633                WindowEvent::RedrawRequested => {
1634                    // 1. Check our redraw flag before processing a11y.
1635                    if !self.redraw_requested.replace(false) {
1636                        self.process_a11y_actions();
1637                        self.process_render_commands();
1638                        log::trace!("RedrawRequested: no frame request, skipping compose");
1639                        return;
1640                    }
1641                    log::trace!("RedrawRequested: frame request pending, composing");
1642
1643                    // 2. Process a11y actions and render commands before compose.
1644                    self.process_a11y_actions();
1645                    self.process_render_commands();
1646
1647                    let Some(win) = self.window.as_ref() else {
1648                        return;
1649                    };
1650                    if self.backend.is_none() {
1651                        return;
1652                    }
1653
1654                    // Advance animations before composition (Compose pattern).
1655                    // Mirrors broadcastFrameClock.sendFrame() before performRecompose().
1656                    repose_core::animation_driver::tick();
1657
1658                    let t0 = Instant::now();
1659                    let scale = win.scale_factor() as f32;
1660                    let size_px_u32 = self.sched.size;
1661                    let focused = self.sched.focused;
1662
1663                    let rc = self.render.clone();
1664                    let root_fn = &mut self.root;
1665                    let mut composed_root = |s: &mut Scheduler| (root_fn)(s, &rc);
1666
1667                    let frame = compose_frame(
1668                        &mut self.sched,
1669                        &mut composed_root,
1670                        scale,
1671                        size_px_u32,
1672                        self.hover_id,
1673                        &self.pressed_ids,
1674                        &self.textfield_states,
1675                        focused,
1676                    );
1677
1678                    if focused.is_some() && self.sched.focused.is_none() && self.ime_preedit {
1679                        rc_web::set_ime_for_textfield(win, false);
1680                        self.ime_preedit = false;
1681                    }
1682
1683                    let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1684
1685                    // UPDATE ACCESSIBILITY TREE
1686                    if let Some(adapter) = &mut self.accesskit_adapter {
1687                        let win = self.window.as_ref().unwrap();
1688                        let scale = win.scale_factor();
1689                        if let Some(update) =
1690                            self.a11y_tree
1691                                .update(&frame.semantics_nodes, scale, self.sched.focused)
1692                        {
1693                            adapter.update_if_active(|| update);
1694                        }
1695                    }
1696
1697                    // Render
1698                    let mut scene = frame.scene.clone();
1699                    // Update HUD metrics before overlay draws
1700                    let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1701                    let signal_count = self.sched.id_count() as usize;
1702                    self.inspector.hud.metrics = Some(repose_devtools::Metrics {
1703                        build_ms: build_layout_ms,
1704                        layout_ms: build_layout_ms * 0.5,
1705                        scene_nodes: scene.nodes.len(),
1706                        widget_count,
1707                        signal_count,
1708                    });
1709                    self.inspector.frame(&mut scene);
1710
1711                    // Drag indicator overlay (internal + file drop)
1712                    repose_core::dnd::overlay_drag_indicator(
1713                        &mut scene,
1714                        self.mouse_pos_px,
1715                        self.external_file_drag,
1716                    );
1717
1718                    // Now borrow backend mutably only for the frame() call
1719                    let win = self.window.as_ref().unwrap();
1720                    let scale = win.scale_factor() as f32;
1721                    if let Some(backend) = self.backend.as_mut() {
1722                        backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1723                    }
1724
1725                    // Initialize TextFieldState for any focused TextField that
1726                    // doesn't have one yet (e.g. after FocusRequester::request_focus)
1727                    if let Some(fid) = self.sched.focused {
1728                        if let Some(hit) = frame.hit_regions.iter().find(|h| h.id == fid)
1729                            && let Some(key) = hit.tf_state_key
1730                            && !self.textfield_states.contains_key(&key)
1731                        {
1732                            self.textfield_states
1733                                .entry(key)
1734                                .or_insert_with(|| {
1735                                    Rc::new(RefCell::new(repose_ui::TextFieldState::new()))
1736                                })
1737                                .borrow_mut()
1738                                .reset_caret_blink();
1739                        }
1740                    }
1741
1742                    repose_core::dnd::set_dnd_frame(Some(frame.clone()));
1743                    self.frame_cache = Some(frame);
1744                    repose_core::dnd::set_dnd_scale(scale);
1745
1746                    self.dispatch_file_drop_now();
1747
1748                    rc::tick_snackbar(self.last_redraw);
1749                    self.last_redraw = Instant::now();
1750                }
1751
1752                _ => {}
1753            }
1754        }
1755
1756        fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1757            // Process cross-thread commands (e.g. tray toggles, deeplinks) before any
1758            // redraw check, so hide/show commands work even when hidden
1759            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1760            if let Some(cb) = ABOUT_TO_WAIT_CALLBACK.lock().unwrap().as_ref() {
1761                cb();
1762            }
1763            process_deeplinks();
1764
1765            // On Wayland, wgpu creates an xdg_surface from the winit window and it shouldn't be recreated with a new id?
1766            // It doesn't take a lot of resources anyway, so let the backend be present.
1767            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1768            if WINDOW_VISIBLE.load(Ordering::Relaxed) && self.backend.is_none() {
1769                if let Some(w) = &self.window {
1770                    log::info!("about_to_wait: recreating GPU backend");
1771                    match repose_render_wgpu::WgpuBackend::new(w.clone()) {
1772                        Ok(b) => self.backend = Some(b),
1773                        Err(e) => log::error!("about_to_wait: failed to recreate backend: {e:?}"),
1774                    }
1775                }
1776            }
1777
1778            if take_frame_request() {
1779                self.pending_redraw = true;
1780            }
1781            if !self.pending_redraw {
1782                let now = Instant::now();
1783                let idle_interval = web_time::Duration::from_millis(1000);
1784                if now.saturating_duration_since(self.last_redraw) >= idle_interval {
1785                    self.redraw_requested.set(true);
1786                    request_frame();
1787                    rc::request_redraw(&self.window);
1788                    self.last_redraw = now;
1789                }
1790                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1791                    self.last_redraw + idle_interval,
1792                ));
1793                return;
1794            }
1795
1796            let now = Instant::now();
1797            let interval = web_time::Duration::from_millis(16);
1798
1799            if now.saturating_duration_since(self.last_redraw) >= interval {
1800                self.pending_redraw = false;
1801                self.redraw_requested.set(true);
1802                rc::request_redraw(&self.window);
1803                self.last_redraw = now;
1804            } else {
1805                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1806                    self.last_redraw + interval,
1807                ));
1808            }
1809        }
1810
1811        fn new_events(
1812            &mut self,
1813            _: &winit::event_loop::ActiveEventLoop,
1814            _: winit::event::StartCause,
1815        ) {
1816        }
1817        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1818            self.pending_redraw = true;
1819        }
1820        fn device_event(
1821            &mut self,
1822            _: &winit::event_loop::ActiveEventLoop,
1823            _: winit::event::DeviceId,
1824            _: winit::event::DeviceEvent,
1825        ) {
1826        }
1827        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1828        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1829        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1830    }
1831
1832    impl App {
1833        /// Dispatch a key event through the focus ancestor chain.
1834        /// Returns true if the event was consumed by a handler.
1835        fn dispatch_focus_key_event(
1836            &self,
1837            key_event: &winit::event::KeyEvent,
1838            mapped_key: &repose_core::input::Key,
1839        ) -> bool {
1840            let Some(f) = &self.frame_cache else {
1841                return false;
1842            };
1843            let Some(focused) = self.sched.focused else {
1844                return false;
1845            };
1846            let utf16 = match mapped_key {
1847                repose_core::input::Key::Character(c) => *c as u16,
1848                _ => 0,
1849            };
1850            let mods = self.modifiers;
1851            let repeat = key_event.repeat;
1852            let ev_type = if key_event.state == ElementState::Pressed {
1853                repose_core::input::KeyEventType::Down
1854            } else {
1855                repose_core::input::KeyEventType::Up
1856            };
1857            let hit_by_id: std::collections::HashMap<u64, &HitRegion> =
1858                f.hit_regions.iter().map(|h| (h.id, h)).collect();
1859            let sem_parent_of: std::collections::HashMap<u64, u64> = f
1860                .semantics_nodes
1861                .iter()
1862                .filter_map(|n| n.parent.map(|p| (n.id, p)))
1863                .collect();
1864            let mut ancestors = Vec::new();
1865            let mut cur = focused;
1866            loop {
1867                ancestors.push(cur);
1868                if let Some(&p) = sem_parent_of.get(&cur) {
1869                    cur = p;
1870                } else {
1871                    break;
1872                }
1873            }
1874            let make_ke = || repose_core::input::KeyEvent {
1875                key: mapped_key.clone(),
1876                modifiers: mods,
1877                is_repeat: repeat,
1878                event_type: ev_type,
1879                utf16_code_point: utf16,
1880            };
1881            // Top-down preview: root → focused
1882            for &id in ancestors.iter().rev() {
1883                if let Some(hit) = hit_by_id.get(&id) {
1884                    if let Some(cb) = &hit.on_preview_key_event {
1885                        if cb(make_ke()) {
1886                            return true;
1887                        }
1888                    }
1889                }
1890            }
1891            // Bottom-up normal: focused → root
1892            for &id in ancestors.iter() {
1893                if let Some(hit) = hit_by_id.get(&id) {
1894                    if let Some(cb) = &hit.on_key_event {
1895                        if cb(make_ke()) {
1896                            return true;
1897                        }
1898                    }
1899                }
1900            }
1901            false
1902        }
1903
1904        fn announce_focus_change(&mut self) {
1905            if let Some(f) = &self.frame_cache {
1906                let focused_node = self
1907                    .sched
1908                    .focused
1909                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1910                self.a11y.focus_changed(focused_node);
1911            }
1912        }
1913
1914        fn notify_text_change(&self, id: u64, text: String) {
1915            if let Some(f) = &self.frame_cache
1916                && let Some(h) = f.hit_regions.iter().find(|h| h.id == id)
1917                && let Some(cb) = &h.on_text_change
1918            {
1919                cb(text);
1920            }
1921        }
1922
1923        fn tf_key_of(&self, visual_id: u64) -> u64 {
1924            rc::tf_key_of_in_frame(&self.frame_cache, visual_id)
1925        }
1926
1927        fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
1928            use repose_core::shortcuts;
1929
1930            if let (Some(f), Some(fid)) = (&self.frame_cache, self.sched.focused)
1931                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
1932                && let Some(cb) = &hit.on_action
1933                && cb(action.clone())
1934            {
1935                return true;
1936            }
1937
1938            if shortcuts::handle(action.clone()) {
1939                return true;
1940            }
1941
1942            // Focus navigation (Tab/arrows)
1943            if let Some(f) = &self.frame_cache {
1944                if let Some(new_id) = repose_core::focus::handle_action(&action, &mut self.sched, f)
1945                {
1946                    if let Some(active) = self.key_pressed_active.take() {
1947                        self.pressed_ids.remove(&active);
1948                    }
1949                    let tf_state_key = f
1950                        .hit_regions
1951                        .iter()
1952                        .find(|h| h.id == new_id)
1953                        .and_then(|h| h.tf_state_key);
1954                    if let Some(key) = tf_state_key {
1955                        self.textfield_states.entry(key).or_insert_with(|| {
1956                            Rc::new(RefCell::new(repose_ui::TextFieldState::new()))
1957                        });
1958                        if let Some(state_rc) = self.textfield_states.get(&key) {
1959                            state_rc.borrow_mut().reset_caret_blink();
1960                        }
1961                    }
1962                    if let Some(win) = &self.window {
1963                        let is_textfield = f.semantics_nodes.iter().any(|n| {
1964                            n.id == new_id && n.role == repose_core::semantics::Role::TextField
1965                        });
1966                        rc_web::set_ime_for_textfield(win, is_textfield);
1967                    }
1968                    self.announce_focus_change();
1969                    return true;
1970                }
1971            }
1972
1973            false
1974        }
1975
1976        fn dispatch_file_drop_now(&mut self) {
1977            let Some(f) = &self.frame_cache else {
1978                self.pending_dropped_files.clear();
1979                self.pending_drop_pos_px = None;
1980                return;
1981            };
1982
1983            if self.pending_dropped_files.is_empty() {
1984                return;
1985            }
1986
1987            let pos_px = self.pending_drop_pos_px.unwrap_or(self.mouse_pos_px);
1988            let pos = Vec2 {
1989                x: pos_px.0,
1990                y: pos_px.1,
1991            };
1992
1993            let mut files = Vec::new();
1994            for p in self.pending_dropped_files.drain(..) {
1995                let name = p
1996                    .file_name()
1997                    .and_then(|s| s.to_str())
1998                    .unwrap_or("file")
1999                    .to_string();
2000                files.push(repose_core::dnd::DroppedFile {
2001                    name,
2002                    path: Some(p),
2003                });
2004            }
2005
2006            let payload: repose_core::dnd::DragPayload =
2007                std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
2008
2009            let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
2010                self.pending_drop_pos_px = None;
2011                return;
2012            };
2013
2014            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
2015                && let Some(cb) = &hit.on_drop
2016            {
2017                let accepted = cb(repose_core::dnd::DropEvent {
2018                    source_id: 0, // external source (OS)
2019                    target_id,
2020                    position: pos,
2021                    modifiers: self.modifiers,
2022                    payload: payload.clone(),
2023                });
2024
2025                if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
2026                {
2027                    let label = node.label.as_deref().unwrap_or("");
2028                    self.a11y.announce(&format!("Dropped files on {}", label));
2029                }
2030            }
2031
2032            self.pending_drop_pos_px = None;
2033            self.request_redraw();
2034        }
2035    }
2036
2037    let event_loop = EventLoop::new()?;
2038    set_event_loop_proxy(event_loop.create_proxy());
2039    let mut app = App::new(Box::new(root));
2040    // Install system clock once
2041    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
2042    event_loop.run_app(&mut app)?;
2043    Ok(())
2044}
2045
2046// Accessibility bridge stub (Noop by default; logs on Linux for now)
2047/// Bridge from Repose's semantics tree to platform accessibility APIs.
2048///
2049/// Implementations are responsible for:
2050/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
2051/// - Updating focus when `focus_changed` is called.
2052/// - Announcing transient messages (e.g. button activation) via screen readers.
2053pub trait A11yBridge: Send {
2054    /// Publish (or update) the full semantics tree for the current frame.
2055    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
2056
2057    /// Notify that the focused node has changed. `None` means focus cleared.
2058    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
2059
2060    /// Announce a one‑off message via the platform's accessibility channel.
2061    fn announce(&mut self, msg: &str);
2062}
2063
2064struct NoopA11y;
2065impl A11yBridge for NoopA11y {
2066    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
2067        // no-op
2068    }
2069    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
2070        if let Some(n) = node {
2071            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
2072        } else {
2073            log::info!("A11y focus: None");
2074        }
2075    }
2076    fn announce(&mut self, msg: &str) {
2077        log::info!("A11y announce: {msg}");
2078    }
2079}
2080
2081#[cfg(target_os = "linux")]
2082struct LinuxAtspiStub;
2083#[cfg(target_os = "linux")]
2084impl A11yBridge for LinuxAtspiStub {
2085    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
2086        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
2087    }
2088    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
2089        if let Some(n) = node {
2090            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
2091        } else {
2092            log::info!("AT-SPI stub focus: None");
2093        }
2094    }
2095    fn announce(&mut self, msg: &str) {
2096        log::info!("AT-SPI stub announce: {msg}");
2097    }
2098}