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::*;
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
17pub mod a11y;
18mod common;
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::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 crate::a11y::A11yTree;
175    use repose_app::ReposeRuntime;
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
236    impl App {
237        fn process_a11y_actions(&mut self) {
238            let mut actions = self.a11y_actions.lock().unwrap_or_else(|e| e.into_inner());
239            if actions.is_empty() {
240                return;
241            }
242            let pending = actions.drain(..).collect::<Vec<_>>();
243            drop(actions);
244
245            let Some(f) = &self.rt.frame_cache else {
246                return;
247            };
248
249            for req in pending {
250                let target_id = req.target_node.0;
251                match req.action {
252                    accesskit::Action::Click => {
253                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
254                            && let Some(cb) = &hit.on_click
255                        {
256                            cb();
257                            self.request_redraw();
258                        }
259                    }
260                    accesskit::Action::Focus => {
261                        // Assistive tech focus should show the focus ring.
262                        let _ = repose_core::request_input_mode(repose_core::InputMode::Keyboard);
263                        self.rt.sched.focused = Some(target_id);
264                        self.request_redraw();
265                    }
266                    _ => {}
267                }
268            }
269        }
270
271        fn new(
272            root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
273            config: AppConfig,
274        ) -> Self {
275            Self {
276                root,
277                render: RenderContext::new(),
278                window: None,
279                backend: None,
280                rt: ReposeRuntime::new(),
281                inspector: if config.enable_inspector {
282                    Some(repose_devtools::Inspector::new())
283                } else {
284                    None
285                },
286                msaa_samples: config.common.msaa_samples,
287                max_fps: config.common.max_fps,
288                present_mode: config.common.present_mode,
289                window_title: config.window_title,
290                window_size: config.window_size,
291                pending_dropped_files: Vec::new(),
292                pending_drop_pos_px: None,
293
294                external_file_drag: false,
295                hovered_files: Vec::new(),
296
297                clipboard: None,
298                a11y: {
299                    #[cfg(target_os = "linux")]
300                    {
301                        Box::new(LinuxAtspiStub) as Box<dyn A11yBridge>
302                    }
303                    #[cfg(not(target_os = "linux"))]
304                    {
305                        Box::new(NoopA11y) as Box<dyn A11yBridge>
306                    }
307                },
308
309                accesskit_adapter: None,
310                a11y_actions: Arc::new(Mutex::new(Vec::new())),
311                a11y_tree: A11yTree::default(),
312
313                last_redraw: Instant::now(),
314                pending_redraw: false,
315                last_window_theme: None,
316                redraw_requested: Cell::new(false),
317                touch_gestures: rc::TouchGestureState::default(),
318            }
319        }
320
321        fn request_redraw(&self) {
322            self.redraw_requested.set(true);
323            repose_core::request_frame();
324            rc::request_redraw(&self.window);
325        }
326
327        fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
328            if self.rt.dispatch_action(action) {
329                if let Some(win) = &self.window {
330                    rc::set_ime_for_textfield(
331                        win,
332                        self.rt
333                            .sched
334                            .focused
335                            .map_or(false, |id| self.rt.is_textfield(id)),
336                    );
337                }
338                return true;
339            }
340            false
341        }
342
343        /// Minimum time between CPU-side redraw requests derived from
344        /// `max_fps`. `Duration::ZERO` means uncapped (redraw immediately).
345        fn frame_interval(&self) -> web_time::Duration {
346            match self.max_fps.filter(|f| *f > 0.0) {
347                Some(fps) => {
348                    let secs = (1.0 / fps as f64).clamp(0.0, 1.0);
349                    web_time::Duration::from_secs_f64(secs)
350                }
351                None => web_time::Duration::ZERO,
352            }
353        }
354
355        fn paste_from_primary(&self) -> Option<String> {
356            let mut opts = clipawl::ClipboardOptions::default();
357            opts.linux.selection = clipawl::LinuxSelection::Primary;
358            if let Ok(cb) = clipawl::Clipboard::new_with_options(opts) {
359                match pollster::block_on(cb.read()) {
360                    Ok(t) => Some(t),
361                    Err(e) => {
362                        eprintln!("Primary paste error: {}", e);
363                        None
364                    }
365                }
366            } else {
367                None
368            }
369        }
370
371        fn process_render_commands(&mut self) {
372            let Some(backend) = self.backend.as_mut() else {
373                return;
374            };
375            repose_render_wgpu::apply_render_commands(backend, self.render.drain());
376        }
377
378        fn reset_pointer_state(&mut self) {
379            self.rt.capture_id = None;
380            self.rt.pressed_ids.clear();
381            self.rt.hover_id = None;
382        }
383    }
384
385    impl ApplicationHandler<()> for App {
386        fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
387            self.clipboard = rc::setup_clipboard();
388
389            if self.window.is_none() {
390                match el.create_window(
391                    WindowAttributes::default()
392                        .with_title(self.window_title.clone())
393                        .with_inner_size(PhysicalSize::new(self.window_size.0, self.window_size.1))
394                        .with_visible(false),
395                ) {
396                    Ok(win) => {
397                        let w = Arc::new(win);
398
399                        let activation_handler = ReposeActivationHandler {
400                            initial_tree: Some(A11yTree::initial_tree()),
401                        };
402
403                        let action_handler = ReposeActionHandler {
404                            pending_actions: self.a11y_actions.clone(),
405                        };
406
407                        let deactivation_handler = ReposeDeactivationHandler;
408
409                        let adapter = Adapter::with_direct_handlers(
410                            el,
411                            &w,
412                            activation_handler,
413                            action_handler,
414                            deactivation_handler,
415                        );
416
417                        self.accesskit_adapter = Some(adapter);
418
419                        w.set_visible(true);
420
421                        let size = w.inner_size();
422                        let sf = w.scale_factor() as f32;
423                        self.rt.set_viewport_and_scale(size.width, size.height, sf);
424
425                        match repose_render_wgpu::WgpuBackend::new_with_options(
426                            w.clone(),
427                            self.msaa_samples,
428                            self.present_mode,
429                        ) {
430                            Ok(mut b) => {
431                                b.set_pixels_per_point(sf);
432                                repose_render_wgpu::offscreen::set_shared_device(
433                                    b.device.clone(),
434                                    b.queue.clone(),
435                                );
436                                self.backend = Some(b);
437                                set_app_window(w.clone());
438                                self.window = Some(w);
439                                self.request_redraw();
440                            }
441                            Err(e) => {
442                                log::error!("Failed to create WGPU backend: {e:?}");
443                                el.exit();
444                            }
445                        }
446                    }
447                    Err(e) => {
448                        log::error!("Failed to create window: {e:?}");
449                        el.exit();
450                    }
451                }
452            }
453        }
454
455        fn window_event(
456            &mut self,
457            el: &winit::event_loop::ActiveEventLoop,
458            _id: winit::window::WindowId,
459            event: WindowEvent,
460        ) {
461            // Process AccessKit events first!
462            if let (Some(adapter), Some(window)) = (&mut self.accesskit_adapter, &self.window) {
463                adapter.process_event(window, &event);
464            }
465
466            match event {
467                WindowEvent::CloseRequested => {
468                    if CLOSE_TO_TRAY.load(Ordering::Relaxed) {
469                        // Drop GPU backend before null-buffer unmap.
470                        self.backend = None;
471                        if let Some(w) = &self.window {
472                            w.set_visible(false);
473                        }
474                        WINDOW_VISIBLE.store(false, Ordering::Relaxed);
475                    } else {
476                        el.exit();
477                    }
478                }
479
480                WindowEvent::Focused(false) => {
481                    // Delegate all common focus-lost cleanup to the runtime
482                    self.rt.handle_focus_lost();
483
484                    // Platform-specific cleanup
485                    self.external_file_drag = false;
486                    self.hovered_files.clear();
487
488                    if let Some(w) = &self.window {
489                        rc::set_ime_for_textfield(w, false);
490                    }
491
492                    self.request_redraw();
493                }
494
495                WindowEvent::CursorLeft { .. } => {
496                    self.rt.pointer_inside = false;
497                    self.rt.clear_hover();
498                    self.external_file_drag = false;
499                    self.hovered_files.clear();
500                    self.request_redraw();
501                }
502
503                WindowEvent::HoveredFile(path) => {
504                    // Mark external drag active and keep a small bounded list
505                    self.external_file_drag = true;
506                    if self.hovered_files.len() < 32 {
507                        self.hovered_files.push(path);
508                    }
509                    if self.pending_drop_pos_px.is_none() {
510                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
511                    }
512                    self.request_redraw();
513                }
514
515                WindowEvent::HoveredFileCancelled => {
516                    self.external_file_drag = false;
517                    self.hovered_files.clear();
518
519                    // Defensive: cancel any internal capture/drag that might be left stuck
520                    self.reset_pointer_state();
521
522                    self.request_redraw();
523                }
524
525                WindowEvent::DroppedFile(path) => {
526                    // DroppedFile is emitted once per file. Batch them.
527                    self.pending_dropped_files.push(path);
528                    if self.pending_drop_pos_px.is_none() {
529                        self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
530                    }
531
532                    // Drop ends the external file drag session.
533                    self.external_file_drag = false;
534                    self.hovered_files.clear();
535
536                    self.request_redraw();
537                }
538
539                WindowEvent::Resized(size) => {
540                    let sf = self
541                        .window
542                        .as_ref()
543                        .map(|w| w.scale_factor() as f32)
544                        .unwrap_or(1.0);
545                    rc::sync_viewport(&mut self.rt, &mut self.backend, size, sf);
546                    if let Some(w) = &self.window {
547                        let sf = w.scale_factor() as f32;
548                        let dp_w = size.width as f32 / sf;
549                        let dp_h = size.height as f32 / sf;
550                        log::info!(
551                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
552                            size.width,
553                            size.height,
554                            sf,
555                            dp_w as i32,
556                            dp_h as i32
557                        );
558                    }
559                    self.request_redraw();
560                }
561
562                WindowEvent::CursorMoved { position, .. } => {
563                    self.rt.pointer_inside = true;
564
565                    if self.external_file_drag {
566                        self.pending_drop_pos_px = Some((position.x as f32, position.y as f32));
567                    }
568
569                    let pos = Vec2 {
570                        x: position.x as f32,
571                        y: position.y as f32,
572                    };
573
574                    // Delegate pointer-move to the host runtime
575                    let result = self.rt.handle_pointer_move(pos);
576
577                    // Inspector hover (platform-specific - devtools inspect)
578                    if let (Some(inspector), Some(f)) = (&mut self.inspector, &self.rt.frame_cache)
579                        && inspector.hud.inspector_enabled
580                    {
581                        let hit = f.hit_regions.iter().find(|h| h.rect.contains(pos));
582                        let hover_rect = hit.map(|h| h.rect);
583                        let hover_info = hit.and_then(|h| {
584                            f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
585                                repose_devtools::HoveredInfo {
586                                    id: s.id,
587                                    role: format!("{:?}", s.role),
588                                    label: s.label.clone(),
589                                }
590                            })
591                        });
592                        inspector.hud.set_hovered(hover_rect, hover_info);
593                    }
594
595                    // Cursor icon via winit window
596                    if let Some(win) = &self.window
597                        && let Some(c) = result.cursor
598                    {
599                        win.set_cursor(winit::window::Cursor::Icon(map_cursor(c)));
600                    }
601
602                    self.request_redraw();
603                }
604
605                WindowEvent::MouseWheel { delta, .. } => {
606                    let scale = self
607                        .window
608                        .as_ref()
609                        .map(|w| w.scale_factor() as f32)
610                        .unwrap_or(1.0);
611                    if crate::runner_common::on_mouse_wheel(&mut self.rt, delta, scale) {
612                        self.request_redraw();
613                    }
614                }
615
616                WindowEvent::MouseInput { state, button, .. } => {
617                    let pos = Vec2 {
618                        x: self.rt.mouse_pos_px.0,
619                        y: self.rt.mouse_pos_px.1,
620                    };
621
622                    let mapped = match button {
623                        MouseButton::Left => PointerButton::Primary,
624                        MouseButton::Right => PointerButton::Secondary,
625                        MouseButton::Middle => PointerButton::Tertiary,
626                        // Forward/Back/other buttons are not dispatched by the runtime.
627                        _ => return,
628                    };
629
630                    match state {
631                        ElementState::Pressed => {
632                            let result = self.rt.handle_pointer_press(pos, mapped);
633
634                            // Platform-specific IME setup for focused textfields
635                            if let Some(fid) = result.focused
636                                && let Some(win) = &self.window
637                                && let Some(f) = &self.rt.frame_cache
638                                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
639                            {
640                                let sf = win.scale_factor();
641                                rc::set_ime_for_textfield_ex(
642                                    win,
643                                    true,
644                                    hit.keyboard_type.ime_purpose_hint(),
645                                    hit.auto_correct.unwrap_or(true),
646                                    hit.capitalization,
647                                );
648                                win.set_ime_cursor_area(
649                                    LogicalPosition::new(
650                                        hit.rect.x as f64 / sf,
651                                        hit.rect.y as f64 / sf,
652                                    ),
653                                    LogicalSize::new(
654                                        hit.rect.w as f64 / sf,
655                                        hit.rect.h as f64 / sf,
656                                    ),
657                                );
658                            }
659
660                            // Click outside - no focus result from runtime, drop IME
661                            if result.focused.is_none() && self.rt.ime_preedit {
662                                if let Some(win) = &self.window {
663                                    rc::set_ime_for_textfield(win, false);
664                                }
665                                self.rt.ime_preedit = false;
666                            }
667
668                            if result.needs_a11y_announce {
669                                self.announce_focus_change();
670                            }
671
672                            // Middle-click: paste the primary selection into a textfield.
673                            if matches!(mapped, PointerButton::Tertiary)
674                                && let Some(f) = &self.rt.frame_cache
675                                && let Some(cid) = self.rt.capture_id
676                                && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
677                                && self.rt.is_textfield(hit.id)
678                                && let Some(txt) = self.paste_from_primary()
679                            {
680                                self.rt.paste_into_focused(&txt);
681                            }
682
683                            // Inspector: click-to-select topmost widget under cursor.
684                            if matches!(mapped, PointerButton::Primary | PointerButton::Secondary)
685                                && let Some(inspector) = &mut self.inspector
686                                && inspector.hud.inspector_enabled
687                                && let Some(f) = &self.rt.frame_cache
688                                && let Some(hit) =
689                                    f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
690                            {
691                                let info =
692                                    f.semantics_nodes.iter().find(|s| s.id == hit.id).map(|s| {
693                                        repose_devtools::HoveredInfo {
694                                            id: s.id,
695                                            role: format!("{:?}", s.role),
696                                            label: s.label.clone(),
697                                        }
698                                    });
699                                inspector
700                                    .hud
701                                    .select_widget(repose_devtools::SelectedWidget {
702                                        id: hit.id,
703                                        role: info
704                                            .as_ref()
705                                            .map(|i| i.role.clone())
706                                            .unwrap_or_default(),
707                                        label: info.as_ref().and_then(|i| i.label.clone()),
708                                        bounds: hit.rect,
709                                    });
710                            }
711
712                            self.request_redraw();
713                        }
714
715                        ElementState::Released => {
716                            let result = self.rt.handle_pointer_release(pos, mapped);
717
718                            // A11y: announce activation when a click fires on release.
719                            // The runtime reports the clicked id before clearing its
720                            // capture state, so this cannot race with `capture_id = None`.
721                            if let Some(cid) = result.clicked_id
722                                && let Some(f) = &self.rt.frame_cache
723                                && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid)
724                            {
725                                let label = node.label.as_deref().unwrap_or("");
726                                self.a11y.announce(&format!("Activated {}", label));
727                            }
728
729                            self.request_redraw();
730                        }
731                    }
732                }
733
734                WindowEvent::Touch(t) => {
735                    let scale = self
736                        .window
737                        .as_ref()
738                        .map(|w| w.scale_factor() as f32)
739                        .unwrap_or(1.0);
740                    let r = crate::runner_common::handle_touch_raw(
741                        &mut self.rt,
742                        &mut self.touch_gestures,
743                        &t,
744                        scale,
745                    );
746                    let mut dirty = r.dirty;
747                    if let Some((delta_scale, center)) = r.pinch {
748                        if self.dispatch_action(repose_core::shortcuts::Action::Gesture(
749                            repose_core::shortcuts::Gesture::PinchWithCenter {
750                                delta_scale,
751                                center,
752                            },
753                        )) {
754                            dirty = true;
755                        }
756                    }
757                    if let Some(delta) = r.pan {
758                        if self.dispatch_action(repose_core::shortcuts::Action::Gesture(
759                            repose_core::shortcuts::Gesture::Pan { delta },
760                        )) {
761                            dirty = true;
762                        }
763                    }
764                    if let Some(right) = r.swipe_right {
765                        let g = if right {
766                            repose_core::shortcuts::Gesture::SwipeRight
767                        } else {
768                            repose_core::shortcuts::Gesture::SwipeLeft
769                        };
770                        if self.dispatch_action(repose_core::shortcuts::Action::Gesture(g)) {
771                            dirty = true;
772                        }
773                    }
774                    if dirty {
775                        self.request_redraw();
776                    }
777                }
778
779                WindowEvent::ModifiersChanged(new_mods) => {
780                    crate::runner_common::on_modifiers_changed(&mut self.rt, &new_mods.state());
781                }
782
783                WindowEvent::KeyboardInput {
784                    event: key_event, ..
785                } => {
786                    if crate::runner_common::on_keyboard_input(
787                        &mut self.rt,
788                        &key_event,
789                        &mut self.inspector,
790                    ) {
791                        self.request_redraw();
792                        return;
793                    }
794
795                    // Escape / BrowserBack: when the runtime didn't cancel a
796                    // drag / dispatch focus, fall back to navigation back.
797                    if key_event.state == ElementState::Pressed && !key_event.repeat {
798                        match key_event.physical_key {
799                            PhysicalKey::Code(KeyCode::BrowserBack)
800                            | PhysicalKey::Code(KeyCode::Escape) => {
801                                use repose_navigation::back;
802                                if !back::handle() {
803                                    // el.exit();
804                                }
805                                return;
806                            }
807                            _ => {}
808                        }
809                    }
810
811                    // --- A11y: keyboard activation announcement ---
812                    if key_event.state == ElementState::Released
813                        && let Some(active_id) = self.rt.key_pressed_active
814                    {
815                        match key_event.physical_key {
816                            PhysicalKey::Code(KeyCode::Space)
817                            | PhysicalKey::Code(KeyCode::Enter) => {
818                                if let Some(f) = &self.rt.frame_cache
819                                    && let Some(node) =
820                                        f.semantics_nodes.iter().find(|n| n.id == active_id)
821                                {
822                                    let label = node.label.as_deref().unwrap_or("");
823                                    self.a11y.announce(&format!("Activated {}", label));
824                                }
825                            }
826                            _ => {}
827                        }
828                    }
829                }
830
831                WindowEvent::Ime(ime) => {
832                    crate::runner_common::on_ime(&mut self.rt, &ime);
833                    self.request_redraw();
834                }
835
836                WindowEvent::RedrawRequested => {
837                    // Allow media (etc.) to queue texture uploads without compose.
838                    crate::run_pre_redraw(&self.render);
839
840                    // 1. Check our redraw flag before processing a11y.
841                    if !self.redraw_requested.replace(false) {
842                        self.process_a11y_actions();
843                        self.process_render_commands();
844                        // Present-only: redraw last cached scene with updated textures
845                        if let (Some(backend), Some(frame)) =
846                            (self.backend.as_mut(), self.rt.frame_cache.as_ref())
847                        {
848                            let scale = self
849                                .window
850                                .as_ref()
851                                .map(|w| w.scale_factor() as f32)
852                                .unwrap_or(1.0);
853                            let mut scene = frame.scene.clone();
854                            if let Some(inspector) = &mut self.inspector {
855                                inspector.frame(&mut scene);
856                            }
857                            backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
858                        }
859                        log::trace!("RedrawRequested: no frame request, skipping compose");
860                        return;
861                    }
862                    log::trace!("RedrawRequested: frame request pending, composing");
863
864                    // 2. Process a11y actions and render commands before compose.
865                    self.process_a11y_actions();
866                    self.process_render_commands();
867
868                    let Some(win) = self.window.as_ref() else {
869                        return;
870                    };
871                    if self.backend.is_none() {
872                        return;
873                    }
874
875                    // Advance animations before composition (Compose pattern).
876                    // Mirrors broadcastFrameClock.sendFrame() before performRecompose().
877                    repose_core::animation_driver::tick();
878
879                    let t0 = Instant::now();
880                    let scale = win.scale_factor() as f32;
881                    self.rt.scale = scale;
882                    let focused = self.rt.sched.focused;
883
884                    let output = self.rt.frame(&mut self.root, &self.render);
885
886                    if let Some(cursor) = &output.platform.cursor {
887                        win.set_cursor(winit::window::Cursor::Icon(map_cursor(*cursor)));
888                    }
889
890                    // Sync OS window chrome (titlebar) to the app theme, deduped.
891                    if let Some(dark) = output.platform.window_theme_dark
892                        && self.last_window_theme != Some(dark)
893                    {
894                        win.set_theme(Some(if dark {
895                            winit::window::Theme::Dark
896                        } else {
897                            winit::window::Theme::Light
898                        }));
899                        self.last_window_theme = Some(dark);
900                    }
901
902                    // Apply IME keyboard hints
903                    if output.platform.ime_allowed {
904                        rc::set_ime_for_textfield_ex(
905                            win,
906                            true,
907                            output.platform.ime_purpose,
908                            output.platform.ime_auto_correct,
909                            output.platform.ime_capitalization,
910                        );
911                        if let Some((x, y, w, h)) = output.platform.ime_cursor_area {
912                            win.set_ime_cursor_area(
913                                LogicalPosition::new(x, y),
914                                LogicalSize::new(w, h),
915                            );
916                        }
917                    } else if self.rt.ime_preedit {
918                        rc::set_ime_for_textfield_ex(
919                            win,
920                            false,
921                            repose_core::ImePurposeHint::Normal,
922                            true,
923                            repose_core::KeyboardCapitalization::Unspecified,
924                        );
925                        self.rt.ime_preedit = false;
926                    }
927
928                    // Apply IME state based on wants_keyboard
929                    if !output.wants_keyboard
930                        && focused.is_some()
931                        && self.rt.sched.focused.is_none()
932                        && self.rt.ime_preedit
933                    {
934                        rc::set_ime_for_textfield(win, false);
935                        self.rt.ime_preedit = false;
936                    }
937
938                    let frame = output.into_frame();
939
940                    let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
941
942                    // UPDATE ACCESSIBILITY TREE
943                    if let (Some(adapter), Some(win)) = (&mut self.accesskit_adapter, &self.window)
944                    {
945                        let scale = win.scale_factor();
946                        if let Some(update) = self.a11y_tree.update(
947                            &frame.semantics_nodes,
948                            scale,
949                            self.rt.sched.focused,
950                        ) {
951                            adapter.update_if_active(|| update);
952                        }
953                    }
954
955                    // Render
956                    let mut scene = frame.scene.clone();
957                    // Update HUD metrics before overlay draws
958                    if let Some(inspector) = &mut self.inspector {
959                        let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
960                        let signal_count = self.rt.sched.id_count() as usize;
961                        let ls = repose_ui::last_layout_stats();
962                        inspector.hud.metrics = Some(repose_devtools::Metrics {
963                            build_ms: build_layout_ms,
964                            layout_ms: ls.layout_time_ms,
965                            paint_ms: ls.paint_time_ms,
966                            scene_nodes: scene.nodes.len(),
967                            widget_count,
968                            signal_count,
969                            taffy_created: ls.taffy_created,
970                            taffy_reused: ls.taffy_reused,
971                            layout_hits: ls.layout_hits,
972                            layout_misses: ls.layout_misses,
973                            paint_cache_hits: ls.paint_cache_hits,
974                            paint_cache_misses: ls.paint_cache_misses,
975                            paint_culled: ls.paint_culled,
976                        });
977                        inspector.frame(&mut scene);
978                    }
979
980                    // Drag indicator overlay (internal + file drop)
981                    repose_core::dnd::overlay_drag_indicator(
982                        &mut scene,
983                        self.rt.mouse_pos_px,
984                        self.external_file_drag,
985                    );
986
987                    // Drain upload commands queued during compose (e.g. VideoSink set_image_*)
988                    // before presenting to avoid 1-frame GPU texture lag.
989                    self.process_render_commands();
990
991                    // Now borrow backend mutably only for the frame() call
992                    let Some(win) = self.window.as_ref() else {
993                        return;
994                    };
995                    let scale = win.scale_factor() as f32;
996                    if let Some(backend) = self.backend.as_mut() {
997                        backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
998                    }
999
1000                    // Initialize TextFieldState for any focused TextField that
1001                    // doesn't have one yet (e.g. after FocusRequester::request_focus),
1002                    // reconcile hover, and publish the DnD frame/scale.
1003                    self.rt.after_compose(&frame, scale);
1004
1005                    // NOTE: hover was already reconciled inside `compose()`.
1006                    // `cache_frame` rebuilds the retained hover-leave map.
1007                    self.rt.cache_frame(frame);
1008
1009                    self.dispatch_file_drop_now();
1010
1011                    self.rt.tick_overlays(self.last_redraw);
1012                    self.last_redraw = Instant::now();
1013                }
1014
1015                _ => {}
1016            }
1017        }
1018
1019        fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1020            // Process cross-thread commands (e.g. tray toggles, deeplinks) before any
1021            // redraw check, so hide/show commands work even when hidden
1022            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1023            if let Some(cb) = ABOUT_TO_WAIT_CALLBACK
1024                .lock()
1025                .unwrap_or_else(|e| e.into_inner())
1026                .as_ref()
1027            {
1028                let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cb()));
1029                if let Err(e) = res {
1030                    log::error!(
1031                        "ABOUT_TO_WAIT_CALLBACK panicked: {}",
1032                        e.downcast_ref::<String>()
1033                            .map(|s| s.as_str())
1034                            .or_else(|| e.downcast_ref::<&str>().copied())
1035                            .unwrap_or("unknown")
1036                    );
1037                }
1038            }
1039            process_deeplinks();
1040
1041            // On Wayland, wgpu creates an xdg_surface from the winit window and it shouldn't be recreated with a new id?
1042            // It doesn't take a lot of resources anyway, so let the backend be present.
1043            #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1044            if WINDOW_VISIBLE.load(Ordering::Relaxed)
1045                && self.backend.is_none()
1046                && let Some(w) = &self.window
1047            {
1048                log::info!("about_to_wait: recreating GPU backend");
1049                match repose_render_wgpu::WgpuBackend::new_with_options(
1050                    w.clone(),
1051                    self.msaa_samples,
1052                    self.present_mode,
1053                ) {
1054                    Ok(b) => {
1055                        repose_render_wgpu::offscreen::set_shared_device(
1056                            b.device.clone(),
1057                            b.queue.clone(),
1058                        );
1059                        self.backend = Some(b)
1060                    }
1061                    Err(e) => log::error!("about_to_wait: failed to recreate backend: {e:?}"),
1062                }
1063            }
1064
1065            let needs_compose = take_frame_request();
1066            let needs_present = take_present_request();
1067
1068            if needs_compose {
1069                self.pending_redraw = true;
1070            }
1071
1072            // Present-only: texture was updated, redraw last cached scene without compose.
1073            if !self.pending_redraw && needs_present && self.rt.frame_cache.is_some() {
1074                let now = Instant::now();
1075                let interval = self.frame_interval();
1076                if now.saturating_duration_since(self.last_redraw) >= interval {
1077                    rc::request_redraw(&self.window);
1078                    self.last_redraw = now;
1079                } else {
1080                    el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1081                        self.last_redraw + interval,
1082                    ));
1083                }
1084                return;
1085            }
1086
1087            if !self.pending_redraw {
1088                let now = Instant::now();
1089                let idle_cap = web_time::Duration::from_millis(1000);
1090                let deadline = self.rt.next_frame_deadline(now, idle_cap);
1091
1092                if now.saturating_duration_since(self.last_redraw) >= idle_cap
1093                    || self.rt.is_wakeup_due(now)
1094                {
1095                    self.redraw_requested.set(true);
1096                    request_frame();
1097                    rc::request_redraw(&self.window);
1098                    self.last_redraw = now;
1099                }
1100                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(Ord::min(
1101                    deadline,
1102                    now + idle_cap,
1103                )));
1104                return;
1105            }
1106
1107            let now = Instant::now();
1108            let interval = self.frame_interval();
1109
1110            if now.saturating_duration_since(self.last_redraw) >= interval {
1111                self.pending_redraw = false;
1112                self.redraw_requested.set(true);
1113                rc::request_redraw(&self.window);
1114                self.last_redraw = now;
1115            } else {
1116                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1117                    self.last_redraw + interval,
1118                ));
1119            }
1120        }
1121
1122        fn new_events(
1123            &mut self,
1124            _: &winit::event_loop::ActiveEventLoop,
1125            _: winit::event::StartCause,
1126        ) {
1127        }
1128        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1129            self.pending_redraw = true;
1130        }
1131        fn device_event(
1132            &mut self,
1133            _: &winit::event_loop::ActiveEventLoop,
1134            _: winit::event::DeviceId,
1135            _: winit::event::DeviceEvent,
1136        ) {
1137        }
1138        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1139        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1140        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1141    }
1142
1143    impl App {
1144        fn announce_focus_change(&mut self) {
1145            if let Some(f) = &self.rt.frame_cache {
1146                let focused_node = self
1147                    .rt
1148                    .sched
1149                    .focused
1150                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1151                self.a11y.focus_changed(focused_node);
1152            }
1153        }
1154
1155        fn dispatch_file_drop_now(&mut self) {
1156            let Some(f) = &self.rt.frame_cache else {
1157                self.pending_dropped_files.clear();
1158                self.pending_drop_pos_px = None;
1159                return;
1160            };
1161
1162            if self.pending_dropped_files.is_empty() {
1163                return;
1164            }
1165
1166            let pos_px = self.pending_drop_pos_px.unwrap_or(self.rt.mouse_pos_px);
1167            let pos = Vec2 {
1168                x: pos_px.0,
1169                y: pos_px.1,
1170            };
1171
1172            let mut files = Vec::new();
1173            for p in self.pending_dropped_files.drain(..) {
1174                let name = p
1175                    .file_name()
1176                    .and_then(|s| s.to_str())
1177                    .unwrap_or("file")
1178                    .to_string();
1179                files.push(repose_core::dnd::DroppedFile {
1180                    name,
1181                    path: Some(p),
1182                });
1183            }
1184
1185            let payload: repose_core::dnd::DragPayload =
1186                std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1187
1188            let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1189                self.pending_drop_pos_px = None;
1190                return;
1191            };
1192
1193            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1194                && let Some(cb) = &hit.on_drop
1195            {
1196                let accepted = cb(repose_core::dnd::DropEvent {
1197                    source_id: 0, // external source (OS)
1198                    target_id,
1199                    position: pos,
1200                    modifiers: self.rt.modifiers,
1201                    payload: payload.clone(),
1202                });
1203
1204                if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
1205                {
1206                    let label = node.label.as_deref().unwrap_or("");
1207                    self.a11y.announce(&format!("Dropped files on {}", label));
1208                }
1209            }
1210
1211            self.pending_drop_pos_px = None;
1212            self.request_redraw();
1213        }
1214    }
1215
1216    let event_loop = EventLoop::new()?;
1217    set_event_loop_proxy(event_loop.create_proxy());
1218    let mut app = App::new(Box::new(root), config);
1219    // Install system clock once
1220    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1221    event_loop.run_app(&mut app)?;
1222    Ok(())
1223}
1224
1225// Accessibility bridge stub (Noop by default; logs on Linux for now)
1226/// Bridge from Repose's semantics tree to platform accessibility APIs.
1227///
1228/// Implementations are responsible for:
1229/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
1230/// - Updating focus when `focus_changed` is called.
1231/// - Announcing transient messages (e.g. button activation) via screen readers.
1232pub trait A11yBridge: Send {
1233    /// Publish (or update) the full semantics tree for the current frame.
1234    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1235
1236    /// Notify that the focused node has changed. `None` means focus cleared.
1237    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1238
1239    /// Announce a one‑off message via the platform's accessibility channel.
1240    fn announce(&mut self, msg: &str);
1241}
1242
1243#[cfg_attr(target_os = "linux", allow(dead_code))]
1244struct NoopA11y;
1245impl A11yBridge for NoopA11y {
1246    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1247        // no-op
1248    }
1249    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1250        if let Some(n) = node {
1251            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1252        } else {
1253            log::info!("A11y focus: None");
1254        }
1255    }
1256    fn announce(&mut self, msg: &str) {
1257        log::info!("A11y announce: {msg}");
1258    }
1259}
1260
1261#[cfg(target_os = "linux")]
1262struct LinuxAtspiStub;
1263#[cfg(target_os = "linux")]
1264impl A11yBridge for LinuxAtspiStub {
1265    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1266        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1267    }
1268    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1269        if let Some(n) = node {
1270            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1271        } else {
1272            log::info!("AT-SPI stub focus: None");
1273        }
1274    }
1275    fn announce(&mut self, msg: &str) {
1276        log::info!("AT-SPI stub announce: {msg}");
1277    }
1278}