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