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