Skip to main content

repose_platform/
lib.rs

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