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