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