Skip to main content

repose_platform/
lib.rs

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