Skip to main content

repose_platform/
lib.rs

1//! Platform runners
2#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
3use accesskit_winit::Adapter;
4use repose_core::a11y::ReposeActionHandler;
5use repose_core::*;
6use std::cell::Cell;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Arc, Mutex};
9use web_time::Instant;
10
11#[cfg(target_os = "android")]
12pub mod android;
13
14#[cfg(target_arch = "wasm32")]
15pub mod web;
16
17mod common;
18pub mod gamepad;
19pub mod render;
20pub mod runner_common;
21pub mod window_v2;
22
23use common as rc;
24
25pub use render::{ImageHandleGuard, RenderCommand, RenderContext};
26
27#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
28use winit::window::Window;
29
30#[cfg(not(target_arch = "wasm32"))]
31use std::sync::OnceLock;
32
33#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
34static APP_WINDOW: OnceLock<Arc<Window>> = OnceLock::new();
35
36#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
37static WINDOW_VISIBLE: AtomicBool = AtomicBool::new(true);
38
39#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
40static CLOSE_TO_TRAY: AtomicBool = AtomicBool::new(false);
41
42#[cfg(not(target_arch = "wasm32"))]
43static EVENT_LOOP_PROXY: OnceLock<winit::event_loop::EventLoopProxy<()>> = OnceLock::new();
44
45/// Optional callback invoked on every AboutToWait, regardless of redraw state.
46#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
47static ABOUT_TO_WAIT_CALLBACK: Mutex<Option<Box<dyn Fn() + Send>>> = Mutex::new(None);
48
49pub use repose_app::lifecycle::{
50    AppLifecycle, current_lifecycle, process_deeplinks, process_lifecycle, run_pre_redraw,
51    set_on_deeplink, set_on_lifecycle, set_pre_redraw,
52};
53
54/// Queue a lifecycle transition and wake the UI loop. Thin wrapper over `repose_app`.
55#[cfg(target_os = "android")]
56pub(crate) fn push_lifecycle(state: AppLifecycle) {
57    repose_app::lifecycle::push_lifecycle(state);
58    #[cfg(not(target_arch = "wasm32"))]
59    wake_event_loop();
60}
61
62/// Push a deeplink payload and wake the UI loop. Thin wrapper over `repose_app`.
63pub fn push_deeplink(data: Vec<u8>) {
64    repose_app::lifecycle::push_deeplink(data);
65    #[cfg(not(target_arch = "wasm32"))]
66    if let Some(proxy) = EVENT_LOOP_PROXY.get() {
67        let _ = proxy.send_event(());
68    }
69}
70
71/// Store the application window handle (called once during app setup).
72#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
73pub fn set_app_window(window: Arc<Window>) {
74    let _ = APP_WINDOW.set(window);
75}
76
77/// Store the event loop proxy so tray commands / deeplinks can wake the event loop.
78#[cfg(not(target_arch = "wasm32"))]
79pub fn set_event_loop_proxy(proxy: winit::event_loop::EventLoopProxy<()>) {
80    let _ = EVENT_LOOP_PROXY.set(proxy);
81}
82
83/// Register a callback invoked on every AboutToWait (used for draining tray commands).
84#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
85pub fn set_about_to_wait_callback(cb: Box<dyn Fn() + Send>) {
86    *ABOUT_TO_WAIT_CALLBACK
87        .lock()
88        .unwrap_or_else(|e| e.into_inner()) = Some(cb);
89}
90
91/// Wake the winit event loop from another thread (e.g. tray's GTK thread, JNI callback).
92#[cfg(not(target_arch = "wasm32"))]
93pub fn wake_event_loop() {
94    if let Some(proxy) = EVENT_LOOP_PROXY.get() {
95        let _ = proxy.send_event(());
96    }
97}
98
99/// Show the application window.
100///
101/// On Wayland, unminimizing might not be supported by the protocol?
102#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
103pub fn show_app_window() {
104    WINDOW_VISIBLE.store(true, Ordering::Relaxed);
105    if let Some(w) = APP_WINDOW.get() {
106        log::info!("show_app_window: calling set_visible(true)");
107        w.set_visible(true);
108        #[allow(deprecated)]
109        w.focus_window();
110    }
111    repose_core::frame_clock::request_frame();
112    wake_event_loop();
113}
114
115#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
116pub fn hide_app_window() {
117    WINDOW_VISIBLE.store(false, Ordering::Relaxed);
118    if let Some(w) = APP_WINDOW.get() {
119        log::info!("hide_app_window: calling set_visible(false)");
120        w.set_visible(false);
121    }
122    repose_core::frame_clock::request_frame();
123    wake_event_loop();
124}
125
126/// Returns whether the application window is currently visible.
127#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
128pub fn window_is_visible() -> bool {
129    WINDOW_VISIBLE.load(Ordering::Relaxed)
130}
131
132/// The close button hides the window (via ``set_visible(false)``) instead of
133/// closing. The tray "Quit" action still exits the process regardless.
134#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
135pub fn set_close_to_tray(enabled: bool) {
136    CLOSE_TO_TRAY.store(enabled, Ordering::Relaxed);
137}
138
139/// Helper: ensure caret visibility - now in `repose_ui::textfield::tf_ensure_visible_in_rect`.
140pub use repose_ui::textfield::tf_ensure_visible_in_rect;
141
142#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
143use common::{cursor_is_hidden, map_cursor};
144
145pub use repose_app::{AndroidOptions, AppConfig, ReposeOptions};
146
147/// Run a desktop app with default [`AppConfig`].
148///
149/// Deprecated: use [`run_desktop_app_with_config`] with
150/// `AppConfig::default()` instead. This may be removed in a future release.
151#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
152#[deprecated(
153    note = "use run_desktop_app_with_config(root, AppConfig) instead; this may be removed in a future release"
154)]
155pub fn run_desktop_app(
156    root: impl FnMut(&mut Scheduler, &RenderContext) -> View + 'static,
157) -> anyhow::Result<()> {
158    run_desktop_app_with_config(root, AppConfig::default())
159}
160
161/// Run a desktop app with the given [`AppConfig`].
162#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
163pub fn run_desktop_app_with_config(
164    root: impl FnMut(&mut Scheduler, &RenderContext) -> View + 'static,
165    config: AppConfig,
166) -> anyhow::Result<()> {
167    use winit::application::ApplicationHandler;
168    use winit::dpi::{LogicalPosition, LogicalSize, PhysicalSize};
169    use winit::event::{ElementState, MouseButton, WindowEvent};
170    use winit::event_loop::EventLoop;
171    use winit::keyboard::{KeyCode, PhysicalKey};
172    use winit::window::{Window, WindowAttributes};
173
174    use repose_app::ReposeRuntime;
175    use repose_core::a11y::A11yTree;
176
177    struct ReposeActivationHandler {
178        initial_tree: Option<accesskit::TreeUpdate>,
179    }
180
181    impl accesskit::ActivationHandler for ReposeActivationHandler {
182        fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
183            self.initial_tree.take()
184        }
185    }
186
187    struct ReposeDeactivationHandler;
188
189    impl accesskit::DeactivationHandler for ReposeDeactivationHandler {
190        fn deactivate_accessibility(&mut self) {
191            // Nothing to clean up for now
192        }
193    }
194
195    struct App {
196        root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
197        render: RenderContext,
198        window: Option<Arc<Window>>,
199        backend: Option<repose_render_wgpu::WgpuBackend>,
200        rt: ReposeRuntime,
201        inspector: Option<repose_devtools::Inspector>,
202        msaa_samples: u32,
203        max_fps: Option<f32>,
204        present_mode: PresentModePref,
205        window_title: String,
206        window_size: (u32, u32),
207
208        // Files
209        pending_dropped_files: Vec<std::path::PathBuf>,
210        pending_drop_pos_px: Option<(f32, f32)>,
211
212        // External file drag hover (HoveredFile / Cancelled)
213        external_file_drag: bool,
214        hovered_files: Vec<std::path::PathBuf>,
215
216        clipboard: Option<clipawl::Clipboard>,
217        a11y: Box<dyn A11yBridge>,
218
219        accesskit_adapter: Option<Adapter>,
220        a11y_actions: Arc<Mutex<Vec<accesskit::ActionRequest>>>,
221        a11y_tree: A11yTree,
222
223        // Last applied OS window theme (dark/light) to avoid spamming set_theme.
224        last_window_theme: Option<bool>,
225
226        last_redraw: Instant,
227        pending_redraw: bool,
228
229        // Tracks whether a redraw was requested by app code
230        redraw_requested: Cell<bool>,
231
232        // Shared touch-scroll / pinch / swipe gesture state (touchscreens)
233        touch_gestures: rc::TouchGestureState,
234
235        gamepad: Option<Box<dyn gamepad::GamepadBackend>>,
236    }
237
238    impl App {
239        fn process_a11y_actions(&mut self) {
240            let mut actions = self.a11y_actions.lock().unwrap_or_else(|e| e.into_inner());
241            if actions.is_empty() {
242                return;
243            }
244            let pending = actions.drain(..).collect::<Vec<_>>();
245            drop(actions);
246
247            let Some(f) = &self.rt.frame_cache else {
248                return;
249            };
250
251            for req in pending {
252                let target_id = req.target_node.0;
253                match req.action {
254                    accesskit::Action::Click => {
255                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
256                            && let Some(cb) = &hit.on_click
257                        {
258                            cb();
259                            self.request_redraw();
260                        }
261                    }
262                    accesskit::Action::Focus => {
263                        // Assistive tech focus should show the focus ring.
264                        let _ = repose_core::request_input_mode(repose_core::InputMode::Keyboard);
265                        self.rt.sched.focused = Some(target_id);
266                        self.request_redraw();
267                    }
268                    _ => {}
269                }
270            }
271        }
272
273        fn new(
274            root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
275            config: AppConfig,
276        ) -> Self {
277            Self {
278                root,
279                render: RenderContext::new(),
280                window: None,
281                backend: None,
282                rt: ReposeRuntime::new(),
283                inspector: if config.enable_inspector {
284                    Some(repose_devtools::Inspector::new())
285                } else {
286                    None
287                },
288                msaa_samples: config.common.msaa_samples,
289                max_fps: config.common.max_fps,
290                present_mode: config.common.present_mode,
291                window_title: config.window_title,
292                window_size: config.window_size,
293                pending_dropped_files: Vec::new(),
294                pending_drop_pos_px: None,
295
296                external_file_drag: false,
297                hovered_files: Vec::new(),
298
299                clipboard: None,
300                a11y: {
301                    #[cfg(target_os = "linux")]
302                    {
303                        Box::new(LinuxAtspiStub) as Box<dyn A11yBridge>
304                    }
305                    #[cfg(not(target_os = "linux"))]
306                    {
307                        Box::new(NoopA11y) as Box<dyn A11yBridge>
308                    }
309                },
310
311                accesskit_adapter: None,
312                a11y_actions: Arc::new(Mutex::new(Vec::new())),
313                a11y_tree: A11yTree::default(),
314
315                last_redraw: Instant::now(),
316                pending_redraw: false,
317                last_window_theme: None,
318                redraw_requested: Cell::new(false),
319                touch_gestures: rc::TouchGestureState::default(),
320                gamepad: gamepad::create_backend()
321                    .map(|b| Box::new(b) as Box<dyn gamepad::GamepadBackend>),
322            }
323        }
324
325        fn request_redraw(&self) {
326            self.redraw_requested.set(true);
327            repose_core::request_frame();
328            rc::request_redraw(&self.window);
329        }
330
331        fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
332            if self.rt.dispatch_action(action) {
333                if let Some(win) = &self.window {
334                    rc::set_ime_for_textfield(
335                        win,
336                        self.rt
337                            .sched
338                            .focused
339                            .is_some_and(|id| self.rt.is_textfield(id)),
340                    );
341                }
342                return true;
343            }
344            false
345        }
346
347        /// Minimum time between CPU-side redraw requests derived from
348        /// `max_fps`. `Duration::ZERO` means uncapped (redraw immediately).
349        fn frame_interval(&self) -> web_time::Duration {
350            match self.max_fps.filter(|f| *f > 0.0) {
351                Some(fps) => {
352                    let secs = (1.0 / fps as f64).clamp(0.0, 1.0);
353                    web_time::Duration::from_secs_f64(secs)
354                }
355                None => web_time::Duration::ZERO,
356            }
357        }
358
359        fn paste_from_primary(&self) -> Option<String> {
360            let mut opts = clipawl::ClipboardOptions::default();
361            opts.linux.selection = clipawl::LinuxSelection::Primary;
362            if let Ok(cb) = clipawl::Clipboard::new_with_options(opts) {
363                match pollster::block_on(cb.read()) {
364                    Ok(t) => Some(t),
365                    Err(e) => {
366                        eprintln!("Primary paste error: {}", e);
367                        None
368                    }
369                }
370            } else {
371                None
372            }
373        }
374
375        fn process_render_commands(&mut self) {
376            let Some(backend) = self.backend.as_mut() else {
377                return;
378            };
379            repose_render_wgpu::apply_render_commands(backend, self.render.drain());
380        }
381
382        fn reset_pointer_state(&mut self) {
383            self.rt.capture_id = None;
384            self.rt.pressed_ids.clear();
385            self.rt.hover_id = None;
386        }
387    }
388
389    impl ApplicationHandler<()> for App {
390        fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
391            self.clipboard = rc::setup_clipboard();
392
393            if self.window.is_none() {
394                match el.create_window(
395                    WindowAttributes::default()
396                        .with_title(self.window_title.clone())
397                        .with_inner_size(PhysicalSize::new(self.window_size.0, self.window_size.1))
398                        .with_visible(false),
399                ) {
400                    Ok(win) => {
401                        let w = Arc::new(win);
402
403                        let activation_handler = ReposeActivationHandler {
404                            initial_tree: Some(A11yTree::initial_tree()),
405                        };
406
407                        let action_handler = ReposeActionHandler {
408                            pending_actions: self.a11y_actions.clone(),
409                        };
410
411                        let deactivation_handler = ReposeDeactivationHandler;
412
413                        let adapter = Adapter::with_direct_handlers(
414                            el,
415                            &w,
416                            activation_handler,
417                            action_handler,
418                            deactivation_handler,
419                        );
420
421                        self.accesskit_adapter = Some(adapter);
422
423                        w.set_visible(true);
424
425                        let size = w.inner_size();
426                        let sf = w.scale_factor() as f32;
427                        self.rt.set_viewport_and_scale(size.width, size.height, sf);
428
429                        match repose_render_wgpu::WgpuBackend::new_with_options(
430                            w.clone(),
431                            self.msaa_samples,
432                            self.present_mode,
433                        ) {
434                            Ok(mut b) => {
435                                b.set_pixels_per_point(sf);
436                                repose_render_wgpu::offscreen::set_shared_device(
437                                    b.device.clone(),
438                                    b.queue.clone(),
439                                );
440                                self.backend = Some(b);
441                                set_app_window(w.clone());
442                                self.window = Some(w);
443                                self.request_redraw();
444                            }
445                            Err(e) => {
446                                log::error!("Failed to create WGPU backend: {e:?}");
447                                el.exit();
448                            }
449                        }
450                    }
451                    Err(e) => {
452                        log::error!("Failed to create window: {e:?}");
453                        el.exit();
454                    }
455                }
456            }
457        }
458
459        fn window_event(
460            &mut self,
461            el: &winit::event_loop::ActiveEventLoop,
462            _id: winit::window::WindowId,
463            event: WindowEvent,
464        ) {
465            // Process AccessKit events first!
466            if let (Some(adapter), Some(window)) = (&mut self.accesskit_adapter, &self.window) {
467                adapter.process_event(window, &event);
468            }
469
470            match event {
471                WindowEvent::CloseRequested => {
472                    if CLOSE_TO_TRAY.load(Ordering::Relaxed) {
473                        // Drop GPU backend before null-buffer unmap.
474                        self.backend = None;
475                        if let Some(w) = &self.window {
476                            w.set_visible(false);
477                        }
478                        WINDOW_VISIBLE.store(false, Ordering::Relaxed);
479                    } else {
480                        el.exit();
481                    }
482                }
483
484                WindowEvent::Focused(focused) => {
485                    self.rt.sched.window_focused = focused;
486                    if !focused {
487                        self.rt.handle_focus_lost();
488                        self.rt.sched.held_keys.clear();
489                        self.rt.sched.mouse_primary = false;
490                        self.rt.sched.mouse_secondary = false;
491                        self.rt.sched.mouse_middle = false;
492                        self.external_file_drag = false;
493                        self.hovered_files.clear();
494
495                        if let Some(w) = &self.window {
496                            rc::set_ime_for_textfield(w, false);
497                        }
498
499                        self.request_redraw();
500                    }
501                }
502
503                WindowEvent::CursorLeft { .. } => {
504                    self.rt.pointer_inside = false;
505                    self.rt.handle_pointer_cancel();
506                    self.external_file_drag = false;
507                    self.hovered_files.clear();
508                    self.request_redraw();
509                }
510
511                WindowEvent::HoveredFile(path) => {
512                    // Mark external drag active and keep a small bounded list
513                    self.external_file_drag = true;
514                    if self.hovered_files.len() < 32 {
515                        self.hovered_files.push(path);
516                    }
517                    if self.pending_drop_pos_px.is_none() {
518                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
519                    }
520                    self.request_redraw();
521                }
522
523                WindowEvent::HoveredFileCancelled => {
524                    self.external_file_drag = false;
525                    self.hovered_files.clear();
526
527                    // Defensive: cancel any internal capture/drag that might be left stuck
528                    self.reset_pointer_state();
529
530                    self.request_redraw();
531                }
532
533                WindowEvent::DroppedFile(path) => {
534                    // DroppedFile is emitted once per file. Batch them.
535                    self.pending_dropped_files.push(path);
536                    if self.pending_drop_pos_px.is_none() {
537                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
538                    }
539
540                    // Drop ends the external file drag session.
541                    self.external_file_drag = false;
542                    self.hovered_files.clear();
543
544                    self.request_redraw();
545                }
546
547                WindowEvent::Resized(size) => {
548                    let sf = self
549                        .window
550                        .as_ref()
551                        .map(|w| w.scale_factor() as f32)
552                        .unwrap_or(1.0);
553                    rc::sync_viewport(&mut self.rt, &mut self.backend, size, sf);
554                    if let Some(w) = &self.window {
555                        let sf = w.scale_factor() as f32;
556                        let dp_w = size.width as f32 / sf;
557                        let dp_h = size.height as f32 / sf;
558                        log::info!(
559                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
560                            size.width,
561                            size.height,
562                            sf,
563                            dp_w as i32,
564                            dp_h as i32
565                        );
566                    }
567                    self.request_redraw();
568                }
569
570                WindowEvent::CursorMoved { position, .. } => {
571                    self.rt.pointer_inside = true;
572
573                    if self.external_file_drag {
574                        self.pending_drop_pos_px = Some((position.x as f32, position.y as f32));
575                    }
576
577                    let pos = Vec2 {
578                        x: position.x as f32,
579                        y: position.y as f32,
580                    };
581
582                    // Delegate pointer-move to the host runtime
583                    let result = self.rt.handle_pointer_move(pos);
584
585                    // Inspector hover (platform-specific - devtools inspect)
586                    if let (Some(inspector), Some(f)) = (&mut self.inspector, &self.rt.frame_cache)
587                        && inspector.hud.inspector_enabled
588                    {
589                        let hit = f.hit_regions.iter().find(|h| h.rect.contains(pos));
590                        let hover_rect = hit.map(|h| h.rect);
591                        let hover_info = hit.and_then(|h| {
592                            f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
593                                repose_devtools::HoveredInfo {
594                                    id: s.id,
595                                    role: format!("{:?}", s.role),
596                                    label: s.label.clone(),
597                                }
598                            })
599                        });
600                        inspector.hud.set_hovered(hover_rect, hover_info);
601                    }
602
603                    if let Some(win) = &self.window
604                        && let Some(c) = result.cursor
605                    {
606                        win.set_cursor_visible(!cursor_is_hidden(c));
607                        win.set_cursor(map_cursor(c));
608                    }
609
610                    self.request_redraw();
611                }
612
613                WindowEvent::MouseWheel { delta, .. } => {
614                    let scale = self
615                        .window
616                        .as_ref()
617                        .map(|w| w.scale_factor() as f32)
618                        .unwrap_or(1.0);
619                    if crate::runner_common::on_mouse_wheel(&mut self.rt, delta, scale) {
620                        self.request_redraw();
621                    }
622                }
623
624                WindowEvent::MouseInput { state, button, .. } => {
625                    let pos = Vec2 {
626                        x: self.rt.mouse_pos_px.0,
627                        y: self.rt.mouse_pos_px.1,
628                    };
629
630                    let mapped = match button {
631                        MouseButton::Left => PointerButton::Primary,
632                        MouseButton::Right => PointerButton::Secondary,
633                        MouseButton::Middle => PointerButton::Tertiary,
634                        // Forward/Back/other buttons are not dispatched by the runtime.
635                        _ => return,
636                    };
637                    match (mapped, state) {
638                        (PointerButton::Primary, ElementState::Pressed) => {
639                            self.rt.sched.mouse_primary = true;
640                        }
641                        (PointerButton::Primary, ElementState::Released) => {
642                            self.rt.sched.mouse_primary = false;
643                        }
644                        (PointerButton::Secondary, ElementState::Pressed) => {
645                            self.rt.sched.mouse_secondary = true;
646                        }
647                        (PointerButton::Secondary, ElementState::Released) => {
648                            self.rt.sched.mouse_secondary = false;
649                        }
650                        (PointerButton::Tertiary, ElementState::Pressed) => {
651                            self.rt.sched.mouse_middle = true;
652                        }
653                        (PointerButton::Tertiary, ElementState::Released) => {
654                            self.rt.sched.mouse_middle = false;
655                        }
656                    }
657
658                    match state {
659                        ElementState::Pressed => {
660                            let result = self.rt.handle_pointer_press(pos, mapped);
661
662                            // Platform-specific IME setup for focused textfields
663                            if let Some(fid) = result.focused
664                                && let Some(win) = &self.window
665                                && let Some(f) = &self.rt.frame_cache
666                                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
667                            {
668                                let sf = win.scale_factor();
669                                rc::set_ime_for_textfield_ex(
670                                    win,
671                                    true,
672                                    hit.keyboard_type.ime_purpose_hint(),
673                                    hit.auto_correct.unwrap_or(true),
674                                    hit.capitalization,
675                                );
676                                win.set_ime_cursor_area(
677                                    LogicalPosition::new(
678                                        hit.rect.x as f64 / sf,
679                                        hit.rect.y as f64 / sf,
680                                    ),
681                                    LogicalSize::new(
682                                        hit.rect.w as f64 / sf,
683                                        hit.rect.h as f64 / sf,
684                                    ),
685                                );
686                            }
687
688                            // Click outside - no focus result from runtime, drop IME
689                            if result.focused.is_none() && self.rt.ime_preedit {
690                                if let Some(win) = &self.window {
691                                    rc::set_ime_for_textfield(win, false);
692                                }
693                                self.rt.ime_preedit = false;
694                            }
695
696                            if result.needs_a11y_announce {
697                                self.announce_focus_change();
698                            }
699
700                            // Middle-click: paste the primary selection into a textfield.
701                            if matches!(mapped, PointerButton::Tertiary)
702                                && let Some(f) = &self.rt.frame_cache
703                                && let Some(cid) = self.rt.capture_id
704                                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
705                                && self.rt.is_textfield(hit.id)
706                                && let Some(txt) = self.paste_from_primary()
707                            {
708                                self.rt.paste_into_focused(&txt);
709                            }
710
711                            // Inspector: click-to-select topmost widget under cursor.
712                            if matches!(mapped, PointerButton::Primary | PointerButton::Secondary)
713                                && let Some(inspector) = &mut self.inspector
714                                && inspector.hud.inspector_enabled
715                                && let Some(f) = &self.rt.frame_cache
716                                && let Some(hit) =
717                                    f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
718                            {
719                                let info =
720                                    f.semantics_nodes.iter().find(|s| s.id == hit.id).map(|s| {
721                                        repose_devtools::HoveredInfo {
722                                            id: s.id,
723                                            role: format!("{:?}", s.role),
724                                            label: s.label.clone(),
725                                        }
726                                    });
727                                inspector
728                                    .hud
729                                    .select_widget(repose_devtools::SelectedWidget {
730                                        id: hit.id,
731                                        role: info
732                                            .as_ref()
733                                            .map(|i| i.role.clone())
734                                            .unwrap_or_default(),
735                                        label: info.as_ref().and_then(|i| i.label.clone()),
736                                        bounds: hit.rect,
737                                    });
738                            }
739
740                            self.request_redraw();
741                        }
742
743                        ElementState::Released => {
744                            let result = self.rt.handle_pointer_release(pos, mapped);
745
746                            // A11y: announce activation when a click fires on release.
747                            // The runtime reports the clicked id before clearing its
748                            // capture state, so this cannot race with `capture_id = None`.
749                            if let Some(cid) = result.clicked_id
750                                && let Some(f) = &self.rt.frame_cache
751                                && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid)
752                            {
753                                let label = node.label.as_deref().unwrap_or("");
754                                self.a11y.announce(&format!("Activated {}", label));
755                            }
756
757                            self.request_redraw();
758                        }
759                    }
760                }
761
762                WindowEvent::Touch(t) => {
763                    let scale = self
764                        .window
765                        .as_ref()
766                        .map(|w| w.scale_factor() as f32)
767                        .unwrap_or(1.0);
768                    let r = crate::runner_common::handle_touch_raw(
769                        &mut self.rt,
770                        &mut self.touch_gestures,
771                        &t,
772                        scale,
773                    );
774                    let mut dirty = r.dirty;
775                    if let Some((delta_scale, center)) = r.pinch
776                        && self.dispatch_action(repose_core::shortcuts::Action::Gesture(
777                            repose_core::shortcuts::Gesture::PinchWithCenter {
778                                delta_scale,
779                                center,
780                            },
781                        ))
782                    {
783                        dirty = true;
784                    }
785                    if let Some((delta, center)) = r.pan
786                        && self.dispatch_action(repose_core::shortcuts::Action::Gesture(
787                            repose_core::shortcuts::Gesture::Pan { delta, center },
788                        ))
789                    {
790                        dirty = true;
791                    }
792                    if let Some((delta_rotation, center)) = r.rotation
793                        && self.dispatch_action(repose_core::shortcuts::Action::Gesture(
794                            repose_core::shortcuts::Gesture::Rotate {
795                                delta_rotation,
796                                center,
797                            },
798                        ))
799                    {
800                        dirty = true;
801                    }
802                    if let Some(right) = r.swipe_right {
803                        let g = if right {
804                            repose_core::shortcuts::Gesture::SwipeRight
805                        } else {
806                            repose_core::shortcuts::Gesture::SwipeLeft
807                        };
808                        if self.dispatch_action(repose_core::shortcuts::Action::Gesture(g)) {
809                            dirty = true;
810                        }
811                    }
812                    if dirty {
813                        self.request_redraw();
814                    }
815                }
816
817                WindowEvent::ModifiersChanged(new_mods) => {
818                    crate::runner_common::on_modifiers_changed(&mut self.rt, &new_mods.state());
819                }
820
821                WindowEvent::KeyboardInput {
822                    event: key_event, ..
823                } => {
824                    if crate::runner_common::on_keyboard_input(
825                        &mut self.rt,
826                        &key_event,
827                        &mut self.inspector,
828                    ) {
829                        self.request_redraw();
830                        return;
831                    }
832
833                    // Escape / BrowserBack: when the runtime didn't cancel a
834                    // drag / dispatch focus, fall back to navigation back.
835                    if key_event.state == ElementState::Pressed && !key_event.repeat {
836                        match key_event.physical_key {
837                            PhysicalKey::Code(KeyCode::BrowserBack)
838                            | PhysicalKey::Code(KeyCode::Escape) => {
839                                use repose_navigation::back;
840                                if !back::handle() {
841                                    // el.exit();
842                                }
843                                return;
844                            }
845                            _ => {}
846                        }
847                    }
848
849                    // --- A11y: keyboard activation announcement ---
850                    if key_event.state == ElementState::Released
851                        && let Some(active_id) = self.rt.key_pressed_active
852                    {
853                        match key_event.physical_key {
854                            PhysicalKey::Code(KeyCode::Space)
855                            | PhysicalKey::Code(KeyCode::Enter) => {
856                                if let Some(f) = &self.rt.frame_cache
857                                    && let Some(node) =
858                                        f.semantics_nodes.iter().find(|n| n.id == active_id)
859                                {
860                                    let label = node.label.as_deref().unwrap_or("");
861                                    self.a11y.announce(&format!("Activated {}", label));
862                                }
863                            }
864                            _ => {}
865                        }
866                    }
867                }
868
869                WindowEvent::Ime(ime) => {
870                    crate::runner_common::on_ime(&mut self.rt, &ime);
871                    self.request_redraw();
872                }
873
874                WindowEvent::RedrawRequested => {
875                    // Allow media (etc.) to queue texture uploads without compose.
876                    crate::run_pre_redraw(&self.render);
877
878                    // 1. Check our redraw flag before processing a11y.
879                    if !self.redraw_requested.replace(false) {
880                        self.process_a11y_actions();
881                        self.process_render_commands();
882                        // Present-only: redraw last cached scene with updated textures
883                        if let (Some(backend), Some(frame)) =
884                            (self.backend.as_mut(), self.rt.frame_cache.as_ref())
885                        {
886                            let scale = self
887                                .window
888                                .as_ref()
889                                .map(|w| w.scale_factor() as f32)
890                                .unwrap_or(1.0);
891                            let mut scene = frame.scene.clone();
892                            if let Some(inspector) = &mut self.inspector {
893                                inspector.frame(&mut scene);
894                            }
895                            backend.frame(
896                                &scene,
897                                GlyphRasterConfig {
898                                    px: Px(18.0 * scale),
899                                },
900                            );
901                        }
902                        log::trace!("RedrawRequested: no frame request, skipping compose");
903                        return;
904                    }
905                    log::trace!("RedrawRequested: frame request pending, composing");
906
907                    // 2. Process a11y actions and render commands before compose.
908                    self.process_a11y_actions();
909                    self.process_render_commands();
910
911                    let Some(win) = self.window.as_ref() else {
912                        return;
913                    };
914                    if self.backend.is_none() {
915                        return;
916                    }
917
918                    // Advance animations before composition (Compose pattern).
919                    // Mirrors broadcastFrameClock.sendFrame() before performRecompose().
920                    repose_core::animation_driver::tick();
921
922                    let t0 = Instant::now();
923                    let scale = win.scale_factor() as f32;
924                    self.rt.scale = scale;
925                    let focused = self.rt.sched.focused;
926
927                    let output = self.rt.frame(&mut self.root, &self.render);
928
929                    if let Some(cursor) = &output.platform.cursor {
930                        win.set_cursor_visible(!cursor_is_hidden(*cursor));
931                        win.set_cursor(map_cursor(*cursor));
932                    }
933
934                    if let Some(dark) = output.platform.window_theme_dark
935                        && self.last_window_theme != Some(dark)
936                    {
937                        win.set_theme(Some(if dark {
938                            winit::window::Theme::Dark
939                        } else {
940                            winit::window::Theme::Light
941                        }));
942                        self.last_window_theme = Some(dark);
943                    }
944
945                    // Apply IME keyboard hints
946                    if output.platform.ime_allowed {
947                        rc::set_ime_for_textfield_ex(
948                            win,
949                            true,
950                            output.platform.ime_purpose,
951                            output.platform.ime_auto_correct,
952                            output.platform.ime_capitalization,
953                        );
954                        if let Some((x, y, w, h)) = output.platform.ime_cursor_area {
955                            win.set_ime_cursor_area(
956                                LogicalPosition::new(x, y),
957                                LogicalSize::new(w, h),
958                            );
959                        }
960                    } else if self.rt.ime_preedit {
961                        rc::set_ime_for_textfield_ex(
962                            win,
963                            false,
964                            repose_core::ImePurposeHint::Normal,
965                            true,
966                            repose_core::KeyboardCapitalization::Unspecified,
967                        );
968                        self.rt.ime_preedit = false;
969                    }
970
971                    // Apply IME state based on wants_keyboard
972                    if !output.wants_keyboard
973                        && focused.is_some()
974                        && self.rt.sched.focused.is_none()
975                        && self.rt.ime_preedit
976                    {
977                        rc::set_ime_for_textfield(win, false);
978                        self.rt.ime_preedit = false;
979                    }
980
981                    let frame = output.into_frame();
982
983                    let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
984
985                    // UPDATE ACCESSIBILITY TREE
986                    if let (Some(adapter), Some(win)) = (&mut self.accesskit_adapter, &self.window)
987                    {
988                        let scale = win.scale_factor();
989                        if let Some(update) = self.a11y_tree.update(
990                            &frame.semantics_nodes,
991                            scale,
992                            self.rt.sched.focused,
993                        ) {
994                            adapter.update_if_active(|| update);
995                        }
996                    }
997
998                    // Render
999                    let mut scene = frame.scene.clone();
1000                    // Update HUD metrics before overlay draws
1001                    if let Some(inspector) = &mut self.inspector {
1002                        let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1003                        let signal_count = self.rt.sched.id_count() as usize;
1004                        let ls = repose_ui::last_layout_stats();
1005                        inspector.hud.metrics = Some(repose_devtools::Metrics {
1006                            build_ms: build_layout_ms,
1007                            layout_ms: ls.layout_time_ms,
1008                            paint_ms: ls.paint_time_ms,
1009                            scene_nodes: scene.nodes.len(),
1010                            widget_count,
1011                            signal_count,
1012                            taffy_created: ls.taffy_created,
1013                            taffy_reused: ls.taffy_reused,
1014                            layout_hits: ls.layout_hits,
1015                            layout_misses: ls.layout_misses,
1016                            paint_cache_hits: ls.paint_cache_hits,
1017                            paint_cache_misses: ls.paint_cache_misses,
1018                            paint_culled: ls.paint_culled,
1019                        });
1020                        inspector.frame(&mut scene);
1021                    }
1022
1023                    // Drag indicator overlay (internal + file drop)
1024                    repose_core::dnd::overlay_drag_indicator(
1025                        &mut scene,
1026                        self.rt.mouse_pos_px,
1027                        self.external_file_drag,
1028                    );
1029
1030                    // Drain upload commands queued during compose (e.g. VideoSink set_image_*)
1031                    // before presenting to avoid 1-frame GPU texture lag.
1032                    self.process_render_commands();
1033
1034                    // Now borrow backend mutably only for the frame() call
1035                    let Some(win) = self.window.as_ref() else {
1036                        return;
1037                    };
1038                    let scale = win.scale_factor() as f32;
1039                    if let Some(backend) = self.backend.as_mut() {
1040                        backend.frame(
1041                            &scene,
1042                            GlyphRasterConfig {
1043                                px: Px(18.0 * scale),
1044                            },
1045                        );
1046                    }
1047
1048                    // Initialize TextFieldState for any focused TextField that
1049                    // doesn't have one yet (e.g. after FocusRequester::request_focus),
1050                    // reconcile hover, and publish the DnD frame/scale.
1051                    self.rt.after_compose(&frame, scale);
1052
1053                    // NOTE: hover was already reconciled inside `compose()`.
1054                    // `cache_frame` rebuilds the retained hover-leave map.
1055                    self.rt.cache_frame(frame);
1056
1057                    self.dispatch_file_drop_now();
1058
1059                    self.rt.tick_overlays();
1060                    self.last_redraw = Instant::now();
1061                }
1062
1063                _ => {}
1064            }
1065        }
1066
1067        fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1068            // Process cross-thread commands (e.g. tray toggles, deeplinks) before any
1069            // redraw check, so hide/show commands work even when hidden
1070            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1071            if let Some(cb) = ABOUT_TO_WAIT_CALLBACK
1072                .lock()
1073                .unwrap_or_else(|e| e.into_inner())
1074                .as_ref()
1075            {
1076                let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(cb));
1077                if let Err(e) = res {
1078                    log::error!(
1079                        "ABOUT_TO_WAIT_CALLBACK panicked: {}",
1080                        e.downcast_ref::<String>()
1081                            .map(|s| s.as_str())
1082                            .or_else(|| e.downcast_ref::<&str>().copied())
1083                            .unwrap_or("unknown")
1084                    );
1085                }
1086            }
1087            process_deeplinks();
1088
1089            if let Some(backend) = &mut self.gamepad {
1090                for ev in backend.poll() {
1091                    self.rt.handle_gamepad(&ev);
1092                }
1093                for (id, low, high, duration_ms) in self.rt.take_rumble_requests() {
1094                    use repose_core::input::GamepadId;
1095                    let gid = GamepadId(id);
1096                    if duration_ms == 0 {
1097                        backend.stop_rumble(gid);
1098                    } else if !backend.set_rumble(gid, low, high, duration_ms) {
1099                        log::warn!("gamepad: rumble not supported for pad {id}");
1100                    }
1101                }
1102            } else {
1103                self.rt.take_rumble_requests();
1104            }
1105
1106            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1107            if WINDOW_VISIBLE.load(Ordering::Relaxed)
1108                && self.backend.is_none()
1109                && let Some(w) = &self.window
1110            {
1111                log::info!("about_to_wait: recreating GPU backend");
1112                match repose_render_wgpu::WgpuBackend::new_with_options(
1113                    w.clone(),
1114                    self.msaa_samples,
1115                    self.present_mode,
1116                ) {
1117                    Ok(b) => {
1118                        repose_render_wgpu::offscreen::set_shared_device(
1119                            b.device.clone(),
1120                            b.queue.clone(),
1121                        );
1122                        self.backend = Some(b)
1123                    }
1124                    Err(e) => log::error!("about_to_wait: failed to recreate backend: {e:?}"),
1125                }
1126            }
1127
1128            let needs_compose = take_frame_request();
1129            let needs_present = take_present_request();
1130
1131            if needs_compose {
1132                self.pending_redraw = true;
1133            }
1134
1135            // Present-only: texture was updated, redraw last cached scene without compose.
1136            if !self.pending_redraw && needs_present && self.rt.frame_cache.is_some() {
1137                let now = Instant::now();
1138                let interval = self.frame_interval();
1139                if now.saturating_duration_since(self.last_redraw) >= interval {
1140                    rc::request_redraw(&self.window);
1141                    self.last_redraw = now;
1142                } else {
1143                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1144                        self.last_redraw + interval,
1145                    ));
1146                }
1147                return;
1148            }
1149
1150            if !self.pending_redraw {
1151                let now = Instant::now();
1152                let idle_cap = web_time::Duration::from_millis(1000);
1153                let deadline = self.rt.next_frame_deadline(now, idle_cap);
1154
1155                if now.saturating_duration_since(self.last_redraw) >= idle_cap
1156                    || self.rt.is_wakeup_due(now)
1157                {
1158                    self.redraw_requested.set(true);
1159                    request_frame();
1160                    rc::request_redraw(&self.window);
1161                    self.last_redraw = now;
1162                }
1163                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(Ord::min(
1164                    deadline,
1165                    now + idle_cap,
1166                )));
1167                return;
1168            }
1169
1170            let now = Instant::now();
1171            let interval = self.frame_interval();
1172
1173            if now.saturating_duration_since(self.last_redraw) >= interval {
1174                self.pending_redraw = false;
1175                self.redraw_requested.set(true);
1176                rc::request_redraw(&self.window);
1177                self.last_redraw = now;
1178            } else {
1179                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1180                    self.last_redraw + interval,
1181                ));
1182            }
1183        }
1184
1185        fn new_events(
1186            &mut self,
1187            _: &winit::event_loop::ActiveEventLoop,
1188            _: winit::event::StartCause,
1189        ) {
1190        }
1191        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1192            self.pending_redraw = true;
1193        }
1194        fn device_event(
1195            &mut self,
1196            _: &winit::event_loop::ActiveEventLoop,
1197            _: winit::event::DeviceId,
1198            _: winit::event::DeviceEvent,
1199        ) {
1200        }
1201        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1202        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1203        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1204    }
1205
1206    impl App {
1207        fn announce_focus_change(&mut self) {
1208            if let Some(f) = &self.rt.frame_cache {
1209                let focused_node = self
1210                    .rt
1211                    .sched
1212                    .focused
1213                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1214                self.a11y.focus_changed(focused_node);
1215            }
1216        }
1217
1218        fn dispatch_file_drop_now(&mut self) {
1219            let Some(f) = &self.rt.frame_cache else {
1220                self.pending_dropped_files.clear();
1221                self.pending_drop_pos_px = None;
1222                return;
1223            };
1224
1225            if self.pending_dropped_files.is_empty() {
1226                return;
1227            }
1228
1229            let pos_px = self.pending_drop_pos_px.unwrap_or(self.rt.mouse_pos_px);
1230            let pos = Vec2 {
1231                x: pos_px.0,
1232                y: pos_px.1,
1233            };
1234
1235            let mut files = Vec::new();
1236            for p in self.pending_dropped_files.drain(..) {
1237                let name = p
1238                    .file_name()
1239                    .and_then(|s| s.to_str())
1240                    .unwrap_or("file")
1241                    .to_string();
1242                files.push(repose_core::dnd::DroppedFile {
1243                    name,
1244                    path: Some(p),
1245                });
1246            }
1247
1248            let payload: repose_core::dnd::DragPayload =
1249                std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1250
1251            let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1252                self.pending_drop_pos_px = None;
1253                return;
1254            };
1255
1256            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1257                && let Some(cb) = &hit.on_drop
1258            {
1259                let accepted = cb(repose_core::dnd::DropEvent {
1260                    source_id: 0, // external source (OS)
1261                    target_id,
1262                    position: pos,
1263                    modifiers: self.rt.modifiers,
1264                    payload: payload.clone(),
1265                });
1266
1267                if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
1268                {
1269                    let label = node.label.as_deref().unwrap_or("");
1270                    self.a11y.announce(&format!("Dropped files on {}", label));
1271                }
1272            }
1273
1274            self.pending_drop_pos_px = None;
1275            self.request_redraw();
1276        }
1277    }
1278
1279    let event_loop = EventLoop::new()?;
1280    set_event_loop_proxy(event_loop.create_proxy());
1281    let mut app = App::new(Box::new(root), config);
1282    // Install system clock once
1283    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1284    event_loop.run_app(&mut app)?;
1285    Ok(())
1286}
1287
1288// Accessibility bridge stub (Noop by default; logs on Linux for now)
1289/// Bridge from Repose's semantics tree to platform accessibility APIs.
1290///
1291/// Implementations are responsible for:
1292/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
1293/// - Updating focus when `focus_changed` is called.
1294/// - Announcing transient messages (e.g. button activation) via screen readers.
1295pub trait A11yBridge: Send {
1296    /// Publish (or update) the full semantics tree for the current frame.
1297    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1298
1299    /// Notify that the focused node has changed. `None` means focus cleared.
1300    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1301
1302    /// Announce a one‑off message via the platform's accessibility channel.
1303    fn announce(&mut self, msg: &str);
1304}
1305
1306#[cfg_attr(target_os = "linux", allow(dead_code))]
1307struct NoopA11y;
1308impl A11yBridge for NoopA11y {
1309    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1310        // no-op
1311    }
1312    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1313        if let Some(n) = node {
1314            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1315        } else {
1316            log::info!("A11y focus: None");
1317        }
1318    }
1319    fn announce(&mut self, msg: &str) {
1320        log::info!("A11y announce: {msg}");
1321    }
1322}
1323
1324#[cfg(target_os = "linux")]
1325struct LinuxAtspiStub;
1326#[cfg(target_os = "linux")]
1327impl A11yBridge for LinuxAtspiStub {
1328    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1329        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1330    }
1331    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1332        if let Some(n) = node {
1333            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1334        } else {
1335            log::info!("AT-SPI stub focus: None");
1336        }
1337    }
1338    fn announce(&mut self, msg: &str) {
1339        log::info!("AT-SPI stub announce: {msg}");
1340    }
1341}