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