Skip to main content

repose_platform/
lib.rs

1//! Platform runners
2use crate::a11y::ReposeActionHandler;
3#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
4use accesskit_winit::Adapter;
5use repose_core::locals::dp_to_px;
6use repose_core::*;
7use repose_ui::textfield::{
8    self, TF_FONT_DP, TF_PADDING_X_DP, TextFieldState, TextMeasureConfig, caret_xy_for_byte, measure_text,
9};
10use std::cell::{Cell, RefCell};
11use std::rc::Rc;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::{Arc, Mutex};
14use web_time::Instant;
15
16#[cfg(target_os = "android")]
17pub mod android;
18
19#[cfg(target_arch = "wasm32")]
20pub mod web;
21
22pub mod a11y;
23mod common;
24#[cfg(not(target_arch = "wasm32"))]
25mod common_android;
26mod common_web;
27pub mod render;
28
29use common as rc;
30#[cfg(not(target_arch = "wasm32"))]
31use common_android as rc_android;
32use common_web as rc_web;
33
34pub use render::{ImageHandleGuard, RenderCommand, RenderContext};
35
36#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
37use winit::window::Window;
38
39#[cfg(not(target_arch = "wasm32"))]
40use std::sync::OnceLock;
41
42#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
43static APP_WINDOW: OnceLock<Arc<Window>> = OnceLock::new();
44
45#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
46static WINDOW_VISIBLE: AtomicBool = AtomicBool::new(true);
47
48#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
49static CLOSE_TO_TRAY: AtomicBool = AtomicBool::new(false);
50
51#[cfg(not(target_arch = "wasm32"))]
52static EVENT_LOOP_PROXY: OnceLock<winit::event_loop::EventLoopProxy<()>> = OnceLock::new();
53
54/// Optional callback invoked on every AboutToWait, regardless of redraw state.
55/// Used for draining cross-thread commands (e.g. tray toggles) that must be
56/// processed even when the window is hidden.
57#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
58static ABOUT_TO_WAIT_CALLBACK: Mutex<Option<Box<dyn Fn() + Send>>> = Mutex::new(None);
59
60static DEEPLINK_CB: Mutex<Option<Box<dyn Fn(Vec<u8>) + Send>>> = Mutex::new(None);
61static PENDING_DEEPLINKS: Mutex<Vec<Vec<u8>>> = Mutex::new(Vec::new());
62
63/// Register a callback to receive deeplink payloads (raw bytes)
64pub fn set_on_deeplink(callback: Box<dyn Fn(Vec<u8>) + Send>) {
65    *DEEPLINK_CB.lock().unwrap() = Some(callback);
66}
67
68/// Push a deeplink payload from any thread (JNI callback, CLI watcher, etc).
69pub fn push_deeplink(data: Vec<u8>) {
70    PENDING_DEEPLINKS.lock().unwrap().push(data);
71    #[cfg(not(target_arch = "wasm32"))]
72    if let Some(proxy) = EVENT_LOOP_PROXY.get() {
73        let _ = proxy.send_event(());
74    }
75}
76
77/// Drain queued deeplinks and dispatch them to the registered callback.
78/// Called from each platform runner's `about_to_wait` handler.
79pub(crate) fn process_deeplinks() {
80    let mut queue = PENDING_DEEPLINKS.lock().unwrap();
81    if queue.is_empty() {
82        return;
83    }
84    let batch = std::mem::take(&mut *queue);
85    drop(queue);
86
87    if let Some(cb) = DEEPLINK_CB.lock().unwrap().as_ref() {
88        for data in batch {
89            cb(data);
90        }
91    }
92}
93
94/// Store the application window handle (called once during app setup).
95#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
96pub fn set_app_window(window: Arc<Window>) {
97    let _ = APP_WINDOW.set(window);
98}
99
100/// Store the event loop proxy so tray commands / deeplinks can wake the event loop.
101#[cfg(not(target_arch = "wasm32"))]
102pub fn set_event_loop_proxy(proxy: winit::event_loop::EventLoopProxy<()>) {
103    let _ = EVENT_LOOP_PROXY.set(proxy);
104}
105
106/// Register a callback invoked on every AboutToWait (used for draining tray commands).
107#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
108pub fn set_about_to_wait_callback(cb: Box<dyn Fn() + Send>) {
109    *ABOUT_TO_WAIT_CALLBACK.lock().unwrap() = Some(cb);
110}
111
112/// Wake the winit event loop from another thread (e.g. tray's GTK thread, JNI callback).
113#[cfg(not(target_arch = "wasm32"))]
114pub fn wake_event_loop() {
115    if let Some(proxy) = EVENT_LOOP_PROXY.get() {
116        let _ = proxy.send_event(());
117    }
118}
119
120/// Show the application window.
121///
122/// On Wayland, unminimizing might not be supported by the protocol?
123#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
124pub fn show_app_window() {
125    WINDOW_VISIBLE.store(true, Ordering::Relaxed);
126    if let Some(w) = APP_WINDOW.get() {
127        log::info!("show_app_window: calling set_visible(true)");
128        w.set_visible(true);
129        #[allow(deprecated)]
130        w.focus_window();
131    }
132    repose_core::frame_clock::request_frame();
133    wake_event_loop();
134}
135
136#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
137pub fn hide_app_window() {
138    WINDOW_VISIBLE.store(false, Ordering::Relaxed);
139    if let Some(w) = APP_WINDOW.get() {
140        log::info!("hide_app_window: calling set_visible(false)");
141        w.set_visible(false);
142    }
143    repose_core::frame_clock::request_frame();
144    wake_event_loop();
145}
146
147/// Returns whether the application window is currently visible.
148#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
149pub fn window_is_visible() -> bool {
150    WINDOW_VISIBLE.load(Ordering::Relaxed)
151}
152
153/// The close button hides the window (via ``set_visible(false)``) instead of
154/// closing. The tray "Quit" action still exits the process regardless.
155#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
156pub fn set_close_to_tray(enabled: bool) {
157    CLOSE_TO_TRAY.store(enabled, Ordering::Relaxed);
158}
159
160/// Compose a single frame with density and text-scale applied, returning Frame.
161pub fn compose_frame<F>(
162    sched: &mut Scheduler,
163    root_fn: &mut F,
164    scale: f32,
165    size_px_u32: (u32, u32),
166    hover_id: Option<u64>,
167    pressed_ids: &std::collections::HashSet<u64>,
168    tf_states: &std::collections::HashMap<u64, Rc<RefCell<repose_ui::TextFieldState>>>,
169    _focused: Option<u64>,
170) -> Frame
171where
172    F: FnMut(&mut Scheduler) -> View,
173{
174    // Process any programmatic focus request from FocusRequester
175    if let Some(requested_id) = repose_core::take_focus_request() {
176        if requested_id == repose_core::runtime::CLEAR_FOCUS_MARKER {
177            sched.focused = None;
178        } else {
179            sched.focused = Some(requested_id);
180        }
181    }
182
183    set_density_default(Density { scale });
184
185    // Use scheduler's focused state (which may have been updated by focus request)
186    let current_focused = sched.focused;
187
188    let frame = sched.repose(
189        {
190            let scale = scale;
191            move |s: &mut Scheduler| with_density(Density { scale }, || (root_fn)(s))
192        },
193        {
194            let hover_id = hover_id;
195            let pressed_ids = pressed_ids.clone();
196            move |view, _size| {
197                let interactions = repose_ui::Interactions {
198                    hover: hover_id,
199                    pressed: pressed_ids.clone(),
200                };
201
202                with_density(Density { scale }, || {
203                    repose_ui::layout_and_paint(
204                        view,
205                        size_px_u32,
206                        tf_states,
207                        &interactions,
208                        current_focused,
209                    )
210                })
211            }
212        },
213    );
214
215    if let Some(fid) = sched.focused {
216        if !frame.focus_chain.contains(&fid) {
217            sched.focused = None;
218        }
219    }
220
221    frame
222}
223
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, TextMeasureConfig::default());
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 dp_px(&self, dp: f32) -> f32 {
466            dp_to_px(dp)
467        }
468    }
469
470    impl ApplicationHandler<()> for App {
471        fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
472            self.clipboard = clipawl::Clipboard::new()
473                .map_err(|e| {
474                    eprintln!("clipawl clipboard init failed: {e}");
475                    e
476                })
477                .ok();
478            repose_core::clipboard::set_clipboard_read_fn(Box::new(|| {
479                clipawl::blocking::read().ok()
480            }));
481            // Register for SelectableText (Ctrl+C) - use blocking API directly
482            repose_core::clipboard::set_clipboard_fn(Box::new(move |text| {
483                if let Err(e) = clipawl::blocking::write(text) {
484                    eprintln!("clipboard write error: {e}");
485                }
486            }));
487
488            repose_core::clipboard::set_primary_fn(Box::new(|text| {
489                let opts = clipawl::ClipboardOptions {
490                    linux: clipawl::LinuxOptions {
491                        selection: clipawl::LinuxSelection::Primary,
492                        ..Default::default()
493                    },
494                };
495                match clipawl::Clipboard::new_with_options(opts) {
496                    Ok(cb) => {
497                        if let Err(e) = pollster::block_on(cb.write(text)) {
498                            eprintln!("primary selection write error: {e}");
499                        }
500                    }
501                    Err(e) => eprintln!("primary clipboard init error: {e}"),
502                }
503            }));
504
505            if self.window.is_none() {
506                match el.create_window(
507                    WindowAttributes::default()
508                        .with_title("Repose")
509                        .with_inner_size(PhysicalSize::new(1280, 800))
510                        .with_visible(false),
511                ) {
512                    Ok(win) => {
513                        let w = Arc::new(win);
514
515                        let activation_handler = ReposeActivationHandler {
516                            initial_tree: Some(A11yTree::initial_tree()),
517                        };
518
519                        let action_handler = ReposeActionHandler {
520                            pending_actions: self.a11y_actions.clone(),
521                        };
522
523                        let deactivation_handler = ReposeDeactivationHandler;
524
525                        let adapter = Adapter::with_direct_handlers(
526                            el,
527                            &w,
528                            activation_handler,
529                            action_handler,
530                            deactivation_handler,
531                        );
532
533                        self.accesskit_adapter = Some(adapter);
534
535                        w.set_visible(true);
536
537                        let size = w.inner_size();
538                        self.sched.size = (size.width, size.height);
539
540                        match repose_render_wgpu::WgpuBackend::new(w.clone()) {
541                            Ok(b) => {
542                                self.backend = Some(b);
543                                set_app_window(w.clone());
544                                self.window = Some(w);
545                                self.request_redraw();
546                            }
547                            Err(e) => {
548                                log::error!("Failed to create WGPU backend: {e:?}");
549                                el.exit();
550                            }
551                        }
552                    }
553                    Err(e) => {
554                        log::error!("Failed to create window: {e:?}");
555                        el.exit();
556                    }
557                }
558            }
559        }
560
561        fn window_event(
562            &mut self,
563            el: &winit::event_loop::ActiveEventLoop,
564            _id: winit::window::WindowId,
565            event: WindowEvent,
566        ) {
567            // Process AccessKit events first!
568            if let Some(adapter) = &mut self.accesskit_adapter {
569                adapter.process_event(self.window.as_ref().unwrap(), &event);
570            }
571
572            match event {
573                WindowEvent::CloseRequested => {
574                    if CLOSE_TO_TRAY.load(Ordering::Relaxed) {
575                        // Drop GPU backend before null-buffer unmap.
576                        self.backend = None;
577                        if let Some(w) = &self.window {
578                            w.set_visible(false);
579                        }
580                        WINDOW_VISIBLE.store(false, Ordering::Relaxed);
581                    } else {
582                        el.exit();
583                    }
584                }
585
586                WindowEvent::Focused(false) => {
587                    // Cancel any active drag operation
588                    repose_core::dnd::handle_drag_action(
589                        &repose_core::shortcuts::DragAction::Cancel,
590                    );
591
592                    // Emit interaction Cancel for the captured hit region
593                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
594                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
595                            if let Some(cb) = &hit.on_pointer_cancel {
596                                let pos = repose_core::Vec2 {
597                                    x: self.mouse_pos_px.0,
598                                    y: self.mouse_pos_px.1,
599                                };
600                                let pe = PointerEvent::new(
601                                    PointerId(0),
602                                    PointerKind::Mouse,
603                                    PointerEventKind::Cancel,
604                                    pos,
605                                    1.0,
606                                    self.modifiers,
607                                );
608                                cb(pe);
609                            }
610                        }
611                    }
612
613                    // Defensive reset: Wayland/KDE can "eat" releases during DnD.
614                    self.external_file_drag = false;
615                    self.hovered_files.clear();
616                    self.reset_pointer_state();
617
618                    if let Some(w) = &self.window {
619                        rc_web::set_ime_for_textfield(w, false);
620                    }
621                    self.ime_preedit = false;
622
623                    self.request_redraw();
624                }
625
626                WindowEvent::HoveredFile(path) => {
627                    // Mark external drag active and keep a small bounded list
628                    self.external_file_drag = true;
629                    if self.hovered_files.len() < 32 {
630                        self.hovered_files.push(path);
631                    }
632                    // Update drop position (best effort)
633                    if self.pending_drop_pos_px.is_none() {
634                        self.pending_drop_pos_px = Some(self.mouse_pos_px);
635                    }
636                    self.request_redraw();
637                }
638
639                WindowEvent::HoveredFileCancelled => {
640                    self.external_file_drag = false;
641                    self.hovered_files.clear();
642
643                    // Defensive: cancel any internal capture/drag that might be left stuck
644                    self.reset_pointer_state();
645
646                    self.request_redraw();
647                }
648
649                WindowEvent::DroppedFile(path) => {
650                    // DroppedFile is emitted once per file. Batch them.
651                    self.pending_dropped_files.push(path);
652                    if self.pending_drop_pos_px.is_none() {
653                        self.pending_drop_pos_px = Some(self.mouse_pos_px);
654                    }
655
656                    // Drop ends the external file drag session.
657                    self.external_file_drag = false;
658                    self.hovered_files.clear();
659
660                    self.request_redraw();
661                }
662
663                WindowEvent::Resized(size) => {
664                    self.sched.size = (size.width, size.height);
665                    if let Some(b) = self.backend.as_mut() {
666                        b.configure_surface(size.width, size.height);
667                    }
668                    if let Some(w) = &self.window {
669                        let sf = w.scale_factor() as f32;
670                        let dp_w = size.width as f32 / sf;
671                        let dp_h = size.height as f32 / sf;
672                        log::info!(
673                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
674                            size.width,
675                            size.height,
676                            sf,
677                            dp_w as i32,
678                            dp_h as i32
679                        );
680                    }
681                    self.request_redraw();
682                }
683
684                WindowEvent::CursorMoved { position, .. } => {
685                    self.mouse_pos_px = (position.x as f32, position.y as f32);
686
687                    if self.external_file_drag {
688                        self.pending_drop_pos_px = Some(self.mouse_pos_px);
689                    }
690
691                    let pos = Vec2 {
692                        x: self.mouse_pos_px.0,
693                        y: self.mouse_pos_px.1,
694                    };
695
696                    if repose_core::dnd::handle_drag_action(
697                        &repose_core::shortcuts::DragAction::Move {
698                            position: pos,
699                            modifiers: self.modifiers,
700                        },
701                    ) {
702                        self.request_redraw();
703                        return;
704                    }
705
706                    // Inspector hover
707                    if self.inspector.hud.inspector_enabled
708                        && let Some(f) = &self.frame_cache
709                    {
710                        let hit = f.hit_regions.iter().find(|h| {
711                            h.rect.contains(Vec2 {
712                                x: self.mouse_pos_px.0,
713                                y: self.mouse_pos_px.1,
714                            })
715                        });
716                        let hover_rect = hit.map(|h| h.rect);
717                        let hover_info = hit.and_then(|h| {
718                            f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
719                                repose_devtools::HoveredInfo {
720                                    id: s.id,
721                                    role: format!("{:?}", s.role),
722                                    label: s.label.clone(),
723                                }
724                            })
725                        });
726                        self.inspector.hud.set_hovered(hover_rect, hover_info);
727                        self.request_redraw();
728                    }
729
730                    // TextField/TextArea drag selection (if captured)
731                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id)
732                        && self.is_textfield(cid)
733                        && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
734                    {
735                        let key = self.tf_key_of(cid);
736                        if let Some(state_rc) = self.textfield_states.get(&key) {
737                            let mut st = state_rc.borrow_mut();
738
739                            let content_x =
740                                (self.mouse_pos_px.0 - hit.rect.x + st.scroll_offset).max(0.0);
741                            let content_y =
742                                (self.mouse_pos_px.1 - hit.rect.y + st.scroll_offset_y).max(0.0);
743
744                            let font_px =
745                                dp_to_px(TF_FONT_DP) * repose_core::locals::text_scale().0;
746
747                            let idx = if hit.tf_multiline {
748                                rc::index_for_xy_bytes_vt(
749                                    &st, font_px, hit.rect.w, content_x, content_y,
750                                )
751                            } else {
752                                rc::index_for_x_bytes_vt(&st, font_px, content_x)
753                            };
754
755                            st.drag_to(idx);
756
757                            // Ensure caret visible
758                            if hit.tf_multiline {
759                                let (cx, cy, _) =
760                                    caret_xy_for_byte(&st.text, font_px, hit.rect.w, st.caret_index());
761                                st.ensure_caret_visible_xy(cx, cy, hit.rect.w, hit.rect.h, dp_to_px(2.0));
762                            } else {
763                                let m = measure_text(&st.text, font_px, TextMeasureConfig::default());
764                                let cx = m.positions.get(st.caret_index()).copied().unwrap_or(0.0);
765                                st.ensure_caret_visible(cx, hit.rect.w, dp_to_px(2.0));
766                            }
767
768                            self.request_redraw();
769                        }
770                    }
771
772                    // Pointer routing: hover + move/capture
773                    if let Some(f) = &self.frame_cache {
774                        // Determine topmost hit
775                        let pos = Vec2 {
776                            x: self.mouse_pos_px.0,
777                            y: self.mouse_pos_px.1,
778                        };
779                        let top = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos));
780
781                        // Update cursor icon based on hit
782                        if let Some(win) = &self.window {
783                            let c = top
784                                .and_then(|h| h.cursor)
785                                .unwrap_or(repose_core::CursorIcon::Default);
786                            win.set_cursor(winit::window::Cursor::Icon(map_cursor(c)));
787                        }
788
789                        let new_hover = top.map(|h| h.id);
790
791                        // Enter/Leave
792                        if new_hover != self.hover_id {
793                            if let Some(prev_id) = self.hover_id
794                                && let Some(prev) = f.hit_regions.iter().find(|h| h.id == prev_id)
795                                && let Some(cb) = &prev.on_pointer_leave
796                            {
797                                let pe = PointerEvent::new(
798                                    PointerId(0),
799                                    PointerKind::Mouse,
800                                    PointerEventKind::Leave,
801                                    pos,
802                                    1.0,
803                                    self.modifiers,
804                                );
805                                cb(pe);
806                            }
807                            if let Some(h) = top
808                                && let Some(cb) = &h.on_pointer_enter
809                            {
810                                let pe = PointerEvent::new(
811                                    PointerId(0),
812                                    PointerKind::Mouse,
813                                    PointerEventKind::Enter,
814                                    pos,
815                                    1.0,
816                                    self.modifiers,
817                                );
818                                cb(pe);
819                            }
820                            self.hover_id = new_hover;
821                            self.request_redraw();
822                        }
823
824                        // Build PointerEvent
825                        let pe = PointerEvent::new(
826                            PointerId(0),
827                            PointerKind::Mouse,
828                            PointerEventKind::Move,
829                            pos,
830                            1.0,
831                            self.modifiers,
832                        );
833
834                        // Move delivery (captured first)
835                        if let Some(cid) = self.capture_id {
836                            if let Some(h) = f.hit_regions.iter().find(|h| h.id == cid)
837                                && let Some(cb) = &h.on_pointer_move
838                            {
839                                cb(pe.clone());
840                            }
841                        } else if let Some(h) = &top
842                            && let Some(cb) = &h.on_pointer_move
843                        {
844                            cb(pe);
845                        }
846                    }
847                }
848
849                WindowEvent::MouseWheel { delta, .. } => {
850                    // Convert line deltas (logical) to px; pixel delta is already px
851                    let (dx_px, dy_px) = match delta {
852                        MouseScrollDelta::LineDelta(x, y) => {
853                            let unit_px = dp_to_px(60.0);
854                            (-(x * unit_px), -(y * unit_px))
855                        }
856                        MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
857                    };
858                    log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
859
860                    if let Some(f) = &self.frame_cache {
861                        let pos = Vec2 {
862                            x: self.mouse_pos_px.0,
863                            y: self.mouse_pos_px.1,
864                        };
865
866                        if rc::dispatch_scroll(f, pos, Vec2 { x: dx_px, y: dy_px }, None).0 {
867                            self.request_redraw();
868                        }
869                    }
870                }
871
872                WindowEvent::MouseInput {
873                    state: ElementState::Pressed,
874                    button: MouseButton::Left,
875                    ..
876                } => {
877                    let mut need_announce = false;
878                    if let Some(f) = &self.frame_cache {
879                        let pos = Vec2 {
880                            x: self.mouse_pos_px.0,
881                            y: self.mouse_pos_px.1,
882                        };
883                        if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
884                        {
885                            repose_core::dnd::handle_drag_action(
886                                &repose_core::shortcuts::DragAction::Press {
887                                    position: Vec2 {
888                                        x: self.mouse_pos_px.0,
889                                        y: self.mouse_pos_px.1,
890                                    },
891                                    capture_id: hit.id,
892                                    kind: repose_core::input::PointerKind::Mouse,
893                                    modifiers: self.modifiers,
894                                },
895                            );
896
897                            // Capture starts on press
898                            self.capture_id = Some(hit.id);
899
900                            // Text input caret placement + begin drag selection
901                            if self.is_textfield(hit.id) {
902                                let key = self.tf_key_of(hit.id);
903                                self.textfield_states.entry(key).or_insert_with(|| {
904                                    Rc::new(RefCell::new(TextFieldState::new()))
905                                });
906                                if let Some(st_rc) = self.textfield_states.get(&key) {
907                                    let mut st = st_rc.borrow_mut();
908                                    let content_x =
909                                        (self.mouse_pos_px.0 - hit.rect.x + st.scroll_offset)
910                                            .max(0.0);
911                                    let content_y = (self.mouse_pos_px.1 - hit.rect.y
912                                        + st.scroll_offset_y)
913                                        .max(0.0);
914                                    let font_px = self.dp_px(TF_FONT_DP)
915                                        * repose_core::locals::text_scale().0;
916
917                                    let idx = if hit.tf_multiline {
918                                        rc::index_for_xy_bytes_vt(
919                                            &st,
920                                            font_px,
921                                            hit.rect.w,
922                                            content_x,
923                                            content_y,
924                                        )
925                                    } else {
926                                        rc::index_for_x_bytes_vt(&st, font_px, content_x)
927                                    };
928
929                                    st.begin_drag(idx, self.modifiers.shift);
930
931                                    // Ensure caret visible
932                                    let caret_idx = st.caret_index();
933                                    let iw = st.inner_width;
934                                    let ih = st.inner_height;
935                                    if hit.tf_multiline {
936                                        let (cx, cy, _) = textfield::caret_xy_for_byte(
937                                            &st.text, font_px, hit.rect.w, caret_idx,
938                                        );
939                                        st.ensure_caret_visible_xy(cx, cy, iw, ih, self.dp_px(2.0));
940                                    } else {
941                                        let m = measure_text(&st.text, font_px, TextMeasureConfig::default());
942                                        let cx = m.positions.get(caret_idx).copied().unwrap_or(0.0);
943                                        st.ensure_caret_visible(cx, iw, self.dp_px(2.0));
944                                    }
945                                }
946                            }
947                            // Pressed visual for mouse
948                            self.pressed_ids.insert(hit.id);
949                            // Repaint for pressed state
950                            self.request_redraw();
951
952                            // Focus & IME first for focusables (so state exists)
953                            if hit.focusable {
954                                self.sched.focused = Some(hit.id);
955                                need_announce = true;
956                                let key = self.tf_key_of(hit.id);
957                                self.textfield_states.entry(key).or_insert_with(|| {
958                                    Rc::new(RefCell::new(TextFieldState::new()))
959                                });
960                                if let Some(win) = &self.window {
961                                    let sf = win.scale_factor();
962                                    rc_web::set_ime_for_textfield(win, true);
963                                    win.set_ime_cursor_area(
964                                        LogicalPosition::new(
965                                            hit.rect.x as f64 / sf,
966                                            hit.rect.y as f64 / sf,
967                                        ),
968                                        LogicalSize::new(
969                                            hit.rect.w as f64 / sf,
970                                            hit.rect.h as f64 / sf,
971                                        ),
972                                    );
973                                }
974                            }
975
976                            // PointerDown callback (legacy)
977                            if let Some(cb) = &hit.on_pointer_down {
978                                let pe = PointerEvent::new(
979                                    PointerId(0),
980                                    PointerKind::Mouse,
981                                    PointerEventKind::Down(PointerButton::Primary),
982                                    pos,
983                                    1.0,
984                                    self.modifiers,
985                                );
986                                cb(pe);
987                            }
988
989                            if need_announce {
990                                self.announce_focus_change();
991                            }
992
993                            self.request_redraw();
994                        } else {
995                            // Click outside: drop focus/IME
996                            if self.ime_preedit {
997                                if let Some(win) = &self.window {
998                                    rc_web::set_ime_for_textfield(win, false);
999                                }
1000                                self.ime_preedit = false;
1001                            }
1002                            self.sched.focused = None;
1003                            self.request_redraw();
1004                        }
1005                    }
1006                }
1007
1008                WindowEvent::MouseInput {
1009                    state: ElementState::Pressed,
1010                    button: MouseButton::Middle,
1011                    ..
1012                } => {
1013                    let Some(f) = &self.frame_cache else {
1014                        return;
1015                    };
1016                    let pos = Vec2 {
1017                        x: self.mouse_pos_px.0,
1018                        y: self.mouse_pos_px.1,
1019                    };
1020                    if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos)) {
1021                        // Dispatch Tertiary pointer event
1022                        if let Some(cb) = &hit.on_pointer_down {
1023                            cb(PointerEvent::new(
1024                                PointerId(0),
1025                                PointerKind::Mouse,
1026                                PointerEventKind::Down(PointerButton::Tertiary),
1027                                pos,
1028                                1.0,
1029                                self.modifiers,
1030                            ));
1031                        }
1032                        // Paste primary selection into textfield
1033                        if self.is_textfield(hit.id) {
1034                            let key = self.tf_key_of(hit.id);
1035                            if let Some(state_rc) = self.textfield_states.get(&key) {
1036                                if let Some(txt) = self.paste_from_primary() {
1037                                    let mut st = state_rc.borrow_mut();
1038                                    st.insert_text_atomic(&txt);
1039                                    self.notify_text_change(hit.id, st.text.clone());
1040                                    if let Some(f) = &self.frame_cache
1041                                        && let Some(h) =
1042                                            f.hit_regions.iter().find(|h| h.id == hit.id)
1043                                    {
1044                                        App::tf_ensure_caret_visible(&mut st, h.tf_multiline);
1045                                    }
1046                                }
1047                            }
1048                        }
1049                    }
1050                    self.request_redraw();
1051                }
1052
1053                WindowEvent::MouseInput {
1054                    state: ElementState::Released,
1055                    button: MouseButton::Left,
1056                    ..
1057                } => {
1058                    let pos = Vec2 {
1059                        x: self.mouse_pos_px.0,
1060                        y: self.mouse_pos_px.1,
1061                    };
1062
1063                    if repose_core::dnd::handle_drag_action(
1064                        &repose_core::shortcuts::DragAction::Release {
1065                            position: pos,
1066                            modifiers: self.modifiers,
1067                        },
1068                    ) {
1069                        self.capture_id = None;
1070                        self.pressed_ids.clear();
1071                        repose_core::request_frame();
1072                        return;
1073                    }
1074
1075                    if let Some(cid) = self.capture_id {
1076                        self.pressed_ids.remove(&cid);
1077                        self.request_redraw();
1078                    }
1079
1080                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id)
1081                        && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
1082                        && let Some(cb) = &hit.on_pointer_up
1083                    {
1084                        let pos = Vec2 {
1085                            x: self.mouse_pos_px.0,
1086                            y: self.mouse_pos_px.1,
1087                        };
1088                        let pe = PointerEvent::new(
1089                            PointerId(0),
1090                            PointerKind::Mouse,
1091                            PointerEventKind::Up(PointerButton::Primary),
1092                            pos,
1093                            1.0,
1094                            self.modifiers,
1095                        );
1096                        cb(pe);
1097                    }
1098
1099                    // Click on release if pointer is still over the captured hit region
1100                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
1101                        let pos = Vec2 {
1102                            x: self.mouse_pos_px.0,
1103                            y: self.mouse_pos_px.1,
1104                        };
1105                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
1106                            && hit.rect.contains(pos)
1107                            && let Some(cb) = &hit.on_click
1108                        {
1109                            cb();
1110                            // A11y: announce activation (mouse)
1111                            if let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid) {
1112                                let label = node.label.as_deref().unwrap_or("");
1113                                self.a11y.announce(&format!("Activated {}", label));
1114                            }
1115                        }
1116                    }
1117                    // TextField drag end
1118                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id)
1119                        && let Some(_sem) = f
1120                            .semantics_nodes
1121                            .iter()
1122                            .find(|n| n.id == cid && n.role == Role::TextField)
1123                    {
1124                        let key = self.tf_key_of(cid);
1125                        if let Some(state_rc) = self.textfield_states.get(&key) {
1126                            state_rc.borrow_mut().end_drag();
1127                        }
1128                    }
1129
1130                    self.capture_id = None;
1131
1132                    repose_core::request_frame();
1133                }
1134
1135                WindowEvent::MouseInput {
1136                    state: ElementState::Released,
1137                    button: MouseButton::Middle,
1138                    ..
1139                } => {
1140                    if let Some(f) = &self.frame_cache {
1141                        let pos = Vec2 {
1142                            x: self.mouse_pos_px.0,
1143                            y: self.mouse_pos_px.1,
1144                        };
1145                        if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
1146                        {
1147                            if let Some(cb) = &hit.on_pointer_up {
1148                                cb(PointerEvent::new(
1149                                    PointerId(0),
1150                                    PointerKind::Mouse,
1151                                    PointerEventKind::Up(PointerButton::Tertiary),
1152                                    pos,
1153                                    1.0,
1154                                    self.modifiers,
1155                                ));
1156                            }
1157                        }
1158                    }
1159                }
1160
1161                WindowEvent::ModifiersChanged(new_mods) => {
1162                    rc::update_modifiers(&mut self.modifiers, &new_mods.state());
1163                }
1164
1165                WindowEvent::KeyboardInput {
1166                    event: key_event, ..
1167                } => {
1168                    let mapped_key = rc::map_key(key_event.physical_key);
1169
1170                    if key_event.state == ElementState::Pressed && !key_event.repeat {
1171                        match key_event.physical_key {
1172                            PhysicalKey::Code(KeyCode::BrowserBack)
1173                            | PhysicalKey::Code(KeyCode::Escape) => {
1174                                use repose_navigation::back;
1175
1176                                if repose_core::dnd::handle_drag_action(
1177                                    &repose_core::shortcuts::DragAction::Cancel,
1178                                ) {
1179                                    return;
1180                                }
1181
1182                                // Try dispatching Escape through the focus chain first
1183                                // (allows dialog on_key_event to intercept Escape)
1184                                if self.dispatch_focus_key_event(&key_event, &mapped_key) {
1185                                    self.request_redraw();
1186                                    return;
1187                                }
1188
1189                                if !back::handle() {
1190                                    // el.exit();
1191                                }
1192                                return;
1193                            }
1194                            _ => {}
1195                        }
1196                    }
1197
1198                    // Dispatch key event through focus ancestor chain (Compose-compatible)
1199                    let utf16 = match mapped_key {
1200                        repose_core::input::Key::Character(c) => c as u16,
1201                        _ => 0,
1202                    };
1203                    let mods = self.modifiers;
1204                    let repeat = key_event.repeat;
1205                    let ev_type = if key_event.state == ElementState::Pressed {
1206                        repose_core::input::KeyEventType::Down
1207                    } else {
1208                        repose_core::input::KeyEventType::Up
1209                    };
1210                    let consumed = self
1211                        .frame_cache
1212                        .as_ref()
1213                        .and_then(|f| {
1214                            let focused = self.sched.focused.or_else(|| {
1215                                f.semantics_nodes
1216                                    .iter()
1217                                    .find(|n| n.parent.is_none())
1218                                    .map(|n| n.id)
1219                            })?;
1220                            let sem_parent_of: std::collections::HashMap<u64, u64> = f
1221                                .semantics_nodes
1222                                .iter()
1223                                .filter_map(|n| n.parent.map(|p| (n.id, p)))
1224                                .collect();
1225                            let hit_by_id: std::collections::HashMap<u64, &HitRegion> =
1226                                f.hit_regions.iter().map(|h| (h.id, h)).collect();
1227                            let mut ancestors = Vec::new();
1228                            let mut cur = focused;
1229                            loop {
1230                                ancestors.push(cur);
1231                                if let Some(&p) = sem_parent_of.get(&cur) {
1232                                    cur = p;
1233                                } else {
1234                                    break;
1235                                }
1236                            }
1237                            let make_ke = || repose_core::input::KeyEvent {
1238                                key: mapped_key.clone(),
1239                                modifiers: mods,
1240                                is_repeat: repeat,
1241                                event_type: ev_type,
1242                                utf16_code_point: utf16,
1243                            };
1244                            // Top-down preview: root → focused
1245                            for &id in ancestors.iter().rev() {
1246                                if let Some(hit) = hit_by_id.get(&id) {
1247                                    if let Some(cb) = &hit.on_preview_key_event {
1248                                        if cb(make_ke()) {
1249                                            return Some(true);
1250                                        }
1251                                    }
1252                                }
1253                            }
1254                            // Bottom-up normal: focused → root
1255                            for &id in ancestors.iter() {
1256                                if let Some(hit) = hit_by_id.get(&id) {
1257                                    if let Some(cb) = &hit.on_key_event {
1258                                        if cb(make_ke()) {
1259                                            return Some(true);
1260                                        }
1261                                    }
1262                                }
1263                            }
1264                            None
1265                        })
1266                        .unwrap_or(false);
1267                    if consumed {
1268                        self.request_redraw();
1269                        return;
1270                    }
1271
1272                    if key_event.state == ElementState::Pressed
1273                        && let Some(action) = repose_core::shortcuts::resolve_action(
1274                            repose_core::shortcuts::KeyChord::new(mapped_key, self.modifiers),
1275                        )
1276                        && self.dispatch_action(action)
1277                    {
1278                        self.request_redraw();
1279                        return;
1280                    }
1281
1282                    if let Some(fid) = self.sched.focused {
1283                        // If focused is NOT a TextField, allow Space/Enter activation
1284                        let is_textfield = if let Some(f) = &self.frame_cache {
1285                            f.semantics_nodes
1286                                .iter()
1287                                .any(|n| n.id == fid && n.role == Role::TextField)
1288                        } else {
1289                            false
1290                        };
1291
1292                        if !is_textfield {
1293                            match key_event.physical_key {
1294                                PhysicalKey::Code(KeyCode::Space)
1295                                | PhysicalKey::Code(KeyCode::Enter) => {
1296                                    if key_event.state == ElementState::Pressed && !key_event.repeat
1297                                    {
1298                                        self.pressed_ids.insert(fid);
1299                                        self.key_pressed_active = Some(fid);
1300                                        self.request_redraw();
1301                                        return;
1302                                    }
1303                                }
1304                                _ => {}
1305                            }
1306                        }
1307                    }
1308
1309                    // Keyboard activation for focused TextField submit on Enter
1310                    // For multiline: Ctrl+Enter or Cmd+Enter submits, plain Enter inserts newline
1311                    // For single-line: Enter submits
1312                    if key_event.state == ElementState::Pressed
1313                        && !key_event.repeat
1314                        && let PhysicalKey::Code(KeyCode::Enter) = key_event.physical_key
1315                        && let Some(focused_id) = self.sched.focused
1316                        && let Some(f) = &self.frame_cache
1317                        && let Some(hit) = f.hit_regions.iter().find(|h| h.id == focused_id)
1318                    {
1319                        let is_multiline = hit.tf_multiline;
1320                        let should_submit = if is_multiline {
1321                            // Multiline: Ctrl+Enter or Cmd+Enter submits
1322                            self.modifiers.ctrl || self.modifiers.meta
1323                        } else {
1324                            // Single-line: Enter always submits
1325                            true
1326                        };
1327
1328                        if should_submit {
1329                            if let Some(on_submit) = &hit.on_text_submit {
1330                                let key = self.tf_key_of(focused_id);
1331                                if let Some(state) = self.textfield_states.get(&key) {
1332                                    let text = state.borrow().text.clone();
1333                                    on_submit(text);
1334                                    self.request_redraw();
1335                                    return;
1336                                }
1337                            }
1338                        } else {
1339                            // Multiline with plain Enter: insert newline
1340                            let key = self.tf_key_of(focused_id);
1341                            if let Some(state_rc) = self.textfield_states.get(&key) {
1342                                let mut st = state_rc.borrow_mut();
1343                                st.insert_text("\n");
1344                                let new_text = st.text.clone();
1345                                self.notify_text_change(focused_id, new_text);
1346                                App::tf_ensure_caret_visible(&mut st, hit.tf_multiline);
1347                                self.request_redraw();
1348                                return;
1349                            }
1350                        }
1351                    }
1352
1353                    if key_event.state == ElementState::Pressed {
1354                        // Inspector hotkey: Ctrl+Shift+I
1355                        if self.modifiers.ctrl
1356                            && self.modifiers.shift
1357                            && let PhysicalKey::Code(KeyCode::KeyI) = key_event.physical_key
1358                        {
1359                            self.inspector.hud.toggle_inspector();
1360                            self.request_redraw();
1361                            return;
1362                        }
1363
1364                        // TextField navigation/edit
1365                        if let Some(focused_id) = self.sched.focused {
1366                            let key = self.tf_key_of(focused_id);
1367                            if let Some(state_rc) = self.textfield_states.get(&key) {
1368                                let mut state = state_rc.borrow_mut();
1369                                match key_event.physical_key {
1370                                    PhysicalKey::Code(KeyCode::Backspace) => {
1371                                        state.delete_backward();
1372                                        let new_text = state.text.clone();
1373                                        self.notify_text_change(focused_id, new_text);
1374                                        App::tf_ensure_caret_visible(
1375                                            &mut state,
1376                                            self.is_multiline_id(focused_id),
1377                                        );
1378                                        self.request_redraw();
1379                                    }
1380                                    PhysicalKey::Code(KeyCode::Delete) => {
1381                                        state.delete_forward();
1382                                        let new_text = state.text.clone();
1383                                        self.notify_text_change(focused_id, new_text);
1384                                        App::tf_ensure_caret_visible(
1385                                            &mut state,
1386                                            self.is_multiline_id(focused_id),
1387                                        );
1388                                        self.request_redraw();
1389                                    }
1390                                    PhysicalKey::Code(KeyCode::ArrowLeft) => {
1391                                        state.move_cursor(-1, self.modifiers.shift);
1392                                        state.preferred_x_px = None; // Reset preferred x on horizontal movement
1393                                        App::tf_ensure_caret_visible(
1394                                            &mut state,
1395                                            self.is_multiline_id(focused_id),
1396                                        );
1397                                        self.request_redraw();
1398                                    }
1399                                    PhysicalKey::Code(KeyCode::ArrowRight) => {
1400                                        state.move_cursor(1, self.modifiers.shift);
1401                                        state.preferred_x_px = None; // Reset preferred x on horizontal movement
1402                                        App::tf_ensure_caret_visible(
1403                                            &mut state,
1404                                            self.is_multiline_id(focused_id),
1405                                        );
1406                                        self.request_redraw();
1407                                    }
1408                                    PhysicalKey::Code(KeyCode::ArrowUp) => {
1409                                        if self.is_multiline_id(focused_id)
1410                                            && let Some(f) = &self.frame_cache
1411                                            && let Some(hit) =
1412                                                f.hit_regions.iter().find(|h| h.id == focused_id)
1413                                        {
1414                                            let font_px = dp_to_px(TF_FONT_DP);
1415                                            let cur = state.caret_index();
1416                                            let (new_pos, px) =
1417                                                repose_ui::textfield::move_caret_vertical(
1418                                                    &state.text,
1419                                                    font_px,
1420                                                    hit.rect.w,
1421                                                    cur,
1422                                                    -1,
1423                                                    state.preferred_x_px,
1424                                                );
1425                                            if self.modifiers.shift {
1426                                                state.selection.end = new_pos;
1427                                            } else {
1428                                                state.selection = new_pos..new_pos;
1429                                            }
1430                                            state.preferred_x_px = Some(px);
1431                                            // Use multiline-aware caret visibility
1432                                            let (cx, cy, _) = caret_xy_for_byte(
1433                                                &state.text,
1434                                                font_px,
1435                                                hit.rect.w,
1436                                                state.caret_index(),
1437                                            );
1438                                            let iw = state.inner_width;
1439                                            let ih = state.inner_height;
1440                                            state.ensure_caret_visible_xy(
1441                                                cx,
1442                                                cy,
1443                                                iw,
1444                                                ih,
1445                                                self.dp_px(2.0),
1446                                            );
1447                                            self.request_redraw();
1448                                        }
1449                                    }
1450                                    PhysicalKey::Code(KeyCode::ArrowDown) => {
1451                                        if self.is_multiline_id(focused_id)
1452                                            && let Some(f) = &self.frame_cache
1453                                            && let Some(hit) =
1454                                                f.hit_regions.iter().find(|h| h.id == focused_id)
1455                                        {
1456                                            let font_px = dp_to_px(TF_FONT_DP);
1457                                            let wrap_w = hit.rect.w;
1458                                            let cur = state.caret_index();
1459                                            let (new_pos, px) =
1460                                                repose_ui::textfield::move_caret_vertical(
1461                                                    &state.text,
1462                                                    font_px,
1463                                                    wrap_w,
1464                                                    cur,
1465                                                    1,
1466                                                    state.preferred_x_px,
1467                                                );
1468                                            if self.modifiers.shift {
1469                                                state.selection.end = new_pos;
1470                                            } else {
1471                                                state.selection = new_pos..new_pos;
1472                                            }
1473                                            state.preferred_x_px = Some(px);
1474                                            // Use multiline-aware caret visibility
1475                                            let (cx, cy, _) = caret_xy_for_byte(
1476                                                &state.text,
1477                                                font_px,
1478                                                wrap_w,
1479                                                state.caret_index(),
1480                                            );
1481                                            let iw = state.inner_width;
1482                                            let ih = state.inner_height;
1483                                            state.ensure_caret_visible_xy(
1484                                                cx,
1485                                                cy,
1486                                                iw,
1487                                                ih,
1488                                                self.dp_px(2.0),
1489                                            );
1490                                            self.request_redraw();
1491                                        }
1492                                    }
1493                                    PhysicalKey::Code(KeyCode::Home) => {
1494                                        state.selection = 0..0;
1495                                        App::tf_ensure_caret_visible(
1496                                            &mut state,
1497                                            self.is_multiline_id(focused_id),
1498                                        );
1499                                        self.request_redraw();
1500                                    }
1501                                    PhysicalKey::Code(KeyCode::End) => {
1502                                        {
1503                                            let end = state.text.len();
1504                                            state.selection = end..end;
1505                                        }
1506                                        App::tf_ensure_caret_visible(
1507                                            &mut state,
1508                                            self.is_multiline_id(focused_id),
1509                                        );
1510                                        self.request_redraw();
1511                                    }
1512                                    _ => {}
1513                                }
1514                            }
1515                        }
1516
1517                        // Plain text input when IME is not active
1518                        if !self.ime_preedit
1519                            && !self.modifiers.ctrl
1520                            && !self.modifiers.alt
1521                            && !self.modifiers.meta
1522                            && let Some(raw) = key_event.text.as_deref()
1523                        {
1524                            let text: String = raw
1525                                .chars()
1526                                .filter(|c| !c.is_control() && *c != '\n' && *c != '\r')
1527                                .collect();
1528                            if !text.is_empty()
1529                                && let Some(fid) = self.sched.focused
1530                            {
1531                                let key = self.tf_key_of(fid);
1532                                if let Some(state_rc) = self.textfield_states.get(&key) {
1533                                    let mut st = state_rc.borrow_mut();
1534                                    st.insert_text(&text);
1535                                    self.notify_text_change(fid, st.text.clone());
1536                                    if let Some(f) = &self.frame_cache
1537                                        && let Some(hit) =
1538                                            f.hit_regions.iter().find(|h| h.id == fid)
1539                                    {
1540                                        App::tf_ensure_caret_visible(&mut st, hit.tf_multiline);
1541                                    }
1542                                    self.request_redraw();
1543                                }
1544                            }
1545                        }
1546                    } else if key_event.state == ElementState::Released {
1547                        // Finish keyboard activation on release (Space/Enter)
1548                        if let Some(active_id) = self.key_pressed_active {
1549                            match key_event.physical_key {
1550                                PhysicalKey::Code(KeyCode::Space)
1551                                | PhysicalKey::Code(KeyCode::Enter) => {
1552                                    self.pressed_ids.remove(&active_id);
1553                                    self.key_pressed_active = None;
1554
1555                                    if let Some(f) = &self.frame_cache
1556                                        && let Some(hit) =
1557                                            f.hit_regions.iter().find(|h| h.id == active_id)
1558                                    {
1559                                        if let Some(cb) = &hit.on_click {
1560                                            cb();
1561                                        } else if let Some(cb) = &hit.on_pointer_down {
1562                                            let pe = PointerEvent::new(
1563                                                PointerId(0),
1564                                                PointerKind::Mouse,
1565                                                PointerEventKind::Down(PointerButton::Primary),
1566                                                Vec2 { x: 0.0, y: 0.0 },
1567                                                1.0,
1568                                                self.modifiers,
1569                                            );
1570                                            cb(pe);
1571                                        }
1572                                        if let Some(node) =
1573                                            f.semantics_nodes.iter().find(|n| n.id == active_id)
1574                                        {
1575                                            let label = node.label.as_deref().unwrap_or("");
1576                                            self.a11y.announce(&format!("Activated {}", label));
1577                                        }
1578                                    }
1579                                    self.request_redraw();
1580                                }
1581                                _ => {}
1582                            }
1583                        }
1584                    }
1585                }
1586
1587                WindowEvent::Ime(ime) => {
1588                    if let Some(focused_id) = self.sched.focused {
1589                        let key = self.tf_key_of(focused_id);
1590                        if let Some(state_rc) = self.textfield_states.get(&key)
1591                            && let Some(f) = &self.frame_cache
1592                            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == focused_id)
1593                        {
1594                            let mut state = state_rc.borrow_mut();
1595                            let hit_rect = hit.rect;
1596                            let on_text_change = hit.on_text_change.clone();
1597                            let mut notify = |text: String| {
1598                                if let Some(cb) = &on_text_change {
1599                                    cb(text);
1600                                }
1601                            };
1602                            rc_android::handle_ime_event(
1603                                ime,
1604                                &mut state,
1605                                hit_rect,
1606                                &mut notify,
1607                                &mut self.ime_preedit,
1608                            );
1609                            self.request_redraw();
1610                        }
1611                    }
1612                }
1613
1614                WindowEvent::RedrawRequested => {
1615                    // 1. Check our redraw flag before processing a11y.
1616                    if !self.redraw_requested.replace(false) {
1617                        self.process_a11y_actions();
1618                        self.process_render_commands();
1619                        log::trace!("RedrawRequested: no frame request, skipping compose");
1620                        return;
1621                    }
1622                    log::trace!("RedrawRequested: frame request pending, composing");
1623
1624                    // 2. Process a11y actions and render commands before compose.
1625                    self.process_a11y_actions();
1626                    self.process_render_commands();
1627
1628                    let Some(win) = self.window.as_ref() else {
1629                        return;
1630                    };
1631                    if self.backend.is_none() {
1632                        return;
1633                    }
1634
1635                    // Advance animations before composition (Compose pattern).
1636                    // Mirrors broadcastFrameClock.sendFrame() before performRecompose().
1637                    repose_core::animation_driver::tick();
1638
1639                    let t0 = Instant::now();
1640                    let scale = win.scale_factor() as f32;
1641                    let size_px_u32 = self.sched.size;
1642                    let focused = self.sched.focused;
1643
1644                    let rc = self.render.clone();
1645                    let root_fn = &mut self.root;
1646                    let mut composed_root = |s: &mut Scheduler| (root_fn)(s, &rc);
1647
1648                    let frame = compose_frame(
1649                        &mut self.sched,
1650                        &mut composed_root,
1651                        scale,
1652                        size_px_u32,
1653                        self.hover_id,
1654                        &self.pressed_ids,
1655                        &self.textfield_states,
1656                        focused,
1657                    );
1658
1659                    if focused.is_some() && self.sched.focused.is_none() && self.ime_preedit {
1660                        rc_web::set_ime_for_textfield(win, false);
1661                        self.ime_preedit = false;
1662                    }
1663
1664                    let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1665
1666                    // UPDATE ACCESSIBILITY TREE
1667                    if let Some(adapter) = &mut self.accesskit_adapter {
1668                        let win = self.window.as_ref().unwrap();
1669                        let scale = win.scale_factor();
1670                        if let Some(update) =
1671                            self.a11y_tree
1672                                .update(&frame.semantics_nodes, scale, self.sched.focused)
1673                        {
1674                            adapter.update_if_active(|| update);
1675                        }
1676                    }
1677
1678                    // Render
1679                    let mut scene = frame.scene.clone();
1680                    // Update HUD metrics before overlay draws
1681                    let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1682                    let signal_count = self.sched.id_count() as usize;
1683                    self.inspector.hud.metrics = Some(repose_devtools::Metrics {
1684                        build_ms: build_layout_ms,
1685                        layout_ms: build_layout_ms * 0.5,
1686                        scene_nodes: scene.nodes.len(),
1687                        widget_count,
1688                        signal_count,
1689                    });
1690                    self.inspector.frame(&mut scene);
1691
1692                    // Drag indicator overlay (internal + file drop)
1693                    repose_core::dnd::overlay_drag_indicator(
1694                        &mut scene,
1695                        self.mouse_pos_px,
1696                        self.external_file_drag,
1697                    );
1698
1699                    // Now borrow backend mutably only for the frame() call
1700                    let win = self.window.as_ref().unwrap();
1701                    let scale = win.scale_factor() as f32;
1702                    if let Some(backend) = self.backend.as_mut() {
1703                        backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1704                    }
1705
1706                    // Initialize TextFieldState for any focused TextField that
1707                    // doesn't have one yet (e.g. after FocusRequester::request_focus)
1708                    if let Some(fid) = self.sched.focused {
1709                        if let Some(hit) = frame.hit_regions.iter().find(|h| h.id == fid)
1710                            && let Some(key) = hit.tf_state_key
1711                            && !self.textfield_states.contains_key(&key)
1712                        {
1713                            self.textfield_states
1714                                .entry(key)
1715                                .or_insert_with(|| {
1716                                    Rc::new(RefCell::new(repose_ui::TextFieldState::new()))
1717                                })
1718                                .borrow_mut()
1719                                .reset_caret_blink();
1720                        }
1721                    }
1722
1723                    repose_core::dnd::set_dnd_frame(Some(frame.clone()));
1724                    self.frame_cache = Some(frame);
1725                    repose_core::dnd::set_dnd_scale(scale);
1726
1727                    self.dispatch_file_drop_now();
1728
1729                    rc::tick_snackbar(self.last_redraw);
1730                    self.last_redraw = Instant::now();
1731                }
1732
1733                _ => {}
1734            }
1735        }
1736
1737        fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1738            // Process cross-thread commands (e.g. tray toggles, deeplinks) before any
1739            // redraw check, so hide/show commands work even when hidden
1740            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1741            if let Some(cb) = ABOUT_TO_WAIT_CALLBACK.lock().unwrap().as_ref() {
1742                cb();
1743            }
1744            process_deeplinks();
1745
1746            // On Wayland, wgpu creates an xdg_surface from the winit window and it shouldn't be recreated with a new id?
1747            // It doesn't take a lot of resources anyway, so let the backend be present.
1748            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1749            if WINDOW_VISIBLE.load(Ordering::Relaxed) && self.backend.is_none() {
1750                if let Some(w) = &self.window {
1751                    log::info!("about_to_wait: recreating GPU backend");
1752                    match repose_render_wgpu::WgpuBackend::new(w.clone()) {
1753                        Ok(b) => self.backend = Some(b),
1754                        Err(e) => log::error!("about_to_wait: failed to recreate backend: {e:?}"),
1755                    }
1756                }
1757            }
1758
1759            if take_frame_request() {
1760                self.pending_redraw = true;
1761            }
1762            if !self.pending_redraw {
1763                let now = Instant::now();
1764                let idle_interval = web_time::Duration::from_millis(1000);
1765                if now.saturating_duration_since(self.last_redraw) >= idle_interval {
1766                    self.redraw_requested.set(true);
1767                    request_frame();
1768                    rc::request_redraw(&self.window);
1769                    self.last_redraw = now;
1770                }
1771                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1772                    self.last_redraw + idle_interval,
1773                ));
1774                return;
1775            }
1776
1777            let now = Instant::now();
1778            let interval = web_time::Duration::from_millis(16);
1779
1780            if now.saturating_duration_since(self.last_redraw) >= interval {
1781                self.pending_redraw = false;
1782                self.redraw_requested.set(true);
1783                rc::request_redraw(&self.window);
1784                self.last_redraw = now;
1785            } else {
1786                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1787                    self.last_redraw + interval,
1788                ));
1789            }
1790        }
1791
1792        fn new_events(
1793            &mut self,
1794            _: &winit::event_loop::ActiveEventLoop,
1795            _: winit::event::StartCause,
1796        ) {
1797        }
1798        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1799            self.pending_redraw = true;
1800        }
1801        fn device_event(
1802            &mut self,
1803            _: &winit::event_loop::ActiveEventLoop,
1804            _: winit::event::DeviceId,
1805            _: winit::event::DeviceEvent,
1806        ) {
1807        }
1808        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1809        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1810        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1811    }
1812
1813    impl App {
1814        /// Dispatch a key event through the focus ancestor chain.
1815        /// Returns true if the event was consumed by a handler.
1816        fn dispatch_focus_key_event(
1817            &self,
1818            key_event: &winit::event::KeyEvent,
1819            mapped_key: &repose_core::input::Key,
1820        ) -> bool {
1821            let Some(f) = &self.frame_cache else {
1822                return false;
1823            };
1824            let Some(focused) = self.sched.focused else {
1825                return false;
1826            };
1827            let utf16 = match mapped_key {
1828                repose_core::input::Key::Character(c) => *c as u16,
1829                _ => 0,
1830            };
1831            let mods = self.modifiers;
1832            let repeat = key_event.repeat;
1833            let ev_type = if key_event.state == ElementState::Pressed {
1834                repose_core::input::KeyEventType::Down
1835            } else {
1836                repose_core::input::KeyEventType::Up
1837            };
1838            let hit_by_id: std::collections::HashMap<u64, &HitRegion> =
1839                f.hit_regions.iter().map(|h| (h.id, h)).collect();
1840            let sem_parent_of: std::collections::HashMap<u64, u64> = f
1841                .semantics_nodes
1842                .iter()
1843                .filter_map(|n| n.parent.map(|p| (n.id, p)))
1844                .collect();
1845            let mut ancestors = Vec::new();
1846            let mut cur = focused;
1847            loop {
1848                ancestors.push(cur);
1849                if let Some(&p) = sem_parent_of.get(&cur) {
1850                    cur = p;
1851                } else {
1852                    break;
1853                }
1854            }
1855            let make_ke = || repose_core::input::KeyEvent {
1856                key: mapped_key.clone(),
1857                modifiers: mods,
1858                is_repeat: repeat,
1859                event_type: ev_type,
1860                utf16_code_point: utf16,
1861            };
1862            // Top-down preview: root → focused
1863            for &id in ancestors.iter().rev() {
1864                if let Some(hit) = hit_by_id.get(&id) {
1865                    if let Some(cb) = &hit.on_preview_key_event {
1866                        if cb(make_ke()) {
1867                            return true;
1868                        }
1869                    }
1870                }
1871            }
1872            // Bottom-up normal: focused → root
1873            for &id in ancestors.iter() {
1874                if let Some(hit) = hit_by_id.get(&id) {
1875                    if let Some(cb) = &hit.on_key_event {
1876                        if cb(make_ke()) {
1877                            return true;
1878                        }
1879                    }
1880                }
1881            }
1882            false
1883        }
1884
1885        fn announce_focus_change(&mut self) {
1886            if let Some(f) = &self.frame_cache {
1887                let focused_node = self
1888                    .sched
1889                    .focused
1890                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1891                self.a11y.focus_changed(focused_node);
1892            }
1893        }
1894
1895        fn notify_text_change(&self, id: u64, text: String) {
1896            if let Some(f) = &self.frame_cache
1897                && let Some(h) = f.hit_regions.iter().find(|h| h.id == id)
1898                && let Some(cb) = &h.on_text_change
1899            {
1900                cb(text);
1901            }
1902        }
1903
1904        fn tf_key_of(&self, visual_id: u64) -> u64 {
1905            rc::tf_key_of_in_frame(&self.frame_cache, visual_id)
1906        }
1907
1908        fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
1909            use repose_core::shortcuts;
1910
1911            if let (Some(f), Some(fid)) = (&self.frame_cache, self.sched.focused)
1912                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
1913                && let Some(cb) = &hit.on_action
1914                && cb(action.clone())
1915            {
1916                return true;
1917            }
1918
1919            if shortcuts::handle(action.clone()) {
1920                return true;
1921            }
1922
1923            // Focus navigation (Tab/arrows)
1924            if let Some(f) = &self.frame_cache {
1925                if let Some(new_id) = repose_core::focus::handle_action(&action, &mut self.sched, f)
1926                {
1927                    if let Some(active) = self.key_pressed_active.take() {
1928                        self.pressed_ids.remove(&active);
1929                    }
1930                    let tf_state_key = f
1931                        .hit_regions
1932                        .iter()
1933                        .find(|h| h.id == new_id)
1934                        .and_then(|h| h.tf_state_key);
1935                    if let Some(key) = tf_state_key {
1936                        self.textfield_states.entry(key).or_insert_with(|| {
1937                            Rc::new(RefCell::new(repose_ui::TextFieldState::new()))
1938                        });
1939                        if let Some(state_rc) = self.textfield_states.get(&key) {
1940                            state_rc.borrow_mut().reset_caret_blink();
1941                        }
1942                    }
1943                    if let Some(win) = &self.window {
1944                        let is_textfield = f.semantics_nodes.iter().any(|n| {
1945                            n.id == new_id && n.role == repose_core::semantics::Role::TextField
1946                        });
1947                        rc_web::set_ime_for_textfield(win, is_textfield);
1948                    }
1949                    self.announce_focus_change();
1950                    return true;
1951                }
1952            }
1953
1954            false
1955        }
1956
1957        fn dispatch_file_drop_now(&mut self) {
1958            let Some(f) = &self.frame_cache else {
1959                self.pending_dropped_files.clear();
1960                self.pending_drop_pos_px = None;
1961                return;
1962            };
1963
1964            if self.pending_dropped_files.is_empty() {
1965                return;
1966            }
1967
1968            let pos_px = self.pending_drop_pos_px.unwrap_or(self.mouse_pos_px);
1969            let pos = Vec2 {
1970                x: pos_px.0,
1971                y: pos_px.1,
1972            };
1973
1974            let mut files = Vec::new();
1975            for p in self.pending_dropped_files.drain(..) {
1976                let name = p
1977                    .file_name()
1978                    .and_then(|s| s.to_str())
1979                    .unwrap_or("file")
1980                    .to_string();
1981                files.push(repose_core::dnd::DroppedFile {
1982                    name,
1983                    path: Some(p),
1984                });
1985            }
1986
1987            let payload: repose_core::dnd::DragPayload =
1988                std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1989
1990            let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1991                self.pending_drop_pos_px = None;
1992                return;
1993            };
1994
1995            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1996                && let Some(cb) = &hit.on_drop
1997            {
1998                let accepted = cb(repose_core::dnd::DropEvent {
1999                    source_id: 0, // external source (OS)
2000                    target_id,
2001                    position: pos,
2002                    modifiers: self.modifiers,
2003                    payload: payload.clone(),
2004                });
2005
2006                if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
2007                {
2008                    let label = node.label.as_deref().unwrap_or("");
2009                    self.a11y.announce(&format!("Dropped files on {}", label));
2010                }
2011            }
2012
2013            self.pending_drop_pos_px = None;
2014            self.request_redraw();
2015        }
2016    }
2017
2018    let event_loop = EventLoop::new()?;
2019    set_event_loop_proxy(event_loop.create_proxy());
2020    let mut app = App::new(Box::new(root));
2021    // Install system clock once
2022    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
2023    event_loop.run_app(&mut app)?;
2024    Ok(())
2025}
2026
2027// Accessibility bridge stub (Noop by default; logs on Linux for now)
2028/// Bridge from Repose's semantics tree to platform accessibility APIs.
2029///
2030/// Implementations are responsible for:
2031/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
2032/// - Updating focus when `focus_changed` is called.
2033/// - Announcing transient messages (e.g. button activation) via screen readers.
2034pub trait A11yBridge: Send {
2035    /// Publish (or update) the full semantics tree for the current frame.
2036    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
2037
2038    /// Notify that the focused node has changed. `None` means focus cleared.
2039    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
2040
2041    /// Announce a one‑off message via the platform's accessibility channel.
2042    fn announce(&mut self, msg: &str);
2043}
2044
2045struct NoopA11y;
2046impl A11yBridge for NoopA11y {
2047    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
2048        // no-op
2049    }
2050    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
2051        if let Some(n) = node {
2052            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
2053        } else {
2054            log::info!("A11y focus: None");
2055        }
2056    }
2057    fn announce(&mut self, msg: &str) {
2058        log::info!("A11y announce: {msg}");
2059    }
2060}
2061
2062#[cfg(target_os = "linux")]
2063struct LinuxAtspiStub;
2064#[cfg(target_os = "linux")]
2065impl A11yBridge for LinuxAtspiStub {
2066    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
2067        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
2068    }
2069    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
2070        if let Some(n) = node {
2071            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
2072        } else {
2073            log::info!("AT-SPI stub focus: None");
2074        }
2075    }
2076    fn announce(&mut self, msg: &str) {
2077        log::info!("AT-SPI stub announce: {msg}");
2078    }
2079}