1#![allow(clippy::type_complexity)]
2
3mod fps_monitor;
4mod hit_path_tracker;
5mod shell_debug;
6mod shell_frame;
7mod shell_input;
8mod surface;
9mod wheel;
10use std::{
11 cell::RefCell,
12 fmt::{Debug, Write},
13 rc::Rc,
14 sync::{
15 Mutex, MutexGuard,
16 atomic::{AtomicBool, Ordering},
17 },
18};
19
20use cranpose_core::{
21 Applier, Composition, Key, MemoryApplier, NodeError, NodeId, collections::map::HashSet,
22 enter_event_handler_scope, location_key, run_in_mutable_snapshot,
23};
24pub use cranpose_foundation::{
25 DEFAULT_ROTARY_SCROLL_FACTOR_DP, Modifiers, PointerSource, RotaryScrollEvent,
26 rotary_scroll_pixels_from_detents,
27};
28use cranpose_foundation::{PointerButton, PointerButtons, PointerEvent, PointerEventKind};
29use cranpose_render_common::{HitTestTarget, RenderScene, Renderer};
30use cranpose_runtime_std::StdRuntime;
31use cranpose_ui::{
32 HeadlessRenderer, LayoutBox, LayoutNode, LayoutTree, MeasureLayoutOptions, SemanticsTree,
33 SubcomposeLayoutNode, WindowRootEntry, clear_transient_scroll_motion_contexts,
34 format_layout_tree, format_render_scene, format_screen_summary,
35 has_pending_focus_invalidations, has_pending_pointer_repasses,
36 has_pending_semantics_invalidations, peek_focus_invalidation, peek_layout_invalidation,
37 peek_pointer_invalidation, peek_render_invalidation, process_focus_invalidations,
38 process_pointer_repasses, process_semantics_invalidations, request_render_invalidation,
39 take_draw_repass_nodes, take_focus_invalidation, take_layout_invalidation,
40 take_pointer_invalidation, take_render_invalidation,
41};
42pub use cranpose_ui::{KeyCode, KeyEvent, KeyEventType};
43use cranpose_ui_graphics::{Point, PointerIcon, Rect, Size};
44pub use fps_monitor::FpsStats;
45use hit_path_tracker::PointerId;
46#[cfg(test)]
47use shell_frame::build_draw_refresh_scope;
48pub use surface::{RootId, RootSurface, SurfaceMut};
49use surface::{TextInputRouter, TextInputRoutes, partition_nodes_by_surface};
50use web_time::Instant;
51pub use wheel::WheelScroll;
52
53#[cfg(all(
54 feature = "clipboard-native",
55 not(target_arch = "wasm32"),
56 not(target_os = "android"),
57 not(target_os = "ios")
58))]
59struct ShellClipboard {
60 inner: std::rc::Rc<std::cell::RefCell<Option<arboard::Clipboard>>>,
61}
62
63#[cfg(all(
64 feature = "clipboard-native",
65 not(target_arch = "wasm32"),
66 not(target_os = "android"),
67 not(target_os = "ios")
68))]
69impl cranpose_ui::clipboard_session::PlatformClipboard for ShellClipboard {
70 fn write_text(&self, text: &str) {
71 if let Some(clipboard) = self.inner.borrow_mut().as_mut() {
72 let _ = clipboard.set_text(text);
73 }
74 }
75
76 fn read_text(&self) -> Option<String> {
77 self.inner
78 .borrow_mut()
79 .as_mut()
80 .and_then(|clipboard| clipboard.get_text().ok())
81 }
82}
83#[cfg(any(test, feature = "test-support"))]
84use cranpose_core::{
85 CompositionPassDebugStats, SlotId,
86 runtime::{RuntimeDebugStats, StateArenaDebugStats},
87 snapshot_pinning::{SnapshotPinningDebugStats, debug_snapshot_pinning_stats},
88 snapshot_state_observer::SnapshotStateObserverDebugStats,
89 snapshot_v2::{SnapshotV2DebugStats, debug_snapshot_v2_stats},
90};
91#[cfg(any(test, feature = "test-support"))]
92use cranpose_core::{
93 MemoryApplierDebugStats, RecomposeScopeRegistryDebugStats, SlotTableDebugStats,
94 debug_recompose_scope_registry_stats,
95};
96pub use cranpose_ui::{ImeEditorState, PlatformTextInputHandler};
97#[cfg(any(test, feature = "test-support"))]
98pub mod accessibility_audit;
99#[cfg(any(test, feature = "test-support"))]
100pub mod placed_semantics;
101
102#[derive(Debug, Clone, Copy, PartialEq, Default)]
112pub enum FrameRatePreference {
113 #[default]
117 Auto,
118 NoPreference,
120 Exact(f32),
123}
124
125impl FrameRatePreference {
126 pub const AUTO_QUIET_RATE_HZ: f32 = 60.0;
133
134 pub fn desired_rate_hz(
148 self,
149 producing_frames: bool,
150 interacting: bool,
151 panel_max_hz: Option<f32>,
152 ) -> f32 {
153 match self {
154 FrameRatePreference::Auto => {
155 if interacting {
156 panel_max_hz
157 .filter(|rate| *rate > 0.0)
158 .unwrap_or(Self::AUTO_QUIET_RATE_HZ)
159 } else if producing_frames {
160 Self::AUTO_QUIET_RATE_HZ
161 } else {
162 0.0
163 }
164 }
165 FrameRatePreference::NoPreference => 0.0,
166 FrameRatePreference::Exact(rate) if rate > 0.0 => rate,
167 FrameRatePreference::Exact(_) => 0.0,
168 }
169 }
170}
171
172pub(crate) struct ShellApp {
173 pub(crate) app_context: Rc<cranpose_ui::AppContext>,
174 pub(crate) runtime: StdRuntime,
175 pub(crate) composition: Composition<MemoryApplier>,
176 pub(crate) content: Box<dyn FnMut()>,
177 pub(crate) start_time: Instant,
178 pub(crate) last_frame_time_nanos: u64,
179 pub(crate) semantics_enabled: bool,
180 pub(crate) semantics_snapshot_revision: u64,
181 pub(crate) layout_requested: bool,
182 pub(crate) force_layout_pass: bool,
183 pub(crate) modifiers: Option<Modifiers>,
184 pub(crate) rotary_scroll_factor: f32,
185 #[cfg(all(feature = "clipboard-native", target_os = "linux"))]
186 pub(crate) clipboard: Option<arboard::Clipboard>,
187 pub(crate) dev_options: DevOptions,
188 pub(crate) fps_monitor: fps_monitor::FpsMonitor,
189 pub(crate) text_input_routes: Rc<RefCell<TextInputRoutes>>,
190 pub(crate) text_input_router_installed: bool,
191 pub(crate) window_roots_seen: Option<u64>,
192}
193
194pub struct AppShell<R>
202where
203 R: Renderer,
204{
205 pub(crate) app: ShellApp,
206 pub(crate) surfaces: Vec<RootSurface<R>>,
207}
208
209#[derive(Clone, Copy, Debug, PartialEq, Eq)]
210pub struct PointerEventTime {
212 pub platform_time_ms: Option<i64>,
214 pub animation_time_nanos: u64,
216}
217
218fn update_stage_telemetry_threshold_ms() -> Option<f64> {
219 static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
220 *THRESHOLD_MS.get_or_init(|| {
221 std::env::var("CRANPOSE_UPDATE_STAGE_TELEMETRY_MS")
222 .ok()
223 .and_then(|value| value.parse::<f64>().ok())
224 .filter(|value| value.is_finite() && *value >= 0.0)
225 })
226}
227
228#[derive(Clone, Copy, Debug)]
229struct UpdateStageTelemetry {
230 started_at: Instant,
231 after_frame_callbacks: Instant,
232 after_ui_drain: Instant,
233 after_reconcile: Instant,
234 after_process_frame: Instant,
235 should_render: bool,
236 reconcile_attempted: bool,
237 reconcile_changed: bool,
238}
239
240fn log_update_stage_telemetry(telemetry: UpdateStageTelemetry) {
241 let Some(threshold_ms) = update_stage_telemetry_threshold_ms() else {
242 return;
243 };
244 let total_ms = telemetry
245 .after_process_frame
246 .duration_since(telemetry.started_at)
247 .as_secs_f64()
248 * 1000.0;
249 if total_ms < threshold_ms {
250 return;
251 }
252
253 let frame_callbacks_ms = telemetry
254 .after_frame_callbacks
255 .duration_since(telemetry.started_at)
256 .as_secs_f64()
257 * 1000.0;
258 let ui_drain_ms = telemetry
259 .after_ui_drain
260 .duration_since(telemetry.after_frame_callbacks)
261 .as_secs_f64()
262 * 1000.0;
263 let reconcile_ms = telemetry
264 .after_reconcile
265 .duration_since(telemetry.after_ui_drain)
266 .as_secs_f64()
267 * 1000.0;
268 let process_frame_ms = telemetry
269 .after_process_frame
270 .duration_since(telemetry.after_reconcile)
271 .as_secs_f64()
272 * 1000.0;
273 eprintln!(
274 "[update-stage-telemetry] total_ms={total_ms:.2} frame_callbacks_ms={frame_callbacks_ms:.2} ui_drain_ms={ui_drain_ms:.2} reconcile_ms={reconcile_ms:.2} process_frame_ms={process_frame_ms:.2} should_render={} reconcile_attempted={} reconcile_changed={}",
275 telemetry.should_render, telemetry.reconcile_attempted, telemetry.reconcile_changed
276 );
277}
278
279#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
280pub enum FramePacingMode {
281 #[default]
284 Vsync,
285 Hard60,
286 Hard120,
287 NoVsync,
290}
291
292impl FramePacingMode {
293 pub const ALL: [Self; 4] = [Self::Vsync, Self::Hard60, Self::Hard120, Self::NoVsync];
294
295 pub fn label(self) -> &'static str {
296 match self {
297 Self::Vsync => "VSync",
298 Self::Hard60 => "60fps",
299 Self::Hard120 => "120fps",
300 Self::NoVsync => "NoVSync",
301 }
302 }
303
304 pub fn target_fps(self) -> Option<u32> {
305 match self {
306 Self::Hard60 => Some(60),
307 Self::Hard120 => Some(120),
308 Self::Vsync | Self::NoVsync => None,
309 }
310 }
311}
312
313#[derive(Clone, Copy, Debug, PartialEq)]
314pub struct FrameSchedule {
315 pub needs_update: bool,
316 pub needs_frame: bool,
317 pub next_deadline: Option<web_time::Instant>,
318}
319
320#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
321pub struct FrameUpdateResult {
322 pub visual_changed: bool,
323 pub structure_changed: bool,
324}
325
326pub trait PlatformFrameDriver {
327 fn request_frame(&self);
328 fn request_wake_at(&self, deadline: web_time::Instant);
329 fn clear_wake(&self);
330}
331
332#[derive(Debug)]
333pub struct FrameScheduler {
334 update_pending: AtomicBool,
335 frame_pending: AtomicBool,
336 next_deadline: Mutex<Option<web_time::Instant>>,
337}
338
339impl Default for FrameScheduler {
340 fn default() -> Self {
341 Self {
342 update_pending: AtomicBool::new(false),
343 frame_pending: AtomicBool::new(false),
344 next_deadline: Mutex::new(None),
345 }
346 }
347}
348
349impl FrameScheduler {
350 fn lock_deadline(&self) -> MutexGuard<'_, Option<web_time::Instant>> {
351 self.next_deadline
352 .lock()
353 .unwrap_or_else(|poisoned| poisoned.into_inner())
354 }
355
356 pub fn record(&self, schedule: FrameSchedule) {
357 self.update_pending
358 .store(schedule.needs_update, Ordering::SeqCst);
359 self.frame_pending
360 .store(schedule.needs_frame, Ordering::SeqCst);
361 let mut next_deadline = self.lock_deadline();
362 *next_deadline = if schedule.needs_update {
363 None
364 } else {
365 schedule.next_deadline
366 };
367 }
368
369 pub fn schedule<D>(&self, schedule: FrameSchedule, driver: &D)
370 where
371 D: PlatformFrameDriver + ?Sized,
372 {
373 self.record(schedule);
374 schedule.apply_to(driver);
375 }
376
377 pub fn snapshot(&self) -> FrameSchedule {
378 FrameSchedule {
379 needs_update: self.update_pending.load(Ordering::SeqCst),
380 needs_frame: self.frame_pending.load(Ordering::SeqCst),
381 next_deadline: *self.lock_deadline(),
382 }
383 }
384}
385
386impl FrameSchedule {
387 pub fn apply_to<D>(self, driver: &D)
388 where
389 D: PlatformFrameDriver + ?Sized,
390 {
391 if self.needs_frame {
392 driver.clear_wake();
393 driver.request_frame();
394 } else if self.needs_update {
395 driver.request_wake_at(web_time::Instant::now());
396 } else if let Some(deadline) = self.next_deadline {
397 driver.request_wake_at(deadline);
398 } else {
399 driver.clear_wake();
400 }
401 }
402}
403
404#[derive(Clone, Copy, Debug)]
405pub(crate) struct DevOverlayControl {
406 bounds: Rect,
407 mode: FramePacingMode,
408}
409
410#[derive(Clone, Debug, Default)]
415pub struct DevOptions {
416 pub fps_counter: bool,
418 pub recomposition_counter: bool,
420 pub layout_timing: bool,
422 pub frame_pacing_controls: bool,
423 pub frame_pacing_mode: FramePacingMode,
424}
425
426#[cfg(any(test, feature = "test-support"))]
427#[doc(hidden)]
428#[derive(Clone, Copy, Debug)]
429pub struct RuntimeLeakDebugStats {
430 pub applier_stats: MemoryApplierDebugStats,
431 pub live_node_heap_bytes: usize,
432 pub recycled_node_heap_bytes: usize,
433 pub slot_table_heap_bytes: usize,
434 pub pass_stats: CompositionPassDebugStats,
435 pub slot_stats: SlotTableDebugStats,
436 pub observer_stats: SnapshotStateObserverDebugStats,
437 pub runtime_stats: RuntimeDebugStats,
438 pub state_arena_stats: StateArenaDebugStats,
439 pub recompose_scope_stats: RecomposeScopeRegistryDebugStats,
440 pub snapshot_v2_stats: SnapshotV2DebugStats,
441 pub snapshot_pinning_stats: SnapshotPinningDebugStats,
442}
443
444impl ShellApp {
445 pub(crate) fn request_layout_pass(&mut self) {
446 self.layout_requested = true;
447 }
448
449 pub(crate) fn request_forced_layout_pass(&mut self) {
450 self.layout_requested = true;
451 self.force_layout_pass = true;
452 }
453
454 pub(crate) fn composition_tree_needs_layout(&mut self) -> bool {
455 let Some(root) = self.composition.root() else {
456 return true;
457 };
458 let mut applier = self.composition.applier_mut();
459 cranpose_ui::tree_needs_layout(&mut *applier, root).unwrap_or_else(|err| {
460 log::warn!(
461 "Cannot check layout dirty status for root #{}: {}",
462 root,
463 err
464 );
465 true
466 })
467 }
468
469 pub(crate) fn has_stale_work_in_context(&self) -> bool {
470 self.layout_requested
471 || peek_render_invalidation()
472 || peek_pointer_invalidation()
473 || peek_focus_invalidation()
474 || peek_layout_invalidation()
475 || cranpose_ui::has_pending_layout_repasses()
476 || cranpose_ui::has_pending_measure_repasses()
477 || cranpose_ui::has_pending_draw_repasses()
478 || has_pending_pointer_repasses()
479 || has_pending_focus_invalidations()
480 }
481
482 pub(crate) fn wants_frame_in_context(&self) -> bool {
483 self.layout_requested
484 || peek_render_invalidation()
485 || peek_pointer_invalidation()
486 || peek_focus_invalidation()
487 || peek_layout_invalidation()
488 || self.composition.should_render()
489 }
490
491 pub(crate) fn needs_ui_update_in_context(&self, surfaces_dirty: bool) -> bool {
492 surfaces_dirty
493 || self.has_stale_work_in_context()
494 || self.composition.runtime_handle().has_pending_ui()
495 || has_pending_semantics_invalidations()
496 || self.composition.should_render()
497 }
498
499 pub(crate) fn next_event_time(&self) -> Option<web_time::Instant> {
500 let app_context = Rc::clone(&self.app_context);
501 app_context.enter(cranpose_ui::next_cursor_blink_time)
502 }
503
504 pub(crate) fn frame_time_nanos_at(&self, now: Instant) -> u64 {
505 now.checked_duration_since(self.start_time)
506 .unwrap_or_default()
507 .as_nanos()
508 .min(u128::from(u64::MAX)) as u64
509 }
510
511 pub(crate) fn realtime_pointer_event_time(
512 &self,
513 platform_time_ms: Option<i64>,
514 ) -> PointerEventTime {
515 PointerEventTime {
516 platform_time_ms,
517 animation_time_nanos: self
518 .frame_time_nanos_at(Instant::now())
519 .max(self.last_frame_time_nanos),
520 }
521 }
522
523 pub(crate) fn install_text_input_router(&mut self) {
524 if self.text_input_router_installed {
525 return;
526 }
527 let router = Rc::new(TextInputRouter {
528 routes: Rc::clone(&self.text_input_routes),
529 });
530 let app_context = Rc::clone(&self.app_context);
531 app_context
532 .enter(|| cranpose_ui::text_input_session::set_platform_text_input_handler(router));
533 self.text_input_router_installed = true;
534 }
535}
536
537impl<R> AppShell<R>
538where
539 R: Renderer,
540 R::Error: Debug,
541{
542 pub fn new(renderer: R, root_key: Key, content: impl FnMut() + 'static) -> Self {
543 Self::new_with_size(renderer, root_key, content, (800, 600), (800.0, 600.0))
544 }
545
546 pub fn new_with_size(
547 renderer: R,
548 root_key: Key,
549 content: impl FnMut() + 'static,
550 buffer_size: (u32, u32),
551 viewport: (f32, f32),
552 ) -> Self {
553 Self::new_with_size_and_density(renderer, root_key, content, buffer_size, viewport, 1.0)
554 }
555
556 pub fn new_with_size_and_density(
557 mut renderer: R,
558 root_key: Key,
559 content: impl FnMut() + 'static,
560 buffer_size: (u32, u32),
561 viewport: (f32, f32),
562 density: f32,
563 ) -> Self {
564 let app_context = cranpose_ui::AppContext::new_with_density(density);
565 let runtime = StdRuntime::new();
566 let mut composition = Composition::with_runtime(MemoryApplier::new(), runtime.runtime());
567 let app_content = Rc::new(std::cell::RefCell::new(content));
568 let mut build: Box<dyn FnMut()> = Box::new(move || {
569 let app_content = Rc::clone(&app_content);
570 cranpose_ui::widgets::PopupHost(move || {
571 (app_content.borrow_mut())();
572 });
573 });
574 renderer.attach_app_context_services(&app_context);
575 app_context.enter(|| {
576 #[cfg(all(
577 feature = "clipboard-native",
578 not(target_arch = "wasm32"),
579 not(target_os = "android"),
580 not(target_os = "ios")
581 ))]
582 {
583 let clipboard =
584 std::rc::Rc::new(std::cell::RefCell::new(arboard::Clipboard::new().ok()));
585 cranpose_ui::clipboard_session::set_platform_clipboard(std::rc::Rc::new(
586 ShellClipboard { inner: clipboard },
587 ));
588 }
589 if let Err(err) = composition.render_stable(root_key, &mut *build) {
590 log::error!("initial render failed: {err}");
591 }
592 });
593 renderer.scene_mut().clear();
594 let app = ShellApp {
595 app_context,
596 runtime,
597 composition,
598 content: build,
599 start_time: Instant::now(),
600 last_frame_time_nanos: 0,
601 semantics_enabled: false,
602 semantics_snapshot_revision: 0,
603 layout_requested: true,
604 force_layout_pass: true,
605 modifiers: None,
606 rotary_scroll_factor: DEFAULT_ROTARY_SCROLL_FACTOR_DP,
607 #[cfg(all(feature = "clipboard-native", target_os = "linux"))]
608 clipboard: arboard::Clipboard::new().ok(),
609 dev_options: DevOptions::default(),
610 fps_monitor: fps_monitor::FpsMonitor::new(),
611 text_input_routes: Rc::new(RefCell::new(TextInputRoutes::default())),
612 text_input_router_installed: false,
613 window_roots_seen: None,
614 };
615 let mut shell = Self {
616 app,
617 surfaces: vec![RootSurface::new(
618 RootId::Primary,
619 renderer,
620 buffer_size,
621 viewport,
622 )],
623 };
624 shell.process_frame();
625 shell
626 }
627
628 pub fn app_context(&self) -> &Rc<cranpose_ui::AppContext> {
633 &self.app.app_context
634 }
635
636 pub fn surface(&mut self, root: RootId) -> Option<SurfaceMut<'_, R>> {
639 let index = self.surface_index(root)?;
640 Some(SurfaceMut::new(self, index))
641 }
642
643 pub fn primary(&mut self) -> SurfaceMut<'_, R> {
645 SurfaceMut::new(self, 0)
646 }
647
648 pub fn root_holding_the_press(&mut self) -> Option<RootId> {
657 let pressed = self
658 .surfaces
659 .iter()
660 .find_map(|surface| surface.hit_path_tracker.dispatch_order(PointerId::PRIMARY))?;
661 let primary_root = self.app.composition.root()?;
662 let mut applier = self.app.composition.applier_mut();
663 let holder = pressed.into_iter().find_map(|node| {
664 let node = applier.scene_node_attached_to(node, primary_root)?;
665 Some(cranpose_ui::nearest_window_root(&mut applier, node))
666 })?;
667 drop(applier);
668 self.surfaces
669 .iter()
670 .find(|surface| surface.owns_nodes_under(holder))
671 .map(|surface| surface.id)
672 }
673
674 fn surface_index(&self, root: RootId) -> Option<usize> {
675 self.surfaces.iter().position(|surface| surface.id == root)
676 }
677
678 pub fn add_window_surface(
688 &mut self,
689 window: u64,
690 renderer: R,
691 buffer_size: (u32, u32),
692 viewport: (f32, f32),
693 ) -> Option<R> {
694 let previous = self.remove_window_surface(window);
695 self.surfaces.push(RootSurface::new(
696 RootId::Window(window),
697 renderer,
698 buffer_size,
699 viewport,
700 ));
701 self.app.window_roots_seen = None;
702 self.sync_window_roots();
703 previous
704 }
705
706 pub fn remove_window_surface(&mut self, window: u64) -> Option<R> {
708 let root = RootId::Window(window);
709 let index = self.surface_index(root)?;
710 self.app.text_input_routes.borrow_mut().remove(root);
711 Some(self.surfaces.remove(index).renderer)
712 }
713
714 pub fn surface_ids(&self) -> Vec<RootId> {
716 self.surfaces.iter().map(|surface| surface.id).collect()
717 }
718
719 pub fn window_roots(&self) -> Vec<WindowRootEntry> {
723 let app_context = Rc::clone(&self.app.app_context);
724 app_context.enter(cranpose_ui::window_roots)
725 }
726
727 pub fn window_roots_revision(&self) -> u64 {
731 let app_context = Rc::clone(&self.app.app_context);
732 app_context.enter(cranpose_ui::window_roots_revision)
733 }
734
735 pub fn primary_has_content(&mut self) -> bool {
740 let app_context = Rc::clone(&self.app.app_context);
741 app_context.enter(|| {
742 self.surfaces[0]
743 .layout_tree_in_context(&mut self.app)
744 .is_some_and(|tree| tree.root().children.iter().any(layout_has_area))
745 })
746 }
747
748 pub fn primary_content_size(&mut self) -> Option<Size> {
754 let app_context = Rc::clone(&self.app.app_context);
755 app_context.enter(|| {
756 self.surfaces[0]
757 .layout_tree_in_context(&mut self.app)
758 .and_then(|tree| {
759 tree.root()
760 .children
761 .iter()
762 .filter_map(layout_extent)
763 .reduce(farther_extent)
764 })
765 })
766 }
767
768 pub(crate) fn drag_and_drop_target_at(
774 &mut self,
775 point: cranpose_ui::DragAndDropPoint,
776 source: usize,
777 ) -> Option<(NodeId, cranpose_ui::Point)> {
778 let app_context = Rc::clone(&self.app.app_context);
779 let candidates: Vec<(usize, cranpose_ui::Point)> = match point.screen {
780 Some(screen) => (0..self.surfaces.len())
781 .rev()
782 .filter_map(|index| {
783 let local = self.surfaces[index].screen_point_inside(screen)?;
784 Some((index, local))
785 })
786 .collect(),
787 None => vec![(source, point.local)],
788 };
789 candidates.into_iter().find_map(|(index, local)| {
790 self.surfaces[index]
791 .renderer
792 .scene()
793 .hit_test_nodes(local.x, local.y)
794 .into_iter()
795 .find(|node| app_context.drag_and_drop().is_target(*node))
796 .map(|node| (node, local))
797 })
798 }
799
800 pub fn set_active_root(&mut self, root: RootId) {
804 self.app.text_input_routes.borrow_mut().set_active(root);
805 }
806
807 pub fn active_root(&self) -> RootId {
809 self.app.text_input_routes.borrow().active()
810 }
811
812 fn sync_window_roots(&mut self) {
813 let app_context = Rc::clone(&self.app.app_context);
814 app_context.enter(|| self.sync_window_roots_in_context());
815 }
816
817 pub(crate) fn sync_window_roots_in_context(&mut self) {
818 let revision = cranpose_ui::window_roots_revision();
819 if self.app.window_roots_seen == Some(revision) {
820 return;
821 }
822 self.app.window_roots_seen = Some(revision);
823 let entries = cranpose_ui::window_roots();
824 for surface in &mut self.surfaces {
825 let RootId::Window(id) = surface.id else {
826 continue;
827 };
828 let root = entries
829 .iter()
830 .find(|entry| entry.node as u64 == id)
831 .map(|entry| entry.node);
832 surface.set_root(root);
833 }
834 }
835
836 pub(crate) fn any_surface_dirty(&self) -> bool {
837 self.surfaces
838 .iter()
839 .any(|surface| surface.is_dirty || surface.scene_dirty)
840 }
841
842 pub(crate) fn mark_all_dirty(&mut self) {
843 for surface in &mut self.surfaces {
844 surface.is_dirty = true;
845 }
846 }
847
848 fn clear_surface_dirt(&mut self) {
849 for surface in &mut self.surfaces {
850 surface.is_dirty = false;
851 }
852 }
853
854 fn invalidate_dev_overlay_text(&mut self) {
855 for surface in &mut self.surfaces {
856 surface.invalidate_dev_overlay_text();
857 }
858 }
859
860 pub fn set_dev_options(&mut self, options: DevOptions) {
865 self.app.dev_options = options;
866 self.invalidate_dev_overlay_text();
867 let app_context = Rc::clone(&self.app.app_context);
868 app_context.enter(request_render_invalidation);
869 self.mark_all_dirty();
870 }
871
872 pub fn dev_options(&self) -> &DevOptions {
874 &self.app.dev_options
875 }
876
877 pub fn frame_pacing_mode(&self) -> FramePacingMode {
878 self.app.dev_options.frame_pacing_mode
879 }
880
881 pub fn current_fps(&self) -> f32 {
882 self.app.fps_monitor.current_fps()
883 }
884
885 pub fn fps_stats(&self) -> FpsStats {
886 self.app.fps_monitor.stats()
887 }
888
889 pub fn reset_fps_stats(&mut self) {
890 self.app.fps_monitor.reset_stats();
891 self.invalidate_dev_overlay_text();
892 }
893
894 pub fn record_presented_frame(
895 &mut self,
896 frame_started_at: Instant,
897 frame_finished_at: Instant,
898 ) {
899 self.app
900 .fps_monitor
901 .record_frame_work(frame_started_at, frame_finished_at);
902 }
903
904 #[cfg(any(test, feature = "test-support"))]
905 #[doc(hidden)]
906 pub fn record_presented_frame_for_test(
907 &mut self,
908 frame_started_nanos: u64,
909 frame_finished_nanos: u64,
910 ) {
911 let started = self.app.start_time + std::time::Duration::from_nanos(frame_started_nanos);
912 let finished = self.app.start_time + std::time::Duration::from_nanos(frame_finished_nanos);
913 self.record_presented_frame(started, finished);
914 }
915
916 pub fn set_frame_pacing_mode(&mut self, mode: FramePacingMode) {
917 if self.app.dev_options.frame_pacing_mode == mode {
918 return;
919 }
920 self.app.dev_options.frame_pacing_mode = mode;
921 self.invalidate_dev_overlay_text();
922 let app_context = Rc::clone(&self.app.app_context);
923 app_context.enter(request_render_invalidation);
924 self.mark_all_dirty();
925 }
926
927 pub fn dev_overlay_control_center(&self, mode: FramePacingMode) -> Option<(f32, f32)> {
934 self.surfaces[0].dev_overlay_control_center(mode)
935 }
936
937 pub fn set_viewport(&mut self, width: f32, height: f32) {
939 self.primary().set_viewport(width, height);
940 self.process_frame();
941 }
942
943 pub fn viewport_size(&self) -> (f32, f32) {
944 self.surfaces[0].viewport
945 }
946
947 pub fn set_screen_origin(&mut self, origin: Option<cranpose_ui_graphics::Point>) {
950 self.surfaces[0].screen_origin = origin;
951 }
952
953 pub fn set_buffer_size(&mut self, width: u32, height: u32) {
954 self.surfaces[0].buffer_size = (width, height);
955 }
956
957 pub fn buffer_size(&self) -> (u32, u32) {
958 self.surfaces[0].buffer_size
959 }
960
961 pub fn scene(&self) -> &R::Scene {
962 self.surfaces[0].renderer.scene()
963 }
964
965 pub fn renderer(&mut self) -> &mut R {
966 &mut self.surfaces[0].renderer
967 }
968
969 #[cfg(not(target_arch = "wasm32"))]
970 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + Sync + 'static) {
971 self.app.runtime.set_frame_waker(waker);
972 }
973
974 #[cfg(target_arch = "wasm32")]
975 pub fn set_frame_waker(&mut self, waker: impl Fn() + 'static) {
976 self.app.runtime.set_frame_waker(waker);
977 }
978
979 pub fn clear_frame_waker(&mut self) {
980 self.app.runtime.clear_frame_waker();
981 }
982
983 pub fn should_render(&self) -> bool {
984 let app_context = Rc::clone(&self.app.app_context);
985 app_context.enter(|| {
986 self.app.wants_frame_in_context()
987 || self.surfaces.iter().any(|surface| surface.scene_dirty)
988 })
989 }
990
991 pub fn needs_update(&self) -> bool {
992 let app_context = Rc::clone(&self.app.app_context);
993 app_context.enter(|| {
994 self.app
995 .needs_ui_update_in_context(self.any_surface_dirty())
996 })
997 }
998
999 pub fn has_pending_ui(&self) -> bool {
1007 let app_context = Rc::clone(&self.app.app_context);
1008 app_context.enter(|| self.app.composition.runtime_handle().has_pending_ui())
1009 }
1010
1011 pub fn needs_redraw(&self) -> bool {
1024 let app_context = Rc::clone(&self.app.app_context);
1025 app_context.enter(|| self.surfaces[0].needs_redraw_in_context(&self.app))
1026 }
1027
1028 pub fn mark_dirty(&mut self) {
1030 self.surfaces[0].is_dirty = true;
1031 }
1032
1033 pub fn request_root_render(&mut self) {
1034 self.app.composition.request_root_render();
1035 self.app.request_forced_layout_pass();
1036 let app_context = Rc::clone(&self.app.app_context);
1037 app_context.enter(request_render_invalidation);
1038 self.mark_all_dirty();
1039 }
1040
1041 pub fn set_density(&mut self, density: f32) {
1042 let app_context = Rc::clone(&self.app.app_context);
1043 let changed = app_context.enter(|| {
1044 let previous = cranpose_ui::current_density().to_bits();
1045 cranpose_ui::set_density(density);
1046 previous != cranpose_ui::current_density().to_bits()
1047 });
1048 if changed {
1049 self.app.request_forced_layout_pass();
1050 self.mark_all_dirty();
1051 }
1052 }
1053
1054 pub fn set_font_scale(&mut self, font_scale: f32) {
1065 self.set_font_scale_curve(cranpose_ui::FontScaleCurve::linear(font_scale));
1066 }
1067
1068 pub fn set_font_scale_curve(&mut self, curve: cranpose_ui::FontScaleCurve) {
1075 let app_context = Rc::clone(&self.app.app_context);
1076 let changed = app_context.enter(|| {
1077 let previous = cranpose_ui::current_font_scale_curve();
1078 cranpose_ui::set_font_scale_curve(curve);
1079 previous != cranpose_ui::current_font_scale_curve()
1080 });
1081 if changed {
1082 self.app.request_forced_layout_pass();
1083 self.mark_all_dirty();
1084 }
1085 }
1086
1087 #[cfg(any(test, feature = "test-support"))]
1088 #[doc(hidden)]
1089 pub fn debug_current_density(&self) -> f32 {
1090 let app_context = Rc::clone(&self.app.app_context);
1091 app_context.enter(cranpose_ui::current_density)
1092 }
1093
1094 #[cfg(any(test, feature = "test-support"))]
1095 #[doc(hidden)]
1096 pub fn debug_current_font_scale(&self) -> f32 {
1097 let app_context = Rc::clone(&self.app.app_context);
1098 app_context.enter(cranpose_ui::current_font_scale)
1099 }
1100
1101 #[cfg(any(test, feature = "test-support"))]
1102 #[doc(hidden)]
1103 pub fn debug_current_font_scale_curve(&self) -> cranpose_ui::FontScaleCurve {
1104 let app_context = Rc::clone(&self.app.app_context);
1105 app_context.enter(cranpose_ui::current_font_scale_curve)
1106 }
1107
1108 #[cfg(any(test, feature = "test-support"))]
1109 #[doc(hidden)]
1110 pub fn debug_enter_app_context<T>(&self, block: impl FnOnce() -> T) -> T {
1111 let app_context = Rc::clone(&self.app.app_context);
1112 app_context.enter(block)
1113 }
1114
1115 pub fn has_active_animations(&self) -> bool {
1117 self.app.composition.should_render()
1118 }
1119
1120 pub fn has_transient_frame_callbacks(&self) -> bool {
1121 self.app
1122 .composition
1123 .runtime_handle()
1124 .has_transient_frame_callbacks()
1125 }
1126
1127 pub fn has_active_pointer_gesture(&self) -> bool {
1128 self.surfaces[0].has_active_pointer_gesture()
1129 }
1130
1131 pub fn frame_owed(&self) -> bool {
1133 self.surfaces[0].frame_owed
1134 }
1135
1136 pub fn take_frame_owed(&mut self) -> bool {
1138 std::mem::take(&mut self.surfaces[0].frame_owed)
1139 }
1140
1141 pub fn next_event_time(&self) -> Option<web_time::Instant> {
1144 self.app.next_event_time()
1145 }
1146
1147 fn compute_frame_schedule(&self) -> FrameSchedule {
1148 self.surfaces[0].compute_frame_schedule(&self.app, self.any_surface_dirty())
1149 }
1150
1151 pub fn frame_schedule(&self) -> FrameSchedule {
1152 let schedule = self.compute_frame_schedule();
1153 self.surfaces[0].frame_scheduler.record(schedule);
1154 schedule
1155 }
1156
1157 pub fn schedule_platform_frame<D>(&self, driver: &D) -> FrameSchedule
1158 where
1159 D: PlatformFrameDriver + ?Sized,
1160 {
1161 let schedule = self.compute_frame_schedule();
1162 self.surfaces[0].frame_scheduler.schedule(schedule, driver);
1163 schedule
1164 }
1165
1166 pub fn frame_scheduler_snapshot(&self) -> FrameSchedule {
1167 self.surfaces[0].frame_scheduler.snapshot()
1168 }
1169
1170 pub fn realtime_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
1172 self.app.realtime_pointer_event_time(platform_time_ms)
1173 }
1174
1175 pub fn exact_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
1177 PointerEventTime {
1178 platform_time_ms,
1179 animation_time_nanos: self.app.last_frame_time_nanos,
1180 }
1181 }
1182
1183 pub fn update_after_frame_interval(
1184 &mut self,
1185 frame_interval: std::time::Duration,
1186 ) -> FrameUpdateResult {
1187 let wall_frame_time = self.app.frame_time_nanos_at(Instant::now());
1188 let base_frame_time = self.app.last_frame_time_nanos.max(wall_frame_time);
1189 let frame_time = base_frame_time
1190 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1191 self.update_at_frame_time_nanos(frame_time)
1192 }
1193
1194 pub fn update_after_exact_interval(
1200 &mut self,
1201 frame_interval: std::time::Duration,
1202 ) -> FrameUpdateResult {
1203 let frame_time = self
1204 .app
1205 .last_frame_time_nanos
1206 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1207 self.update_at_frame_time_nanos(frame_time)
1208 }
1209
1210 pub fn update_at_frame_time_nanos(&mut self, frame_time: u64) -> FrameUpdateResult {
1211 let app_context = Rc::clone(&self.app.app_context);
1212 app_context.enter(|| {
1213 let update_started_at = Instant::now();
1214 let frame_time = frame_time.max(self.app.last_frame_time_nanos);
1215 self.app.last_frame_time_nanos = frame_time;
1216 let runtime_handle = self.app.runtime.runtime_handle();
1217 runtime_handle.with_deferred_state_releases(|| {
1218 self.app.runtime.drain_frame_callbacks(frame_time);
1219 let after_frame_callbacks = Instant::now();
1220 runtime_handle.drain_ui();
1221 let after_ui_drain = Instant::now();
1222 let should_render = self.app.composition.should_recompose();
1223 let mut reconcile_attempted = false;
1224 let mut reconcile_changed = false;
1225 if should_render {
1226 log::trace!(
1227 target: "cranpose::input",
1228 "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
1229 self.app.layout_requested,
1230 self.surfaces[0].scene_dirty,
1231 self.surfaces[0].is_dirty
1232 );
1233 (reconcile_attempted, reconcile_changed) = self.reconcile_in_context();
1234 }
1235 let after_reconcile = Instant::now();
1236 let result = self.process_frame_in_context(reconcile_changed);
1237 let after_process_frame = Instant::now();
1238 log_update_stage_telemetry(UpdateStageTelemetry {
1239 started_at: update_started_at,
1240 after_frame_callbacks,
1241 after_ui_drain,
1242 after_reconcile,
1243 after_process_frame,
1244 should_render,
1245 reconcile_attempted,
1246 reconcile_changed,
1247 });
1248 self.clear_surface_dirt();
1249 result
1250 })
1251 })
1252 }
1253
1254 fn reconcile_in_context(&mut self) -> (bool, bool) {
1255 let Some(root_key) = self.app.composition.root_key() else {
1256 return (false, false);
1257 };
1258 match self
1259 .app
1260 .composition
1261 .reconcile(root_key, &mut *self.app.content)
1262 {
1263 Ok(changed) => {
1264 log::trace!(
1265 target: "cranpose::input",
1266 "reconcile changed={changed}"
1267 );
1268 if changed {
1269 self.app.fps_monitor.record_recomposition();
1270 if self.app.composition_tree_needs_layout() {
1271 self.app.request_layout_pass();
1272 }
1273 request_render_invalidation();
1274 }
1275 (true, changed)
1276 }
1277 Err(NodeError::Missing { id }) => {
1278 log::debug!("Recomposition skipped: node {} no longer exists", id);
1279 self.app.request_layout_pass();
1280 request_render_invalidation();
1281 (true, false)
1282 }
1283 Err(err) => {
1284 log::error!("recomposition failed: {err}");
1285 self.app.request_layout_pass();
1286 request_render_invalidation();
1287 (true, false)
1288 }
1289 }
1290 }
1291
1292 pub fn update(&mut self) -> FrameUpdateResult {
1293 let frame_time = self.app.frame_time_nanos_at(Instant::now());
1294 self.update_at_frame_time_nanos(frame_time)
1295 }
1296}
1297
1298impl<R> Drop for AppShell<R>
1299where
1300 R: Renderer,
1301{
1302 fn drop(&mut self) {
1303 self.app.runtime.clear_frame_waker();
1304 }
1305}
1306
1307fn layout_has_area(layout: &cranpose_ui::LayoutBox) -> bool {
1308 (layout.rect.width > 0.0 && layout.rect.height > 0.0)
1309 || layout.children.iter().any(layout_has_area)
1310}
1311
1312fn layout_extent(layout: &cranpose_ui::LayoutBox) -> Option<Size> {
1313 let own = (layout.rect.width > 0.0 && layout.rect.height > 0.0).then(|| {
1314 Size::new(
1315 layout.rect.x + layout.rect.width,
1316 layout.rect.y + layout.rect.height,
1317 )
1318 });
1319 layout
1320 .children
1321 .iter()
1322 .filter_map(layout_extent)
1323 .chain(own)
1324 .reduce(farther_extent)
1325}
1326
1327fn farther_extent(a: Size, b: Size) -> Size {
1328 Size::new(a.width.max(b.width), a.height.max(b.height))
1329}
1330
1331pub fn default_root_key() -> Key {
1332 location_key(file!(), line!(), column!())
1333}
1334
1335#[cfg(test)]
1336#[path = "tests/app_shell_frame_pacing_tests.rs"]
1337mod frame_pacing_tests;
1338
1339#[cfg(test)]
1340#[path = "tests/app_shell_tests.rs"]
1341mod tests;
1342
1343#[cfg(test)]
1344#[path = "tests/surface_tests.rs"]
1345mod surface_tests;