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 let _ = repose_core::request_input_mode(repose_core::InputMode::Keyboard);
433 self.rt.sched.focused = Some(target_id);
434 self.request_redraw();
435 }
436 _ => {}
437 }
438 }
439 }
440
441 fn new(
442 root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
443 config: AppConfig,
444 ) -> Self {
445 Self {
446 root,
447 render: RenderContext::new(),
448 window: None,
449 backend: None,
450 rt: ReposeRuntime::new(),
451 inspector: if config.enable_inspector {
452 Some(repose_devtools::Inspector::new())
453 } else {
454 None
455 },
456 msaa_samples: config.common.msaa_samples,
457 max_fps: config.common.max_fps,
458 present_mode: config.common.present_mode,
459 window_title: config.window_title,
460 window_size: config.window_size,
461 pending_dropped_files: Vec::new(),
462 pending_drop_pos_px: None,
463
464 external_file_drag: false,
465 hovered_files: Vec::new(),
466
467 clipboard: None,
468 a11y: {
469 #[cfg(target_os = "linux")]
470 {
471 Box::new(LinuxAtspiStub) as Box<dyn A11yBridge>
472 }
473 #[cfg(not(target_os = "linux"))]
474 {
475 Box::new(NoopA11y) as Box<dyn A11yBridge>
476 }
477 },
478
479 accesskit_adapter: None,
480 a11y_actions: Arc::new(Mutex::new(Vec::new())),
481 a11y_tree: A11yTree::default(),
482
483 last_redraw: Instant::now(),
484 pending_redraw: false,
485 last_window_theme: None,
486 redraw_requested: Cell::new(false),
487 touch_gestures: rc::TouchGestureState::default(),
488 }
489 }
490
491 fn request_redraw(&self) {
492 self.redraw_requested.set(true);
493 repose_core::request_frame();
494 rc::request_redraw(&self.window);
495 }
496
497 fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
498 if self.rt.dispatch_action(action) {
499 if let Some(win) = &self.window {
500 rc_web::set_ime_for_textfield(
501 win,
502 self.rt
503 .sched
504 .focused
505 .map_or(false, |id| self.rt.is_textfield(id)),
506 );
507 }
508 return true;
509 }
510 false
511 }
512
513 fn frame_interval(&self) -> web_time::Duration {
516 match self.max_fps.filter(|f| *f > 0.0) {
517 Some(fps) => {
518 let secs = (1.0 / fps as f64).clamp(0.0, 1.0);
519 web_time::Duration::from_secs_f64(secs)
520 }
521 None => web_time::Duration::ZERO,
522 }
523 }
524
525 fn paste_from_primary(&self) -> Option<String> {
526 let mut opts = clipawl::ClipboardOptions::default();
527 opts.linux.selection = clipawl::LinuxSelection::Primary;
528 if let Ok(cb) = clipawl::Clipboard::new_with_options(opts) {
529 match pollster::block_on(cb.read()) {
530 Ok(t) => Some(t),
531 Err(e) => {
532 eprintln!("Primary paste error: {}", e);
533 None
534 }
535 }
536 } else {
537 None
538 }
539 }
540
541 fn process_render_commands(&mut self) {
542 let Some(backend) = self.backend.as_mut() else {
543 return;
544 };
545 repose_render_wgpu::apply_render_commands(backend, self.render.drain());
546 }
547
548 fn reset_pointer_state(&mut self) {
549 self.rt.capture_id = None;
550 self.rt.pressed_ids.clear();
551 self.rt.hover_id = None;
552 }
553 }
554
555 impl ApplicationHandler<()> for App {
556 fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
557 self.clipboard = clipawl::Clipboard::new()
558 .map_err(|e| {
559 eprintln!("clipawl clipboard init failed: {e}");
560 e
561 })
562 .ok();
563 repose_core::clipboard::set_clipboard_read_fn(Box::new(|| {
564 clipawl::blocking::read().ok()
565 }));
566 repose_core::clipboard::set_clipboard_fn(Box::new(move |text| {
568 if let Err(e) = clipawl::blocking::write(text) {
569 eprintln!("clipboard write error: {e}");
570 }
571 }));
572
573 repose_core::clipboard::set_primary_fn(Box::new(|text| {
574 let mut opts = clipawl::ClipboardOptions::default();
575 opts.linux.selection = clipawl::LinuxSelection::Primary;
576 match clipawl::Clipboard::new_with_options(opts) {
577 Ok(cb) => {
578 if let Err(e) = pollster::block_on(cb.write(text)) {
579 eprintln!("primary selection write error: {e}");
580 }
581 }
582 Err(e) => eprintln!("primary clipboard init error: {e}"),
583 }
584 }));
585
586 if self.window.is_none() {
587 match el.create_window(
588 WindowAttributes::default()
589 .with_title(self.window_title.clone())
590 .with_inner_size(PhysicalSize::new(self.window_size.0, self.window_size.1))
591 .with_visible(false),
592 ) {
593 Ok(win) => {
594 let w = Arc::new(win);
595
596 let activation_handler = ReposeActivationHandler {
597 initial_tree: Some(A11yTree::initial_tree()),
598 };
599
600 let action_handler = ReposeActionHandler {
601 pending_actions: self.a11y_actions.clone(),
602 };
603
604 let deactivation_handler = ReposeDeactivationHandler;
605
606 let adapter = Adapter::with_direct_handlers(
607 el,
608 &w,
609 activation_handler,
610 action_handler,
611 deactivation_handler,
612 );
613
614 self.accesskit_adapter = Some(adapter);
615
616 w.set_visible(true);
617
618 let size = w.inner_size();
619 let sf = w.scale_factor() as f32;
620 self.rt.set_viewport_and_scale(size.width, size.height, sf);
621
622 match repose_render_wgpu::WgpuBackend::new_with_options(
623 w.clone(),
624 self.msaa_samples,
625 self.present_mode,
626 ) {
627 Ok(b) => {
628 self.backend = Some(b);
629 set_app_window(w.clone());
630 self.window = Some(w);
631 self.request_redraw();
632 }
633 Err(e) => {
634 log::error!("Failed to create WGPU backend: {e:?}");
635 el.exit();
636 }
637 }
638 }
639 Err(e) => {
640 log::error!("Failed to create window: {e:?}");
641 el.exit();
642 }
643 }
644 }
645 }
646
647 fn window_event(
648 &mut self,
649 el: &winit::event_loop::ActiveEventLoop,
650 _id: winit::window::WindowId,
651 event: WindowEvent,
652 ) {
653 if let Some(adapter) = &mut self.accesskit_adapter {
655 adapter.process_event(self.window.as_ref().unwrap(), &event);
656 }
657
658 match event {
659 WindowEvent::CloseRequested => {
660 if CLOSE_TO_TRAY.load(Ordering::Relaxed) {
661 self.backend = None;
663 if let Some(w) = &self.window {
664 w.set_visible(false);
665 }
666 WINDOW_VISIBLE.store(false, Ordering::Relaxed);
667 } else {
668 el.exit();
669 }
670 }
671
672 WindowEvent::Focused(false) => {
673 self.rt.handle_focus_lost();
675
676 self.external_file_drag = false;
678 self.hovered_files.clear();
679
680 if let Some(w) = &self.window {
681 rc_web::set_ime_for_textfield(w, false);
682 }
683
684 self.request_redraw();
685 }
686
687 WindowEvent::CursorLeft { .. } => {
688 self.rt.pointer_inside = false;
689 self.rt.clear_hover();
690 self.external_file_drag = false;
691 self.hovered_files.clear();
692 self.request_redraw();
693 }
694
695 WindowEvent::HoveredFile(path) => {
696 self.external_file_drag = true;
698 if self.hovered_files.len() < 32 {
699 self.hovered_files.push(path);
700 }
701 if self.pending_drop_pos_px.is_none() {
703 self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
704 }
705 self.request_redraw();
706 }
707
708 WindowEvent::HoveredFileCancelled => {
709 self.external_file_drag = false;
710 self.hovered_files.clear();
711
712 self.reset_pointer_state();
714
715 self.request_redraw();
716 }
717
718 WindowEvent::DroppedFile(path) => {
719 self.pending_dropped_files.push(path);
721 if self.pending_drop_pos_px.is_none() {
722 self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
723 }
724
725 self.external_file_drag = false;
727 self.hovered_files.clear();
728
729 self.request_redraw();
730 }
731
732 WindowEvent::Resized(size) => {
733 let sf = self
734 .window
735 .as_ref()
736 .map(|w| w.scale_factor() as f32)
737 .unwrap_or(1.0);
738 self.rt.set_viewport_and_scale(size.width, size.height, sf);
739 if let Some(b) = self.backend.as_mut() {
740 b.configure_surface(size.width, size.height);
741 }
742 if let Some(w) = &self.window {
743 let sf = w.scale_factor() as f32;
744 let dp_w = size.width as f32 / sf;
745 let dp_h = size.height as f32 / sf;
746 log::info!(
747 "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
748 size.width,
749 size.height,
750 sf,
751 dp_w as i32,
752 dp_h as i32
753 );
754 }
755 self.request_redraw();
756 }
757
758 WindowEvent::CursorMoved { position, .. } => {
759 self.rt.pointer_inside = true;
760
761 if self.external_file_drag {
762 self.pending_drop_pos_px = Some((position.x as f32, position.y as f32));
763 }
764
765 let pos = Vec2 {
766 x: position.x as f32,
767 y: position.y as f32,
768 };
769
770 let result = self.rt.handle_pointer_move(pos);
772
773 if let (Some(inspector), Some(f)) = (&mut self.inspector, &self.rt.frame_cache)
775 && inspector.hud.inspector_enabled
776 {
777 let hit = f.hit_regions.iter().find(|h| h.rect.contains(pos));
778 let hover_rect = hit.map(|h| h.rect);
779 let hover_info = hit.and_then(|h| {
780 f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
781 repose_devtools::HoveredInfo {
782 id: s.id,
783 role: format!("{:?}", s.role),
784 label: s.label.clone(),
785 }
786 })
787 });
788 inspector.hud.set_hovered(hover_rect, hover_info);
789 }
790
791 if let Some(win) = &self.window
793 && let Some(c) = result.cursor
794 {
795 win.set_cursor(winit::window::Cursor::Icon(map_cursor(c)));
796 }
797
798 self.request_redraw();
799 }
800
801 WindowEvent::MouseWheel { delta, .. } => {
802 let (dx_px, dy_px) = match delta {
803 MouseScrollDelta::LineDelta(x, y) => {
804 let unit_px = dp_to_px(60.0);
805 (-(x * unit_px), -(y * unit_px))
806 }
807 MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
808 };
809 log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
810
811 if self.rt.handle_scroll(Vec2 { x: dx_px, y: dy_px }) {
812 self.request_redraw();
813 }
814 }
815
816 WindowEvent::MouseInput { state, button, .. } => {
817 let pos = Vec2 {
818 x: self.rt.mouse_pos_px.0,
819 y: self.rt.mouse_pos_px.1,
820 };
821
822 let mapped = match button {
823 MouseButton::Left => PointerButton::Primary,
824 MouseButton::Right => PointerButton::Secondary,
825 MouseButton::Middle => PointerButton::Tertiary,
826 _ => return,
828 };
829
830 match state {
831 ElementState::Pressed => {
832 let result = self.rt.handle_pointer_press(pos, mapped);
833
834 if let Some(fid) = result.focused
836 && let Some(win) = &self.window
837 && let Some(f) = &self.rt.frame_cache
838 && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
839 {
840 let sf = win.scale_factor();
841 rc_web::set_ime_for_textfield_ex(
842 win,
843 true,
844 hit.keyboard_type.ime_purpose_hint(),
845 hit.auto_correct.unwrap_or(true),
846 hit.capitalization,
847 );
848 win.set_ime_cursor_area(
849 LogicalPosition::new(
850 hit.rect.x as f64 / sf,
851 hit.rect.y as f64 / sf,
852 ),
853 LogicalSize::new(
854 hit.rect.w as f64 / sf,
855 hit.rect.h as f64 / sf,
856 ),
857 );
858 }
859
860 if result.focused.is_none() && self.rt.ime_preedit {
862 if let Some(win) = &self.window {
863 rc_web::set_ime_for_textfield(win, false);
864 }
865 self.rt.ime_preedit = false;
866 }
867
868 if result.needs_a11y_announce {
869 self.announce_focus_change();
870 }
871
872 if matches!(mapped, PointerButton::Tertiary)
874 && let Some(f) = &self.rt.frame_cache
875 && let Some(cid) = self.rt.capture_id
876 && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
877 && self.rt.is_textfield(hit.id)
878 && let Some(txt) = self.paste_from_primary()
879 {
880 self.rt.paste_into_focused(&txt);
881 }
882
883 if matches!(mapped, PointerButton::Primary | PointerButton::Secondary)
885 && let Some(inspector) = &mut self.inspector
886 && inspector.hud.inspector_enabled
887 && let Some(f) = &self.rt.frame_cache
888 && let Some(hit) =
889 f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
890 {
891 let info =
892 f.semantics_nodes.iter().find(|s| s.id == hit.id).map(|s| {
893 repose_devtools::HoveredInfo {
894 id: s.id,
895 role: format!("{:?}", s.role),
896 label: s.label.clone(),
897 }
898 });
899 inspector
900 .hud
901 .select_widget(repose_devtools::SelectedWidget {
902 id: hit.id,
903 role: info
904 .as_ref()
905 .map(|i| i.role.clone())
906 .unwrap_or_default(),
907 label: info.as_ref().and_then(|i| i.label.clone()),
908 bounds: hit.rect,
909 });
910 }
911
912 self.request_redraw();
913 }
914
915 ElementState::Released => {
916 let result = self.rt.handle_pointer_release(pos, mapped);
917
918 if let Some(cid) = result.clicked_id
922 && let Some(f) = &self.rt.frame_cache
923 && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid)
924 {
925 let label = node.label.as_deref().unwrap_or("");
926 self.a11y.announce(&format!("Activated {}", label));
927 }
928
929 self.request_redraw();
930 }
931 }
932 }
933
934 WindowEvent::Touch(t) => {
935 let pos_px = (t.location.x as f32, t.location.y as f32);
936 let tid = t.id;
937 let scale = self
938 .window
939 .as_ref()
940 .map(|w| w.scale_factor() as f32)
941 .unwrap_or(1.0);
942
943 match t.phase {
944 winit::event::TouchPhase::Started => {
945 self.touch_gestures.touch_started(&mut self.rt, tid, pos_px);
946 self.request_redraw();
947 }
948
949 winit::event::TouchPhase::Moved => {
950 let (mut dirty, pinch, pan) = self
951 .touch_gestures
952 .touch_moved(&mut self.rt, tid, pos_px, scale);
953
954 if let Some((delta_scale, center)) = pinch
955 && self.dispatch_action(
956 repose_core::shortcuts::Action::Gesture(
957 repose_core::shortcuts::Gesture::PinchWithCenter {
958 delta_scale,
959 center,
960 },
961 ),
962 )
963 {
964 dirty = true;
965 }
966 if let Some(delta) = pan
967 && self.dispatch_action(
968 repose_core::shortcuts::Action::Gesture(
969 repose_core::shortcuts::Gesture::Pan { delta },
970 ),
971 )
972 {
973 dirty = true;
974 }
975
976 if dirty {
977 self.request_redraw();
978 }
979 }
980
981 winit::event::TouchPhase::Ended
982 | winit::event::TouchPhase::Cancelled => {
983 let cancelled = t.phase == winit::event::TouchPhase::Cancelled;
984 let swipe_right =
985 self.touch_gestures
986 .touch_ended(&mut self.rt, tid, pos_px, cancelled);
987
988 use repose_core::shortcuts::{Action, Gesture};
989 let mut dirty = false;
990 if let Some(right) = swipe_right {
991 let g = if right {
992 Gesture::SwipeRight
993 } else {
994 Gesture::SwipeLeft
995 };
996 if self.dispatch_action(Action::Gesture(g)) {
997 dirty = true;
998 }
999 }
1000
1001 if dirty {
1002 self.request_redraw();
1003 }
1004 }
1005 }
1006 }
1007
1008 WindowEvent::ModifiersChanged(new_mods) => {
1009 let state = new_mods.state();
1010 self.rt.modifiers.shift = state.shift_key();
1011 self.rt.modifiers.ctrl = state.control_key();
1012 self.rt.modifiers.alt = state.alt_key();
1013 self.rt.modifiers.meta = state.super_key();
1014 self.rt.modifiers.command = if cfg!(target_os = "macos") {
1015 self.rt.modifiers.meta
1016 } else {
1017 self.rt.modifiers.ctrl
1018 };
1019 }
1020
1021 WindowEvent::KeyboardInput {
1022 event: key_event, ..
1023 } => {
1024 if key_event.state == ElementState::Pressed
1026 && !key_event.repeat
1027 && self.rt.modifiers.ctrl
1028 && self.rt.modifiers.shift
1029 && key_event.physical_key == PhysicalKey::Code(KeyCode::KeyI)
1030 && let Some(inspector) = &mut self.inspector
1031 {
1032 inspector.hud.toggle_inspector();
1033 self.request_redraw();
1034 return;
1035 }
1036
1037 let mapped = rc::map_key(key_event.physical_key, &self.rt.modifiers);
1041 let ke = winit_key_to_repose(&key_event, &mapped, &self.rt.modifiers);
1042 if self.rt.handle_key_with_text(&ke, key_event.text.as_deref()) {
1043 self.request_redraw();
1044 return;
1045 }
1046
1047 if key_event.state == ElementState::Pressed && !key_event.repeat {
1050 match key_event.physical_key {
1051 PhysicalKey::Code(KeyCode::BrowserBack)
1052 | PhysicalKey::Code(KeyCode::Escape) => {
1053 use repose_navigation::back;
1054 if !back::handle() {
1055 }
1057 return;
1058 }
1059 _ => {}
1060 }
1061 }
1062
1063 if key_event.state == ElementState::Released
1065 && let Some(active_id) = self.rt.key_pressed_active
1066 {
1067 match key_event.physical_key {
1068 PhysicalKey::Code(KeyCode::Space)
1069 | PhysicalKey::Code(KeyCode::Enter) => {
1070 if let Some(f) = &self.rt.frame_cache
1071 && let Some(node) =
1072 f.semantics_nodes.iter().find(|n| n.id == active_id)
1073 {
1074 let label = node.label.as_deref().unwrap_or("");
1075 self.a11y.announce(&format!("Activated {}", label));
1076 }
1077 }
1078 _ => {}
1079 }
1080 }
1081 }
1082
1083 WindowEvent::Ime(ime) => {
1084 let ime_event = match ime {
1087 winit::event::Ime::Enabled => repose_core::input::ImeEvent::Start,
1088 winit::event::Ime::Preedit(text, cursor) => {
1089 repose_core::input::ImeEvent::Update { text, cursor }
1090 }
1091 winit::event::Ime::Commit(text) => {
1092 repose_core::input::ImeEvent::Commit(text)
1093 }
1094 winit::event::Ime::Disabled => repose_core::input::ImeEvent::Cancel,
1095 };
1096 self.rt.handle_ime(&ime_event);
1097 self.request_redraw();
1098 }
1099
1100 WindowEvent::RedrawRequested => {
1101 if !self.redraw_requested.replace(false) {
1103 self.process_a11y_actions();
1104 self.process_render_commands();
1105 if let (Some(backend), Some(frame)) =
1107 (self.backend.as_mut(), self.rt.frame_cache.as_ref())
1108 {
1109 let scale = self
1110 .window
1111 .as_ref()
1112 .map(|w| w.scale_factor() as f32)
1113 .unwrap_or(1.0);
1114 let mut scene = frame.scene.clone();
1115 if let Some(inspector) = &mut self.inspector {
1116 inspector.frame(&mut scene);
1117 }
1118 backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1119 }
1120 log::trace!("RedrawRequested: no frame request, skipping compose");
1121 return;
1122 }
1123 log::trace!("RedrawRequested: frame request pending, composing");
1124
1125 self.process_a11y_actions();
1127 self.process_render_commands();
1128
1129 let Some(win) = self.window.as_ref() else {
1130 return;
1131 };
1132 if self.backend.is_none() {
1133 return;
1134 }
1135
1136 repose_core::animation_driver::tick();
1139
1140 let t0 = Instant::now();
1141 let scale = win.scale_factor() as f32;
1142 self.rt.scale = scale;
1143 let focused = self.rt.sched.focused;
1144
1145 let output = self.rt.frame(&mut self.root, &self.render);
1146
1147 if let Some(cursor) = &output.platform.cursor {
1149 win.set_cursor(winit::window::Cursor::Icon(map_cursor(*cursor)));
1150 }
1151
1152 if let Some(dark) = output.platform.window_theme_dark
1154 && self.last_window_theme != Some(dark)
1155 {
1156 win.set_theme(Some(if dark {
1157 winit::window::Theme::Dark
1158 } else {
1159 winit::window::Theme::Light
1160 }));
1161 self.last_window_theme = Some(dark);
1162 }
1163
1164 if output.platform.ime_allowed {
1166 rc_web::set_ime_for_textfield_ex(
1167 win,
1168 true,
1169 output.platform.ime_purpose,
1170 output.platform.ime_auto_correct,
1171 output.platform.ime_capitalization,
1172 );
1173 if let Some((x, y, w, h)) = output.platform.ime_cursor_area {
1174 win.set_ime_cursor_area(
1175 LogicalPosition::new(x, y),
1176 LogicalSize::new(w, h),
1177 );
1178 }
1179 } else if self.rt.ime_preedit {
1180 rc_web::set_ime_for_textfield_ex(
1181 win,
1182 false,
1183 repose_core::ImePurposeHint::Normal,
1184 true,
1185 repose_core::KeyboardCapitalization::Unspecified,
1186 );
1187 self.rt.ime_preedit = false;
1188 }
1189
1190 if !output.wants_keyboard
1192 && focused.is_some()
1193 && self.rt.sched.focused.is_none()
1194 && self.rt.ime_preedit
1195 {
1196 rc_web::set_ime_for_textfield(win, false);
1197 self.rt.ime_preedit = false;
1198 }
1199
1200 let frame = output.into_frame();
1201
1202 let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1203
1204 if let Some(adapter) = &mut self.accesskit_adapter {
1206 let win = self.window.as_ref().unwrap();
1207 let scale = win.scale_factor();
1208 if let Some(update) = self.a11y_tree.update(
1209 &frame.semantics_nodes,
1210 scale,
1211 self.rt.sched.focused,
1212 ) {
1213 adapter.update_if_active(|| update);
1214 }
1215 }
1216
1217 let mut scene = frame.scene.clone();
1219 if let Some(inspector) = &mut self.inspector {
1221 let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1222 let signal_count = self.rt.sched.id_count() as usize;
1223 let ls = repose_ui::last_layout_stats();
1224 inspector.hud.metrics = Some(repose_devtools::Metrics {
1225 build_ms: build_layout_ms,
1226 layout_ms: ls.layout_time_ms,
1227 paint_ms: ls.paint_time_ms,
1228 scene_nodes: scene.nodes.len(),
1229 widget_count,
1230 signal_count,
1231 taffy_created: ls.taffy_created,
1232 taffy_reused: ls.taffy_reused,
1233 layout_hits: ls.layout_hits,
1234 layout_misses: ls.layout_misses,
1235 paint_cache_hits: ls.paint_cache_hits,
1236 paint_cache_misses: ls.paint_cache_misses,
1237 paint_culled: ls.paint_culled,
1238 });
1239 inspector.frame(&mut scene);
1240 }
1241
1242 repose_core::dnd::overlay_drag_indicator(
1244 &mut scene,
1245 self.rt.mouse_pos_px,
1246 self.external_file_drag,
1247 );
1248
1249 self.process_render_commands();
1252
1253 let win = self.window.as_ref().unwrap();
1255 let scale = win.scale_factor() as f32;
1256 if let Some(backend) = self.backend.as_mut() {
1257 backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1258 }
1259
1260 self.rt.after_compose(&frame, scale);
1264
1265 self.rt.cache_frame(frame);
1268
1269 self.dispatch_file_drop_now();
1270
1271 self.rt.tick_overlays(self.last_redraw);
1272 self.last_redraw = Instant::now();
1273 }
1274
1275 _ => {}
1276 }
1277 }
1278
1279 fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1280 #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1283 if let Some(cb) = ABOUT_TO_WAIT_CALLBACK.lock().unwrap().as_ref() {
1284 cb();
1285 }
1286 process_deeplinks();
1287
1288 #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1291 if WINDOW_VISIBLE.load(Ordering::Relaxed)
1292 && self.backend.is_none()
1293 && let Some(w) = &self.window
1294 {
1295 log::info!("about_to_wait: recreating GPU backend");
1296 match repose_render_wgpu::WgpuBackend::new_with_options(
1297 w.clone(),
1298 self.msaa_samples,
1299 self.present_mode,
1300 ) {
1301 Ok(b) => self.backend = Some(b),
1302 Err(e) => log::error!("about_to_wait: failed to recreate backend: {e:?}"),
1303 }
1304 }
1305
1306 let needs_compose = take_frame_request();
1307 let needs_present = take_present_request();
1308
1309 if needs_compose {
1310 self.pending_redraw = true;
1311 }
1312
1313 if !self.pending_redraw && needs_present && self.rt.frame_cache.is_some() {
1315 let now = Instant::now();
1316 let interval = self.frame_interval();
1317 if now.saturating_duration_since(self.last_redraw) >= interval {
1318 rc::request_redraw(&self.window);
1319 self.last_redraw = now;
1320 } else {
1321 el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1322 self.last_redraw + interval,
1323 ));
1324 }
1325 return;
1326 }
1327
1328 if !self.pending_redraw {
1329 let now = Instant::now();
1330 let idle_cap = web_time::Duration::from_millis(1000);
1331 let deadline = self
1332 .rt
1333 .next_caret_blink_deadline()
1334 .unwrap_or(now + idle_cap);
1335
1336 if now.saturating_duration_since(self.last_redraw) >= idle_cap || now >= deadline {
1337 self.redraw_requested.set(true);
1338 request_frame();
1339 rc::request_redraw(&self.window);
1340 self.last_redraw = now;
1341 }
1342 el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(Ord::min(
1343 deadline,
1344 now + idle_cap,
1345 )));
1346 return;
1347 }
1348
1349 let now = Instant::now();
1350 let interval = self.frame_interval();
1351
1352 if now.saturating_duration_since(self.last_redraw) >= interval {
1353 self.pending_redraw = false;
1354 self.redraw_requested.set(true);
1355 rc::request_redraw(&self.window);
1356 self.last_redraw = now;
1357 } else {
1358 el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1359 self.last_redraw + interval,
1360 ));
1361 }
1362 }
1363
1364 fn new_events(
1365 &mut self,
1366 _: &winit::event_loop::ActiveEventLoop,
1367 _: winit::event::StartCause,
1368 ) {
1369 }
1370 fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1371 self.pending_redraw = true;
1372 }
1373 fn device_event(
1374 &mut self,
1375 _: &winit::event_loop::ActiveEventLoop,
1376 _: winit::event::DeviceId,
1377 _: winit::event::DeviceEvent,
1378 ) {
1379 }
1380 fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1381 fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1382 fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1383 }
1384
1385 impl App {
1386 fn announce_focus_change(&mut self) {
1387 if let Some(f) = &self.rt.frame_cache {
1388 let focused_node = self
1389 .rt
1390 .sched
1391 .focused
1392 .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1393 self.a11y.focus_changed(focused_node);
1394 }
1395 }
1396
1397 fn dispatch_file_drop_now(&mut self) {
1398 let Some(f) = &self.rt.frame_cache else {
1399 self.pending_dropped_files.clear();
1400 self.pending_drop_pos_px = None;
1401 return;
1402 };
1403
1404 if self.pending_dropped_files.is_empty() {
1405 return;
1406 }
1407
1408 let pos_px = self.pending_drop_pos_px.unwrap_or(self.rt.mouse_pos_px);
1409 let pos = Vec2 {
1410 x: pos_px.0,
1411 y: pos_px.1,
1412 };
1413
1414 let mut files = Vec::new();
1415 for p in self.pending_dropped_files.drain(..) {
1416 let name = p
1417 .file_name()
1418 .and_then(|s| s.to_str())
1419 .unwrap_or("file")
1420 .to_string();
1421 files.push(repose_core::dnd::DroppedFile {
1422 name,
1423 path: Some(p),
1424 });
1425 }
1426
1427 let payload: repose_core::dnd::DragPayload =
1428 std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1429
1430 let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1431 self.pending_drop_pos_px = None;
1432 return;
1433 };
1434
1435 if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1436 && let Some(cb) = &hit.on_drop
1437 {
1438 let accepted = cb(repose_core::dnd::DropEvent {
1439 source_id: 0, target_id,
1441 position: pos,
1442 modifiers: self.rt.modifiers,
1443 payload: payload.clone(),
1444 });
1445
1446 if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
1447 {
1448 let label = node.label.as_deref().unwrap_or("");
1449 self.a11y.announce(&format!("Dropped files on {}", label));
1450 }
1451 }
1452
1453 self.pending_drop_pos_px = None;
1454 self.request_redraw();
1455 }
1456 }
1457
1458 let event_loop = EventLoop::new()?;
1459 set_event_loop_proxy(event_loop.create_proxy());
1460 let mut app = App::new(Box::new(root), config);
1461 repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1463 event_loop.run_app(&mut app)?;
1464 Ok(())
1465}
1466
1467pub trait A11yBridge: Send {
1475 fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1477
1478 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1480
1481 fn announce(&mut self, msg: &str);
1483}
1484
1485#[cfg_attr(target_os = "linux", allow(dead_code))]
1486struct NoopA11y;
1487impl A11yBridge for NoopA11y {
1488 fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1489 }
1491 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1492 if let Some(n) = node {
1493 log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1494 } else {
1495 log::info!("A11y focus: None");
1496 }
1497 }
1498 fn announce(&mut self, msg: &str) {
1499 log::info!("A11y announce: {msg}");
1500 }
1501}
1502
1503#[cfg(target_os = "linux")]
1504struct LinuxAtspiStub;
1505#[cfg(target_os = "linux")]
1506impl A11yBridge for LinuxAtspiStub {
1507 fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1508 log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1509 }
1510 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1511 if let Some(n) = node {
1512 log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1513 } else {
1514 log::info!("AT-SPI stub focus: None");
1515 }
1516 }
1517 fn announce(&mut self, msg: &str) {
1518 log::info!("AT-SPI stub announce: {msg}");
1519 }
1520}