Skip to main content

repose_platform/
lib.rs

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