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