1use crate::a11y::ReposeActionHandler;
3#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
4use accesskit_winit::Adapter;
5use repose_core::locals::dp_to_px;
6use repose_core::*;
7use repose_ui::textfield::{TF_FONT_DP, TF_PADDING_X_DP, TextMeasureConfig, measure_text};
8use std::cell::Cell;
9use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
10use std::sync::{Arc, Mutex};
11use web_time::Instant;
12
13#[cfg(target_os = "android")]
14pub mod android;
15
16#[cfg(target_arch = "wasm32")]
17pub mod web;
18
19pub mod a11y;
20mod common;
21mod common_web;
22pub mod render;
23
24use common as rc;
25use common_web as rc_web;
26
27pub use render::{ImageHandleGuard, RenderCommand, RenderContext};
28
29#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
30use winit::window::Window;
31
32#[cfg(not(target_arch = "wasm32"))]
33use std::sync::OnceLock;
34
35#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
36static APP_WINDOW: OnceLock<Arc<Window>> = OnceLock::new();
37
38#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
39static WINDOW_VISIBLE: AtomicBool = AtomicBool::new(true);
40
41#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
42static CLOSE_TO_TRAY: AtomicBool = AtomicBool::new(false);
43
44#[cfg(not(target_arch = "wasm32"))]
45static EVENT_LOOP_PROXY: OnceLock<winit::event_loop::EventLoopProxy<()>> = OnceLock::new();
46
47#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
51static ABOUT_TO_WAIT_CALLBACK: Mutex<Option<Box<dyn Fn() + Send>>> = Mutex::new(None);
52
53static DEEPLINK_CB: Mutex<Option<Box<dyn Fn(Vec<u8>) + Send>>> = Mutex::new(None);
54static PENDING_DEEPLINKS: Mutex<Vec<Vec<u8>>> = Mutex::new(Vec::new());
55
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum AppLifecycle {
60 Foreground,
62 Background,
64}
65
66static CURRENT_LIFECYCLE: AtomicU8 = AtomicU8::new(0);
68static LIFECYCLE_CB: Mutex<Option<Box<dyn Fn(AppLifecycle) + Send>>> = Mutex::new(None);
69#[cfg(target_os = "android")]
70static PENDING_LIFECYCLE: Mutex<Vec<AppLifecycle>> = Mutex::new(Vec::new());
71
72pub fn set_on_lifecycle(callback: Box<dyn Fn(AppLifecycle) + Send>) {
77 *LIFECYCLE_CB.lock().unwrap() = Some(callback);
78}
79
80pub fn current_lifecycle() -> Option<AppLifecycle> {
82 match CURRENT_LIFECYCLE.load(Ordering::Relaxed) {
83 1 => Some(AppLifecycle::Foreground),
84 2 => Some(AppLifecycle::Background),
85 _ => None,
86 }
87}
88
89#[cfg(target_os = "android")]
92pub(crate) fn push_lifecycle(state: AppLifecycle) {
93 let code = match state {
94 AppLifecycle::Foreground => 1,
95 AppLifecycle::Background => 2,
96 };
97 CURRENT_LIFECYCLE.store(code, Ordering::Relaxed);
98 PENDING_LIFECYCLE.lock().unwrap().push(state);
99 #[cfg(not(target_arch = "wasm32"))]
100 wake_event_loop();
101}
102
103#[cfg(target_os = "android")]
106pub(crate) fn process_lifecycle() {
107 let batch = std::mem::take(&mut *PENDING_LIFECYCLE.lock().unwrap());
108 if batch.is_empty() {
109 return;
110 }
111 if let Some(last) = batch.last().copied()
113 && let Some(cb) = LIFECYCLE_CB.lock().unwrap().as_ref()
114 {
115 cb(last);
116 }
117}
118
119pub fn set_on_deeplink(callback: Box<dyn Fn(Vec<u8>) + Send>) {
121 *DEEPLINK_CB.lock().unwrap() = Some(callback);
122}
123
124pub fn push_deeplink(data: Vec<u8>) {
126 PENDING_DEEPLINKS.lock().unwrap().push(data);
127 #[cfg(not(target_arch = "wasm32"))]
128 if let Some(proxy) = EVENT_LOOP_PROXY.get() {
129 let _ = proxy.send_event(());
130 }
131}
132
133pub(crate) fn process_deeplinks() {
136 let mut queue = PENDING_DEEPLINKS.lock().unwrap();
137 if queue.is_empty() {
138 return;
139 }
140 let batch = std::mem::take(&mut *queue);
141 drop(queue);
142
143 if let Some(cb) = DEEPLINK_CB.lock().unwrap().as_ref() {
144 for data in batch {
145 cb(data);
146 }
147 }
148}
149
150#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
152pub fn set_app_window(window: Arc<Window>) {
153 let _ = APP_WINDOW.set(window);
154}
155
156#[cfg(not(target_arch = "wasm32"))]
158pub fn set_event_loop_proxy(proxy: winit::event_loop::EventLoopProxy<()>) {
159 let _ = EVENT_LOOP_PROXY.set(proxy);
160}
161
162#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
164pub fn set_about_to_wait_callback(cb: Box<dyn Fn() + Send>) {
165 *ABOUT_TO_WAIT_CALLBACK.lock().unwrap() = Some(cb);
166}
167
168#[cfg(not(target_arch = "wasm32"))]
170pub fn wake_event_loop() {
171 if let Some(proxy) = EVENT_LOOP_PROXY.get() {
172 let _ = proxy.send_event(());
173 }
174}
175
176#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
180pub fn show_app_window() {
181 WINDOW_VISIBLE.store(true, Ordering::Relaxed);
182 if let Some(w) = APP_WINDOW.get() {
183 log::info!("show_app_window: calling set_visible(true)");
184 w.set_visible(true);
185 #[allow(deprecated)]
186 w.focus_window();
187 }
188 repose_core::frame_clock::request_frame();
189 wake_event_loop();
190}
191
192#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
193pub fn hide_app_window() {
194 WINDOW_VISIBLE.store(false, Ordering::Relaxed);
195 if let Some(w) = APP_WINDOW.get() {
196 log::info!("hide_app_window: calling set_visible(false)");
197 w.set_visible(false);
198 }
199 repose_core::frame_clock::request_frame();
200 wake_event_loop();
201}
202
203#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
205pub fn window_is_visible() -> bool {
206 WINDOW_VISIBLE.load(Ordering::Relaxed)
207}
208
209#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
212pub fn set_close_to_tray(enabled: bool) {
213 CLOSE_TO_TRAY.store(enabled, Ordering::Relaxed);
214}
215
216pub fn tf_ensure_visible_in_rect(state: &mut repose_ui::TextFieldState, inner_rect: Rect) {
218 let font_px = dp_to_px(TF_FONT_DP) * repose_core::locals::text_scale().0;
219 let m = measure_text(&state.text, font_px, TextMeasureConfig::default());
220 let caret_x_px = m.positions.get(state.caret_index()).copied().unwrap_or(0.0);
221 state.ensure_caret_visible(
222 caret_x_px,
223 inner_rect.w - 2.0 * dp_to_px(TF_PADDING_X_DP),
224 dp_to_px(2.0),
225 );
226}
227
228fn winit_key_to_repose(
230 ev: &winit::event::KeyEvent,
231 mapped_key: &repose_core::input::Key,
232 mods: &repose_core::input::Modifiers,
233) -> repose_core::input::KeyEvent {
234 let utf16 = match mapped_key {
235 repose_core::input::Key::Character(c) => *c as u16,
236 _ => 0,
237 };
238 repose_core::input::KeyEvent {
239 key: mapped_key.clone(),
240 modifiers: *mods,
241 is_repeat: ev.repeat,
242 event_type: if ev.state == winit::event::ElementState::Pressed {
243 repose_core::input::KeyEventType::Down
244 } else {
245 repose_core::input::KeyEventType::Up
246 },
247 utf16_code_point: utf16,
248 }
249}
250
251#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
252fn map_cursor(c: repose_core::CursorIcon) -> winit::window::CursorIcon {
253 use winit::window::CursorIcon as W;
254 match c {
255 repose_core::CursorIcon::Default => W::Default,
256 repose_core::CursorIcon::Pointer => W::Pointer,
257 repose_core::CursorIcon::Text => W::Text,
258 repose_core::CursorIcon::EwResize => W::EwResize,
259 repose_core::CursorIcon::NsResize => W::NsResize,
260 repose_core::CursorIcon::Grab => W::Grab,
261 repose_core::CursorIcon::Grabbing => W::Grabbing,
262 }
263}
264
265#[derive(Clone, Copy, Debug)]
267pub struct ReposeOptions {
268 pub msaa_samples: u32,
271 pub max_fps: Option<f32>,
275 pub present_mode: PresentModePref,
277}
278
279impl Default for ReposeOptions {
280 fn default() -> Self {
281 Self {
282 msaa_samples: 4,
283 max_fps: None,
284 present_mode: PresentModePref::Auto,
285 }
286 }
287}
288
289#[derive(Clone, Debug)]
295pub struct AppConfig {
296 pub common: ReposeOptions,
298 pub window_title: String,
300 pub window_size: (u32, u32),
302 pub enable_inspector: bool,
304}
305
306impl Default for AppConfig {
307 fn default() -> Self {
308 Self {
309 common: ReposeOptions::default(),
310 window_title: "Repose".to_string(),
311 window_size: (1280, 800),
312 enable_inspector: true,
313 }
314 }
315}
316
317#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
322#[deprecated(
323 note = "use run_desktop_app_with_config(root, AppConfig) instead; this may be removed in a future release"
324)]
325pub fn run_desktop_app(
326 root: impl FnMut(&mut Scheduler, &RenderContext) -> View + 'static,
327) -> anyhow::Result<()> {
328 run_desktop_app_with_config(root, AppConfig::default())
329}
330
331#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
333pub fn run_desktop_app_with_config(
334 root: impl FnMut(&mut Scheduler, &RenderContext) -> View + 'static,
335 config: AppConfig,
336) -> anyhow::Result<()> {
337 use winit::application::ApplicationHandler;
338 use winit::dpi::{LogicalPosition, LogicalSize, PhysicalSize};
339 use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
340 use winit::event_loop::EventLoop;
341 use winit::keyboard::{KeyCode, PhysicalKey};
342 use winit::window::{Window, WindowAttributes};
343
344 use crate::a11y::A11yTree;
345 use repose_app::ReposeRuntime;
346
347 struct ReposeActivationHandler {
348 initial_tree: Option<accesskit::TreeUpdate>,
349 }
350
351 impl accesskit::ActivationHandler for ReposeActivationHandler {
352 fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
353 self.initial_tree.take()
354 }
355 }
356
357 struct ReposeDeactivationHandler;
358
359 impl accesskit::DeactivationHandler for ReposeDeactivationHandler {
360 fn deactivate_accessibility(&mut self) {
361 }
363 }
364
365 struct App {
366 root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
367 render: RenderContext,
368 window: Option<Arc<Window>>,
369 backend: Option<repose_render_wgpu::WgpuBackend>,
370 rt: ReposeRuntime,
371 inspector: Option<repose_devtools::Inspector>,
372 msaa_samples: u32,
373 max_fps: Option<f32>,
374 present_mode: PresentModePref,
375 window_title: String,
376 window_size: (u32, u32),
377
378 pending_dropped_files: Vec<std::path::PathBuf>,
380 pending_drop_pos_px: Option<(f32, f32)>,
381
382 external_file_drag: bool,
384 hovered_files: Vec<std::path::PathBuf>,
385
386 clipboard: Option<clipawl::Clipboard>,
387 a11y: Box<dyn A11yBridge>,
388
389 accesskit_adapter: Option<Adapter>,
390 a11y_actions: Arc<Mutex<Vec<accesskit::ActionRequest>>>,
391 a11y_tree: A11yTree,
392
393 last_window_theme: Option<bool>,
395
396 last_redraw: Instant,
397 pending_redraw: bool,
398
399 redraw_requested: Cell<bool>,
401
402 touch_gestures: rc::TouchGestureState,
404 }
405
406 impl App {
407 fn process_a11y_actions(&mut self) {
408 let mut actions = self.a11y_actions.lock().unwrap();
409 if actions.is_empty() {
410 return;
411 }
412 let pending = actions.drain(..).collect::<Vec<_>>();
413 drop(actions);
414
415 let Some(f) = &self.rt.frame_cache else {
416 return;
417 };
418
419 for req in pending {
420 let target_id = req.target_node.0;
421 match req.action {
422 accesskit::Action::Click => {
423 if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
424 && let Some(cb) = &hit.on_click
425 {
426 cb();
427 self.request_redraw();
428 }
429 }
430 accesskit::Action::Focus => {
431 self.rt.sched.focused = Some(target_id);
432 self.request_redraw();
433 }
434 _ => {}
435 }
436 }
437 }
438
439 fn new(
440 root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
441 config: AppConfig,
442 ) -> Self {
443 Self {
444 root,
445 render: RenderContext::new(),
446 window: None,
447 backend: None,
448 rt: ReposeRuntime::new(),
449 inspector: if config.enable_inspector {
450 Some(repose_devtools::Inspector::new())
451 } else {
452 None
453 },
454 msaa_samples: config.common.msaa_samples,
455 max_fps: config.common.max_fps,
456 present_mode: config.common.present_mode,
457 window_title: config.window_title,
458 window_size: config.window_size,
459 pending_dropped_files: Vec::new(),
460 pending_drop_pos_px: None,
461
462 external_file_drag: false,
463 hovered_files: Vec::new(),
464
465 clipboard: None,
466 a11y: {
467 #[cfg(target_os = "linux")]
468 {
469 Box::new(LinuxAtspiStub) as Box<dyn A11yBridge>
470 }
471 #[cfg(not(target_os = "linux"))]
472 {
473 Box::new(NoopA11y) as Box<dyn A11yBridge>
474 }
475 },
476
477 accesskit_adapter: None,
478 a11y_actions: Arc::new(Mutex::new(Vec::new())),
479 a11y_tree: A11yTree::default(),
480
481 last_redraw: Instant::now(),
482 pending_redraw: false,
483 last_window_theme: None,
484 redraw_requested: Cell::new(false),
485 touch_gestures: rc::TouchGestureState::default(),
486 }
487 }
488
489 fn request_redraw(&self) {
490 self.redraw_requested.set(true);
491 repose_core::request_frame();
492 rc::request_redraw(&self.window);
493 }
494
495 fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
496 if self.rt.dispatch_action(action) {
497 if let Some(win) = &self.window {
498 rc_web::set_ime_for_textfield(
499 win,
500 self.rt
501 .sched
502 .focused
503 .map_or(false, |id| self.rt.is_textfield(id)),
504 );
505 }
506 return true;
507 }
508 false
509 }
510
511 fn frame_interval(&self) -> web_time::Duration {
514 match self.max_fps.filter(|f| *f > 0.0) {
515 Some(fps) => {
516 let secs = (1.0 / fps as f64).clamp(0.0, 1.0);
517 web_time::Duration::from_secs_f64(secs)
518 }
519 None => web_time::Duration::ZERO,
520 }
521 }
522
523 fn paste_from_primary(&self) -> Option<String> {
524 let mut opts = clipawl::ClipboardOptions::default();
525 opts.linux.selection = clipawl::LinuxSelection::Primary;
526 if let Ok(cb) = clipawl::Clipboard::new_with_options(opts) {
527 match pollster::block_on(cb.read()) {
528 Ok(t) => Some(t),
529 Err(e) => {
530 eprintln!("Primary paste error: {}", e);
531 None
532 }
533 }
534 } else {
535 None
536 }
537 }
538
539 fn process_render_commands(&mut self) {
540 let Some(backend) = self.backend.as_mut() else {
541 return;
542 };
543 repose_render_wgpu::apply_render_commands(backend, self.render.drain());
544 }
545
546 fn reset_pointer_state(&mut self) {
547 self.rt.capture_id = None;
548 self.rt.pressed_ids.clear();
549 self.rt.hover_id = None;
550 }
551 }
552
553 impl ApplicationHandler<()> for App {
554 fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
555 self.clipboard = clipawl::Clipboard::new()
556 .map_err(|e| {
557 eprintln!("clipawl clipboard init failed: {e}");
558 e
559 })
560 .ok();
561 repose_core::clipboard::set_clipboard_read_fn(Box::new(|| {
562 clipawl::blocking::read().ok()
563 }));
564 repose_core::clipboard::set_clipboard_fn(Box::new(move |text| {
566 if let Err(e) = clipawl::blocking::write(text) {
567 eprintln!("clipboard write error: {e}");
568 }
569 }));
570
571 repose_core::clipboard::set_primary_fn(Box::new(|text| {
572 let mut opts = clipawl::ClipboardOptions::default();
573 opts.linux.selection = clipawl::LinuxSelection::Primary;
574 match clipawl::Clipboard::new_with_options(opts) {
575 Ok(cb) => {
576 if let Err(e) = pollster::block_on(cb.write(text)) {
577 eprintln!("primary selection write error: {e}");
578 }
579 }
580 Err(e) => eprintln!("primary clipboard init error: {e}"),
581 }
582 }));
583
584 if self.window.is_none() {
585 match el.create_window(
586 WindowAttributes::default()
587 .with_title(self.window_title.clone())
588 .with_inner_size(PhysicalSize::new(self.window_size.0, self.window_size.1))
589 .with_visible(false),
590 ) {
591 Ok(win) => {
592 let w = Arc::new(win);
593
594 let activation_handler = ReposeActivationHandler {
595 initial_tree: Some(A11yTree::initial_tree()),
596 };
597
598 let action_handler = ReposeActionHandler {
599 pending_actions: self.a11y_actions.clone(),
600 };
601
602 let deactivation_handler = ReposeDeactivationHandler;
603
604 let adapter = Adapter::with_direct_handlers(
605 el,
606 &w,
607 activation_handler,
608 action_handler,
609 deactivation_handler,
610 );
611
612 self.accesskit_adapter = Some(adapter);
613
614 w.set_visible(true);
615
616 let size = w.inner_size();
617 let sf = w.scale_factor() as f32;
618 self.rt.set_viewport_and_scale(size.width, size.height, sf);
619
620 match repose_render_wgpu::WgpuBackend::new_with_options(
621 w.clone(),
622 self.msaa_samples,
623 self.present_mode,
624 ) {
625 Ok(b) => {
626 self.backend = Some(b);
627 set_app_window(w.clone());
628 self.window = Some(w);
629 self.request_redraw();
630 }
631 Err(e) => {
632 log::error!("Failed to create WGPU backend: {e:?}");
633 el.exit();
634 }
635 }
636 }
637 Err(e) => {
638 log::error!("Failed to create window: {e:?}");
639 el.exit();
640 }
641 }
642 }
643 }
644
645 fn window_event(
646 &mut self,
647 el: &winit::event_loop::ActiveEventLoop,
648 _id: winit::window::WindowId,
649 event: WindowEvent,
650 ) {
651 if let Some(adapter) = &mut self.accesskit_adapter {
653 adapter.process_event(self.window.as_ref().unwrap(), &event);
654 }
655
656 match event {
657 WindowEvent::CloseRequested => {
658 if CLOSE_TO_TRAY.load(Ordering::Relaxed) {
659 self.backend = None;
661 if let Some(w) = &self.window {
662 w.set_visible(false);
663 }
664 WINDOW_VISIBLE.store(false, Ordering::Relaxed);
665 } else {
666 el.exit();
667 }
668 }
669
670 WindowEvent::Focused(false) => {
671 self.rt.handle_focus_lost();
673
674 self.external_file_drag = false;
676 self.hovered_files.clear();
677
678 if let Some(w) = &self.window {
679 rc_web::set_ime_for_textfield(w, false);
680 }
681
682 self.request_redraw();
683 }
684
685 WindowEvent::CursorLeft { .. } => {
686 self.rt.pointer_inside = false;
687 self.rt.clear_hover();
688 self.external_file_drag = false;
689 self.hovered_files.clear();
690 self.request_redraw();
691 }
692
693 WindowEvent::HoveredFile(path) => {
694 self.external_file_drag = true;
696 if self.hovered_files.len() < 32 {
697 self.hovered_files.push(path);
698 }
699 if self.pending_drop_pos_px.is_none() {
701 self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
702 }
703 self.request_redraw();
704 }
705
706 WindowEvent::HoveredFileCancelled => {
707 self.external_file_drag = false;
708 self.hovered_files.clear();
709
710 self.reset_pointer_state();
712
713 self.request_redraw();
714 }
715
716 WindowEvent::DroppedFile(path) => {
717 self.pending_dropped_files.push(path);
719 if self.pending_drop_pos_px.is_none() {
720 self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
721 }
722
723 self.external_file_drag = false;
725 self.hovered_files.clear();
726
727 self.request_redraw();
728 }
729
730 WindowEvent::Resized(size) => {
731 let sf = self
732 .window
733 .as_ref()
734 .map(|w| w.scale_factor() as f32)
735 .unwrap_or(1.0);
736 self.rt.set_viewport_and_scale(size.width, size.height, sf);
737 if let Some(b) = self.backend.as_mut() {
738 b.configure_surface(size.width, size.height);
739 }
740 if let Some(w) = &self.window {
741 let sf = w.scale_factor() as f32;
742 let dp_w = size.width as f32 / sf;
743 let dp_h = size.height as f32 / sf;
744 log::info!(
745 "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
746 size.width,
747 size.height,
748 sf,
749 dp_w as i32,
750 dp_h as i32
751 );
752 }
753 self.request_redraw();
754 }
755
756 WindowEvent::CursorMoved { position, .. } => {
757 self.rt.pointer_inside = true;
758
759 if self.external_file_drag {
760 self.pending_drop_pos_px = Some((position.x as f32, position.y as f32));
761 }
762
763 let pos = Vec2 {
764 x: position.x as f32,
765 y: position.y as f32,
766 };
767
768 let result = self.rt.handle_pointer_move(pos);
770
771 if let (Some(inspector), Some(f)) = (&mut self.inspector, &self.rt.frame_cache)
773 && inspector.hud.inspector_enabled
774 {
775 let hit = f.hit_regions.iter().find(|h| h.rect.contains(pos));
776 let hover_rect = hit.map(|h| h.rect);
777 let hover_info = hit.and_then(|h| {
778 f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
779 repose_devtools::HoveredInfo {
780 id: s.id,
781 role: format!("{:?}", s.role),
782 label: s.label.clone(),
783 }
784 })
785 });
786 inspector.hud.set_hovered(hover_rect, hover_info);
787 }
788
789 if let Some(win) = &self.window
791 && let Some(c) = result.cursor
792 {
793 win.set_cursor(winit::window::Cursor::Icon(map_cursor(c)));
794 }
795
796 self.request_redraw();
797 }
798
799 WindowEvent::MouseWheel { delta, .. } => {
800 let (dx_px, dy_px) = match delta {
801 MouseScrollDelta::LineDelta(x, y) => {
802 let unit_px = dp_to_px(60.0);
803 (-(x * unit_px), -(y * unit_px))
804 }
805 MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
806 };
807 log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
808
809 if self.rt.handle_scroll(Vec2 { x: dx_px, y: dy_px }) {
810 self.request_redraw();
811 }
812 }
813
814 WindowEvent::MouseInput { state, button, .. } => {
815 let pos = Vec2 {
816 x: self.rt.mouse_pos_px.0,
817 y: self.rt.mouse_pos_px.1,
818 };
819
820 let mapped = match button {
821 MouseButton::Left => PointerButton::Primary,
822 MouseButton::Right => PointerButton::Secondary,
823 MouseButton::Middle => PointerButton::Tertiary,
824 _ => return,
826 };
827
828 match state {
829 ElementState::Pressed => {
830 let result = self.rt.handle_pointer_press(pos, mapped);
831
832 if let Some(fid) = result.focused
834 && let Some(win) = &self.window
835 && let Some(f) = &self.rt.frame_cache
836 && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
837 {
838 let sf = win.scale_factor();
839 rc_web::set_ime_for_textfield_ex(
840 win,
841 true,
842 hit.keyboard_type.ime_purpose_hint(),
843 hit.auto_correct.unwrap_or(true),
844 hit.capitalization,
845 );
846 win.set_ime_cursor_area(
847 LogicalPosition::new(
848 hit.rect.x as f64 / sf,
849 hit.rect.y as f64 / sf,
850 ),
851 LogicalSize::new(
852 hit.rect.w as f64 / sf,
853 hit.rect.h as f64 / sf,
854 ),
855 );
856 }
857
858 if result.focused.is_none() && self.rt.ime_preedit {
860 if let Some(win) = &self.window {
861 rc_web::set_ime_for_textfield(win, false);
862 }
863 self.rt.ime_preedit = false;
864 }
865
866 if result.needs_a11y_announce {
867 self.announce_focus_change();
868 }
869
870 if matches!(mapped, PointerButton::Tertiary)
872 && let Some(f) = &self.rt.frame_cache
873 && let Some(cid) = self.rt.capture_id
874 && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
875 && self.rt.is_textfield(hit.id)
876 && let Some(txt) = self.paste_from_primary()
877 {
878 self.rt.paste_into_focused(&txt);
879 }
880
881 if matches!(mapped, PointerButton::Primary | PointerButton::Secondary)
883 && let Some(inspector) = &mut self.inspector
884 && inspector.hud.inspector_enabled
885 && let Some(f) = &self.rt.frame_cache
886 && let Some(hit) =
887 f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
888 {
889 let info =
890 f.semantics_nodes.iter().find(|s| s.id == hit.id).map(|s| {
891 repose_devtools::HoveredInfo {
892 id: s.id,
893 role: format!("{:?}", s.role),
894 label: s.label.clone(),
895 }
896 });
897 inspector
898 .hud
899 .select_widget(repose_devtools::SelectedWidget {
900 id: hit.id,
901 role: info
902 .as_ref()
903 .map(|i| i.role.clone())
904 .unwrap_or_default(),
905 label: info.as_ref().and_then(|i| i.label.clone()),
906 bounds: hit.rect,
907 });
908 }
909
910 self.request_redraw();
911 }
912
913 ElementState::Released => {
914 let result = self.rt.handle_pointer_release(pos, mapped);
915
916 if let Some(cid) = result.clicked_id
920 && let Some(f) = &self.rt.frame_cache
921 && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid)
922 {
923 let label = node.label.as_deref().unwrap_or("");
924 self.a11y.announce(&format!("Activated {}", label));
925 }
926
927 self.request_redraw();
928 }
929 }
930 }
931
932 WindowEvent::Touch(t) => {
933 let pos_px = (t.location.x as f32, t.location.y as f32);
934 let tid = t.id;
935 let scale = self
936 .window
937 .as_ref()
938 .map(|w| w.scale_factor() as f32)
939 .unwrap_or(1.0);
940
941 match t.phase {
942 winit::event::TouchPhase::Started => {
943 self.touch_gestures.touch_started(&mut self.rt, tid, pos_px);
944 self.request_redraw();
945 }
946
947 winit::event::TouchPhase::Moved => {
948 let (mut dirty, pinch_delta) = self
949 .touch_gestures
950 .touch_moved(&mut self.rt, tid, pos_px, scale);
951
952 if let Some(delta_scale) = pinch_delta
953 && self.dispatch_action(
954 repose_core::shortcuts::Action::Gesture(
955 repose_core::shortcuts::Gesture::Pinch {
956 delta_scale,
957 },
958 ),
959 )
960 {
961 dirty = true;
962 }
963
964 if dirty {
965 self.request_redraw();
966 }
967 }
968
969 winit::event::TouchPhase::Ended
970 | winit::event::TouchPhase::Cancelled => {
971 let cancelled = t.phase == winit::event::TouchPhase::Cancelled;
972 let swipe_right =
973 self.touch_gestures
974 .touch_ended(&mut self.rt, tid, pos_px, cancelled);
975
976 use repose_core::shortcuts::{Action, Gesture};
977 let mut dirty = false;
978 if let Some(right) = swipe_right {
979 let g = if right {
980 Gesture::SwipeRight
981 } else {
982 Gesture::SwipeLeft
983 };
984 if self.dispatch_action(Action::Gesture(g)) {
985 dirty = true;
986 }
987 }
988
989 if dirty {
990 self.request_redraw();
991 }
992 }
993 }
994 }
995
996 WindowEvent::ModifiersChanged(new_mods) => {
997 let state = new_mods.state();
998 self.rt.modifiers.shift = state.shift_key();
999 self.rt.modifiers.ctrl = state.control_key();
1000 self.rt.modifiers.alt = state.alt_key();
1001 self.rt.modifiers.meta = state.super_key();
1002 self.rt.modifiers.command = if cfg!(target_os = "macos") {
1003 self.rt.modifiers.meta
1004 } else {
1005 self.rt.modifiers.ctrl
1006 };
1007 }
1008
1009 WindowEvent::KeyboardInput {
1010 event: key_event, ..
1011 } => {
1012 if key_event.state == ElementState::Pressed
1014 && !key_event.repeat
1015 && self.rt.modifiers.ctrl
1016 && self.rt.modifiers.shift
1017 && key_event.physical_key == PhysicalKey::Code(KeyCode::KeyI)
1018 && let Some(inspector) = &mut self.inspector
1019 {
1020 inspector.hud.toggle_inspector();
1021 self.request_redraw();
1022 return;
1023 }
1024
1025 let mapped = rc::map_key(key_event.physical_key);
1029 let ke = winit_key_to_repose(&key_event, &mapped, &self.rt.modifiers);
1030 if self.rt.handle_key_with_text(&ke, key_event.text.as_deref()) {
1031 self.request_redraw();
1032 return;
1033 }
1034
1035 if key_event.state == ElementState::Pressed && !key_event.repeat {
1038 match key_event.physical_key {
1039 PhysicalKey::Code(KeyCode::BrowserBack)
1040 | PhysicalKey::Code(KeyCode::Escape) => {
1041 use repose_navigation::back;
1042 if !back::handle() {
1043 }
1045 return;
1046 }
1047 _ => {}
1048 }
1049 }
1050
1051 if key_event.state == ElementState::Released
1053 && let Some(active_id) = self.rt.key_pressed_active
1054 {
1055 match key_event.physical_key {
1056 PhysicalKey::Code(KeyCode::Space)
1057 | PhysicalKey::Code(KeyCode::Enter) => {
1058 if let Some(f) = &self.rt.frame_cache
1059 && let Some(node) =
1060 f.semantics_nodes.iter().find(|n| n.id == active_id)
1061 {
1062 let label = node.label.as_deref().unwrap_or("");
1063 self.a11y.announce(&format!("Activated {}", label));
1064 }
1065 }
1066 _ => {}
1067 }
1068 }
1069 }
1070
1071 WindowEvent::Ime(ime) => {
1072 let ime_event = match ime {
1075 winit::event::Ime::Enabled => repose_core::input::ImeEvent::Start,
1076 winit::event::Ime::Preedit(text, cursor) => {
1077 repose_core::input::ImeEvent::Update { text, cursor }
1078 }
1079 winit::event::Ime::Commit(text) => {
1080 repose_core::input::ImeEvent::Commit(text)
1081 }
1082 winit::event::Ime::Disabled => repose_core::input::ImeEvent::Cancel,
1083 };
1084 self.rt.handle_ime(&ime_event);
1085 self.request_redraw();
1086 }
1087
1088 WindowEvent::RedrawRequested => {
1089 if !self.redraw_requested.replace(false) {
1091 self.process_a11y_actions();
1092 self.process_render_commands();
1093 if let (Some(backend), Some(frame)) =
1095 (self.backend.as_mut(), self.rt.frame_cache.as_ref())
1096 {
1097 let scale = self
1098 .window
1099 .as_ref()
1100 .map(|w| w.scale_factor() as f32)
1101 .unwrap_or(1.0);
1102 let mut scene = frame.scene.clone();
1103 if let Some(inspector) = &mut self.inspector {
1104 inspector.frame(&mut scene);
1105 }
1106 backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1107 }
1108 log::trace!("RedrawRequested: no frame request, skipping compose");
1109 return;
1110 }
1111 log::trace!("RedrawRequested: frame request pending, composing");
1112
1113 self.process_a11y_actions();
1115 self.process_render_commands();
1116
1117 let Some(win) = self.window.as_ref() else {
1118 return;
1119 };
1120 if self.backend.is_none() {
1121 return;
1122 }
1123
1124 repose_core::animation_driver::tick();
1127
1128 let t0 = Instant::now();
1129 let scale = win.scale_factor() as f32;
1130 self.rt.scale = scale;
1131 let focused = self.rt.sched.focused;
1132
1133 let output = self.rt.frame(&mut self.root, &self.render);
1134
1135 if let Some(cursor) = &output.platform.cursor {
1137 win.set_cursor(winit::window::Cursor::Icon(map_cursor(*cursor)));
1138 }
1139
1140 if let Some(dark) = output.platform.window_theme_dark
1142 && self.last_window_theme != Some(dark)
1143 {
1144 win.set_theme(Some(if dark {
1145 winit::window::Theme::Dark
1146 } else {
1147 winit::window::Theme::Light
1148 }));
1149 self.last_window_theme = Some(dark);
1150 }
1151
1152 if output.platform.ime_allowed {
1154 rc_web::set_ime_for_textfield_ex(
1155 win,
1156 true,
1157 output.platform.ime_purpose,
1158 output.platform.ime_auto_correct,
1159 output.platform.ime_capitalization,
1160 );
1161 if let Some((x, y, w, h)) = output.platform.ime_cursor_area {
1162 win.set_ime_cursor_area(
1163 LogicalPosition::new(x, y),
1164 LogicalSize::new(w, h),
1165 );
1166 }
1167 } else if self.rt.ime_preedit {
1168 rc_web::set_ime_for_textfield_ex(
1169 win,
1170 false,
1171 repose_core::ImePurposeHint::Normal,
1172 true,
1173 repose_core::KeyboardCapitalization::Unspecified,
1174 );
1175 self.rt.ime_preedit = false;
1176 }
1177
1178 if !output.wants_keyboard
1180 && focused.is_some()
1181 && self.rt.sched.focused.is_none()
1182 && self.rt.ime_preedit
1183 {
1184 rc_web::set_ime_for_textfield(win, false);
1185 self.rt.ime_preedit = false;
1186 }
1187
1188 let frame = output.into_frame();
1189
1190 let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1191
1192 if let Some(adapter) = &mut self.accesskit_adapter {
1194 let win = self.window.as_ref().unwrap();
1195 let scale = win.scale_factor();
1196 if let Some(update) = self.a11y_tree.update(
1197 &frame.semantics_nodes,
1198 scale,
1199 self.rt.sched.focused,
1200 ) {
1201 adapter.update_if_active(|| update);
1202 }
1203 }
1204
1205 let mut scene = frame.scene.clone();
1207 if let Some(inspector) = &mut self.inspector {
1209 let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1210 let signal_count = self.rt.sched.id_count() as usize;
1211 let ls = repose_ui::last_layout_stats();
1212 inspector.hud.metrics = Some(repose_devtools::Metrics {
1213 build_ms: build_layout_ms,
1214 layout_ms: ls.layout_time_ms,
1215 paint_ms: ls.paint_time_ms,
1216 scene_nodes: scene.nodes.len(),
1217 widget_count,
1218 signal_count,
1219 taffy_created: ls.taffy_created,
1220 taffy_reused: ls.taffy_reused,
1221 layout_hits: ls.layout_hits,
1222 layout_misses: ls.layout_misses,
1223 paint_cache_hits: ls.paint_cache_hits,
1224 paint_cache_misses: ls.paint_cache_misses,
1225 paint_culled: ls.paint_culled,
1226 });
1227 inspector.frame(&mut scene);
1228 }
1229
1230 repose_core::dnd::overlay_drag_indicator(
1232 &mut scene,
1233 self.rt.mouse_pos_px,
1234 self.external_file_drag,
1235 );
1236
1237 self.process_render_commands();
1240
1241 let win = self.window.as_ref().unwrap();
1243 let scale = win.scale_factor() as f32;
1244 if let Some(backend) = self.backend.as_mut() {
1245 backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1246 }
1247
1248 self.rt.after_compose(&frame, scale);
1252
1253 self.rt.cache_frame(frame);
1256
1257 self.dispatch_file_drop_now();
1258
1259 self.rt.tick_overlays(self.last_redraw);
1260 self.last_redraw = Instant::now();
1261 }
1262
1263 _ => {}
1264 }
1265 }
1266
1267 fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1268 #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1271 if let Some(cb) = ABOUT_TO_WAIT_CALLBACK.lock().unwrap().as_ref() {
1272 cb();
1273 }
1274 process_deeplinks();
1275
1276 #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1279 if WINDOW_VISIBLE.load(Ordering::Relaxed)
1280 && self.backend.is_none()
1281 && let Some(w) = &self.window
1282 {
1283 log::info!("about_to_wait: recreating GPU backend");
1284 match repose_render_wgpu::WgpuBackend::new_with_options(
1285 w.clone(),
1286 self.msaa_samples,
1287 self.present_mode,
1288 ) {
1289 Ok(b) => self.backend = Some(b),
1290 Err(e) => log::error!("about_to_wait: failed to recreate backend: {e:?}"),
1291 }
1292 }
1293
1294 let needs_compose = take_frame_request();
1295 let needs_present = take_present_request();
1296
1297 if needs_compose {
1298 self.pending_redraw = true;
1299 }
1300
1301 if !self.pending_redraw && needs_present && self.rt.frame_cache.is_some() {
1303 let now = Instant::now();
1304 let interval = self.frame_interval();
1305 if now.saturating_duration_since(self.last_redraw) >= interval {
1306 rc::request_redraw(&self.window);
1307 self.last_redraw = now;
1308 } else {
1309 el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1310 self.last_redraw + interval,
1311 ));
1312 }
1313 return;
1314 }
1315
1316 if !self.pending_redraw {
1317 let now = Instant::now();
1318 let idle_cap = web_time::Duration::from_millis(1000);
1319 let deadline = self
1320 .rt
1321 .next_caret_blink_deadline()
1322 .unwrap_or(now + idle_cap);
1323
1324 if now.saturating_duration_since(self.last_redraw) >= idle_cap || now >= deadline {
1325 self.redraw_requested.set(true);
1326 request_frame();
1327 rc::request_redraw(&self.window);
1328 self.last_redraw = now;
1329 }
1330 el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(Ord::min(
1331 deadline,
1332 now + idle_cap,
1333 )));
1334 return;
1335 }
1336
1337 let now = Instant::now();
1338 let interval = self.frame_interval();
1339
1340 if now.saturating_duration_since(self.last_redraw) >= interval {
1341 self.pending_redraw = false;
1342 self.redraw_requested.set(true);
1343 rc::request_redraw(&self.window);
1344 self.last_redraw = now;
1345 } else {
1346 el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1347 self.last_redraw + interval,
1348 ));
1349 }
1350 }
1351
1352 fn new_events(
1353 &mut self,
1354 _: &winit::event_loop::ActiveEventLoop,
1355 _: winit::event::StartCause,
1356 ) {
1357 }
1358 fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1359 self.pending_redraw = true;
1360 }
1361 fn device_event(
1362 &mut self,
1363 _: &winit::event_loop::ActiveEventLoop,
1364 _: winit::event::DeviceId,
1365 _: winit::event::DeviceEvent,
1366 ) {
1367 }
1368 fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1369 fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1370 fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1371 }
1372
1373 impl App {
1374 fn announce_focus_change(&mut self) {
1375 if let Some(f) = &self.rt.frame_cache {
1376 let focused_node = self
1377 .rt
1378 .sched
1379 .focused
1380 .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1381 self.a11y.focus_changed(focused_node);
1382 }
1383 }
1384
1385 fn dispatch_file_drop_now(&mut self) {
1386 let Some(f) = &self.rt.frame_cache else {
1387 self.pending_dropped_files.clear();
1388 self.pending_drop_pos_px = None;
1389 return;
1390 };
1391
1392 if self.pending_dropped_files.is_empty() {
1393 return;
1394 }
1395
1396 let pos_px = self.pending_drop_pos_px.unwrap_or(self.rt.mouse_pos_px);
1397 let pos = Vec2 {
1398 x: pos_px.0,
1399 y: pos_px.1,
1400 };
1401
1402 let mut files = Vec::new();
1403 for p in self.pending_dropped_files.drain(..) {
1404 let name = p
1405 .file_name()
1406 .and_then(|s| s.to_str())
1407 .unwrap_or("file")
1408 .to_string();
1409 files.push(repose_core::dnd::DroppedFile {
1410 name,
1411 path: Some(p),
1412 });
1413 }
1414
1415 let payload: repose_core::dnd::DragPayload =
1416 std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1417
1418 let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1419 self.pending_drop_pos_px = None;
1420 return;
1421 };
1422
1423 if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1424 && let Some(cb) = &hit.on_drop
1425 {
1426 let accepted = cb(repose_core::dnd::DropEvent {
1427 source_id: 0, target_id,
1429 position: pos,
1430 modifiers: self.rt.modifiers,
1431 payload: payload.clone(),
1432 });
1433
1434 if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
1435 {
1436 let label = node.label.as_deref().unwrap_or("");
1437 self.a11y.announce(&format!("Dropped files on {}", label));
1438 }
1439 }
1440
1441 self.pending_drop_pos_px = None;
1442 self.request_redraw();
1443 }
1444 }
1445
1446 let event_loop = EventLoop::new()?;
1447 set_event_loop_proxy(event_loop.create_proxy());
1448 let mut app = App::new(Box::new(root), config);
1449 repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1451 event_loop.run_app(&mut app)?;
1452 Ok(())
1453}
1454
1455pub trait A11yBridge: Send {
1463 fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1465
1466 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1468
1469 fn announce(&mut self, msg: &str);
1471}
1472
1473#[cfg_attr(target_os = "linux", allow(dead_code))]
1474struct NoopA11y;
1475impl A11yBridge for NoopA11y {
1476 fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1477 }
1479 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1480 if let Some(n) = node {
1481 log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1482 } else {
1483 log::info!("A11y focus: None");
1484 }
1485 }
1486 fn announce(&mut self, msg: &str) {
1487 log::info!("A11y announce: {msg}");
1488 }
1489}
1490
1491#[cfg(target_os = "linux")]
1492struct LinuxAtspiStub;
1493#[cfg(target_os = "linux")]
1494impl A11yBridge for LinuxAtspiStub {
1495 fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1496 log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1497 }
1498 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1499 if let Some(n) = node {
1500 log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1501 } else {
1502 log::info!("AT-SPI stub focus: None");
1503 }
1504 }
1505 fn announce(&mut self, msg: &str) {
1506 log::info!("AT-SPI stub announce: {msg}");
1507 }
1508}