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(mut b) => {
651 b.set_pixels_per_point(sf);
652 repose_render_wgpu::offscreen::set_shared_device(
653 b.device.clone(),
654 b.queue.clone(),
655 );
656 self.backend = Some(b);
657 set_app_window(w.clone());
658 self.window = Some(w);
659 self.request_redraw();
660 }
661 Err(e) => {
662 log::error!("Failed to create WGPU backend: {e:?}");
663 el.exit();
664 }
665 }
666 }
667 Err(e) => {
668 log::error!("Failed to create window: {e:?}");
669 el.exit();
670 }
671 }
672 }
673 }
674
675 fn window_event(
676 &mut self,
677 el: &winit::event_loop::ActiveEventLoop,
678 _id: winit::window::WindowId,
679 event: WindowEvent,
680 ) {
681 if let Some(adapter) = &mut self.accesskit_adapter {
683 adapter.process_event(self.window.as_ref().unwrap(), &event);
684 }
685
686 match event {
687 WindowEvent::CloseRequested => {
688 if CLOSE_TO_TRAY.load(Ordering::Relaxed) {
689 self.backend = None;
691 if let Some(w) = &self.window {
692 w.set_visible(false);
693 }
694 WINDOW_VISIBLE.store(false, Ordering::Relaxed);
695 } else {
696 el.exit();
697 }
698 }
699
700 WindowEvent::Focused(false) => {
701 self.rt.handle_focus_lost();
703
704 self.external_file_drag = false;
706 self.hovered_files.clear();
707
708 if let Some(w) = &self.window {
709 rc_web::set_ime_for_textfield(w, false);
710 }
711
712 self.request_redraw();
713 }
714
715 WindowEvent::CursorLeft { .. } => {
716 self.rt.pointer_inside = false;
717 self.rt.clear_hover();
718 self.external_file_drag = false;
719 self.hovered_files.clear();
720 self.request_redraw();
721 }
722
723 WindowEvent::HoveredFile(path) => {
724 self.external_file_drag = true;
726 if self.hovered_files.len() < 32 {
727 self.hovered_files.push(path);
728 }
729 if self.pending_drop_pos_px.is_none() {
730 self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
731 }
732 self.request_redraw();
733 }
734
735 WindowEvent::HoveredFileCancelled => {
736 self.external_file_drag = false;
737 self.hovered_files.clear();
738
739 self.reset_pointer_state();
741
742 self.request_redraw();
743 }
744
745 WindowEvent::DroppedFile(path) => {
746 self.pending_dropped_files.push(path);
748 if self.pending_drop_pos_px.is_none() {
749 self.pending_drop_pos_px = Some(self.rt.mouse_pos_px);
750 }
751
752 self.external_file_drag = false;
754 self.hovered_files.clear();
755
756 self.request_redraw();
757 }
758
759 WindowEvent::Resized(size) => {
760 let sf = self
761 .window
762 .as_ref()
763 .map(|w| w.scale_factor() as f32)
764 .unwrap_or(1.0);
765 self.rt.set_viewport_and_scale(size.width, size.height, sf);
766 if let Some(b) = self.backend.as_mut() {
767 b.configure_surface(size.width, size.height);
768 b.set_pixels_per_point(sf);
769 }
770 if let Some(w) = &self.window {
771 let sf = w.scale_factor() as f32;
772 let dp_w = size.width as f32 / sf;
773 let dp_h = size.height as f32 / sf;
774 log::info!(
775 "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
776 size.width,
777 size.height,
778 sf,
779 dp_w as i32,
780 dp_h as i32
781 );
782 }
783 self.request_redraw();
784 }
785
786 WindowEvent::CursorMoved { position, .. } => {
787 self.rt.pointer_inside = true;
788
789 if self.external_file_drag {
790 self.pending_drop_pos_px = Some((position.x as f32, position.y as f32));
791 }
792
793 let pos = Vec2 {
794 x: position.x as f32,
795 y: position.y as f32,
796 };
797
798 let result = self.rt.handle_pointer_move(pos);
800
801 if let (Some(inspector), Some(f)) = (&mut self.inspector, &self.rt.frame_cache)
803 && inspector.hud.inspector_enabled
804 {
805 let hit = f.hit_regions.iter().find(|h| h.rect.contains(pos));
806 let hover_rect = hit.map(|h| h.rect);
807 let hover_info = hit.and_then(|h| {
808 f.semantics_nodes.iter().find(|s| s.id == h.id).map(|s| {
809 repose_devtools::HoveredInfo {
810 id: s.id,
811 role: format!("{:?}", s.role),
812 label: s.label.clone(),
813 }
814 })
815 });
816 inspector.hud.set_hovered(hover_rect, hover_info);
817 }
818
819 if let Some(win) = &self.window
821 && let Some(c) = result.cursor
822 {
823 win.set_cursor(winit::window::Cursor::Icon(map_cursor(c)));
824 }
825
826 self.request_redraw();
827 }
828
829 WindowEvent::MouseWheel { delta, .. } => {
830 let (dx_px, dy_px) = match delta {
831 MouseScrollDelta::LineDelta(x, y) => {
832 let unit_px = dp_to_px(60.0);
833 (-(x * unit_px), -(y * unit_px))
834 }
835 MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
836 };
837 log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
838
839 if self.rt.handle_scroll(Vec2 { x: dx_px, y: dy_px }) {
840 self.request_redraw();
841 }
842 }
843
844 WindowEvent::MouseInput { state, button, .. } => {
845 let pos = Vec2 {
846 x: self.rt.mouse_pos_px.0,
847 y: self.rt.mouse_pos_px.1,
848 };
849
850 let mapped = match button {
851 MouseButton::Left => PointerButton::Primary,
852 MouseButton::Right => PointerButton::Secondary,
853 MouseButton::Middle => PointerButton::Tertiary,
854 _ => return,
856 };
857
858 match state {
859 ElementState::Pressed => {
860 let result = self.rt.handle_pointer_press(pos, mapped);
861
862 if let Some(fid) = result.focused
864 && let Some(win) = &self.window
865 && let Some(f) = &self.rt.frame_cache
866 && let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid)
867 {
868 let sf = win.scale_factor();
869 rc_web::set_ime_for_textfield_ex(
870 win,
871 true,
872 hit.keyboard_type.ime_purpose_hint(),
873 hit.auto_correct.unwrap_or(true),
874 hit.capitalization,
875 );
876 win.set_ime_cursor_area(
877 LogicalPosition::new(
878 hit.rect.x as f64 / sf,
879 hit.rect.y as f64 / sf,
880 ),
881 LogicalSize::new(
882 hit.rect.w as f64 / sf,
883 hit.rect.h as f64 / sf,
884 ),
885 );
886 }
887
888 if result.focused.is_none() && self.rt.ime_preedit {
890 if let Some(win) = &self.window {
891 rc_web::set_ime_for_textfield(win, false);
892 }
893 self.rt.ime_preedit = false;
894 }
895
896 if result.needs_a11y_announce {
897 self.announce_focus_change();
898 }
899
900 if matches!(mapped, PointerButton::Tertiary)
902 && let Some(f) = &self.rt.frame_cache
903 && let Some(cid) = self.rt.capture_id
904 && let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
905 && self.rt.is_textfield(hit.id)
906 && let Some(txt) = self.paste_from_primary()
907 {
908 self.rt.paste_into_focused(&txt);
909 }
910
911 if matches!(mapped, PointerButton::Primary | PointerButton::Secondary)
913 && let Some(inspector) = &mut self.inspector
914 && inspector.hud.inspector_enabled
915 && let Some(f) = &self.rt.frame_cache
916 && let Some(hit) =
917 f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
918 {
919 let info =
920 f.semantics_nodes.iter().find(|s| s.id == hit.id).map(|s| {
921 repose_devtools::HoveredInfo {
922 id: s.id,
923 role: format!("{:?}", s.role),
924 label: s.label.clone(),
925 }
926 });
927 inspector
928 .hud
929 .select_widget(repose_devtools::SelectedWidget {
930 id: hit.id,
931 role: info
932 .as_ref()
933 .map(|i| i.role.clone())
934 .unwrap_or_default(),
935 label: info.as_ref().and_then(|i| i.label.clone()),
936 bounds: hit.rect,
937 });
938 }
939
940 self.request_redraw();
941 }
942
943 ElementState::Released => {
944 let result = self.rt.handle_pointer_release(pos, mapped);
945
946 if let Some(cid) = result.clicked_id
950 && let Some(f) = &self.rt.frame_cache
951 && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid)
952 {
953 let label = node.label.as_deref().unwrap_or("");
954 self.a11y.announce(&format!("Activated {}", label));
955 }
956
957 self.request_redraw();
958 }
959 }
960 }
961
962 WindowEvent::Touch(t) => {
963 let pos_px = (t.location.x as f32, t.location.y as f32);
964 let tid = t.id;
965 let scale = self
966 .window
967 .as_ref()
968 .map(|w| w.scale_factor() as f32)
969 .unwrap_or(1.0);
970
971 match t.phase {
972 winit::event::TouchPhase::Started => {
973 self.touch_gestures.touch_started(&mut self.rt, tid, pos_px);
974 self.request_redraw();
975 }
976
977 winit::event::TouchPhase::Moved => {
978 let (mut dirty, pinch, pan) =
979 self.touch_gestures
980 .touch_moved(&mut self.rt, tid, pos_px, scale);
981
982 if let Some((delta_scale, center)) = pinch
983 && self.dispatch_action(repose_core::shortcuts::Action::Gesture(
984 repose_core::shortcuts::Gesture::PinchWithCenter {
985 delta_scale,
986 center,
987 },
988 ))
989 {
990 dirty = true;
991 }
992 if let Some(delta) = pan
993 && self.dispatch_action(repose_core::shortcuts::Action::Gesture(
994 repose_core::shortcuts::Gesture::Pan { delta },
995 ))
996 {
997 dirty = true;
998 }
999
1000 if dirty {
1001 self.request_redraw();
1002 }
1003 }
1004
1005 winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => {
1006 let cancelled = t.phase == winit::event::TouchPhase::Cancelled;
1007 let swipe_right = self.touch_gestures.touch_ended(
1008 &mut self.rt,
1009 tid,
1010 pos_px,
1011 cancelled,
1012 );
1013
1014 use repose_core::shortcuts::{Action, Gesture};
1015 let mut dirty = false;
1016 if let Some(right) = swipe_right {
1017 let g = if right {
1018 Gesture::SwipeRight
1019 } else {
1020 Gesture::SwipeLeft
1021 };
1022 if self.dispatch_action(Action::Gesture(g)) {
1023 dirty = true;
1024 }
1025 }
1026
1027 if dirty {
1028 self.request_redraw();
1029 }
1030 }
1031 }
1032 }
1033
1034 WindowEvent::ModifiersChanged(new_mods) => {
1035 let state = new_mods.state();
1036 self.rt.modifiers.shift = state.shift_key();
1037 self.rt.modifiers.ctrl = state.control_key();
1038 self.rt.modifiers.alt = state.alt_key();
1039 self.rt.modifiers.meta = state.super_key();
1040 self.rt.modifiers.command = if cfg!(target_os = "macos") {
1041 self.rt.modifiers.meta
1042 } else {
1043 self.rt.modifiers.ctrl
1044 };
1045 }
1046
1047 WindowEvent::KeyboardInput {
1048 event: key_event, ..
1049 } => {
1050 if key_event.state == ElementState::Pressed
1052 && !key_event.repeat
1053 && self.rt.modifiers.ctrl
1054 && self.rt.modifiers.shift
1055 && key_event.physical_key == PhysicalKey::Code(KeyCode::KeyI)
1056 && let Some(inspector) = &mut self.inspector
1057 {
1058 inspector.hud.toggle_inspector();
1059 self.request_redraw();
1060 return;
1061 }
1062
1063 let mapped = rc::map_key(key_event.physical_key, &self.rt.modifiers);
1067 let ke = winit_key_to_repose(&key_event, &mapped, &self.rt.modifiers);
1068 if self.rt.handle_key_with_text(&ke, key_event.text.as_deref()) {
1069 self.request_redraw();
1070 return;
1071 }
1072
1073 if key_event.state == ElementState::Pressed && !key_event.repeat {
1076 match key_event.physical_key {
1077 PhysicalKey::Code(KeyCode::BrowserBack)
1078 | PhysicalKey::Code(KeyCode::Escape) => {
1079 use repose_navigation::back;
1080 if !back::handle() {
1081 }
1083 return;
1084 }
1085 _ => {}
1086 }
1087 }
1088
1089 if key_event.state == ElementState::Released
1091 && let Some(active_id) = self.rt.key_pressed_active
1092 {
1093 match key_event.physical_key {
1094 PhysicalKey::Code(KeyCode::Space)
1095 | PhysicalKey::Code(KeyCode::Enter) => {
1096 if let Some(f) = &self.rt.frame_cache
1097 && let Some(node) =
1098 f.semantics_nodes.iter().find(|n| n.id == active_id)
1099 {
1100 let label = node.label.as_deref().unwrap_or("");
1101 self.a11y.announce(&format!("Activated {}", label));
1102 }
1103 }
1104 _ => {}
1105 }
1106 }
1107 }
1108
1109 WindowEvent::Ime(ime) => {
1110 let ime_event = match ime {
1113 winit::event::Ime::Enabled => repose_core::input::ImeEvent::Start,
1114 winit::event::Ime::Preedit(text, cursor) => {
1115 repose_core::input::ImeEvent::Update { text, cursor }
1116 }
1117 winit::event::Ime::Commit(text) => {
1118 repose_core::input::ImeEvent::Commit(text)
1119 }
1120 winit::event::Ime::Disabled => repose_core::input::ImeEvent::Cancel,
1121 };
1122 self.rt.handle_ime(&ime_event);
1123 self.request_redraw();
1124 }
1125
1126 WindowEvent::RedrawRequested => {
1127 crate::run_pre_redraw(&self.render);
1129
1130 if !self.redraw_requested.replace(false) {
1132 self.process_a11y_actions();
1133 self.process_render_commands();
1134 if let (Some(backend), Some(frame)) =
1136 (self.backend.as_mut(), self.rt.frame_cache.as_ref())
1137 {
1138 let scale = self
1139 .window
1140 .as_ref()
1141 .map(|w| w.scale_factor() as f32)
1142 .unwrap_or(1.0);
1143 let mut scene = frame.scene.clone();
1144 if let Some(inspector) = &mut self.inspector {
1145 inspector.frame(&mut scene);
1146 }
1147 backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1148 }
1149 log::trace!("RedrawRequested: no frame request, skipping compose");
1150 return;
1151 }
1152 log::trace!("RedrawRequested: frame request pending, composing");
1153
1154 self.process_a11y_actions();
1156 self.process_render_commands();
1157
1158 let Some(win) = self.window.as_ref() else {
1159 return;
1160 };
1161 if self.backend.is_none() {
1162 return;
1163 }
1164
1165 repose_core::animation_driver::tick();
1168
1169 let t0 = Instant::now();
1170 let scale = win.scale_factor() as f32;
1171 self.rt.scale = scale;
1172 let focused = self.rt.sched.focused;
1173
1174 let output = self.rt.frame(&mut self.root, &self.render);
1175
1176 if let Some(cursor) = &output.platform.cursor {
1177 win.set_cursor(winit::window::Cursor::Icon(map_cursor(*cursor)));
1178 }
1179
1180 if let Some(dark) = output.platform.window_theme_dark
1182 && self.last_window_theme != Some(dark)
1183 {
1184 win.set_theme(Some(if dark {
1185 winit::window::Theme::Dark
1186 } else {
1187 winit::window::Theme::Light
1188 }));
1189 self.last_window_theme = Some(dark);
1190 }
1191
1192 if output.platform.ime_allowed {
1194 rc_web::set_ime_for_textfield_ex(
1195 win,
1196 true,
1197 output.platform.ime_purpose,
1198 output.platform.ime_auto_correct,
1199 output.platform.ime_capitalization,
1200 );
1201 if let Some((x, y, w, h)) = output.platform.ime_cursor_area {
1202 win.set_ime_cursor_area(
1203 LogicalPosition::new(x, y),
1204 LogicalSize::new(w, h),
1205 );
1206 }
1207 } else if self.rt.ime_preedit {
1208 rc_web::set_ime_for_textfield_ex(
1209 win,
1210 false,
1211 repose_core::ImePurposeHint::Normal,
1212 true,
1213 repose_core::KeyboardCapitalization::Unspecified,
1214 );
1215 self.rt.ime_preedit = false;
1216 }
1217
1218 if !output.wants_keyboard
1220 && focused.is_some()
1221 && self.rt.sched.focused.is_none()
1222 && self.rt.ime_preedit
1223 {
1224 rc_web::set_ime_for_textfield(win, false);
1225 self.rt.ime_preedit = false;
1226 }
1227
1228 let frame = output.into_frame();
1229
1230 let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1231
1232 if let Some(adapter) = &mut self.accesskit_adapter {
1234 let win = self.window.as_ref().unwrap();
1235 let scale = win.scale_factor();
1236 if let Some(update) = self.a11y_tree.update(
1237 &frame.semantics_nodes,
1238 scale,
1239 self.rt.sched.focused,
1240 ) {
1241 adapter.update_if_active(|| update);
1242 }
1243 }
1244
1245 let mut scene = frame.scene.clone();
1247 if let Some(inspector) = &mut self.inspector {
1249 let widget_count = frame.semantics_nodes.len() + frame.hit_regions.len();
1250 let signal_count = self.rt.sched.id_count() as usize;
1251 let ls = repose_ui::last_layout_stats();
1252 inspector.hud.metrics = Some(repose_devtools::Metrics {
1253 build_ms: build_layout_ms,
1254 layout_ms: ls.layout_time_ms,
1255 paint_ms: ls.paint_time_ms,
1256 scene_nodes: scene.nodes.len(),
1257 widget_count,
1258 signal_count,
1259 taffy_created: ls.taffy_created,
1260 taffy_reused: ls.taffy_reused,
1261 layout_hits: ls.layout_hits,
1262 layout_misses: ls.layout_misses,
1263 paint_cache_hits: ls.paint_cache_hits,
1264 paint_cache_misses: ls.paint_cache_misses,
1265 paint_culled: ls.paint_culled,
1266 });
1267 inspector.frame(&mut scene);
1268 }
1269
1270 repose_core::dnd::overlay_drag_indicator(
1272 &mut scene,
1273 self.rt.mouse_pos_px,
1274 self.external_file_drag,
1275 );
1276
1277 self.process_render_commands();
1280
1281 let win = self.window.as_ref().unwrap();
1283 let scale = win.scale_factor() as f32;
1284 if let Some(backend) = self.backend.as_mut() {
1285 backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1286 }
1287
1288 self.rt.after_compose(&frame, scale);
1292
1293 self.rt.cache_frame(frame);
1296
1297 self.dispatch_file_drop_now();
1298
1299 self.rt.tick_overlays(self.last_redraw);
1300 self.last_redraw = Instant::now();
1301 }
1302
1303 _ => {}
1304 }
1305 }
1306
1307 fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1308 #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1311 if let Some(cb) = ABOUT_TO_WAIT_CALLBACK.lock().unwrap().as_ref() {
1312 cb();
1313 }
1314 process_deeplinks();
1315
1316 #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
1319 if WINDOW_VISIBLE.load(Ordering::Relaxed)
1320 && self.backend.is_none()
1321 && let Some(w) = &self.window
1322 {
1323 log::info!("about_to_wait: recreating GPU backend");
1324 match repose_render_wgpu::WgpuBackend::new_with_options(
1325 w.clone(),
1326 self.msaa_samples,
1327 self.present_mode,
1328 ) {
1329 Ok(b) => {
1330 repose_render_wgpu::offscreen::set_shared_device(
1331 b.device.clone(),
1332 b.queue.clone(),
1333 );
1334 self.backend = Some(b)
1335 }
1336 Err(e) => log::error!("about_to_wait: failed to recreate backend: {e:?}"),
1337 }
1338 }
1339
1340 let needs_compose = take_frame_request();
1341 let needs_present = take_present_request();
1342
1343 if needs_compose {
1344 self.pending_redraw = true;
1345 }
1346
1347 if !self.pending_redraw && needs_present && self.rt.frame_cache.is_some() {
1349 let now = Instant::now();
1350 let interval = self.frame_interval();
1351 if now.saturating_duration_since(self.last_redraw) >= interval {
1352 rc::request_redraw(&self.window);
1353 self.last_redraw = now;
1354 } else {
1355 el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1356 self.last_redraw + interval,
1357 ));
1358 }
1359 return;
1360 }
1361
1362 if !self.pending_redraw {
1363 let now = Instant::now();
1364 let idle_cap = web_time::Duration::from_millis(1000);
1365 let deadline = self.rt.next_frame_deadline(now, idle_cap);
1366
1367 if now.saturating_duration_since(self.last_redraw) >= idle_cap
1368 || self.rt.is_wakeup_due(now)
1369 {
1370 self.redraw_requested.set(true);
1371 request_frame();
1372 rc::request_redraw(&self.window);
1373 self.last_redraw = now;
1374 }
1375 el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(Ord::min(
1376 deadline,
1377 now + idle_cap,
1378 )));
1379 return;
1380 }
1381
1382 let now = Instant::now();
1383 let interval = self.frame_interval();
1384
1385 if now.saturating_duration_since(self.last_redraw) >= interval {
1386 self.pending_redraw = false;
1387 self.redraw_requested.set(true);
1388 rc::request_redraw(&self.window);
1389 self.last_redraw = now;
1390 } else {
1391 el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1392 self.last_redraw + interval,
1393 ));
1394 }
1395 }
1396
1397 fn new_events(
1398 &mut self,
1399 _: &winit::event_loop::ActiveEventLoop,
1400 _: winit::event::StartCause,
1401 ) {
1402 }
1403 fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {
1404 self.pending_redraw = true;
1405 }
1406 fn device_event(
1407 &mut self,
1408 _: &winit::event_loop::ActiveEventLoop,
1409 _: winit::event::DeviceId,
1410 _: winit::event::DeviceEvent,
1411 ) {
1412 }
1413 fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1414 fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1415 fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1416 }
1417
1418 impl App {
1419 fn announce_focus_change(&mut self) {
1420 if let Some(f) = &self.rt.frame_cache {
1421 let focused_node = self
1422 .rt
1423 .sched
1424 .focused
1425 .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1426 self.a11y.focus_changed(focused_node);
1427 }
1428 }
1429
1430 fn dispatch_file_drop_now(&mut self) {
1431 let Some(f) = &self.rt.frame_cache else {
1432 self.pending_dropped_files.clear();
1433 self.pending_drop_pos_px = None;
1434 return;
1435 };
1436
1437 if self.pending_dropped_files.is_empty() {
1438 return;
1439 }
1440
1441 let pos_px = self.pending_drop_pos_px.unwrap_or(self.rt.mouse_pos_px);
1442 let pos = Vec2 {
1443 x: pos_px.0,
1444 y: pos_px.1,
1445 };
1446
1447 let mut files = Vec::new();
1448 for p in self.pending_dropped_files.drain(..) {
1449 let name = p
1450 .file_name()
1451 .and_then(|s| s.to_str())
1452 .unwrap_or("file")
1453 .to_string();
1454 files.push(repose_core::dnd::DroppedFile {
1455 name,
1456 path: Some(p),
1457 });
1458 }
1459
1460 let payload: repose_core::dnd::DragPayload =
1461 std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1462
1463 let Some(target_id) = repose_core::dnd::dnd_target_id_at(f, pos) else {
1464 self.pending_drop_pos_px = None;
1465 return;
1466 };
1467
1468 if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id)
1469 && let Some(cb) = &hit.on_drop
1470 {
1471 let accepted = cb(repose_core::dnd::DropEvent {
1472 source_id: 0, target_id,
1474 position: pos,
1475 modifiers: self.rt.modifiers,
1476 payload: payload.clone(),
1477 });
1478
1479 if accepted && let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id)
1480 {
1481 let label = node.label.as_deref().unwrap_or("");
1482 self.a11y.announce(&format!("Dropped files on {}", label));
1483 }
1484 }
1485
1486 self.pending_drop_pos_px = None;
1487 self.request_redraw();
1488 }
1489 }
1490
1491 let event_loop = EventLoop::new()?;
1492 set_event_loop_proxy(event_loop.create_proxy());
1493 let mut app = App::new(Box::new(root), config);
1494 repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1496 event_loop.run_app(&mut app)?;
1497 Ok(())
1498}
1499
1500pub trait A11yBridge: Send {
1508 fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1510
1511 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1513
1514 fn announce(&mut self, msg: &str);
1516}
1517
1518#[cfg_attr(target_os = "linux", allow(dead_code))]
1519struct NoopA11y;
1520impl A11yBridge for NoopA11y {
1521 fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1522 }
1524 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1525 if let Some(n) = node {
1526 log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1527 } else {
1528 log::info!("A11y focus: None");
1529 }
1530 }
1531 fn announce(&mut self, msg: &str) {
1532 log::info!("A11y announce: {msg}");
1533 }
1534}
1535
1536#[cfg(target_os = "linux")]
1537struct LinuxAtspiStub;
1538#[cfg(target_os = "linux")]
1539impl A11yBridge for LinuxAtspiStub {
1540 fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1541 log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1542 }
1543 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1544 if let Some(n) = node {
1545 log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1546 } else {
1547 log::info!("AT-SPI stub focus: None");
1548 }
1549 }
1550 fn announce(&mut self, msg: &str) {
1551 log::info!("AT-SPI stub announce: {msg}");
1552 }
1553}