Skip to main content

repose_platform/
lib.rs

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