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, TextMeasureConfig, caret_xy_for_byte, measure_text,
9};
10use std::cell::{Cell, RefCell};
11use std::rc::Rc;
12use std::sync::atomic::{AtomicBool, AtomicU8, 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/// Coarse application lifecycle state, derived from the runner's
64/// `suspended` or `resumed` callbacks (eg. Android activity pause/resume).
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum AppLifecycle {
67    /// Surface available and the activity is interactive (after `resumed`).
68    Foreground,
69    /// Surface torn down / activity no longer interactive (after `suspended`).
70    Background,
71}
72
73// 0 = unknown, 1 = Foreground, 2 = Background
74static CURRENT_LIFECYCLE: AtomicU8 = AtomicU8::new(0);
75static LIFECYCLE_CB: Mutex<Option<Box<dyn Fn(AppLifecycle) + Send>>> = Mutex::new(None);
76static PENDING_LIFECYCLE: Mutex<Vec<AppLifecycle>> = Mutex::new(Vec::new());
77
78/// Register a callback for coarse app lifecycle (foreground/background).
79///
80/// Safe to call from any thread. Deliveries are coalesced to the latest state
81/// and dispatched on the UI loop via `about_to_wait` (same pattern as deeplinks).
82pub fn set_on_lifecycle(callback: Box<dyn Fn(AppLifecycle) + Send>) {
83    *LIFECYCLE_CB.lock().unwrap() = Some(callback);
84}
85
86/// Current lifecycle state, if the runner has reported one yet.
87pub fn current_lifecycle() -> Option<AppLifecycle> {
88    match CURRENT_LIFECYCLE.load(Ordering::Relaxed) {
89        1 => Some(AppLifecycle::Foreground),
90        2 => Some(AppLifecycle::Background),
91        _ => None,
92    }
93}
94
95/// Queue a lifecycle transition and wake the UI loop. Called by platform runners
96/// (e.g. from `suspended` / `resumed`), which already run on the UI thread.
97pub(crate) fn push_lifecycle(state: AppLifecycle) {
98    let code = match state {
99        AppLifecycle::Foreground => 1,
100        AppLifecycle::Background => 2,
101    };
102    CURRENT_LIFECYCLE.store(code, Ordering::Relaxed);
103    PENDING_LIFECYCLE.lock().unwrap().push(state);
104    #[cfg(not(target_arch = "wasm32"))]
105    wake_event_loop();
106}
107
108/// Drain queued lifecycle transitions and dispatch the latest to the callback.
109/// Called from each platform runner's `about_to_wait` handler.
110pub(crate) fn process_lifecycle() {
111    let batch = std::mem::take(&mut *PENDING_LIFECYCLE.lock().unwrap());
112    if batch.is_empty() {
113        return;
114    }
115    // Coalesce to the last state if multiple transitions fired in one pump.
116    if let Some(last) = batch.last().copied()
117        && let Some(cb) = LIFECYCLE_CB.lock().unwrap().as_ref()
118    {
119        cb(last);
120    }
121}
122
123/// Register a callback to receive deeplink payloads (raw bytes)
124pub fn set_on_deeplink(callback: Box<dyn Fn(Vec<u8>) + Send>) {
125    *DEEPLINK_CB.lock().unwrap() = Some(callback);
126}
127
128/// Push a deeplink payload from any thread (JNI callback, CLI watcher, etc).
129pub fn push_deeplink(data: Vec<u8>) {
130    PENDING_DEEPLINKS.lock().unwrap().push(data);
131    #[cfg(not(target_arch = "wasm32"))]
132    if let Some(proxy) = EVENT_LOOP_PROXY.get() {
133        let _ = proxy.send_event(());
134    }
135}
136
137/// Drain queued deeplinks and dispatch them to the registered callback.
138/// Called from each platform runner's `about_to_wait` handler.
139pub(crate) fn process_deeplinks() {
140    let mut queue = PENDING_DEEPLINKS.lock().unwrap();
141    if queue.is_empty() {
142        return;
143    }
144    let batch = std::mem::take(&mut *queue);
145    drop(queue);
146
147    if let Some(cb) = DEEPLINK_CB.lock().unwrap().as_ref() {
148        for data in batch {
149            cb(data);
150        }
151    }
152}
153
154/// Store the application window handle (called once during app setup).
155#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
156pub fn set_app_window(window: Arc<Window>) {
157    let _ = APP_WINDOW.set(window);
158}
159
160/// Store the event loop proxy so tray commands / deeplinks can wake the event loop.
161#[cfg(not(target_arch = "wasm32"))]
162pub fn set_event_loop_proxy(proxy: winit::event_loop::EventLoopProxy<()>) {
163    let _ = EVENT_LOOP_PROXY.set(proxy);
164}
165
166/// Register a callback invoked on every AboutToWait (used for draining tray commands).
167#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
168pub fn set_about_to_wait_callback(cb: Box<dyn Fn() + Send>) {
169    *ABOUT_TO_WAIT_CALLBACK.lock().unwrap() = Some(cb);
170}
171
172/// Wake the winit event loop from another thread (e.g. tray's GTK thread, JNI callback).
173#[cfg(not(target_arch = "wasm32"))]
174pub fn wake_event_loop() {
175    if let Some(proxy) = EVENT_LOOP_PROXY.get() {
176        let _ = proxy.send_event(());
177    }
178}
179
180/// Show the application window.
181///
182/// On Wayland, unminimizing might not be supported by the protocol?
183#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
184pub fn show_app_window() {
185    WINDOW_VISIBLE.store(true, Ordering::Relaxed);
186    if let Some(w) = APP_WINDOW.get() {
187        log::info!("show_app_window: calling set_visible(true)");
188        w.set_visible(true);
189        #[allow(deprecated)]
190        w.focus_window();
191    }
192    repose_core::frame_clock::request_frame();
193    wake_event_loop();
194}
195
196#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
197pub fn hide_app_window() {
198    WINDOW_VISIBLE.store(false, Ordering::Relaxed);
199    if let Some(w) = APP_WINDOW.get() {
200        log::info!("hide_app_window: calling set_visible(false)");
201        w.set_visible(false);
202    }
203    repose_core::frame_clock::request_frame();
204    wake_event_loop();
205}
206
207/// Returns whether the application window is currently visible.
208#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
209pub fn window_is_visible() -> bool {
210    WINDOW_VISIBLE.load(Ordering::Relaxed)
211}
212
213/// The close button hides the window (via ``set_visible(false)``) instead of
214/// closing. The tray "Quit" action still exits the process regardless.
215#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
216pub fn set_close_to_tray(enabled: bool) {
217    CLOSE_TO_TRAY.store(enabled, Ordering::Relaxed);
218}
219
220/// Compose a single frame with density and text-scale applied, returning Frame.
221pub fn compose_frame<F>(
222    sched: &mut Scheduler,
223    root_fn: &mut F,
224    scale: f32,
225    size_px_u32: (u32, u32),
226    hover_id: Option<u64>,
227    pressed_ids: &std::collections::HashSet<u64>,
228    tf_states: &std::collections::HashMap<u64, Rc<RefCell<repose_ui::TextFieldState>>>,
229    _focused: Option<u64>,
230) -> Frame
231where
232    F: FnMut(&mut Scheduler) -> View,
233{
234    // Process any programmatic focus request from FocusRequester
235    if let Some(requested_id) = repose_core::take_focus_request() {
236        if requested_id == repose_core::runtime::CLEAR_FOCUS_MARKER {
237            sched.focused = None;
238        } else {
239            sched.focused = Some(requested_id);
240        }
241    }
242
243    set_density_default(Density { scale });
244
245    // Use scheduler's focused state (which may have been updated by focus request)
246    let current_focused = sched.focused;
247
248    let frame = sched.repose(
249        {
250            let scale = scale;
251            move |s: &mut Scheduler| with_density(Density { scale }, || (root_fn)(s))
252        },
253        {
254            let hover_id = hover_id;
255            let pressed_ids = pressed_ids.clone();
256            move |view, _size| {
257                let interactions = repose_ui::Interactions {
258                    hover: hover_id,
259                    pressed: pressed_ids.clone(),
260                };
261
262                with_density(Density { scale }, || {
263                    repose_ui::layout_and_paint(
264                        view,
265                        size_px_u32,
266                        tf_states,
267                        &interactions,
268                        current_focused,
269                    )
270                })
271            }
272        },
273    );
274
275    if let Some(fid) = sched.focused {
276        if !frame.focus_chain.contains(&fid) {
277            sched.focused = None;
278        }
279    }
280
281    frame
282}
283
284pub(crate) fn next_caret_blink_deadline(
285    sched: &Scheduler,
286    frame_cache: &Option<Frame>,
287    textfield_states: &std::collections::HashMap<u64, Rc<RefCell<TextFieldState>>>,
288) -> Option<Instant> {
289    let fid = sched.focused?;
290    let frame = frame_cache.as_ref()?;
291    let hit = frame.hit_regions.iter().find(|h| h.id == fid)?;
292    let key = hit.tf_state_key?;
293    textfield_states.get(&key)?.borrow().next_blink_deadline()
294}
295
296/// Helper: ensure caret visibility for a TextFieldState inside a given rect (px).
297pub fn tf_ensure_visible_in_rect(state: &mut repose_ui::TextFieldState, inner_rect: Rect) {
298    let font_px = dp_to_px(TF_FONT_DP) * repose_core::locals::text_scale().0;
299    let m = measure_text(&state.text, font_px, TextMeasureConfig::default());
300    let caret_x_px = m.positions.get(state.caret_index()).copied().unwrap_or(0.0);
301    state.ensure_caret_visible(
302        caret_x_px,
303        inner_rect.w - 2.0 * dp_to_px(TF_PADDING_X_DP),
304        dp_to_px(2.0),
305    );
306}
307
308/// Convert a winit `KeyEvent` + mapped `Key` + modifiers into a repose `KeyEvent`.
309fn winit_key_to_repose(
310    ev: &winit::event::KeyEvent,
311    mapped_key: &repose_core::input::Key,
312    mods: &repose_core::input::Modifiers,
313) -> repose_core::input::KeyEvent {
314    let utf16 = match mapped_key {
315        repose_core::input::Key::Character(c) => *c as u16,
316        _ => 0,
317    };
318    repose_core::input::KeyEvent {
319        key: mapped_key.clone(),
320        modifiers: *mods,
321        is_repeat: ev.repeat,
322        event_type: if ev.state == winit::event::ElementState::Pressed {
323            repose_core::input::KeyEventType::Down
324        } else {
325            repose_core::input::KeyEventType::Up
326        },
327        utf16_code_point: utf16,
328    }
329}
330
331#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
332fn map_cursor(c: repose_core::CursorIcon) -> winit::window::CursorIcon {
333    use winit::window::CursorIcon as W;
334    match c {
335        repose_core::CursorIcon::Default => W::Default,
336        repose_core::CursorIcon::Pointer => W::Pointer,
337        repose_core::CursorIcon::Text => W::Text,
338        repose_core::CursorIcon::EwResize => W::EwResize,
339        repose_core::CursorIcon::NsResize => W::NsResize,
340        repose_core::CursorIcon::Grab => W::Grab,
341        repose_core::CursorIcon::Grabbing => W::Grabbing,
342    }
343}
344
345#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
346pub fn run_desktop_app(
347    root: impl FnMut(&mut Scheduler, &RenderContext) -> View + 'static,
348) -> anyhow::Result<()> {
349    use std::collections::{HashMap, HashSet};
350    use winit::application::ApplicationHandler;
351    use winit::dpi::{LogicalPosition, LogicalSize, PhysicalSize};
352    use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
353    use winit::event_loop::EventLoop;
354    use winit::keyboard::{KeyCode, PhysicalKey};
355    use winit::window::{Window, WindowAttributes};
356
357    use crate::a11y::A11yTree;
358    use repose_app::ReposeRuntime;
359
360    struct ReposeActivationHandler {
361        initial_tree: Option<accesskit::TreeUpdate>,
362    }
363
364    impl accesskit::ActivationHandler for ReposeActivationHandler {
365        fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
366            self.initial_tree.take()
367        }
368    }
369
370    struct ReposeDeactivationHandler;
371
372    impl accesskit::DeactivationHandler for ReposeDeactivationHandler {
373        fn deactivate_accessibility(&mut self) {
374            // Nothing to clean up for now
375        }
376    }
377
378    struct App {
379        root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
380        render: RenderContext,
381        window: Option<Arc<Window>>,
382        backend: Option<repose_render_wgpu::WgpuBackend>,
383        rt: ReposeRuntime,
384        inspector: repose_devtools::Inspector,
385
386        // Files
387        pending_dropped_files: Vec<std::path::PathBuf>,
388        pending_drop_pos_px: Option<(f32, f32)>,
389
390        // External file drag hover (HoveredFile / Cancelled)
391        external_file_drag: bool,
392        hovered_files: Vec<std::path::PathBuf>,
393
394        clipboard: Option<clipawl::Clipboard>,
395        a11y: Box<dyn A11yBridge>,
396
397        accesskit_adapter: Option<Adapter>,
398        a11y_actions: Arc<Mutex<Vec<accesskit::ActionRequest>>>,
399        a11y_tree: A11yTree,
400
401        last_redraw: Instant,
402        pending_redraw: bool,
403
404        // Tracks whether a redraw was requested by app code
405        redraw_requested: Cell<bool>,
406    }
407
408    impl App {
409        fn process_a11y_actions(&mut self) {
410            let mut actions = self.a11y_actions.lock().unwrap();
411            if actions.is_empty() {
412                return;
413            }
414            let pending = actions.drain(..).collect::<Vec<_>>();
415            drop(actions);
416
417            let Some(f) = &self.rt.frame_cache else {
418                return;
419            };
420
421            for req in pending {
422                let target_id = req.target_node.0;
423                match req.action {
424                    accesskit::Action::Click => {
425                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
426                            && let Some(cb) = &hit.on_click
427                        {
428                            cb();
429                            self.request_redraw();
430                        }
431                    }
432                    accesskit::Action::Focus => {
433                        self.rt.sched.focused = Some(target_id);
434                        self.request_redraw();
435                    }
436                    _ => {}
437                }
438            }
439        }
440
441        fn new(root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>) -> Self {
442            Self {
443                root,
444                render: RenderContext::new(),
445                window: None,
446                backend: None,
447                rt: ReposeRuntime::new(),
448                inspector: repose_devtools::Inspector::new(),
449                pending_dropped_files: Vec::new(),
450                pending_drop_pos_px: None,
451
452                external_file_drag: false,
453                hovered_files: Vec::new(),
454
455                clipboard: None,
456                a11y: {
457                    #[cfg(target_os = "linux")]
458                    {
459                        Box::new(LinuxAtspiStub) as Box<dyn A11yBridge>
460                    }
461                    #[cfg(not(target_os = "linux"))]
462                    {
463                        Box::new(NoopA11y) as Box<dyn A11yBridge>
464                    }
465                },
466
467                accesskit_adapter: None,
468                a11y_actions: Arc::new(Mutex::new(Vec::new())),
469                a11y_tree: A11yTree::default(),
470
471                last_redraw: Instant::now(),
472                pending_redraw: false,
473                redraw_requested: Cell::new(false),
474            }
475        }
476
477        fn request_redraw(&self) {
478            self.redraw_requested.set(true);
479            repose_core::request_frame();
480            rc::request_redraw(&self.window);
481        }
482
483        // Ensure caret is visible after edits/moves (all units in px)
484        fn tf_ensure_caret_visible(st: &mut TextFieldState, is_multiline: bool) {
485            rc::tf_ensure_caret_visible(st, is_multiline);
486        }
487
488        fn paste_from_primary(&self) -> Option<String> {
489            let mut opts = clipawl::ClipboardOptions::default();
490            opts.linux.selection = clipawl::LinuxSelection::Primary;
491            if let Ok(cb) = clipawl::Clipboard::new_with_options(opts) {
492                match pollster::block_on(cb.read()) {
493                    Ok(t) => Some(t),
494                    Err(e) => {
495                        eprintln!("Primary paste error: {}", e);
496                        None
497                    }
498                }
499            } else {
500                None
501            }
502        }
503
504        fn process_render_commands(&mut self) {
505            let Some(backend) = self.backend.as_mut() else {
506                return;
507            };
508            rc::process_render_commands(backend, self.render.drain());
509        }
510
511        fn reset_pointer_state(&mut self) {
512            self.rt.capture_id = None;
513            self.rt.pressed_ids.clear();
514            self.rt.hover_id = None;
515        }
516
517        fn is_textfield(&self, id: u64) -> bool {
518            rc::is_textfield_in_frame(&self.rt.frame_cache, id)
519        }
520
521        fn is_multiline_id(&self, id: u64) -> bool {
522            if let Some(f) = &self.rt.frame_cache {
523                f.hit_regions
524                    .iter()
525                    .find(|h| h.id == id)
526                    .map(|h| h.tf_multiline)
527                    .unwrap_or(false)
528            } else {
529                false
530            }
531        }
532
533        fn hit_by_id(f: &Frame, id: u64) -> Option<&HitRegion> {
534            f.hit_regions.iter().find(|h| h.id == id)
535        }
536
537        fn dp_px(&self, dp: f32) -> f32 {
538            dp_to_px(dp)
539        }
540    }
541
542    impl ApplicationHandler<()> for App {
543        fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
544            self.clipboard = clipawl::Clipboard::new()
545                .map_err(|e| {
546                    eprintln!("clipawl clipboard init failed: {e}");
547                    e
548                })
549                .ok();
550            repose_core::clipboard::set_clipboard_read_fn(Box::new(|| {
551                clipawl::blocking::read().ok()
552            }));
553            // Register for SelectableText (Ctrl+C) - use blocking API directly
554            repose_core::clipboard::set_clipboard_fn(Box::new(move |text| {
555                if let Err(e) = clipawl::blocking::write(text) {
556                    eprintln!("clipboard write error: {e}");
557                }
558            }));
559
560            repose_core::clipboard::set_primary_fn(Box::new(|text| {
561                let mut opts = clipawl::ClipboardOptions::default();
562                opts.linux.selection = clipawl::LinuxSelection::Primary;
563                match clipawl::Clipboard::new_with_options(opts) {
564                    Ok(cb) => {
565                        if let Err(e) = pollster::block_on(cb.write(text)) {
566                            eprintln!("primary selection write error: {e}");
567                        }
568                    }
569                    Err(e) => eprintln!("primary clipboard init error: {e}"),
570                }
571            }));
572
573            if self.window.is_none() {
574                match el.create_window(
575                    WindowAttributes::default()
576                        .with_title("Repose")
577                        .with_inner_size(PhysicalSize::new(1280, 800))
578                        .with_visible(false),
579                ) {
580                    Ok(win) => {
581                        let w = Arc::new(win);
582
583                        let activation_handler = ReposeActivationHandler {
584                            initial_tree: Some(A11yTree::initial_tree()),
585                        };
586
587                        let action_handler = ReposeActionHandler {
588                            pending_actions: self.a11y_actions.clone(),
589                        };
590
591                        let deactivation_handler = ReposeDeactivationHandler;
592
593                        let adapter = Adapter::with_direct_handlers(
594                            el,
595                            &w,
596                            activation_handler,
597                            action_handler,
598                            deactivation_handler,
599                        );
600
601                        self.accesskit_adapter = Some(adapter);
602
603                        w.set_visible(true);
604
605                        let size = w.inner_size();
606                        let sf = w.scale_factor() as f32;
607                        self.rt.set_viewport_and_scale(size.width, size.height, sf);
608
609                        match repose_render_wgpu::WgpuBackend::new(w.clone()) {
610                            Ok(b) => {
611                                self.backend = Some(b);
612                                set_app_window(w.clone());
613                                self.window = Some(w);
614                                self.request_redraw();
615                            }
616                            Err(e) => {
617                                log::error!("Failed to create WGPU backend: {e:?}");
618                                el.exit();
619                            }
620                        }
621                    }
622                    Err(e) => {
623                        log::error!("Failed to create window: {e:?}");
624                        el.exit();
625                    }
626                }
627            }
628        }
629
630        fn window_event(
631            &mut self,
632            el: &winit::event_loop::ActiveEventLoop,
633            _id: winit::window::WindowId,
634            event: WindowEvent,
635        ) {
636            // Process AccessKit events first!
637            if let Some(adapter) = &mut self.accesskit_adapter {
638                adapter.process_event(self.window.as_ref().unwrap(), &event);
639            }
640
641            match event {
642                WindowEvent::CloseRequested => {
643                    if CLOSE_TO_TRAY.load(Ordering::Relaxed) {
644                        // Drop GPU backend before null-buffer unmap.
645                        self.backend = None;
646                        if let Some(w) = &self.window {
647                            w.set_visible(false);
648                        }
649                        WINDOW_VISIBLE.store(false, Ordering::Relaxed);
650                    } else {
651                        el.exit();
652                    }
653                }
654
655                WindowEvent::Focused(false) => {
656                    // Delegate all common focus-lost cleanup to the runtime
657                    self.rt.handle_focus_lost();
658
659                    // Platform-specific cleanup
660                    self.external_file_drag = false;
661                    self.hovered_files.clear();
662
663                    if let Some(w) = &self.window {
664                        rc_web::set_ime_for_textfield(w, false);
665                    }
666
667                    self.request_redraw();
668                }
669
670                WindowEvent::HoveredFile(path) => {
671                    // Mark external drag active and keep a small bounded list
672                    self.external_file_drag = true;
673                    if self.hovered_files.len() < 32 {
674                        self.hovered_files.push(path);
675                    }
676                    // Update drop position (best effort)
677                    if self.pending_drop_pos_px.is_none() {
678                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
679                    }
680                    self.request_redraw();
681                }
682
683                WindowEvent::HoveredFileCancelled => {
684                    self.external_file_drag = false;
685                    self.hovered_files.clear();
686
687                    // Defensive: cancel any internal capture/drag that might be left stuck
688                    self.reset_pointer_state();
689
690                    self.request_redraw();
691                }
692
693                WindowEvent::DroppedFile(path) => {
694                    // DroppedFile is emitted once per file. Batch them.
695                    self.pending_dropped_files.push(path);
696                    if self.pending_drop_pos_px.is_none() {
697                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
698                    }
699
700                    // Drop ends the external file drag session.
701                    self.external_file_drag = false;
702                    self.hovered_files.clear();
703
704                    self.request_redraw();
705                }
706
707                WindowEvent::Resized(size) => {
708                    let sf = self.window.as_ref().map(|w| w.scale_factor() as f32).unwrap_or(1.0);
709                    self.rt.set_viewport_and_scale(size.width, size.height, sf);
710                    if let Some(b) = self.backend.as_mut() {
711                        b.configure_surface(size.width, size.height);
712                    }
713                    if let Some(w) = &self.window {
714                        let sf = w.scale_factor() as f32;
715                        let dp_w = size.width as f32 / sf;
716                        let dp_h = size.height as f32 / sf;
717                        log::info!(
718                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
719                            size.width,
720                            size.height,
721                            sf,
722                            dp_w as i32,
723                            dp_h as i32
724                        );
725                    }
726                    self.request_redraw();
727                }
728
729                WindowEvent::CursorMoved { position, .. } => {
730                    if self.external_file_drag {
731                        self.pending_drop_pos_px = Some((position.x as f32, position.y as f32));
732                    }
733
734                    let pos = Vec2 {
735                        x: position.x as f32,
736                        y: position.y as f32,
737                    };
738
739                    // Delegate pointer-move to the host runtime
740                    let result = self.rt.handle_pointer_move(pos);
741
742                    // Inspector hover (platform-specific - devtools inspect)
743                    if self.inspector.hud.inspector_enabled
744                        && let Some(f) = &self.rt.frame_cache
745                    {
746                        let hit = f.hit_regions.iter().find(|h| {
747                            h.rect.contains(pos)
748                        });
749                        let hover_rect = hit.map(|h| h.rect);
750                        let hover_info = hit.and_then(|h| {
751                            f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
752                                repose_devtools::HoveredInfo {
753                                    id: s.id,
754                                    role: format!("{:?}", s.role),
755                                    label: s.label.clone(),
756                                }
757                            })
758                        });
759                        self.inspector.hud.set_hovered(hover_rect, hover_info);
760                    }
761
762                    // Cursor icon via winit window
763                    if let Some(win) = &self.window {
764                        if let Some(c) = result.cursor {
765                            win.set_cursor(winit::window::Cursor::Icon(map_cursor(c)));
766                        }
767                    }
768
769                    self.request_redraw();
770                }
771
772                WindowEvent::MouseWheel { delta, .. } => {
773                    let (dx_px, dy_px) = match delta {
774                        MouseScrollDelta::LineDelta(x, y) => {
775                            let unit_px = dp_to_px(60.0);
776                            (-(x * unit_px), -(y * unit_px))
777                        }
778                        MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
779                    };
780                    log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
781
782                    if self.rt.handle_scroll(Vec2 { x: dx_px, y: dy_px }) {
783                        self.request_redraw();
784                    }
785                }
786
787                WindowEvent::MouseInput {
788                    state: ElementState::Pressed,
789                    button: MouseButton::Left,
790                    ..
791                } => {
792                    let pos = Vec2 {
793                        x: self.rt.mouse_pos_px.0,
794                        y: self.rt.mouse_pos_px.1,
795                    };
796
797                    let result = self.rt.handle_pointer_press(pos, PointerButton::Primary);
798
799                    // Platform-specific IME setup for focused textfields
800                    if let Some(fid) = result.focused {
801                        if let Some(win) = &self.window
802                            && let Some(f) = &self.rt.frame_cache
803                            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
804                        {
805                            let sf = win.scale_factor();
806                            rc_web::set_ime_for_textfield(win, true);
807                            win.set_ime_cursor_area(
808                                LogicalPosition::new(
809                                    hit.rect.x as f64 / sf,
810                                    hit.rect.y as f64 / sf,
811                                ),
812                                LogicalSize::new(
813                                    hit.rect.w as f64 / sf,
814                                    hit.rect.h as f64 / sf,
815                                ),
816                            );
817                        }
818                    }
819
820                    // Click outside - no focus result from runtime, drop IME
821                    if result.focused.is_none() && self.rt.ime_preedit {
822                        if let Some(win) = &self.window {
823                            rc_web::set_ime_for_textfield(win, false);
824                        }
825                        self.rt.ime_preedit = false;
826                    }
827
828                    if result.needs_a11y_announce {
829                        self.announce_focus_change();
830                    }
831
832                    self.request_redraw();
833                }
834
835                WindowEvent::MouseInput {
836                    state: ElementState::Pressed,
837                    button: MouseButton::Middle,
838                    ..
839                } => {
840                    let Some(f) = &self.rt.frame_cache else {
841                        return;
842                    };
843                    let pos = Vec2 {
844                        x: self.rt.mouse_pos_px.0,
845                        y: self.rt.mouse_pos_px.1,
846                    };
847                    if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos)) {
848                        // Dispatch Tertiary pointer event
849                        if let Some(cb) = &hit.on_pointer_down {
850                            cb(PointerEvent::new(
851                                PointerId(0),
852                                PointerKind::Mouse,
853                                PointerEventKind::Down(PointerButton::Tertiary),
854                                pos,
855                                1.0,
856                                self.rt.modifiers,
857                            ));
858                        }
859                        // Paste primary selection into textfield
860                        if self.is_textfield(hit.id) {
861                            let key = self.tf_key_of(hit.id);
862                            if let Some(state_rc) = self.rt.textfield_states.get(&key) {
863                                if let Some(txt) = self.paste_from_primary() {
864                                    let mut st = state_rc.borrow_mut();
865                                    st.insert_text_atomic(&txt);
866                                    self.notify_text_change(hit.id, st.text.clone());
867                                    if let Some(f) = &self.rt.frame_cache
868                                        && let Some(h) =
869                                            f.hit_regions.iter().find(|h| h.id == hit.id)
870                                    {
871                                        App::tf_ensure_caret_visible(&mut st, h.tf_multiline);
872                                    }
873                                }
874                            }
875                        }
876                    }
877                    self.request_redraw();
878                }
879
880                WindowEvent::MouseInput {
881                    state: ElementState::Released,
882                    button: MouseButton::Left,
883                    ..
884                } => {
885                    let pos = Vec2 {
886                        x: self.rt.mouse_pos_px.0,
887                        y: self.rt.mouse_pos_px.1,
888                    };
889
890                    self.rt.handle_pointer_release(pos, PointerButton::Primary);
891
892                    // A11y: announce activation when a click fires on release
893                    if let (Some(f), Some(cid)) = (&self.rt.frame_cache, self.rt.capture_id) {
894                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
895                            && hit.rect.contains(pos)
896                            && hit.on_click.is_some()
897                            && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid)
898                        {
899                            let label = node.label.as_deref().unwrap_or("");
900                            self.a11y.announce(&format!("Activated {}", label));
901                        }
902                    }
903
904                    repose_core::request_frame();
905                }
906
907                WindowEvent::MouseInput {
908                    state: ElementState::Released,
909                    button: MouseButton::Middle,
910                    ..
911                } => {
912                    if let Some(f) = &self.rt.frame_cache {
913                        let pos = Vec2 {
914                            x: self.rt.mouse_pos_px.0,
915                            y: self.rt.mouse_pos_px.1,
916                        };
917                        if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
918                        {
919                            if let Some(cb) = &hit.on_pointer_up {
920                                cb(PointerEvent::new(
921                                    PointerId(0),
922                                    PointerKind::Mouse,
923                                    PointerEventKind::Up(PointerButton::Tertiary),
924                                    pos,
925                                    1.0,
926                                    self.rt.modifiers,
927                                ));
928                            }
929                        }
930                    }
931                }
932
933                WindowEvent::ModifiersChanged(new_mods) => {
934                    let state = new_mods.state();
935                    self.rt.modifiers.shift = state.shift_key();
936                    self.rt.modifiers.ctrl = state.control_key();
937                    self.rt.modifiers.alt = state.alt_key();
938                    self.rt.modifiers.meta = state.super_key();
939                    self.rt.modifiers.command = if cfg!(target_os = "macos") {
940                        self.rt.modifiers.meta
941                    } else {
942                        self.rt.modifiers.ctrl
943                    };
944                }
945
946                WindowEvent::KeyboardInput {
947                    event: key_event, ..
948                } => {
949                    // --- Platform-specific shortcuts (before generic dispatch) ---
950
951                    // Escape / BrowserBack: cancel DnD, try focus chain, then navigation back
952                    if key_event.state == ElementState::Pressed && !key_event.repeat {
953                        match key_event.physical_key {
954                            PhysicalKey::Code(KeyCode::BrowserBack)
955                            | PhysicalKey::Code(KeyCode::Escape) => {
956                                use repose_navigation::back;
957
958                                if repose_core::dnd::handle_drag_action(
959                                    &repose_core::shortcuts::DragAction::Cancel,
960                                ) {
961                                    return;
962                                }
963
964                                // Try focus-ancestor dispatch without handle_key's always-true return
965                                let mapped = rc::map_key(key_event.physical_key);
966                                if self.dispatch_focus_key_event(&key_event, &mapped) {
967                                    self.request_redraw();
968                                    return;
969                                }
970
971                                if !back::handle() {
972                                    // el.exit();
973                                }
974                                return;
975                            }
976                            _ => {}
977                        }
978                    }
979
980                    // Inspector hotkey: Ctrl+Shift+I
981                    if key_event.state == ElementState::Pressed
982                        && self.rt.modifiers.ctrl
983                        && self.rt.modifiers.shift
984                        && key_event.physical_key == PhysicalKey::Code(KeyCode::KeyI)
985                    {
986                        self.inspector.hud.toggle_inspector();
987                        self.request_redraw();
988                        return;
989                    }
990
991                    // Text undo/redo (Ctrl+Z / Ctrl+Shift+Z)
992                    if key_event.state == ElementState::Pressed
993                        && !key_event.repeat
994                        && self.rt.modifiers.command
995                    {
996                        match key_event.physical_key {
997                            PhysicalKey::Code(KeyCode::KeyZ) if self.rt.modifiers.shift => {
998                                if let Some(fid) = self.rt.sched.focused {
999                                    let key = self.tf_key_of(fid);
1000                                    if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1001                                        let mut st = state_rc.borrow_mut();
1002                                        if st.can_redo() {
1003                                            st.redo();
1004                                            self.notify_text_change(fid, st.text.clone());
1005                                            self.request_redraw();
1006                                            return;
1007                                        }
1008                                    }
1009                                }
1010                            }
1011                            PhysicalKey::Code(KeyCode::KeyZ) => {
1012                                if let Some(fid) = self.rt.sched.focused {
1013                                    let key = self.tf_key_of(fid);
1014                                    if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1015                                        let mut st = state_rc.borrow_mut();
1016                                        if st.can_undo() {
1017                                            st.undo();
1018                                            self.notify_text_change(fid, st.text.clone());
1019                                            self.request_redraw();
1020                                            return;
1021                                        }
1022                                    }
1023                                }
1024                            }
1025                            _ => {}
1026                        }
1027                    }
1028
1029                    // --- Delegate all generic keyboard dispatch to the runtime ---
1030
1031                    let mapped = rc::map_key(key_event.physical_key);
1032                    let ke = winit_key_to_repose(&key_event, &mapped, &self.rt.modifiers);
1033                    let consumed = self.rt.handle_key(&ke);
1034                    if consumed {
1035                        self.request_redraw();
1036                        return;
1037                    }
1038
1039                    // --- Platform-specific text input (winit key_event.text) ---
1040                    // The runtime handles text via Key::Character, but we ALSO try
1041                    // winit's composed `key_event.text` for proper IME-less input
1042                    // on international keyboard layouts.
1043                    if key_event.state == ElementState::Pressed
1044                        && !key_event.repeat
1045                        && !self.rt.ime_preedit
1046                        && !self.rt.modifiers.ctrl
1047                        && !self.rt.modifiers.alt
1048                        && !self.rt.modifiers.meta
1049                        && let Some(raw) = key_event.text.as_deref()
1050                    {
1051                        let text: String = raw
1052                            .chars()
1053                            .filter(|c| !c.is_control() && *c != '\n' && *c != '\r')
1054                            .collect();
1055                        if !text.is_empty()
1056                            && let Some(fid) = self.rt.sched.focused
1057                        {
1058                            let key = self.tf_key_of(fid);
1059                            if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1060                                let mut st = state_rc.borrow_mut();
1061                                st.insert_text(&text);
1062                                self.notify_text_change(fid, st.text.clone());
1063                                if let Some(f) = &self.rt.frame_cache
1064                                    && let Some(hit) =
1065                                        f.hit_regions.iter().find(|h| h.id == fid)
1066                                {
1067                                    App::tf_ensure_caret_visible(&mut st, hit.tf_multiline);
1068                                }
1069                                self.request_redraw();
1070                                return;
1071                            }
1072                        }
1073                    }
1074
1075                    // --- A11y: keyboard activation announcement ---
1076                    if key_event.state == ElementState::Released
1077                        && let Some(active_id) = self.rt.key_pressed_active
1078                    {
1079                        match key_event.physical_key {
1080                            PhysicalKey::Code(KeyCode::Space)
1081                            | PhysicalKey::Code(KeyCode::Enter) => {
1082                                if let Some(f) = &self.rt.frame_cache
1083                                    && let Some(node) =
1084                                        f.semantics_nodes.iter().find(|n| n.id == active_id)
1085                                {
1086                                    let label = node.label.as_deref().unwrap_or("");
1087                                    self.a11y.announce(&format!("Activated {}", label));
1088                                }
1089                            }
1090                            _ => {}
1091                        }
1092                    }
1093                }
1094
1095                WindowEvent::Ime(ime) => {
1096                    if let Some(focused_id) = self.rt.sched.focused {
1097                        let key = self.tf_key_of(focused_id);
1098                        if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1099                            let mut state = state_rc.borrow_mut();
1100                            let on_text_change = self.rt
1101                                .frame_cache
1102                                .as_ref()
1103                                .and_then(|f| f.hit_regions.iter().find(|h| h.id == focused_id))
1104                                .and_then(|h| h.on_text_change.clone());
1105                            let mut notify = |text: String| {
1106                                if let Some(cb) = &on_text_change {
1107                                    cb(text);
1108                                }
1109                            };
1110                            rc_android::handle_ime_event(
1111                                ime,
1112                                &mut state,
1113                                &mut notify,
1114                                &mut self.rt.ime_preedit,
1115                            );
1116                            self.request_redraw();
1117                        }
1118                    }
1119                }
1120
1121                WindowEvent::RedrawRequested => {
1122                    // 1. Check our redraw flag before processing a11y.
1123                    if !self.redraw_requested.replace(false) {
1124                        self.process_a11y_actions();
1125                        self.process_render_commands();
1126                        // Present-only: redraw last cached scene with updated textures
1127                        if let (Some(backend), Some(frame)) =
1128                            (self.backend.as_mut(), self.rt.frame_cache.as_ref())
1129                        {
1130                            let scale = self.window.as_ref().map(|w| w.scale_factor() as f32).unwrap_or(1.0);
1131                            let mut scene = frame.scene.clone();
1132                            self.inspector.frame(&mut scene);
1133                            backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1134                        }
1135                        log::trace!("RedrawRequested: no frame request, skipping compose");
1136                        return;
1137                    }
1138                    log::trace!("RedrawRequested: frame request pending, composing");
1139
1140                    // 2. Process a11y actions and render commands before compose.
1141                    self.process_a11y_actions();
1142                    self.process_render_commands();
1143
1144                    let Some(win) = self.window.as_ref() else {
1145                        return;
1146                    };
1147                    if self.backend.is_none() {
1148                        return;
1149                    }
1150
1151                    // Advance animations before composition (Compose pattern).
1152                    // Mirrors broadcastFrameClock.sendFrame() before performRecompose().
1153                    repose_core::animation_driver::tick();
1154
1155                    let t0 = Instant::now();
1156                    let scale = win.scale_factor() as f32;
1157                    self.rt.scale = scale;
1158                    let focused = self.rt.sched.focused;
1159
1160                    let output = self.rt.frame(&mut self.root, &self.render);
1161
1162                    // Apply cursor from platform output
1163                    if let Some(cursor) = &output.platform.cursor {
1164                        win.set_cursor(winit::window::Cursor::Icon(map_cursor(*cursor)));
1165                    }
1166
1167                    // Apply IME state based on wants_keyboard
1168                    if !output.wants_keyboard && focused.is_some() && self.rt.sched.focused.is_none() && self.rt.ime_preedit {
1169                        rc_web::set_ime_for_textfield(win, false);
1170                        self.rt.ime_preedit = false;
1171                    }
1172
1173                    let frame = Frame {
1174                        scene: output.scene,
1175                        hit_regions: output.hit_regions,
1176                        semantics_nodes: output.semantics_nodes,
1177                        focus_chain: output.focus_chain,
1178                    };
1179
1180                    let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1181
1182                    // UPDATE ACCESSIBILITY TREE
1183                    if let Some(adapter) = &mut self.accesskit_adapter {
1184                        let win = self.window.as_ref().unwrap();
1185                        let scale = win.scale_factor();
1186                        if let Some(update) =
1187                            self.a11y_tree
1188                                .update(&frame.semantics_nodes, scale, self.rt.sched.focused)
1189                        {
1190                            adapter.update_if_active(|| update);
1191                        }
1192                    }
1193
1194                    // Render
1195                    let mut scene = frame.scene.clone();
1196                    // Update HUD metrics before overlay draws
1197                    let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1198                    let signal_count = self.rt.sched.id_count() as usize;
1199                    self.inspector.hud.metrics = Some(repose_devtools::Metrics {
1200                        build_ms: build_layout_ms,
1201                        layout_ms: build_layout_ms * 0.5,
1202                        scene_nodes: scene.nodes.len(),
1203                        widget_count,
1204                        signal_count,
1205                    });
1206                    self.inspector.frame(&mut scene);
1207
1208                    // Drag indicator overlay (internal + file drop)
1209                    repose_core::dnd::overlay_drag_indicator(
1210                        &mut scene,
1211                        self.rt.mouse_pos_px,
1212                        self.external_file_drag,
1213                    );
1214
1215                    // Drain upload commands queued during compose (e.g. VideoSink set_image_*)
1216                    // before presenting to avoid 1-frame GPU texture lag.
1217                    self.process_render_commands();
1218
1219                    // Now borrow backend mutably only for the frame() call
1220                    let win = self.window.as_ref().unwrap();
1221                    let scale = win.scale_factor() as f32;
1222                    if let Some(backend) = self.backend.as_mut() {
1223                        backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1224                    }
1225
1226                    // Initialize TextFieldState for any focused TextField that
1227                    // doesn't have one yet (e.g. after FocusRequester::request_focus)
1228                    if let Some(fid) = self.rt.sched.focused {
1229                        if let Some(hit) = frame.hit_regions.iter().find(|h| h.id == fid)
1230                            && let Some(key) = hit.tf_state_key
1231                            && !self.rt.textfield_states.contains_key(&key)
1232                        {
1233                            self.rt.textfield_states
1234                                .entry(key)
1235                                .or_insert_with(|| {
1236                                    Rc::new(RefCell::new(repose_ui::TextFieldState::new()))
1237                                })
1238                                .borrow_mut()
1239                                .reset_caret_blink();
1240                        }
1241                    }
1242
1243                    repose_core::dnd::set_dnd_frame(Some(frame.clone()));
1244                    self.rt.frame_cache = Some(frame);
1245                    repose_core::dnd::set_dnd_scale(scale);
1246
1247                    self.dispatch_file_drop_now();
1248
1249                    rc::tick_snackbar(self.last_redraw);
1250                    self.last_redraw = Instant::now();
1251                }
1252
1253                _ => {}
1254            }
1255        }
1256
1257        fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1258            // Process cross-thread commands (e.g. tray toggles, deeplinks) before any
1259            // redraw check, so hide/show commands work even when hidden
1260            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1261            if let Some(cb) = ABOUT_TO_WAIT_CALLBACK.lock().unwrap().as_ref() {
1262                cb();
1263            }
1264            process_deeplinks();
1265
1266            // On Wayland, wgpu creates an xdg_surface from the winit window and it shouldn't be recreated with a new id?
1267            // It doesn't take a lot of resources anyway, so let the backend be present.
1268            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1269            if WINDOW_VISIBLE.load(Ordering::Relaxed) && self.backend.is_none() {
1270                if let Some(w) = &self.window {
1271                    log::info!("about_to_wait: recreating GPU backend");
1272                    match repose_render_wgpu::WgpuBackend::new(w.clone()) {
1273                        Ok(b) => self.backend = Some(b),
1274                        Err(e) => log::error!("about_to_wait: failed to recreate backend: {e:?}"),
1275                    }
1276                }
1277            }
1278
1279            let needs_compose = take_frame_request();
1280            let needs_present = take_present_request();
1281
1282            if needs_compose {
1283                self.pending_redraw = true;
1284            }
1285
1286            // Present-only: texture was updated, redraw last cached scene without compose.
1287            if !self.pending_redraw && needs_present && self.rt.frame_cache.is_some() {
1288                let now = Instant::now();
1289                let interval = web_time::Duration::from_millis(16);
1290                if now.saturating_duration_since(self.last_redraw) >= interval {
1291                    rc::request_redraw(&self.window);
1292                    self.last_redraw = now;
1293                } else {
1294                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1295                        self.last_redraw + interval,
1296                    ));
1297                }
1298                return;
1299            }
1300
1301            if !self.pending_redraw {
1302                let now = Instant::now();
1303                let idle_cap = web_time::Duration::from_millis(1000);
1304                let deadline = self
1305                    .next_caret_blink_deadline()
1306                    .unwrap_or(now + idle_cap);
1307
1308                if now.saturating_duration_since(self.last_redraw) >= idle_cap || now >= deadline {
1309                    self.redraw_requested.set(true);
1310                    request_frame();
1311                    rc::request_redraw(&self.window);
1312                    self.last_redraw = now;
1313                }
1314                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1315                    Ord::min(deadline, now + idle_cap),
1316                ));
1317                return;
1318            }
1319
1320            let now = Instant::now();
1321            let interval = web_time::Duration::from_millis(16);
1322
1323            if now.saturating_duration_since(self.last_redraw) >= interval {
1324                self.pending_redraw = false;
1325                self.redraw_requested.set(true);
1326                rc::request_redraw(&self.window);
1327                self.last_redraw = now;
1328            } else {
1329                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1330                    self.last_redraw + interval,
1331                ));
1332            }
1333        }
1334
1335        fn new_events(
1336            &mut self,
1337            _: &winit::event_loop::ActiveEventLoop,
1338            _: winit::event::StartCause,
1339        ) {
1340        }
1341        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1342            self.pending_redraw = true;
1343        }
1344        fn device_event(
1345            &mut self,
1346            _: &winit::event_loop::ActiveEventLoop,
1347            _: winit::event::DeviceId,
1348            _: winit::event::DeviceEvent,
1349        ) {
1350        }
1351        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1352        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1353        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1354    }
1355
1356    impl App {
1357        /// Dispatch a key event through the focus ancestor chain.
1358        /// Returns true if the event was consumed by a handler.
1359        fn dispatch_focus_key_event(
1360            &self,
1361            key_event: &winit::event::KeyEvent,
1362            mapped_key: &repose_core::input::Key,
1363        ) -> bool {
1364            let Some(f) = &self.rt.frame_cache else {
1365                return false;
1366            };
1367            let Some(focused) = self.rt.sched.focused else {
1368                return false;
1369            };
1370            let utf16 = match mapped_key {
1371                repose_core::input::Key::Character(c) => *c as u16,
1372                _ => 0,
1373            };
1374            let mods = self.rt.modifiers;
1375            let repeat = key_event.repeat;
1376            let ev_type = if key_event.state == ElementState::Pressed {
1377                repose_core::input::KeyEventType::Down
1378            } else {
1379                repose_core::input::KeyEventType::Up
1380            };
1381            let hit_by_id: std::collections::HashMap<u64, &HitRegion> =
1382                f.hit_regions.iter().map(|h| (h.id, h)).collect();
1383            let sem_parent_of: std::collections::HashMap<u64, u64> = f
1384                .semantics_nodes
1385                .iter()
1386                .filter_map(|n| n.parent.map(|p| (n.id, p)))
1387                .collect();
1388            let mut ancestors = Vec::new();
1389            let mut cur = focused;
1390            loop {
1391                ancestors.push(cur);
1392                if let Some(&p) = sem_parent_of.get(&cur) {
1393                    cur = p;
1394                } else {
1395                    break;
1396                }
1397            }
1398            let make_ke = || repose_core::input::KeyEvent {
1399                key: mapped_key.clone(),
1400                modifiers: mods,
1401                is_repeat: repeat,
1402                event_type: ev_type,
1403                utf16_code_point: utf16,
1404            };
1405            // Top-down preview: root → focused
1406            for &id in ancestors.iter().rev() {
1407                if let Some(hit) = hit_by_id.get(&id) {
1408                    if let Some(cb) = &hit.on_preview_key_event {
1409                        if cb(make_ke()) {
1410                            return true;
1411                        }
1412                    }
1413                }
1414            }
1415            // Bottom-up normal: focused → root
1416            for &id in ancestors.iter() {
1417                if let Some(hit) = hit_by_id.get(&id) {
1418                    if let Some(cb) = &hit.on_key_event {
1419                        if cb(make_ke()) {
1420                            return true;
1421                        }
1422                    }
1423                }
1424            }
1425            false
1426        }
1427
1428        fn announce_focus_change(&mut self) {
1429            if let Some(f) = &self.rt.frame_cache {
1430                let focused_node = self.rt
1431                    .sched
1432                    .focused
1433                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1434                self.a11y.focus_changed(focused_node);
1435            }
1436        }
1437
1438        fn notify_text_change(&self, id: u64, text: String) {
1439            if let Some(f) = &self.rt.frame_cache
1440                && let Some(h) = f.hit_regions.iter().find(|h| h.id == id)
1441                && let Some(cb) = &h.on_text_change
1442            {
1443                cb(text);
1444            }
1445        }
1446
1447        fn tf_key_of(&self, visual_id: u64) -> u64 {
1448            rc::tf_key_of_in_frame(&self.rt.frame_cache, visual_id)
1449        }
1450
1451        /// If a text field is focused with a collapsed selection (caret blinking),
1452        /// return the [`Instant`] of the next 500 ms blink edge.
1453        fn next_caret_blink_deadline(&self) -> Option<Instant> {
1454            next_caret_blink_deadline(&self.rt.sched, &self.rt.frame_cache, &self.rt.textfield_states)
1455        }
1456
1457        fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
1458            use repose_core::shortcuts;
1459
1460            if let (Some(f), Some(fid)) = (&self.rt.frame_cache, self.rt.sched.focused)
1461                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
1462                && let Some(cb) = &hit.on_action
1463                && cb(action.clone())
1464            {
1465                return true;
1466            }
1467
1468            if shortcuts::handle(action.clone()) {
1469                return true;
1470            }
1471
1472            // Focus navigation (Tab/arrows)
1473            if let Some(f) = &self.rt.frame_cache {
1474                if let Some(new_id) = repose_core::focus::handle_action(&action, &mut self.rt.sched, f)
1475                {
1476                    if let Some(active) = self.rt.key_pressed_active.take() {
1477                        self.rt.pressed_ids.remove(&active);
1478                    }
1479                    let tf_state_key = f
1480                        .hit_regions
1481                        .iter()
1482                        .find(|h| h.id == new_id)
1483                        .and_then(|h| h.tf_state_key);
1484                    if let Some(key) = tf_state_key {
1485                        self.rt.textfield_states.entry(key).or_insert_with(|| {
1486                            Rc::new(RefCell::new(repose_ui::TextFieldState::new()))
1487                        });
1488                        if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1489                            state_rc.borrow_mut().reset_caret_blink();
1490                        }
1491                    }
1492                    if let Some(win) = &self.window {
1493                        let is_textfield = f.semantics_nodes.iter().any(|n| {
1494                            n.id == new_id && n.role == repose_core::semantics::Role::TextField
1495                        });
1496                        rc_web::set_ime_for_textfield(win, is_textfield);
1497                    }
1498                    self.announce_focus_change();
1499                    return true;
1500                }
1501            }
1502
1503            false
1504        }
1505
1506        fn dispatch_file_drop_now(&mut self) {
1507            let Some(f) = &self.rt.frame_cache else {
1508                self.pending_dropped_files.clear();
1509                self.pending_drop_pos_px = None;
1510                return;
1511            };
1512
1513            if self.pending_dropped_files.is_empty() {
1514                return;
1515            }
1516
1517            let pos_px = self.pending_drop_pos_px.unwrap_or(self.rt.mouse_pos_px);
1518            let pos = Vec2 {
1519                x: pos_px.0,
1520                y: pos_px.1,
1521            };
1522
1523            let mut files = Vec::new();
1524            for p in self.pending_dropped_files.drain(..) {
1525                let name = p
1526                    .file_name()
1527                    .and_then(|s| s.to_str())
1528                    .unwrap_or("file")
1529                    .to_string();
1530                files.push(repose_core::dnd::DroppedFile {
1531                    name,
1532                    path: Some(p),
1533                });
1534            }
1535
1536            let payload: repose_core::dnd::DragPayload =
1537                std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1538
1539            let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1540                self.pending_drop_pos_px = None;
1541                return;
1542            };
1543
1544            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1545                && let Some(cb) = &hit.on_drop
1546            {
1547                let accepted = cb(repose_core::dnd::DropEvent {
1548                    source_id: 0, // external source (OS)
1549                    target_id,
1550                    position: pos,
1551                    modifiers: self.rt.modifiers,
1552                    payload: payload.clone(),
1553                });
1554
1555                if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
1556                {
1557                    let label = node.label.as_deref().unwrap_or("");
1558                    self.a11y.announce(&format!("Dropped files on {}", label));
1559                }
1560            }
1561
1562            self.pending_drop_pos_px = None;
1563            self.request_redraw();
1564        }
1565    }
1566
1567    let event_loop = EventLoop::new()?;
1568    set_event_loop_proxy(event_loop.create_proxy());
1569    let mut app = App::new(Box::new(root));
1570    // Install system clock once
1571    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1572    event_loop.run_app(&mut app)?;
1573    Ok(())
1574}
1575
1576// Accessibility bridge stub (Noop by default; logs on Linux for now)
1577/// Bridge from Repose's semantics tree to platform accessibility APIs.
1578///
1579/// Implementations are responsible for:
1580/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
1581/// - Updating focus when `focus_changed` is called.
1582/// - Announcing transient messages (e.g. button activation) via screen readers.
1583pub trait A11yBridge: Send {
1584    /// Publish (or update) the full semantics tree for the current frame.
1585    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1586
1587    /// Notify that the focused node has changed. `None` means focus cleared.
1588    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1589
1590    /// Announce a one‑off message via the platform's accessibility channel.
1591    fn announce(&mut self, msg: &str);
1592}
1593
1594struct NoopA11y;
1595impl A11yBridge for NoopA11y {
1596    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1597        // no-op
1598    }
1599    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1600        if let Some(n) = node {
1601            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1602        } else {
1603            log::info!("A11y focus: None");
1604        }
1605    }
1606    fn announce(&mut self, msg: &str) {
1607        log::info!("A11y announce: {msg}");
1608    }
1609}
1610
1611#[cfg(target_os = "linux")]
1612struct LinuxAtspiStub;
1613#[cfg(target_os = "linux")]
1614impl A11yBridge for LinuxAtspiStub {
1615    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1616        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1617    }
1618    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1619        if let Some(n) = node {
1620            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1621        } else {
1622            log::info!("AT-SPI stub focus: None");
1623        }
1624    }
1625    fn announce(&mut self, msg: &str) {
1626        log::info!("AT-SPI stub announce: {msg}");
1627    }
1628}