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