Skip to main content

repose_platform/
lib.rs

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