Skip to main content

repose_platform/
lib.rs

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