1#![doc = include_str!("../README.md")]
2
3use std::sync::PoisonError;
4mod focus_reveal;
5mod fps_monitor;
6mod frame_rate_boost;
7mod hit_path_tracker;
8pub mod inspector;
9mod modal_focus;
10#[cfg(debug_assertions)]
11mod semantics_check;
12mod shell_debug;
13mod shell_frame;
14mod shell_input;
15mod surface;
16mod wheel;
17use std::{
18 cell::RefCell,
19 fmt::{Debug, Write},
20 rc::Rc,
21 sync::{
22 Mutex, MutexGuard,
23 atomic::{AtomicBool, Ordering},
24 },
25};
26
27use cranpose_core::{
28 Applier, Composition, Key, MemoryApplier, NodeError, NodeId, SceneNodeAttachmentScratch,
29 collections::map::HashSet, enter_event_handler_scope, location_key, run_in_mutable_snapshot,
30};
31pub use cranpose_foundation::{
32 DEFAULT_ROTARY_SCROLL_FACTOR_DP, Modifiers, PointerSource, RotaryScrollEvent,
33 rotary_scroll_pixels_from_detents,
34};
35use cranpose_foundation::{PointerButton, PointerButtons, PointerEvent, PointerEventKind};
36use cranpose_render_common::{HitTestTarget, RenderScene, Renderer, SceneUpdates};
37use cranpose_runtime_std::StdRuntime;
38use cranpose_ui::{
39 HeadlessRenderer, LayoutBox, LayoutNode, LayoutTree, MeasureLayoutOptions, SemanticsTree,
40 SubcomposeLayoutNode, WindowRootEntry, clear_transient_scroll_motion_contexts,
41 format_layout_tree, format_render_scene, format_screen_summary,
42 has_pending_focus_invalidations, has_pending_pointer_repasses,
43 has_pending_semantics_invalidations, peek_focus_invalidation, peek_layout_invalidation,
44 peek_pointer_invalidation, peek_render_invalidation, process_focus_invalidations,
45 process_pointer_repasses, process_semantics_invalidations, request_render_invalidation,
46 take_focus_invalidation, take_layout_invalidation, take_pointer_invalidation,
47 take_render_invalidation,
48};
49pub use cranpose_ui::{KeyCode, KeyEvent, KeyEventType};
50use cranpose_ui_graphics::{Point, PointerIcon, Rect, Size};
51pub use fps_monitor::FpsStats;
52pub use frame_rate_boost::FrameRateBoost;
53use hit_path_tracker::PointerId;
54#[cfg(test)]
55use shell_frame::build_draw_refresh_scope;
56pub use surface::{RootId, RootSurface, SurfaceMut};
57use surface::{SurfaceDirtyLane, TextInputRouter, TextInputRoutes, route_nodes_by_surface};
58use web_time::Instant;
59pub use wheel::WheelScroll;
60
61#[cfg(all(
62 feature = "clipboard-native",
63 not(target_arch = "wasm32"),
64 not(target_os = "android"),
65 not(target_os = "ios")
66))]
67struct ShellClipboard {
68 inner: std::rc::Rc<std::cell::RefCell<Option<arboard::Clipboard>>>,
69}
70
71#[cfg(all(
72 feature = "clipboard-native",
73 not(target_arch = "wasm32"),
74 not(target_os = "android"),
75 not(target_os = "ios")
76))]
77impl cranpose_ui::clipboard_session::PlatformClipboard for ShellClipboard {
78 fn write_text(&self, text: &str) {
79 if let Some(clipboard) = self.inner.borrow_mut().as_mut() {
80 let _ = clipboard.set_text(text);
81 }
82 }
83
84 fn read_text(&self) -> Option<String> {
85 self.inner
86 .borrow_mut()
87 .as_mut()
88 .and_then(|clipboard| clipboard.get_text().ok())
89 }
90}
91#[cfg(any(test, feature = "test-support"))]
92use cranpose_core::{
93 CompositionPassDebugStats, SlotId,
94 runtime::{RuntimeDebugStats, StateArenaDebugStats},
95 snapshot_pinning::{SnapshotPinningDebugStats, debug_snapshot_pinning_stats},
96 snapshot_v2::{SnapshotV2DebugStats, debug_snapshot_v2_stats},
97};
98#[cfg(any(test, feature = "test-support"))]
99use cranpose_core::{
100 MemoryApplierDebugStats, RecomposeScopeRegistryDebugStats, SlotTableDebugStats,
101 debug_recompose_scope_registry_stats,
102};
103pub use cranpose_ui::{ImeEditorState, PlatformTextInputHandler};
104#[cfg(any(test, feature = "test-support"))]
105pub mod accessibility_audit;
106#[cfg(any(test, feature = "test-support"))]
107pub mod placed_semantics;
108
109#[derive(Debug, Clone, Copy, PartialEq, Default)]
119pub enum FrameRatePreference {
120 #[default]
125 Auto,
126 NoPreference,
128 Exact(f32),
131}
132
133impl FrameRatePreference {
134 pub const AUTO_QUIET_RATE_HZ: f32 = 60.0;
141
142 pub fn desired_rate_hz(
156 self,
157 producing_frames: bool,
158 boosted: bool,
159 panel_max_hz: Option<f32>,
160 ) -> f32 {
161 match self {
162 FrameRatePreference::Auto => {
163 if boosted {
164 panel_max_hz
165 .filter(|rate| *rate > 0.0)
166 .unwrap_or(Self::AUTO_QUIET_RATE_HZ)
167 } else if producing_frames {
168 Self::AUTO_QUIET_RATE_HZ
169 } else {
170 0.0
171 }
172 }
173 FrameRatePreference::NoPreference => 0.0,
174 FrameRatePreference::Exact(rate) if rate > 0.0 => rate,
175 FrameRatePreference::Exact(_) => 0.0,
176 }
177 }
178}
179
180pub(crate) struct ShellApp {
181 pub(crate) app_context: Rc<cranpose_ui::AppContext>,
182 pub(crate) runtime: StdRuntime,
183 pub(crate) composition: Composition<MemoryApplier>,
184 pub(crate) content: Box<dyn FnMut()>,
185 pub(crate) start_time: Instant,
186 pub(crate) last_frame_time_nanos: u64,
187 pub(crate) semantics_enabled: bool,
188 pub(crate) semantics_snapshot_revision: u64,
189 pub(crate) revealed_focus: Option<NodeId>,
190 pub(crate) layout_requested: bool,
191 pub(crate) content_moved: bool,
192 pub(crate) force_layout_pass: bool,
193 pub(crate) modifiers: Option<Modifiers>,
194 pub(crate) rotary_scroll_factor: f32,
195 #[cfg(all(feature = "clipboard-native", target_os = "linux"))]
196 pub(crate) clipboard: Option<arboard::Clipboard>,
197 pub(crate) dev_options: DevOptions,
198 pub(crate) inspector_projector: Option<inspector::InspectorProjector>,
199 pub(crate) fps_monitor: fps_monitor::FpsMonitor,
200 pub(crate) text_input_routes: Rc<RefCell<TextInputRoutes>>,
201 pub(crate) text_input_router_installed: bool,
202 pub(crate) window_roots_seen: Option<u64>,
203 pub(crate) rebuilt_frames_since_observation_prune: u32,
205}
206
207pub struct AppShell<R>
215where
216 R: Renderer,
217{
218 pub(crate) app: ShellApp,
219 pub(crate) surfaces: Vec<RootSurface<R>>,
220 pub(crate) routing_scratch: SurfaceRoutingScratch,
221 pub(crate) pending_dirty_nodes: Vec<NodeId>,
222 pub(crate) pending_layer_property_nodes: Vec<NodeId>,
223 pub(crate) geometry_scene_nodes: cranpose_ui::GeometrySceneNodes,
224}
225
226#[derive(Default)]
227pub(crate) struct SurfaceRoutingScratch {
228 pub(crate) attached: Vec<Option<NodeId>>,
229 pub(crate) owners: Vec<Option<NodeId>>,
230 pub(crate) seen: HashSet<(usize, NodeId)>,
231 pub(crate) attachment: SceneNodeAttachmentScratch,
232 pub(crate) window_roots: cranpose_ui::WindowRootRoutingScratch,
233}
234
235#[derive(Clone, Copy, Debug, PartialEq, Eq)]
236pub struct PointerEventTime {
238 pub platform_time_ms: Option<i64>,
240 pub animation_time_nanos: u64,
242}
243
244fn update_stage_telemetry_threshold_ms() -> Option<f64> {
245 static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
246 *THRESHOLD_MS.get_or_init(|| {
247 std::env::var("CRANPOSE_UPDATE_STAGE_TELEMETRY_MS")
248 .ok()
249 .and_then(|value| value.parse::<f64>().ok())
250 .filter(|value| value.is_finite() && *value >= 0.0)
251 })
252}
253
254#[derive(Clone, Copy, Debug)]
255struct UpdateStageTelemetry {
256 started_at: Instant,
257 after_frame_callbacks: Instant,
258 after_ui_drain: Instant,
259 after_reconcile: Instant,
260 after_process_frame: Instant,
261 should_render: bool,
262 reconcile_attempted: bool,
263 reconcile_changed: bool,
264}
265
266fn log_update_stage_telemetry(telemetry: UpdateStageTelemetry) {
267 let Some(threshold_ms) = update_stage_telemetry_threshold_ms() else {
268 return;
269 };
270 let total_ms = telemetry
271 .after_process_frame
272 .duration_since(telemetry.started_at)
273 .as_secs_f64()
274 * 1000.0;
275 if total_ms < threshold_ms {
276 return;
277 }
278
279 let frame_callbacks_ms = telemetry
280 .after_frame_callbacks
281 .duration_since(telemetry.started_at)
282 .as_secs_f64()
283 * 1000.0;
284 let ui_drain_ms = telemetry
285 .after_ui_drain
286 .duration_since(telemetry.after_frame_callbacks)
287 .as_secs_f64()
288 * 1000.0;
289 let reconcile_ms = telemetry
290 .after_reconcile
291 .duration_since(telemetry.after_ui_drain)
292 .as_secs_f64()
293 * 1000.0;
294 let process_frame_ms = telemetry
295 .after_process_frame
296 .duration_since(telemetry.after_reconcile)
297 .as_secs_f64()
298 * 1000.0;
299 eprintln!(
300 "[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={}",
301 telemetry.should_render, telemetry.reconcile_attempted, telemetry.reconcile_changed
302 );
303}
304
305#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
306pub enum FramePacingMode {
307 #[default]
310 Vsync,
311 Hard60,
312 Hard120,
313 NoVsync,
316}
317
318impl FramePacingMode {
319 pub const ALL: [Self; 4] = [Self::Vsync, Self::Hard60, Self::Hard120, Self::NoVsync];
320
321 pub fn label(self) -> &'static str {
322 match self {
323 Self::Vsync => "VSync",
324 Self::Hard60 => "60fps",
325 Self::Hard120 => "120fps",
326 Self::NoVsync => "NoVSync",
327 }
328 }
329
330 pub fn target_fps(self) -> Option<u32> {
331 match self {
332 Self::Hard60 => Some(60),
333 Self::Hard120 => Some(120),
334 Self::Vsync | Self::NoVsync => None,
335 }
336 }
337}
338
339#[derive(Clone, Copy, Debug, PartialEq)]
340pub struct FrameSchedule {
341 pub needs_update: bool,
342 pub needs_frame: bool,
343 pub next_deadline: Option<web_time::Instant>,
344}
345
346#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
347pub struct FrameUpdateResult {
348 pub visual_changed: bool,
349 pub structure_changed: bool,
350 pub content_moved: bool,
355 pub content_redrawn: bool,
358}
359
360pub trait PlatformFrameDriver {
361 fn request_frame(&self);
362 fn request_wake_at(&self, deadline: web_time::Instant);
363 fn clear_wake(&self);
364}
365
366#[derive(Debug)]
367pub struct FrameScheduler {
368 update_pending: AtomicBool,
369 frame_pending: AtomicBool,
370 next_deadline: Mutex<Option<web_time::Instant>>,
371}
372
373impl Default for FrameScheduler {
374 fn default() -> Self {
375 Self {
376 update_pending: AtomicBool::new(false),
377 frame_pending: AtomicBool::new(false),
378 next_deadline: Mutex::new(None),
379 }
380 }
381}
382
383impl FrameScheduler {
384 fn lock_deadline(&self) -> MutexGuard<'_, Option<web_time::Instant>> {
385 self.next_deadline
386 .lock()
387 .unwrap_or_else(PoisonError::into_inner)
388 }
389
390 pub fn record(&self, schedule: FrameSchedule) {
391 self.update_pending
392 .store(schedule.needs_update, Ordering::SeqCst);
393 self.frame_pending
394 .store(schedule.needs_frame, Ordering::SeqCst);
395 let mut next_deadline = self.lock_deadline();
396 *next_deadline = if schedule.needs_update {
397 None
398 } else {
399 schedule.next_deadline
400 };
401 }
402
403 pub fn schedule<D>(&self, schedule: FrameSchedule, driver: &D)
404 where
405 D: PlatformFrameDriver + ?Sized,
406 {
407 self.record(schedule);
408 schedule.apply_to(driver);
409 }
410
411 pub fn snapshot(&self) -> FrameSchedule {
412 FrameSchedule {
413 needs_update: self.update_pending.load(Ordering::SeqCst),
414 needs_frame: self.frame_pending.load(Ordering::SeqCst),
415 next_deadline: *self.lock_deadline(),
416 }
417 }
418}
419
420impl FrameSchedule {
421 pub fn apply_to<D>(self, driver: &D)
422 where
423 D: PlatformFrameDriver + ?Sized,
424 {
425 if self.needs_frame {
426 driver.clear_wake();
427 driver.request_frame();
428 } else if self.needs_update {
429 driver.request_wake_at(web_time::Instant::now());
430 } else if let Some(deadline) = self.next_deadline {
431 driver.request_wake_at(deadline);
432 } else {
433 driver.clear_wake();
434 }
435 }
436}
437
438#[derive(Clone, Copy, Debug)]
439pub(crate) struct DevOverlayControl {
440 bounds: Rect,
441 mode: FramePacingMode,
442}
443
444#[derive(Clone, Debug, Default)]
449pub struct DevOptions {
450 pub fps_counter: bool,
452 pub recomposition_counter: bool,
454 pub layout_timing: bool,
456 pub frame_pacing_controls: bool,
457 pub frame_pacing_mode: FramePacingMode,
458}
459
460#[cfg(any(test, feature = "test-support"))]
461#[doc(hidden)]
462#[derive(Clone, Copy, Debug)]
463pub struct RuntimeLeakDebugStats {
464 pub applier_stats: MemoryApplierDebugStats,
465 pub live_node_heap_bytes: usize,
466 pub recycled_node_heap_bytes: usize,
467 pub slot_table_heap_bytes: usize,
468 pub pass_stats: CompositionPassDebugStats,
469 pub slot_stats: SlotTableDebugStats,
470 pub runtime_stats: RuntimeDebugStats,
471 pub state_arena_stats: StateArenaDebugStats,
472 pub recompose_scope_stats: RecomposeScopeRegistryDebugStats,
473 pub snapshot_v2_stats: SnapshotV2DebugStats,
474 pub snapshot_pinning_stats: SnapshotPinningDebugStats,
475}
476
477impl ShellApp {
478 pub(crate) fn flush_semantics_invalidations(&mut self) {
479 if has_pending_semantics_invalidations() {
480 let mut applier = self.composition.applier_mut();
481 process_semantics_invalidations(|node_id| {
482 cranpose_core::bubble_semantics_dirty(&mut *applier, node_id);
483 });
484 }
485 }
486
487 pub(crate) fn request_layout_pass(&mut self) {
488 self.layout_requested = true;
489 }
490
491 pub(crate) fn request_forced_layout_pass(&mut self) {
492 self.layout_requested = true;
493 self.force_layout_pass = true;
494 }
495
496 pub(crate) fn composition_tree_needs_layout(&mut self) -> bool {
497 let Some(root) = self.composition.root() else {
498 return true;
499 };
500 let mut applier = self.composition.applier_mut();
501 cranpose_ui::tree_needs_layout(&mut *applier, root).unwrap_or_else(|err| {
502 log::warn!("Cannot check layout dirty status for root #{root}: {err}");
503 true
504 })
505 }
506
507 pub(crate) fn has_stale_work_in_context(&self) -> bool {
508 self.layout_requested
509 || peek_render_invalidation()
510 || peek_pointer_invalidation()
511 || peek_focus_invalidation()
512 || peek_layout_invalidation()
513 || cranpose_ui::has_pending_layout_repasses()
514 || cranpose_ui::has_pending_measure_repasses()
515 || cranpose_ui::has_pending_draw_repasses()
516 || cranpose_ui::has_pending_layer_property_repasses()
517 || has_pending_pointer_repasses()
518 || has_pending_focus_invalidations()
519 }
520
521 pub(crate) fn wants_frame_in_context(&self) -> bool {
522 self.layout_requested
523 || peek_render_invalidation()
524 || peek_pointer_invalidation()
525 || peek_focus_invalidation()
526 || peek_layout_invalidation()
527 || cranpose_ui::has_pending_layout_repasses()
528 || cranpose_ui::has_pending_measure_repasses()
529 || cranpose_ui::has_pending_layer_property_repasses()
530 || self.composition.should_render()
531 }
532
533 pub(crate) fn needs_ui_update_in_context(&self, surfaces_dirty: bool) -> bool {
534 surfaces_dirty
535 || self.has_stale_work_in_context()
536 || self.composition.runtime_handle().has_pending_ui()
537 || has_pending_semantics_invalidations()
538 || self.composition.should_render()
539 }
540
541 pub(crate) fn next_event_time(&self) -> Option<web_time::Instant> {
542 let app_context = Rc::clone(&self.app_context);
543 app_context.enter(cranpose_ui::next_cursor_blink_time)
544 }
545
546 pub(crate) fn frame_time_nanos_at(&self, now: Instant) -> u64 {
547 now.checked_duration_since(self.start_time)
548 .unwrap_or_default()
549 .as_nanos()
550 .min(u128::from(u64::MAX)) as u64
551 }
552
553 pub(crate) fn realtime_pointer_event_time(
554 &self,
555 platform_time_ms: Option<i64>,
556 ) -> PointerEventTime {
557 PointerEventTime {
558 platform_time_ms,
559 animation_time_nanos: self
560 .frame_time_nanos_at(Instant::now())
561 .max(self.last_frame_time_nanos),
562 }
563 }
564
565 pub(crate) fn install_text_input_router(&mut self) {
566 if self.text_input_router_installed {
567 return;
568 }
569 let router = Rc::new(TextInputRouter {
570 routes: Rc::clone(&self.text_input_routes),
571 });
572 let app_context = Rc::clone(&self.app_context);
573 app_context
574 .enter(|| cranpose_ui::text_input_session::set_platform_text_input_handler(router));
575 self.text_input_router_installed = true;
576 }
577}
578
579impl<R> AppShell<R>
580where
581 R: Renderer,
582 R::Error: Debug,
583{
584 pub fn new(renderer: R, root_key: Key, content: impl FnMut() + 'static) -> Self {
585 Self::new_with_size(renderer, root_key, content, (800, 600), (800.0, 600.0))
586 }
587
588 pub fn new_with_size(
589 renderer: R,
590 root_key: Key,
591 content: impl FnMut() + 'static,
592 buffer_size: (u32, u32),
593 viewport: (f32, f32),
594 ) -> Self {
595 Self::new_with_size_and_density(renderer, root_key, content, buffer_size, viewport, 1.0)
596 }
597
598 pub fn new_with_size_and_density(
601 mut renderer: R,
602 root_key: Key,
603 content: impl FnMut() + 'static,
604 buffer_size: (u32, u32),
605 viewport: (f32, f32),
606 density: f32,
607 ) -> Self {
608 renderer.set_root_scale(density);
609 let app_context = cranpose_ui::AppContext::new_with_density(density);
610 let runtime = StdRuntime::new();
611 let mut composition = Composition::with_runtime(MemoryApplier::new(), runtime.runtime());
612 let app_content = Rc::new(std::cell::RefCell::new(content));
613 let mut build: Box<dyn FnMut()> = Box::new(move || {
614 let app_content = Rc::clone(&app_content);
615 cranpose_ui::density::ProvideDensity(cranpose_ui::Density::from_host(), move || {
616 cranpose_ui::widgets::PopupHost(move || {
617 (app_content.borrow_mut())();
618 });
619 });
620 });
621 renderer.attach_app_context_services(&app_context);
622 app_context.enter(|| {
623 #[cfg(all(
624 feature = "clipboard-native",
625 not(target_arch = "wasm32"),
626 not(target_os = "android"),
627 not(target_os = "ios")
628 ))]
629 {
630 let clipboard =
631 std::rc::Rc::new(std::cell::RefCell::new(arboard::Clipboard::new().ok()));
632 cranpose_ui::clipboard_session::set_platform_clipboard(std::rc::Rc::new(
633 ShellClipboard { inner: clipboard },
634 ));
635 }
636 if let Err(err) = composition.render_stable(root_key, &mut *build) {
637 log::error!("initial render failed: {err}");
638 }
639 });
640 renderer.scene_mut().clear();
641 let app = ShellApp {
642 app_context,
643 runtime,
644 composition,
645 content: build,
646 start_time: Instant::now(),
647 last_frame_time_nanos: 0,
648 semantics_enabled: false,
649 semantics_snapshot_revision: 0,
650 revealed_focus: None,
651 layout_requested: true,
652 content_moved: false,
653 force_layout_pass: true,
654 modifiers: None,
655 rotary_scroll_factor: DEFAULT_ROTARY_SCROLL_FACTOR_DP,
656 #[cfg(all(feature = "clipboard-native", target_os = "linux"))]
657 clipboard: arboard::Clipboard::new().ok(),
658 dev_options: DevOptions::default(),
659 inspector_projector: None,
660 fps_monitor: fps_monitor::FpsMonitor::new(),
661 text_input_routes: Rc::new(RefCell::new(TextInputRoutes::default())),
662 text_input_router_installed: false,
663 window_roots_seen: None,
664 rebuilt_frames_since_observation_prune: 0,
665 };
666 let mut shell = Self {
667 app,
668 surfaces: vec![RootSurface::new(
669 RootId::Primary,
670 renderer,
671 buffer_size,
672 viewport,
673 )],
674 routing_scratch: SurfaceRoutingScratch::default(),
675 pending_dirty_nodes: Vec::new(),
676 pending_layer_property_nodes: Vec::new(),
677 geometry_scene_nodes: cranpose_ui::GeometrySceneNodes::default(),
678 };
679 shell.process_frame();
680 shell
681 }
682
683 pub fn app_context(&self) -> &Rc<cranpose_ui::AppContext> {
688 &self.app.app_context
689 }
690
691 pub fn surface(&mut self, root: RootId) -> Option<SurfaceMut<'_, R>> {
694 let index = self.surface_index(root)?;
695 Some(SurfaceMut::new(self, index))
696 }
697
698 pub fn primary(&mut self) -> SurfaceMut<'_, R> {
700 SurfaceMut::new(self, 0)
701 }
702
703 pub fn root_holding_the_press(&mut self) -> Option<RootId> {
712 let pressed = self
713 .surfaces
714 .iter()
715 .find_map(|surface| surface.hit_path_tracker.dispatch_order(PointerId::PRIMARY))?;
716 let primary_root = self.app.composition.root()?;
717 let mut applier = self.app.composition.applier_mut();
718 let holder = pressed.into_iter().find_map(|node| {
719 let node = applier.scene_node_attached_to(node, primary_root)?;
720 Some(cranpose_ui::nearest_window_root(&mut applier, node))
721 })?;
722 drop(applier);
723 self.surfaces
724 .iter()
725 .find(|surface| surface.owns_nodes_under(holder))
726 .map(|surface| surface.id)
727 }
728
729 fn surface_index(&self, root: RootId) -> Option<usize> {
730 self.surfaces.iter().position(|surface| surface.id == root)
731 }
732
733 pub fn add_window_surface(
743 &mut self,
744 window: u64,
745 renderer: R,
746 buffer_size: (u32, u32),
747 viewport: (f32, f32),
748 ) -> Option<R> {
749 let previous = self.remove_window_surface(window);
750 self.surfaces.push(RootSurface::new(
751 RootId::Window(window),
752 renderer,
753 buffer_size,
754 viewport,
755 ));
756 self.app.window_roots_seen = None;
757 self.sync_window_roots();
758 previous
759 }
760
761 pub fn remove_window_surface(&mut self, window: u64) -> Option<R> {
763 let root = RootId::Window(window);
764 let index = self.surface_index(root)?;
765 self.app.text_input_routes.borrow_mut().remove(root);
766 Some(self.surfaces.remove(index).renderer)
767 }
768
769 pub fn surface_ids(&self) -> Vec<RootId> {
771 self.surfaces.iter().map(|surface| surface.id).collect()
772 }
773
774 pub fn window_roots(&self) -> Vec<WindowRootEntry> {
778 let app_context = Rc::clone(&self.app.app_context);
779 app_context.enter(cranpose_ui::window_roots)
780 }
781
782 pub fn window_roots_revision(&self) -> u64 {
786 let app_context = Rc::clone(&self.app.app_context);
787 app_context.enter(cranpose_ui::window_roots_revision)
788 }
789
790 pub fn primary_has_content(&mut self) -> bool {
795 self.read_primary_boxes(cranpose_ui::has_placed_content)
796 .unwrap_or(false)
797 }
798
799 pub fn primary_content_size(&mut self) -> Option<Size> {
805 self.read_primary_boxes(cranpose_ui::placed_content_extent)
806 .flatten()
807 }
808
809 fn read_primary_boxes<T>(
813 &mut self,
814 read: impl FnOnce(&mut MemoryApplier, NodeId) -> Result<T, NodeError>,
815 ) -> Option<T> {
816 let app_context = Rc::clone(&self.app.app_context);
817 app_context.enter(|| {
818 let root = self.surfaces[0].root_node(&self.app)?;
819 let mut applier = self.app.composition.applier_mut();
820 read(&mut applier, root)
821 .inspect_err(|err| log::debug!("failed to read the primary root's boxes: {err}"))
822 .ok()
823 })
824 }
825
826 pub(crate) fn drag_and_drop_target_at(
832 &mut self,
833 point: cranpose_ui::DragAndDropPoint,
834 source: usize,
835 ) -> Option<(NodeId, cranpose_ui::Point)> {
836 let app_context = Rc::clone(&self.app.app_context);
837 let candidates: Vec<(usize, cranpose_ui::Point)> = match point.screen {
838 Some(screen) => (0..self.surfaces.len())
839 .rev()
840 .filter_map(|index| {
841 let local = self.surfaces[index].screen_point_inside(screen)?;
842 Some((index, local))
843 })
844 .collect(),
845 None => vec![(source, point.local)],
846 };
847 candidates.into_iter().find_map(|(index, local)| {
848 self.surfaces[index]
849 .renderer
850 .scene()
851 .hit_test_nodes(local.x, local.y)
852 .into_iter()
853 .find(|node| app_context.drag_and_drop().is_target(*node))
854 .map(|node| (node, local))
855 })
856 }
857
858 pub fn set_active_root(&mut self, root: RootId) {
862 self.app.text_input_routes.borrow_mut().set_active(root);
863 }
864
865 pub fn active_root(&self) -> RootId {
867 self.app.text_input_routes.borrow().active()
868 }
869
870 fn sync_window_roots(&mut self) {
871 let app_context = Rc::clone(&self.app.app_context);
872 app_context.enter(|| self.sync_window_roots_in_context());
873 }
874
875 pub(crate) fn sync_window_roots_in_context(&mut self) {
876 let revision = cranpose_ui::window_roots_revision();
877 if self.app.window_roots_seen == Some(revision) {
878 return;
879 }
880 self.app.window_roots_seen = Some(revision);
881 let entries = cranpose_ui::window_roots();
882 for surface in &mut self.surfaces {
883 let RootId::Window(id) = surface.id else {
884 continue;
885 };
886 let root = entries
887 .iter()
888 .find(|entry| entry.node as u64 == id)
889 .map(|entry| entry.node);
890 surface.set_root(root);
891 }
892 }
893
894 pub(crate) fn any_surface_dirty(&self) -> bool {
895 self.surfaces
896 .iter()
897 .any(|surface| surface.is_dirty || surface.scene_dirty)
898 }
899
900 pub(crate) fn mark_all_dirty(&mut self) {
901 for surface in &mut self.surfaces {
902 surface.is_dirty = true;
903 }
904 }
905
906 fn clear_surface_dirt(&mut self) {
907 for surface in &mut self.surfaces {
908 surface.is_dirty = false;
909 }
910 }
911
912 fn invalidate_dev_overlay_text(&mut self) {
913 for surface in &mut self.surfaces {
914 surface.invalidate_dev_overlay_text();
915 }
916 }
917
918 pub fn set_dev_options(&mut self, options: DevOptions) {
923 self.app.dev_options = options;
924 self.invalidate_dev_overlay_text();
925 let app_context = Rc::clone(&self.app.app_context);
926 app_context.enter(request_render_invalidation);
927 self.mark_all_dirty();
928 }
929
930 pub fn dev_options(&self) -> &DevOptions {
932 &self.app.dev_options
933 }
934
935 pub fn frame_pacing_mode(&self) -> FramePacingMode {
936 self.app.dev_options.frame_pacing_mode
937 }
938
939 pub fn current_fps(&self) -> f32 {
940 self.app.fps_monitor.current_fps()
941 }
942
943 pub fn fps_stats(&self) -> FpsStats {
944 self.app.fps_monitor.stats()
945 }
946
947 pub fn reset_fps_stats(&mut self) {
948 self.app.fps_monitor.reset_stats();
949 self.invalidate_dev_overlay_text();
950 }
951
952 pub fn record_presented_frame(
953 &mut self,
954 frame_started_at: Instant,
955 frame_finished_at: Instant,
956 ) {
957 self.app
958 .fps_monitor
959 .record_frame_work(frame_started_at, frame_finished_at);
960 }
961
962 #[cfg(any(test, feature = "test-support"))]
963 #[doc(hidden)]
964 pub fn record_presented_frame_for_test(
965 &mut self,
966 frame_started_nanos: u64,
967 frame_finished_nanos: u64,
968 ) {
969 let started = self.app.start_time + std::time::Duration::from_nanos(frame_started_nanos);
970 let finished = self.app.start_time + std::time::Duration::from_nanos(frame_finished_nanos);
971 self.record_presented_frame(started, finished);
972 }
973
974 pub fn set_frame_pacing_mode(&mut self, mode: FramePacingMode) {
975 if self.app.dev_options.frame_pacing_mode == mode {
976 return;
977 }
978 self.app.dev_options.frame_pacing_mode = mode;
979 self.invalidate_dev_overlay_text();
980 let app_context = Rc::clone(&self.app.app_context);
981 app_context.enter(request_render_invalidation);
982 self.mark_all_dirty();
983 }
984
985 pub fn dev_overlay_control_center(&self, mode: FramePacingMode) -> Option<(f32, f32)> {
992 self.surfaces[0].dev_overlay_control_center(mode)
993 }
994
995 pub fn set_viewport(&mut self, width: f32, height: f32) {
998 if self.surfaces[0].viewport == (width, height) {
999 return;
1000 }
1001 self.primary().set_viewport(width, height);
1002 self.process_frame();
1003 }
1004
1005 pub fn viewport_size(&self) -> (f32, f32) {
1006 self.surfaces[0].viewport
1007 }
1008
1009 pub fn set_screen_origin(&mut self, origin: Option<cranpose_ui_graphics::Point>) {
1012 self.surfaces[0].screen_origin = origin;
1013 }
1014
1015 pub fn set_buffer_size(&mut self, width: u32, height: u32) {
1016 self.surfaces[0].buffer_size = (width, height);
1017 }
1018
1019 pub fn buffer_size(&self) -> (u32, u32) {
1020 self.surfaces[0].buffer_size
1021 }
1022
1023 pub fn scene(&self) -> &R::Scene {
1024 self.surfaces[0].renderer.scene()
1025 }
1026
1027 pub fn renderer(&mut self) -> &mut R {
1028 &mut self.surfaces[0].renderer
1029 }
1030
1031 #[cfg(not(target_arch = "wasm32"))]
1032 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + Sync + 'static) {
1033 self.app.runtime.set_frame_waker(waker);
1034 }
1035
1036 #[cfg(target_arch = "wasm32")]
1037 pub fn set_frame_waker(&mut self, waker: impl Fn() + 'static) {
1038 self.app.runtime.set_frame_waker(waker);
1039 }
1040
1041 pub fn clear_frame_waker(&mut self) {
1042 self.app.runtime.clear_frame_waker();
1043 }
1044
1045 pub fn should_render(&self) -> bool {
1046 let app_context = Rc::clone(&self.app.app_context);
1047 app_context.enter(|| {
1048 self.app.wants_frame_in_context()
1049 || self.surfaces.iter().any(|surface| surface.scene_dirty)
1050 })
1051 }
1052
1053 pub fn needs_update(&self) -> bool {
1054 let app_context = Rc::clone(&self.app.app_context);
1055 app_context.enter(|| {
1056 self.app
1057 .needs_ui_update_in_context(self.any_surface_dirty())
1058 })
1059 }
1060
1061 pub fn needs_update_without_frame(&self) -> bool {
1064 let app_context = Rc::clone(&self.app.app_context);
1065 app_context.enter(|| {
1066 self.any_surface_dirty()
1067 || self.app.has_stale_work_in_context()
1068 || self.app.composition.should_recompose()
1069 || self.app.composition.runtime_handle().has_pending_ui()
1070 || has_pending_semantics_invalidations()
1071 || cranpose_ui::next_cursor_blink_time().is_some_and(|at| at <= Instant::now())
1072 || self
1073 .surfaces
1074 .iter()
1075 .any(|surface| surface.renderer_warmup_due(&self.app))
1076 })
1077 }
1078
1079 pub fn has_pending_ui(&self) -> bool {
1087 let app_context = Rc::clone(&self.app.app_context);
1088 app_context.enter(|| self.app.composition.runtime_handle().has_pending_ui())
1089 }
1090
1091 pub fn run_pending_tasks(&mut self) {
1098 let app_context = Rc::clone(&self.app.app_context);
1099 app_context.enter(|| {
1100 let runtime_handle = self.app.composition.runtime_handle();
1101 if runtime_handle.has_pending_ui() {
1102 runtime_handle.with_deferred_state_releases(|| runtime_handle.drain_ui());
1103 }
1104 });
1105 }
1106
1107 pub fn needs_redraw(&self) -> bool {
1120 let app_context = Rc::clone(&self.app.app_context);
1121 app_context.enter(|| self.surfaces[0].needs_redraw_in_context(&self.app))
1122 }
1123
1124 pub fn mark_dirty(&mut self) {
1126 self.surfaces[0].is_dirty = true;
1127 }
1128
1129 pub fn request_root_render(&mut self) {
1130 self.app.composition.request_root_render();
1131 self.app.request_forced_layout_pass();
1132 let app_context = Rc::clone(&self.app.app_context);
1133 app_context.enter(request_render_invalidation);
1134 self.mark_all_dirty();
1135 }
1136
1137 pub fn set_density(&mut self, density: f32) {
1140 self.surfaces[0].renderer.set_root_scale(density);
1141 let app_context = Rc::clone(&self.app.app_context);
1142 let changed = app_context.enter(|| {
1143 let previous = cranpose_ui::current_density().to_bits();
1144 cranpose_ui::set_density(density);
1145 previous != cranpose_ui::current_density().to_bits()
1146 });
1147 if changed {
1148 self.request_root_render();
1149 }
1150 }
1151
1152 pub fn set_font_scale(&mut self, font_scale: f32) {
1163 self.set_font_scale_curve(cranpose_ui::FontScaleCurve::linear(font_scale));
1164 }
1165
1166 pub fn set_font_scale_curve(&mut self, curve: cranpose_ui::FontScaleCurve) {
1173 let app_context = Rc::clone(&self.app.app_context);
1174 let changed = app_context.enter(|| {
1175 let previous = cranpose_ui::current_font_scale_curve();
1176 cranpose_ui::set_font_scale_curve(curve);
1177 previous != cranpose_ui::current_font_scale_curve()
1178 });
1179 if changed {
1180 self.request_root_render();
1181 }
1182 }
1183
1184 #[cfg(any(test, feature = "test-support"))]
1185 #[doc(hidden)]
1186 pub fn debug_current_density(&self) -> f32 {
1187 let app_context = Rc::clone(&self.app.app_context);
1188 app_context.enter(cranpose_ui::current_density)
1189 }
1190
1191 #[cfg(any(test, feature = "test-support"))]
1192 #[doc(hidden)]
1193 pub fn debug_current_font_scale(&self) -> f32 {
1194 let app_context = Rc::clone(&self.app.app_context);
1195 app_context.enter(cranpose_ui::current_font_scale)
1196 }
1197
1198 #[cfg(any(test, feature = "test-support"))]
1199 #[doc(hidden)]
1200 pub fn debug_current_font_scale_curve(&self) -> cranpose_ui::FontScaleCurve {
1201 let app_context = Rc::clone(&self.app.app_context);
1202 app_context.enter(cranpose_ui::current_font_scale_curve)
1203 }
1204
1205 #[cfg(any(test, feature = "test-support"))]
1206 #[doc(hidden)]
1207 pub fn debug_enter_app_context<T>(&self, block: impl FnOnce() -> T) -> T {
1208 let app_context = Rc::clone(&self.app.app_context);
1209 app_context.enter(block)
1210 }
1211
1212 pub fn has_active_animations(&self) -> bool {
1214 self.app.composition.should_render()
1215 }
1216
1217 pub fn has_transient_frame_callbacks(&self) -> bool {
1218 self.app
1219 .composition
1220 .runtime_handle()
1221 .has_transient_frame_callbacks()
1222 }
1223
1224 pub fn has_active_pointer_gesture(&self) -> bool {
1225 self.surfaces[0].has_active_pointer_gesture()
1226 }
1227
1228 pub fn frame_owed(&self) -> bool {
1230 self.surfaces[0].frame_owed
1231 }
1232
1233 pub fn take_frame_owed(&mut self) -> bool {
1235 std::mem::take(&mut self.surfaces[0].frame_owed)
1236 }
1237
1238 pub fn next_event_time(&self) -> Option<web_time::Instant> {
1241 self.app.next_event_time()
1242 }
1243
1244 fn compute_frame_schedule(&self) -> FrameSchedule {
1245 self.surfaces[0].compute_frame_schedule(&self.app, self.any_surface_dirty())
1246 }
1247
1248 pub fn frame_schedule(&self) -> FrameSchedule {
1249 let schedule = self.compute_frame_schedule();
1250 self.surfaces[0].frame_scheduler.record(schedule);
1251 schedule
1252 }
1253
1254 pub fn schedule_platform_frame<D>(&self, driver: &D) -> FrameSchedule
1255 where
1256 D: PlatformFrameDriver + ?Sized,
1257 {
1258 let schedule = self.compute_frame_schedule();
1259 self.surfaces[0].frame_scheduler.schedule(schedule, driver);
1260 schedule
1261 }
1262
1263 pub fn frame_scheduler_snapshot(&self) -> FrameSchedule {
1264 self.surfaces[0].frame_scheduler.snapshot()
1265 }
1266
1267 pub fn realtime_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
1269 self.app.realtime_pointer_event_time(platform_time_ms)
1270 }
1271
1272 pub fn exact_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
1274 PointerEventTime {
1275 platform_time_ms,
1276 animation_time_nanos: self.app.last_frame_time_nanos,
1277 }
1278 }
1279
1280 pub fn update_after_frame_interval(
1281 &mut self,
1282 frame_interval: std::time::Duration,
1283 ) -> FrameUpdateResult {
1284 let wall_frame_time = self.app.frame_time_nanos_at(Instant::now());
1285 let base_frame_time = self.app.last_frame_time_nanos.max(wall_frame_time);
1286 let frame_time = base_frame_time
1287 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1288 self.update_at_frame_time_nanos(frame_time)
1289 }
1290
1291 pub fn update_after_exact_interval(
1297 &mut self,
1298 frame_interval: std::time::Duration,
1299 ) -> FrameUpdateResult {
1300 let frame_time = self
1301 .app
1302 .last_frame_time_nanos
1303 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1304 self.update_at_frame_time_nanos(frame_time)
1305 }
1306
1307 pub fn dispatch_frame_at(&mut self, frame_time: Instant) {
1313 let frame_time = self.app.frame_time_nanos_at(frame_time);
1314 let app_context = Rc::clone(&self.app.app_context);
1315 app_context.enter(|| {
1316 let runtime_handle = self.app.runtime.runtime_handle();
1317 runtime_handle.with_deferred_state_releases(|| {
1318 self.app.last_frame_time_nanos = frame_time.max(self.app.last_frame_time_nanos);
1319 self.app
1320 .runtime
1321 .drain_frame_callbacks(self.app.last_frame_time_nanos);
1322 runtime_handle.drain_ui();
1323 self.dispatch_requested_mouse_moves(self.app.last_frame_time_nanos);
1324 });
1325 });
1326 }
1327
1328 pub fn update_without_frame(&mut self) -> FrameUpdateResult {
1331 self.update_with_frame_time(None)
1332 }
1333
1334 pub fn update_at_frame_time_nanos(&mut self, frame_time: u64) -> FrameUpdateResult {
1338 self.update_with_frame_time(Some(frame_time))
1339 }
1340
1341 fn update_with_frame_time(&mut self, frame_time: Option<u64>) -> FrameUpdateResult {
1342 let app_context = Rc::clone(&self.app.app_context);
1343 app_context.enter(|| {
1344 let update_started_at = Instant::now();
1345 let runtime_handle = self.app.runtime.runtime_handle();
1346 runtime_handle.with_deferred_state_releases(|| {
1347 if let Some(frame_time) = frame_time {
1348 self.app.last_frame_time_nanos = frame_time.max(self.app.last_frame_time_nanos);
1349 self.app.runtime.drain_frame_callbacks(self.app.last_frame_time_nanos);
1350 }
1351 let after_frame_callbacks = Instant::now();
1352 runtime_handle.drain_ui();
1353 self.dispatch_requested_mouse_moves(self.app.last_frame_time_nanos);
1354 let after_ui_drain = Instant::now();
1355 let should_render = self.app.composition.should_recompose();
1356 let mut reconcile_attempted = false;
1357 let mut reconcile_changed = false;
1358 if should_render {
1359 log::trace!(
1360 target: "cranpose::input",
1361 "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
1362 self.app.layout_requested,
1363 self.surfaces[0].scene_dirty,
1364 self.surfaces[0].is_dirty
1365 );
1366 (reconcile_attempted, reconcile_changed) = self.reconcile_in_context();
1367 }
1368 let after_reconcile = Instant::now();
1369 let result = self.process_frame_in_context(reconcile_changed);
1370 let after_process_frame = Instant::now();
1371 log_update_stage_telemetry(UpdateStageTelemetry {
1372 started_at: update_started_at,
1373 after_frame_callbacks,
1374 after_ui_drain,
1375 after_reconcile,
1376 after_process_frame,
1377 should_render,
1378 reconcile_attempted,
1379 reconcile_changed,
1380 });
1381 self.clear_surface_dirt();
1382 result
1383 })
1384 })
1385 }
1386
1387 fn dispatch_requested_mouse_moves(&mut self, frame_time: u64) {
1388 use cranpose_ui::mouse_input::MouseInputTarget;
1389 let pending = self.app.app_context.pending_mouse_moves();
1390 for _ in 0..pending {
1391 let Some(request) = self.app.app_context.take_mouse_move() else {
1392 break;
1393 };
1394 let root = match request.target {
1395 MouseInputTarget::Primary => RootId::Primary,
1396 MouseInputTarget::Window(id) => RootId::Window(id),
1397 };
1398 if let Some(mut surface) = self.surface(root) {
1399 surface.set_pointer_source(PointerSource::Mouse);
1400 surface.set_cursor_at_event_time(request.position.x, request.position.y, PointerEventTime {
1401 platform_time_ms: Some((frame_time / 1_000_000).min(i64::MAX as u64) as i64),
1402 animation_time_nanos: frame_time,
1403 });
1404 }
1405 }
1406 }
1407
1408 fn reconcile_in_context(&mut self) -> (bool, bool) {
1409 let Some(root_key) = self.app.composition.root_key() else {
1410 return (false, false);
1411 };
1412 match self
1413 .app
1414 .composition
1415 .reconcile(root_key, &mut *self.app.content)
1416 {
1417 Ok(changed) => {
1418 log::trace!(
1419 target: "cranpose::input",
1420 "reconcile changed={changed}"
1421 );
1422 if changed {
1423 self.app.fps_monitor.record_recomposition();
1424 if self.app.composition_tree_needs_layout() {
1425 self.app.request_layout_pass();
1426 }
1427 request_render_invalidation();
1428 }
1429 (true, changed)
1430 }
1431 Err(NodeError::Missing { id }) => {
1432 log::debug!("Recomposition skipped: node {id} no longer exists");
1433 self.app.request_layout_pass();
1434 request_render_invalidation();
1435 (true, false)
1436 }
1437 Err(err) => {
1438 log::error!("recomposition failed: {err}");
1439 self.app.request_layout_pass();
1440 request_render_invalidation();
1441 (true, false)
1442 }
1443 }
1444 }
1445
1446 pub fn update(&mut self) -> FrameUpdateResult {
1447 let frame_time = self.app.frame_time_nanos_at(Instant::now());
1448 self.update_at_frame_time_nanos(frame_time)
1449 }
1450}
1451
1452impl<R> Drop for AppShell<R>
1453where
1454 R: Renderer,
1455{
1456 fn drop(&mut self) {
1457 self.app.runtime.clear_frame_waker();
1458 }
1459}
1460
1461pub fn default_root_key() -> Key {
1462 location_key(file!(), line!(), column!())
1463}
1464
1465#[cfg(test)]
1466#[path = "tests/app_shell_frame_pacing_tests.rs"]
1467mod frame_pacing_tests;
1468
1469#[cfg(test)]
1470#[path = "tests/app_shell_tests.rs"]
1471mod tests;
1472
1473#[cfg(test)]
1474#[path = "tests/surface_tests.rs"]
1475mod surface_tests;