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