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/// Options common to all platforms.
346#[derive(Clone, Copy, Debug)]
347pub struct ReposeOptions {
348    /// MSAA sample count for the UI surface pass. The renderer falls back to
349    /// the largest supported count <= this value.
350    pub msaa_samples: u32,
351}
352
353impl Default for ReposeOptions {
354    fn default() -> Self {
355        Self { msaa_samples: 4 }
356    }
357}
358
359/// Configuration for [`run_desktop_app`].
360///
361/// Uses [`Default`] so new options can be added without breaking existing
362/// callers. Configure via struct update syntax, e.g.
363/// `AppConfig { window_title: "My Game".into(), ..Default::default() }`.
364#[derive(Clone, Debug)]
365pub struct AppConfig {
366    /// Common options shared with other platforms.
367    pub common: ReposeOptions,
368    /// Window title.
369    pub window_title: String,
370    /// Initial window size in physical pixels.
371    pub window_size: (u32, u32),
372    /// Enable the devtools inspector (hover + HUD). Disable for release builds.
373    pub enable_inspector: bool,
374}
375
376impl Default for AppConfig {
377    fn default() -> Self {
378        Self {
379            common: ReposeOptions::default(),
380            window_title: "Repose".to_string(),
381            window_size: (1280, 800),
382            enable_inspector: true,
383        }
384    }
385}
386
387/// Run a desktop app with default [`AppConfig`].
388///
389/// Deprecated: use [`run_desktop_app_with_config`] with
390/// `AppConfig::default()` instead. This may be removed in a future release.
391#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
392#[deprecated(
393    note = "use run_desktop_app_with_config(root, AppConfig) instead; this may be removed in a future release"
394)]
395pub fn run_desktop_app(
396    root: impl FnMut(&mut Scheduler, &RenderContext) -> View + 'static,
397) -> anyhow::Result<()> {
398    run_desktop_app_with_config(root, AppConfig::default())
399}
400
401/// Run a desktop app with the given [`AppConfig`].
402#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
403pub fn run_desktop_app_with_config(
404    root: impl FnMut(&mut Scheduler, &RenderContext) -> View + 'static,
405    config: AppConfig,
406) -> anyhow::Result<()> {
407    use std::collections::{HashMap, HashSet};
408    use winit::application::ApplicationHandler;
409    use winit::dpi::{LogicalPosition, LogicalSize, PhysicalSize};
410    use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
411    use winit::event_loop::EventLoop;
412    use winit::keyboard::{KeyCode, PhysicalKey};
413    use winit::window::{Window, WindowAttributes};
414
415    use crate::a11y::A11yTree;
416    use repose_app::ReposeRuntime;
417
418    struct ReposeActivationHandler {
419        initial_tree: Option<accesskit::TreeUpdate>,
420    }
421
422    impl accesskit::ActivationHandler for ReposeActivationHandler {
423        fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
424            self.initial_tree.take()
425        }
426    }
427
428    struct ReposeDeactivationHandler;
429
430    impl accesskit::DeactivationHandler for ReposeDeactivationHandler {
431        fn deactivate_accessibility(&mut self) {
432            // Nothing to clean up for now
433        }
434    }
435
436    struct App {
437        root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
438        render: RenderContext,
439        window: Option<Arc<Window>>,
440        backend: Option<repose_render_wgpu::WgpuBackend>,
441        rt: ReposeRuntime,
442        inspector: Option<repose_devtools::Inspector>,
443        msaa_samples: u32,
444        window_title: String,
445        window_size: (u32, u32),
446
447        // Files
448        pending_dropped_files: Vec<std::path::PathBuf>,
449        pending_drop_pos_px: Option<(f32, f32)>,
450
451        // External file drag hover (HoveredFile / Cancelled)
452        external_file_drag: bool,
453        hovered_files: Vec<std::path::PathBuf>,
454
455        clipboard: Option<clipawl::Clipboard>,
456        a11y: Box<dyn A11yBridge>,
457
458        accesskit_adapter: Option<Adapter>,
459        a11y_actions: Arc<Mutex<Vec<accesskit::ActionRequest>>>,
460        a11y_tree: A11yTree,
461
462        last_redraw: Instant,
463        pending_redraw: bool,
464
465        // Tracks whether a redraw was requested by app code
466        redraw_requested: Cell<bool>,
467    }
468
469    impl App {
470        fn process_a11y_actions(&mut self) {
471            let mut actions = self.a11y_actions.lock().unwrap();
472            if actions.is_empty() {
473                return;
474            }
475            let pending = actions.drain(..).collect::<Vec<_>>();
476            drop(actions);
477
478            let Some(f) = &self.rt.frame_cache else {
479                return;
480            };
481
482            for req in pending {
483                let target_id = req.target_node.0;
484                match req.action {
485                    accesskit::Action::Click => {
486                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
487                            && let Some(cb) = &hit.on_click
488                        {
489                            cb();
490                            self.request_redraw();
491                        }
492                    }
493                    accesskit::Action::Focus => {
494                        self.rt.sched.focused = Some(target_id);
495                        self.request_redraw();
496                    }
497                    _ => {}
498                }
499            }
500        }
501
502        fn new(
503            root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
504            config: AppConfig,
505        ) -> Self {
506            Self {
507                root,
508                render: RenderContext::new(),
509                window: None,
510                backend: None,
511                rt: ReposeRuntime::new(),
512                inspector: if config.enable_inspector {
513                    Some(repose_devtools::Inspector::new())
514                } else {
515                    None
516                },
517                msaa_samples: config.common.msaa_samples,
518                window_title: config.window_title,
519                window_size: config.window_size,
520                pending_dropped_files: Vec::new(),
521                pending_drop_pos_px: None,
522
523                external_file_drag: false,
524                hovered_files: Vec::new(),
525
526                clipboard: None,
527                a11y: {
528                    #[cfg(target_os = "linux")]
529                    {
530                        Box::new(LinuxAtspiStub) as Box<dyn A11yBridge>
531                    }
532                    #[cfg(not(target_os = "linux"))]
533                    {
534                        Box::new(NoopA11y) as Box<dyn A11yBridge>
535                    }
536                },
537
538                accesskit_adapter: None,
539                a11y_actions: Arc::new(Mutex::new(Vec::new())),
540                a11y_tree: A11yTree::default(),
541
542                last_redraw: Instant::now(),
543                pending_redraw: false,
544                redraw_requested: Cell::new(false),
545            }
546        }
547
548        fn request_redraw(&self) {
549            self.redraw_requested.set(true);
550            repose_core::request_frame();
551            rc::request_redraw(&self.window);
552        }
553
554        // Ensure caret is visible after edits/moves (all units in px)
555        fn tf_ensure_caret_visible(st: &mut TextFieldState, is_multiline: bool) {
556            rc::tf_ensure_caret_visible(st, is_multiline);
557        }
558
559        fn paste_from_primary(&self) -> Option<String> {
560            let mut opts = clipawl::ClipboardOptions::default();
561            opts.linux.selection = clipawl::LinuxSelection::Primary;
562            if let Ok(cb) = clipawl::Clipboard::new_with_options(opts) {
563                match pollster::block_on(cb.read()) {
564                    Ok(t) => Some(t),
565                    Err(e) => {
566                        eprintln!("Primary paste error: {}", e);
567                        None
568                    }
569                }
570            } else {
571                None
572            }
573        }
574
575        fn process_render_commands(&mut self) {
576            let Some(backend) = self.backend.as_mut() else {
577                return;
578            };
579            rc::process_render_commands(backend, self.render.drain());
580        }
581
582        fn reset_pointer_state(&mut self) {
583            self.rt.capture_id = None;
584            self.rt.pressed_ids.clear();
585            self.rt.hover_id = None;
586        }
587
588        fn is_textfield(&self, id: u64) -> bool {
589            rc::is_textfield_in_frame(&self.rt.frame_cache, id)
590        }
591
592        fn is_multiline_id(&self, id: u64) -> bool {
593            if let Some(f) = &self.rt.frame_cache {
594                f.hit_regions
595                    .iter()
596                    .find(|h| h.id == id)
597                    .map(|h| h.tf_multiline)
598                    .unwrap_or(false)
599            } else {
600                false
601            }
602        }
603
604        fn hit_by_id(f: &Frame, id: u64) -> Option<&HitRegion> {
605            f.hit_regions.iter().find(|h| h.id == id)
606        }
607
608        fn dp_px(&self, dp: f32) -> f32 {
609            dp_to_px(dp)
610        }
611    }
612
613    impl ApplicationHandler<()> for App {
614        fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
615            self.clipboard = clipawl::Clipboard::new()
616                .map_err(|e| {
617                    eprintln!("clipawl clipboard init failed: {e}");
618                    e
619                })
620                .ok();
621            repose_core::clipboard::set_clipboard_read_fn(Box::new(|| {
622                clipawl::blocking::read().ok()
623            }));
624            // Register for SelectableText (Ctrl+C) - use blocking API directly
625            repose_core::clipboard::set_clipboard_fn(Box::new(move |text| {
626                if let Err(e) = clipawl::blocking::write(text) {
627                    eprintln!("clipboard write error: {e}");
628                }
629            }));
630
631            repose_core::clipboard::set_primary_fn(Box::new(|text| {
632                let mut opts = clipawl::ClipboardOptions::default();
633                opts.linux.selection = clipawl::LinuxSelection::Primary;
634                match clipawl::Clipboard::new_with_options(opts) {
635                    Ok(cb) => {
636                        if let Err(e) = pollster::block_on(cb.write(text)) {
637                            eprintln!("primary selection write error: {e}");
638                        }
639                    }
640                    Err(e) => eprintln!("primary clipboard init error: {e}"),
641                }
642            }));
643
644            if self.window.is_none() {
645                match el.create_window(
646                    WindowAttributes::default()
647                        .with_title(self.window_title.clone())
648                        .with_inner_size(PhysicalSize::new(
649                            self.window_size.0,
650                            self.window_size.1,
651                        ))
652                        .with_visible(false),
653                ) {
654                    Ok(win) => {
655                        let w = Arc::new(win);
656
657                        let activation_handler = ReposeActivationHandler {
658                            initial_tree: Some(A11yTree::initial_tree()),
659                        };
660
661                        let action_handler = ReposeActionHandler {
662                            pending_actions: self.a11y_actions.clone(),
663                        };
664
665                        let deactivation_handler = ReposeDeactivationHandler;
666
667                        let adapter = Adapter::with_direct_handlers(
668                            el,
669                            &w,
670                            activation_handler,
671                            action_handler,
672                            deactivation_handler,
673                        );
674
675                        self.accesskit_adapter = Some(adapter);
676
677                        w.set_visible(true);
678
679                        let size = w.inner_size();
680                        let sf = w.scale_factor() as f32;
681                        self.rt.set_viewport_and_scale(size.width, size.height, sf);
682
683                        match repose_render_wgpu::WgpuBackend::new_with_msaa(
684                            w.clone(),
685                            self.msaa_samples,
686                        ) {
687                            Ok(b) => {
688                                self.backend = Some(b);
689                                set_app_window(w.clone());
690                                self.window = Some(w);
691                                self.request_redraw();
692                            }
693                            Err(e) => {
694                                log::error!("Failed to create WGPU backend: {e:?}");
695                                el.exit();
696                            }
697                        }
698                    }
699                    Err(e) => {
700                        log::error!("Failed to create window: {e:?}");
701                        el.exit();
702                    }
703                }
704            }
705        }
706
707        fn window_event(
708            &mut self,
709            el: &winit::event_loop::ActiveEventLoop,
710            _id: winit::window::WindowId,
711            event: WindowEvent,
712        ) {
713            // Process AccessKit events first!
714            if let Some(adapter) = &mut self.accesskit_adapter {
715                adapter.process_event(self.window.as_ref().unwrap(), &event);
716            }
717
718            match event {
719                WindowEvent::CloseRequested => {
720                    if CLOSE_TO_TRAY.load(Ordering::Relaxed) {
721                        // Drop GPU backend before null-buffer unmap.
722                        self.backend = None;
723                        if let Some(w) = &self.window {
724                            w.set_visible(false);
725                        }
726                        WINDOW_VISIBLE.store(false, Ordering::Relaxed);
727                    } else {
728                        el.exit();
729                    }
730                }
731
732                WindowEvent::Focused(false) => {
733                    // Delegate all common focus-lost cleanup to the runtime
734                    self.rt.handle_focus_lost();
735
736                    // Platform-specific cleanup
737                    self.external_file_drag = false;
738                    self.hovered_files.clear();
739
740                    if let Some(w) = &self.window {
741                        rc_web::set_ime_for_textfield(w, false);
742                    }
743
744                    self.request_redraw();
745                }
746
747                WindowEvent::CursorLeft { .. } => {
748                    self.rt.pointer_inside = false;
749                    self.rt.clear_hover();
750                    self.external_file_drag = false;
751                    self.hovered_files.clear();
752                    self.request_redraw();
753                }
754
755                WindowEvent::HoveredFile(path) => {
756                    // Mark external drag active and keep a small bounded list
757                    self.external_file_drag = true;
758                    if self.hovered_files.len() < 32 {
759                        self.hovered_files.push(path);
760                    }
761                    // Update drop position (best effort)
762                    if self.pending_drop_pos_px.is_none() {
763                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
764                    }
765                    self.request_redraw();
766                }
767
768                WindowEvent::HoveredFileCancelled => {
769                    self.external_file_drag = false;
770                    self.hovered_files.clear();
771
772                    // Defensive: cancel any internal capture/drag that might be left stuck
773                    self.reset_pointer_state();
774
775                    self.request_redraw();
776                }
777
778                WindowEvent::DroppedFile(path) => {
779                    // DroppedFile is emitted once per file. Batch them.
780                    self.pending_dropped_files.push(path);
781                    if self.pending_drop_pos_px.is_none() {
782                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
783                    }
784
785                    // Drop ends the external file drag session.
786                    self.external_file_drag = false;
787                    self.hovered_files.clear();
788
789                    self.request_redraw();
790                }
791
792                WindowEvent::Resized(size) => {
793                    let sf = self.window.as_ref().map(|w| w.scale_factor() as f32).unwrap_or(1.0);
794                    self.rt.set_viewport_and_scale(size.width, size.height, sf);
795                    if let Some(b) = self.backend.as_mut() {
796                        b.configure_surface(size.width, size.height);
797                    }
798                    if let Some(w) = &self.window {
799                        let sf = w.scale_factor() as f32;
800                        let dp_w = size.width as f32 / sf;
801                        let dp_h = size.height as f32 / sf;
802                        log::info!(
803                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
804                            size.width,
805                            size.height,
806                            sf,
807                            dp_w as i32,
808                            dp_h as i32
809                        );
810                    }
811                    self.request_redraw();
812                }
813
814                WindowEvent::CursorMoved { position, .. } => {
815                    self.rt.pointer_inside = true;
816
817                    if self.external_file_drag {
818                        self.pending_drop_pos_px = Some((position.x as f32, position.y as f32));
819                    }
820
821                    let pos = Vec2 {
822                        x: position.x as f32,
823                        y: position.y as f32,
824                    };
825
826                    // Delegate pointer-move to the host runtime
827                    let result = self.rt.handle_pointer_move(pos);
828
829                    // Inspector hover (platform-specific - devtools inspect)
830                    if let (Some(inspector), Some(f)) =
831                        (&mut self.inspector, &self.rt.frame_cache)
832                        && inspector.hud.inspector_enabled
833                    {
834                        let hit = f.hit_regions.iter().find(|h| {
835                            h.rect.contains(pos)
836                        });
837                        let hover_rect = hit.map(|h| h.rect);
838                        let hover_info = hit.and_then(|h| {
839                            f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
840                                repose_devtools::HoveredInfo {
841                                    id: s.id,
842                                    role: format!("{:?}", s.role),
843                                    label: s.label.clone(),
844                                }
845                            })
846                        });
847                        inspector.hud.set_hovered(hover_rect, hover_info);
848                    }
849
850                    // Cursor icon via winit window
851                    if let Some(win) = &self.window {
852                        if let Some(c) = result.cursor {
853                            win.set_cursor(winit::window::Cursor::Icon(map_cursor(c)));
854                        }
855                    }
856
857                    self.request_redraw();
858                }
859
860                WindowEvent::MouseWheel { delta, .. } => {
861                    let (dx_px, dy_px) = match delta {
862                        MouseScrollDelta::LineDelta(x, y) => {
863                            let unit_px = dp_to_px(60.0);
864                            (-(x * unit_px), -(y * unit_px))
865                        }
866                        MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
867                    };
868                    log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
869
870                    if self.rt.handle_scroll(Vec2 { x: dx_px, y: dy_px }) {
871                        self.request_redraw();
872                    }
873                }
874
875                WindowEvent::MouseInput {
876                    state: ElementState::Pressed,
877                    button: MouseButton::Left,
878                    ..
879                } => {
880                    let pos = Vec2 {
881                        x: self.rt.mouse_pos_px.0,
882                        y: self.rt.mouse_pos_px.1,
883                    };
884
885                    let result = self.rt.handle_pointer_press(pos, PointerButton::Primary);
886
887                    // Platform-specific IME setup for focused textfields
888                    if let Some(fid) = result.focused {
889                        if let Some(win) = &self.window
890                            && let Some(f) = &self.rt.frame_cache
891                            && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
892                        {
893                            let sf = win.scale_factor();
894                            rc_web::set_ime_for_textfield(win, true);
895                            win.set_ime_cursor_area(
896                                LogicalPosition::new(
897                                    hit.rect.x as f64 / sf,
898                                    hit.rect.y as f64 / sf,
899                                ),
900                                LogicalSize::new(
901                                    hit.rect.w as f64 / sf,
902                                    hit.rect.h as f64 / sf,
903                                ),
904                            );
905                        }
906                    }
907
908                    // Click outside - no focus result from runtime, drop IME
909                    if result.focused.is_none() && self.rt.ime_preedit {
910                        if let Some(win) = &self.window {
911                            rc_web::set_ime_for_textfield(win, false);
912                        }
913                        self.rt.ime_preedit = false;
914                    }
915
916                    if result.needs_a11y_announce {
917                        self.announce_focus_change();
918                    }
919
920                    // Inspector: click-to-select topmost widget under cursor.
921                    if let Some(inspector) = &mut self.inspector
922                        && inspector.hud.inspector_enabled
923                        && let Some(f) = &self.rt.frame_cache
924                        && let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
925                    {
926                        let info = f
927                            .semantics_nodes
928                            .iter()
929                            .find(|s| s.id == hit.id)
930                            .map(|s| repose_devtools::HoveredInfo {
931                                id: s.id,
932                                role: format!("{:?}", s.role),
933                                label: s.label.clone(),
934                            });
935                        inspector.hud.select_widget(repose_devtools::SelectedWidget {
936                            id: hit.id,
937                            role: info
938                                .as_ref()
939                                .map(|i| i.role.clone())
940                                .unwrap_or_default(),
941                            label: info.as_ref().and_then(|i| i.label.clone()),
942                            bounds: hit.rect,
943                        });
944                    }
945
946                    self.request_redraw();
947                }
948
949                WindowEvent::MouseInput {
950                    state: ElementState::Pressed,
951                    button: MouseButton::Middle,
952                    ..
953                } => {
954                    let Some(f) = &self.rt.frame_cache else {
955                        return;
956                    };
957                    let pos = Vec2 {
958                        x: self.rt.mouse_pos_px.0,
959                        y: self.rt.mouse_pos_px.1,
960                    };
961                    if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos)) {
962                        // Dispatch Tertiary pointer event
963                        if let Some(cb) = &hit.on_pointer_down {
964                            cb(PointerEvent::new(
965                                PointerId(0),
966                                PointerKind::Mouse,
967                                PointerEventKind::Down(PointerButton::Tertiary),
968                                pos,
969                                1.0,
970                                self.rt.modifiers,
971                            ));
972                        }
973                        // Paste primary selection into textfield
974                        if self.is_textfield(hit.id) {
975                            let key = self.tf_key_of(hit.id);
976                            if let Some(state_rc) = self.rt.textfield_states.get(&key) {
977                                if let Some(txt) = self.paste_from_primary() {
978                                    let mut st = state_rc.borrow_mut();
979                                    st.insert_text_atomic(&txt);
980                                    self.notify_text_change(hit.id, st.text.clone());
981                                    if let Some(f) = &self.rt.frame_cache
982                                        && let Some(h) =
983                                            f.hit_regions.iter().find(|h| h.id == hit.id)
984                                    {
985                                        App::tf_ensure_caret_visible(&mut st, h.tf_multiline);
986                                    }
987                                }
988                            }
989                        }
990                    }
991                    self.request_redraw();
992                }
993
994                WindowEvent::MouseInput {
995                    state: ElementState::Released,
996                    button: MouseButton::Left,
997                    ..
998                } => {
999                    let pos = Vec2 {
1000                        x: self.rt.mouse_pos_px.0,
1001                        y: self.rt.mouse_pos_px.1,
1002                    };
1003
1004                    self.rt.handle_pointer_release(pos, PointerButton::Primary);
1005
1006                    // A11y: announce activation when a click fires on release
1007                    if let (Some(f), Some(cid)) = (&self.rt.frame_cache, self.rt.capture_id) {
1008                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
1009                            && hit.rect.contains(pos)
1010                            && hit.on_click.is_some()
1011                            && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid)
1012                        {
1013                            let label = node.label.as_deref().unwrap_or("");
1014                            self.a11y.announce(&format!("Activated {}", label));
1015                        }
1016                    }
1017
1018                    repose_core::request_frame();
1019                }
1020
1021                WindowEvent::MouseInput {
1022                    state: ElementState::Released,
1023                    button: MouseButton::Middle,
1024                    ..
1025                } => {
1026                    if let Some(f) = &self.rt.frame_cache {
1027                        let pos = Vec2 {
1028                            x: self.rt.mouse_pos_px.0,
1029                            y: self.rt.mouse_pos_px.1,
1030                        };
1031                        if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
1032                        {
1033                            if let Some(cb) = &hit.on_pointer_up {
1034                                cb(PointerEvent::new(
1035                                    PointerId(0),
1036                                    PointerKind::Mouse,
1037                                    PointerEventKind::Up(PointerButton::Tertiary),
1038                                    pos,
1039                                    1.0,
1040                                    self.rt.modifiers,
1041                                ));
1042                            }
1043                        }
1044                    }
1045                }
1046
1047                WindowEvent::ModifiersChanged(new_mods) => {
1048                    let state = new_mods.state();
1049                    self.rt.modifiers.shift = state.shift_key();
1050                    self.rt.modifiers.ctrl = state.control_key();
1051                    self.rt.modifiers.alt = state.alt_key();
1052                    self.rt.modifiers.meta = state.super_key();
1053                    self.rt.modifiers.command = if cfg!(target_os = "macos") {
1054                        self.rt.modifiers.meta
1055                    } else {
1056                        self.rt.modifiers.ctrl
1057                    };
1058                }
1059
1060                WindowEvent::KeyboardInput {
1061                    event: key_event, ..
1062                } => {
1063                    // --- Platform-specific shortcuts (before generic dispatch) ---
1064
1065                    // Escape / BrowserBack: cancel DnD, try focus chain, then navigation back
1066                    if key_event.state == ElementState::Pressed && !key_event.repeat {
1067                        match key_event.physical_key {
1068                            PhysicalKey::Code(KeyCode::BrowserBack)
1069                            | PhysicalKey::Code(KeyCode::Escape) => {
1070                                use repose_navigation::back;
1071
1072                                if repose_core::dnd::handle_drag_action(
1073                                    &repose_core::shortcuts::DragAction::Cancel,
1074                                ) {
1075                                    return;
1076                                }
1077
1078                                // Try focus-ancestor dispatch without handle_key's always-true return
1079                                let mapped = rc::map_key(key_event.physical_key);
1080                                if self.dispatch_focus_key_event(&key_event, &mapped) {
1081                                    self.request_redraw();
1082                                    return;
1083                                }
1084
1085                                if !back::handle() {
1086                                    // el.exit();
1087                                }
1088                                return;
1089                            }
1090                            _ => {}
1091                        }
1092                    }
1093
1094                    // Inspector hotkey: Ctrl+Shift+I
1095                    if let Some(inspector) = &mut self.inspector
1096                        && key_event.state == ElementState::Pressed
1097                        && self.rt.modifiers.ctrl
1098                        && self.rt.modifiers.shift
1099                        && key_event.physical_key == PhysicalKey::Code(KeyCode::KeyI)
1100                    {
1101                        inspector.hud.toggle_inspector();
1102                        self.request_redraw();
1103                        return;
1104                    }
1105
1106                    // Text undo/redo (Ctrl+Z / Ctrl+Shift+Z)
1107                    if key_event.state == ElementState::Pressed
1108                        && !key_event.repeat
1109                        && self.rt.modifiers.command
1110                    {
1111                        match key_event.physical_key {
1112                            PhysicalKey::Code(KeyCode::KeyZ) if self.rt.modifiers.shift => {
1113                                if let Some(fid) = self.rt.sched.focused {
1114                                    let key = self.tf_key_of(fid);
1115                                    if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1116                                        let mut st = state_rc.borrow_mut();
1117                                        if st.can_redo() {
1118                                            st.redo();
1119                                            self.notify_text_change(fid, st.text.clone());
1120                                            self.request_redraw();
1121                                            return;
1122                                        }
1123                                    }
1124                                }
1125                            }
1126                            PhysicalKey::Code(KeyCode::KeyZ) => {
1127                                if let Some(fid) = self.rt.sched.focused {
1128                                    let key = self.tf_key_of(fid);
1129                                    if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1130                                        let mut st = state_rc.borrow_mut();
1131                                        if st.can_undo() {
1132                                            st.undo();
1133                                            self.notify_text_change(fid, st.text.clone());
1134                                            self.request_redraw();
1135                                            return;
1136                                        }
1137                                    }
1138                                }
1139                            }
1140                            _ => {}
1141                        }
1142                    }
1143
1144                    // --- Delegate all generic keyboard dispatch to the runtime ---
1145
1146                    let mapped = rc::map_key(key_event.physical_key);
1147                    let ke = winit_key_to_repose(&key_event, &mapped, &self.rt.modifiers);
1148                    let consumed = self.rt.handle_key(&ke);
1149                    if consumed {
1150                        self.request_redraw();
1151                        return;
1152                    }
1153
1154                    // --- Platform-specific text input (winit key_event.text) ---
1155                    // The runtime handles text via Key::Character, but we ALSO try
1156                    // winit's composed `key_event.text` for proper IME-less input
1157                    // on international keyboard layouts.
1158                    if key_event.state == ElementState::Pressed
1159                        && !key_event.repeat
1160                        && !self.rt.ime_preedit
1161                        && !self.rt.modifiers.ctrl
1162                        && !self.rt.modifiers.alt
1163                        && !self.rt.modifiers.meta
1164                        && let Some(raw) = key_event.text.as_deref()
1165                    {
1166                        let text: String = raw
1167                            .chars()
1168                            .filter(|c| !c.is_control() && *c != '\n' && *c != '\r')
1169                            .collect();
1170                        if !text.is_empty()
1171                            && let Some(fid) = self.rt.sched.focused
1172                        {
1173                            let key = self.tf_key_of(fid);
1174                            if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1175                                let mut st = state_rc.borrow_mut();
1176                                st.insert_text(&text);
1177                                self.notify_text_change(fid, st.text.clone());
1178                                if let Some(f) = &self.rt.frame_cache
1179                                    && let Some(hit) =
1180                                        f.hit_regions.iter().find(|h| h.id == fid)
1181                                {
1182                                    App::tf_ensure_caret_visible(&mut st, hit.tf_multiline);
1183                                }
1184                                self.request_redraw();
1185                                return;
1186                            }
1187                        }
1188                    }
1189
1190                    // --- A11y: keyboard activation announcement ---
1191                    if key_event.state == ElementState::Released
1192                        && let Some(active_id) = self.rt.key_pressed_active
1193                    {
1194                        match key_event.physical_key {
1195                            PhysicalKey::Code(KeyCode::Space)
1196                            | PhysicalKey::Code(KeyCode::Enter) => {
1197                                if let Some(f) = &self.rt.frame_cache
1198                                    && let Some(node) =
1199                                        f.semantics_nodes.iter().find(|n| n.id == active_id)
1200                                {
1201                                    let label = node.label.as_deref().unwrap_or("");
1202                                    self.a11y.announce(&format!("Activated {}", label));
1203                                }
1204                            }
1205                            _ => {}
1206                        }
1207                    }
1208                }
1209
1210                WindowEvent::Ime(ime) => {
1211                    if let Some(focused_id) = self.rt.sched.focused {
1212                        let key = self.tf_key_of(focused_id);
1213                        if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1214                            let mut state = state_rc.borrow_mut();
1215                            let on_text_change = self.rt
1216                                .frame_cache
1217                                .as_ref()
1218                                .and_then(|f| f.hit_regions.iter().find(|h| h.id == focused_id))
1219                                .and_then(|h| h.on_text_change.clone());
1220                            let mut notify = |text: String| {
1221                                if let Some(cb) = &on_text_change {
1222                                    cb(text);
1223                                }
1224                            };
1225                            rc_android::handle_ime_event(
1226                                ime,
1227                                &mut state,
1228                                &mut notify,
1229                                &mut self.rt.ime_preedit,
1230                            );
1231                            self.request_redraw();
1232                        }
1233                    }
1234                }
1235
1236                WindowEvent::RedrawRequested => {
1237                    // 1. Check our redraw flag before processing a11y.
1238                    if !self.redraw_requested.replace(false) {
1239                        self.process_a11y_actions();
1240                        self.process_render_commands();
1241                        // Present-only: redraw last cached scene with updated textures
1242                        if let (Some(backend), Some(frame)) =
1243                            (self.backend.as_mut(), self.rt.frame_cache.as_ref())
1244                        {
1245                            let scale = self.window.as_ref().map(|w| w.scale_factor() as f32).unwrap_or(1.0);
1246                            let mut scene = frame.scene.clone();
1247                            if let Some(inspector) = &mut self.inspector {
1248                                inspector.frame(&mut scene);
1249                            }
1250                            backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1251                        }
1252                        log::trace!("RedrawRequested: no frame request, skipping compose");
1253                        return;
1254                    }
1255                    log::trace!("RedrawRequested: frame request pending, composing");
1256
1257                    // 2. Process a11y actions and render commands before compose.
1258                    self.process_a11y_actions();
1259                    self.process_render_commands();
1260
1261                    let Some(win) = self.window.as_ref() else {
1262                        return;
1263                    };
1264                    if self.backend.is_none() {
1265                        return;
1266                    }
1267
1268                    // Advance animations before composition (Compose pattern).
1269                    // Mirrors broadcastFrameClock.sendFrame() before performRecompose().
1270                    repose_core::animation_driver::tick();
1271
1272                    let t0 = Instant::now();
1273                    let scale = win.scale_factor() as f32;
1274                    self.rt.scale = scale;
1275                    let focused = self.rt.sched.focused;
1276
1277                    let output = self.rt.frame(&mut self.root, &self.render);
1278
1279                    // Apply cursor from platform output
1280                    if let Some(cursor) = &output.platform.cursor {
1281                        win.set_cursor(winit::window::Cursor::Icon(map_cursor(*cursor)));
1282                    }
1283
1284                    // Apply IME state based on wants_keyboard
1285                    if !output.wants_keyboard && focused.is_some() && self.rt.sched.focused.is_none() && self.rt.ime_preedit {
1286                        rc_web::set_ime_for_textfield(win, false);
1287                        self.rt.ime_preedit = false;
1288                    }
1289
1290                    let frame = Frame {
1291                        scene: output.scene,
1292                        hit_regions: output.hit_regions,
1293                        semantics_nodes: output.semantics_nodes,
1294                        focus_chain: output.focus_chain,
1295                    };
1296
1297                    let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1298
1299                    // UPDATE ACCESSIBILITY TREE
1300                    if let Some(adapter) = &mut self.accesskit_adapter {
1301                        let win = self.window.as_ref().unwrap();
1302                        let scale = win.scale_factor();
1303                        if let Some(update) =
1304                            self.a11y_tree
1305                                .update(&frame.semantics_nodes, scale, self.rt.sched.focused)
1306                        {
1307                            adapter.update_if_active(|| update);
1308                        }
1309                    }
1310
1311                    // Render
1312                    let mut scene = frame.scene.clone();
1313                    // Update HUD metrics before overlay draws
1314                    if let Some(inspector) = &mut self.inspector {
1315                        let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1316                        let signal_count = self.rt.sched.id_count() as usize;
1317                        let ls = repose_ui::last_layout_stats();
1318                        inspector.hud.metrics = Some(repose_devtools::Metrics {
1319                            build_ms: build_layout_ms,
1320                            layout_ms: ls.layout_time_ms,
1321                            paint_ms: ls.paint_time_ms,
1322                            scene_nodes: scene.nodes.len(),
1323                            widget_count,
1324                            signal_count,
1325                            taffy_created: ls.taffy_created,
1326                            taffy_reused: ls.taffy_reused,
1327                            layout_hits: ls.layout_hits,
1328                            layout_misses: ls.layout_misses,
1329                            paint_cache_hits: ls.paint_cache_hits,
1330                            paint_cache_misses: ls.paint_cache_misses,
1331                            paint_culled: ls.paint_culled,
1332                        });
1333                        inspector.frame(&mut scene);
1334                    }
1335
1336                    // Drag indicator overlay (internal + file drop)
1337                    repose_core::dnd::overlay_drag_indicator(
1338                        &mut scene,
1339                        self.rt.mouse_pos_px,
1340                        self.external_file_drag,
1341                    );
1342
1343                    // Drain upload commands queued during compose (e.g. VideoSink set_image_*)
1344                    // before presenting to avoid 1-frame GPU texture lag.
1345                    self.process_render_commands();
1346
1347                    // Now borrow backend mutably only for the frame() call
1348                    let win = self.window.as_ref().unwrap();
1349                    let scale = win.scale_factor() as f32;
1350                    if let Some(backend) = self.backend.as_mut() {
1351                        backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1352                    }
1353
1354                    // Initialize TextFieldState for any focused TextField that
1355                    // doesn't have one yet (e.g. after FocusRequester::request_focus)
1356                    if let Some(fid) = self.rt.sched.focused {
1357                        if let Some(hit) = frame.hit_regions.iter().find(|h| h.id == fid)
1358                            && let Some(key) = hit.tf_state_key
1359                            && !self.rt.textfield_states.contains_key(&key)
1360                        {
1361                            self.rt.textfield_states
1362                                .entry(key)
1363                                .or_insert_with(|| {
1364                                    Rc::new(RefCell::new(repose_ui::TextFieldState::new()))
1365                                })
1366                                .borrow_mut()
1367                                .reset_caret_blink();
1368                        }
1369                    }
1370
1371                    self.rt.reconcile_hover_from_mouse_pos(&frame);
1372                    repose_core::dnd::set_dnd_frame(Some(frame.clone()));
1373                    self.rt.frame_cache = Some(frame);
1374                    repose_core::dnd::set_dnd_scale(scale);
1375
1376                    self.dispatch_file_drop_now();
1377
1378                    rc::tick_snackbar(self.last_redraw);
1379                    self.last_redraw = Instant::now();
1380                }
1381
1382                _ => {}
1383            }
1384        }
1385
1386        fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1387            // Process cross-thread commands (e.g. tray toggles, deeplinks) before any
1388            // redraw check, so hide/show commands work even when hidden
1389            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1390            if let Some(cb) = ABOUT_TO_WAIT_CALLBACK.lock().unwrap().as_ref() {
1391                cb();
1392            }
1393            process_deeplinks();
1394
1395            // On Wayland, wgpu creates an xdg_surface from the winit window and it shouldn't be recreated with a new id?
1396            // It doesn't take a lot of resources anyway, so let the backend be present.
1397            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1398            if WINDOW_VISIBLE.load(Ordering::Relaxed) && self.backend.is_none() {
1399                if let Some(w) = &self.window {
1400                    log::info!("about_to_wait: recreating GPU backend");
1401                    match repose_render_wgpu::WgpuBackend::new_with_msaa(
1402                        w.clone(),
1403                        self.msaa_samples,
1404                    ) {
1405                        Ok(b) => self.backend = Some(b),
1406                        Err(e) => log::error!("about_to_wait: failed to recreate backend: {e:?}"),
1407                    }
1408                }
1409            }
1410
1411            let needs_compose = take_frame_request();
1412            let needs_present = take_present_request();
1413
1414            if needs_compose {
1415                self.pending_redraw = true;
1416            }
1417
1418            // Present-only: texture was updated, redraw last cached scene without compose.
1419            if !self.pending_redraw && needs_present && self.rt.frame_cache.is_some() {
1420                let now = Instant::now();
1421                let interval = web_time::Duration::from_millis(16);
1422                if now.saturating_duration_since(self.last_redraw) >= interval {
1423                    rc::request_redraw(&self.window);
1424                    self.last_redraw = now;
1425                } else {
1426                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1427                        self.last_redraw + interval,
1428                    ));
1429                }
1430                return;
1431            }
1432
1433            if !self.pending_redraw {
1434                let now = Instant::now();
1435                let idle_cap = web_time::Duration::from_millis(1000);
1436                let deadline = self
1437                    .next_caret_blink_deadline()
1438                    .unwrap_or(now + idle_cap);
1439
1440                if now.saturating_duration_since(self.last_redraw) >= idle_cap || now >= deadline {
1441                    self.redraw_requested.set(true);
1442                    request_frame();
1443                    rc::request_redraw(&self.window);
1444                    self.last_redraw = now;
1445                }
1446                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1447                    Ord::min(deadline, now + idle_cap),
1448                ));
1449                return;
1450            }
1451
1452            let now = Instant::now();
1453            let interval = web_time::Duration::from_millis(16);
1454
1455            if now.saturating_duration_since(self.last_redraw) >= interval {
1456                self.pending_redraw = false;
1457                self.redraw_requested.set(true);
1458                rc::request_redraw(&self.window);
1459                self.last_redraw = now;
1460            } else {
1461                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1462                    self.last_redraw + interval,
1463                ));
1464            }
1465        }
1466
1467        fn new_events(
1468            &mut self,
1469            _: &winit::event_loop::ActiveEventLoop,
1470            _: winit::event::StartCause,
1471        ) {
1472        }
1473        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1474            self.pending_redraw = true;
1475        }
1476        fn device_event(
1477            &mut self,
1478            _: &winit::event_loop::ActiveEventLoop,
1479            _: winit::event::DeviceId,
1480            _: winit::event::DeviceEvent,
1481        ) {
1482        }
1483        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1484        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1485        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1486    }
1487
1488    impl App {
1489        /// Dispatch a key event through the focus ancestor chain.
1490        /// Returns true if the event was consumed by a handler.
1491        fn dispatch_focus_key_event(
1492            &self,
1493            key_event: &winit::event::KeyEvent,
1494            mapped_key: &repose_core::input::Key,
1495        ) -> bool {
1496            let Some(f) = &self.rt.frame_cache else {
1497                return false;
1498            };
1499            let Some(focused) = self.rt.sched.focused else {
1500                return false;
1501            };
1502            let utf16 = match mapped_key {
1503                repose_core::input::Key::Character(c) => *c as u16,
1504                _ => 0,
1505            };
1506            let mods = self.rt.modifiers;
1507            let repeat = key_event.repeat;
1508            let ev_type = if key_event.state == ElementState::Pressed {
1509                repose_core::input::KeyEventType::Down
1510            } else {
1511                repose_core::input::KeyEventType::Up
1512            };
1513            let hit_by_id: std::collections::HashMap<u64, &HitRegion> =
1514                f.hit_regions.iter().map(|h| (h.id, h)).collect();
1515            let sem_parent_of: std::collections::HashMap<u64, u64> = f
1516                .semantics_nodes
1517                .iter()
1518                .filter_map(|n| n.parent.map(|p| (n.id, p)))
1519                .collect();
1520            let mut ancestors = Vec::new();
1521            let mut cur = focused;
1522            loop {
1523                ancestors.push(cur);
1524                if let Some(&p) = sem_parent_of.get(&cur) {
1525                    cur = p;
1526                } else {
1527                    break;
1528                }
1529            }
1530            let make_ke = || repose_core::input::KeyEvent {
1531                key: mapped_key.clone(),
1532                modifiers: mods,
1533                is_repeat: repeat,
1534                event_type: ev_type,
1535                utf16_code_point: utf16,
1536            };
1537            // Top-down preview: root → focused
1538            for &id in ancestors.iter().rev() {
1539                if let Some(hit) = hit_by_id.get(&id) {
1540                    if let Some(cb) = &hit.on_preview_key_event {
1541                        if cb(make_ke()) {
1542                            return true;
1543                        }
1544                    }
1545                }
1546            }
1547            // Bottom-up normal: focused → root
1548            for &id in ancestors.iter() {
1549                if let Some(hit) = hit_by_id.get(&id) {
1550                    if let Some(cb) = &hit.on_key_event {
1551                        if cb(make_ke()) {
1552                            return true;
1553                        }
1554                    }
1555                }
1556            }
1557            false
1558        }
1559
1560        fn announce_focus_change(&mut self) {
1561            if let Some(f) = &self.rt.frame_cache {
1562                let focused_node = self.rt
1563                    .sched
1564                    .focused
1565                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1566                self.a11y.focus_changed(focused_node);
1567            }
1568        }
1569
1570        fn notify_text_change(&self, id: u64, text: String) {
1571            if let Some(f) = &self.rt.frame_cache
1572                && let Some(h) = f.hit_regions.iter().find(|h| h.id == id)
1573                && let Some(cb) = &h.on_text_change
1574            {
1575                cb(text);
1576            }
1577        }
1578
1579        fn tf_key_of(&self, visual_id: u64) -> u64 {
1580            rc::tf_key_of_in_frame(&self.rt.frame_cache, visual_id)
1581        }
1582
1583        /// If a text field is focused with a collapsed selection (caret blinking),
1584        /// return the [`Instant`] of the next 500 ms blink edge.
1585        fn next_caret_blink_deadline(&self) -> Option<Instant> {
1586            next_caret_blink_deadline(&self.rt.sched, &self.rt.frame_cache, &self.rt.textfield_states)
1587        }
1588
1589        fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
1590            use repose_core::shortcuts;
1591
1592            if let (Some(f), Some(fid)) = (&self.rt.frame_cache, self.rt.sched.focused)
1593                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
1594                && let Some(cb) = &hit.on_action
1595                && cb(action.clone())
1596            {
1597                return true;
1598            }
1599
1600            if shortcuts::handle(action.clone()) {
1601                return true;
1602            }
1603
1604            // Focus navigation (Tab/arrows)
1605            if let Some(f) = &self.rt.frame_cache {
1606                if let Some(new_id) = repose_core::focus::handle_action(&action, &mut self.rt.sched, f)
1607                {
1608                    if let Some(active) = self.rt.key_pressed_active.take() {
1609                        self.rt.pressed_ids.remove(&active);
1610                    }
1611                    let tf_state_key = f
1612                        .hit_regions
1613                        .iter()
1614                        .find(|h| h.id == new_id)
1615                        .and_then(|h| h.tf_state_key);
1616                    if let Some(key) = tf_state_key {
1617                        self.rt.textfield_states.entry(key).or_insert_with(|| {
1618                            Rc::new(RefCell::new(repose_ui::TextFieldState::new()))
1619                        });
1620                        if let Some(state_rc) = self.rt.textfield_states.get(&key) {
1621                            state_rc.borrow_mut().reset_caret_blink();
1622                        }
1623                    }
1624                    if let Some(win) = &self.window {
1625                        let is_textfield = f.semantics_nodes.iter().any(|n| {
1626                            n.id == new_id && n.role == repose_core::semantics::Role::TextField
1627                        });
1628                        rc_web::set_ime_for_textfield(win, is_textfield);
1629                    }
1630                    self.announce_focus_change();
1631                    return true;
1632                }
1633            }
1634
1635            false
1636        }
1637
1638        fn dispatch_file_drop_now(&mut self) {
1639            let Some(f) = &self.rt.frame_cache else {
1640                self.pending_dropped_files.clear();
1641                self.pending_drop_pos_px = None;
1642                return;
1643            };
1644
1645            if self.pending_dropped_files.is_empty() {
1646                return;
1647            }
1648
1649            let pos_px = self.pending_drop_pos_px.unwrap_or(self.rt.mouse_pos_px);
1650            let pos = Vec2 {
1651                x: pos_px.0,
1652                y: pos_px.1,
1653            };
1654
1655            let mut files = Vec::new();
1656            for p in self.pending_dropped_files.drain(..) {
1657                let name = p
1658                    .file_name()
1659                    .and_then(|s| s.to_str())
1660                    .unwrap_or("file")
1661                    .to_string();
1662                files.push(repose_core::dnd::DroppedFile {
1663                    name,
1664                    path: Some(p),
1665                });
1666            }
1667
1668            let payload: repose_core::dnd::DragPayload =
1669                std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1670
1671            let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1672                self.pending_drop_pos_px = None;
1673                return;
1674            };
1675
1676            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1677                && let Some(cb) = &hit.on_drop
1678            {
1679                let accepted = cb(repose_core::dnd::DropEvent {
1680                    source_id: 0, // external source (OS)
1681                    target_id,
1682                    position: pos,
1683                    modifiers: self.rt.modifiers,
1684                    payload: payload.clone(),
1685                });
1686
1687                if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
1688                {
1689                    let label = node.label.as_deref().unwrap_or("");
1690                    self.a11y.announce(&format!("Dropped files on {}", label));
1691                }
1692            }
1693
1694            self.pending_drop_pos_px = None;
1695            self.request_redraw();
1696        }
1697    }
1698
1699    let event_loop = EventLoop::new()?;
1700    set_event_loop_proxy(event_loop.create_proxy());
1701    let mut app = App::new(Box::new(root), config);
1702    // Install system clock once
1703    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1704    event_loop.run_app(&mut app)?;
1705    Ok(())
1706}
1707
1708// Accessibility bridge stub (Noop by default; logs on Linux for now)
1709/// Bridge from Repose's semantics tree to platform accessibility APIs.
1710///
1711/// Implementations are responsible for:
1712/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
1713/// - Updating focus when `focus_changed` is called.
1714/// - Announcing transient messages (e.g. button activation) via screen readers.
1715pub trait A11yBridge: Send {
1716    /// Publish (or update) the full semantics tree for the current frame.
1717    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1718
1719    /// Notify that the focused node has changed. `None` means focus cleared.
1720    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1721
1722    /// Announce a one‑off message via the platform's accessibility channel.
1723    fn announce(&mut self, msg: &str);
1724}
1725
1726struct NoopA11y;
1727impl A11yBridge for NoopA11y {
1728    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1729        // no-op
1730    }
1731    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1732        if let Some(n) = node {
1733            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1734        } else {
1735            log::info!("A11y focus: None");
1736        }
1737    }
1738    fn announce(&mut self, msg: &str) {
1739        log::info!("A11y announce: {msg}");
1740    }
1741}
1742
1743#[cfg(target_os = "linux")]
1744struct LinuxAtspiStub;
1745#[cfg(target_os = "linux")]
1746impl A11yBridge for LinuxAtspiStub {
1747    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1748        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1749    }
1750    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1751        if let Some(n) = node {
1752            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1753        } else {
1754            log::info!("AT-SPI stub focus: None");
1755        }
1756    }
1757    fn announce(&mut self, msg: &str) {
1758        log::info!("AT-SPI stub announce: {msg}");
1759    }
1760}