Skip to main content

repose_platform/
lib.rs

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