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(
599 mut renderer: R,
600 root_key: Key,
601 content: impl FnMut() + 'static,
602 buffer_size: (u32, u32),
603 viewport: (f32, f32),
604 density: f32,
605 ) -> Self {
606 let app_context = cranpose_ui::AppContext::new_with_density(density);
607 let runtime = StdRuntime::new();
608 let mut composition = Composition::with_runtime(MemoryApplier::new(), runtime.runtime());
609 let app_content = Rc::new(std::cell::RefCell::new(content));
610 let mut build: Box<dyn FnMut()> = Box::new(move || {
611 let app_content = Rc::clone(&app_content);
612 cranpose_ui::density::ProvideDensity(cranpose_ui::Density::from_host(), move || {
613 cranpose_ui::widgets::PopupHost(move || {
614 (app_content.borrow_mut())();
615 });
616 });
617 });
618 renderer.attach_app_context_services(&app_context);
619 app_context.enter(|| {
620 #[cfg(all(
621 feature = "clipboard-native",
622 not(target_arch = "wasm32"),
623 not(target_os = "android"),
624 not(target_os = "ios")
625 ))]
626 {
627 let clipboard =
628 std::rc::Rc::new(std::cell::RefCell::new(arboard::Clipboard::new().ok()));
629 cranpose_ui::clipboard_session::set_platform_clipboard(std::rc::Rc::new(
630 ShellClipboard { inner: clipboard },
631 ));
632 }
633 if let Err(err) = composition.render_stable(root_key, &mut *build) {
634 log::error!("initial render failed: {err}");
635 }
636 });
637 renderer.scene_mut().clear();
638 let app = ShellApp {
639 app_context,
640 runtime,
641 composition,
642 content: build,
643 start_time: Instant::now(),
644 last_frame_time_nanos: 0,
645 semantics_enabled: false,
646 semantics_snapshot_revision: 0,
647 revealed_focus: None,
648 layout_requested: true,
649 content_moved: false,
650 force_layout_pass: true,
651 modifiers: None,
652 rotary_scroll_factor: DEFAULT_ROTARY_SCROLL_FACTOR_DP,
653 #[cfg(all(feature = "clipboard-native", target_os = "linux"))]
654 clipboard: arboard::Clipboard::new().ok(),
655 dev_options: DevOptions::default(),
656 inspector_projector: None,
657 fps_monitor: fps_monitor::FpsMonitor::new(),
658 text_input_routes: Rc::new(RefCell::new(TextInputRoutes::default())),
659 text_input_router_installed: false,
660 window_roots_seen: None,
661 rebuilt_frames_since_observation_prune: 0,
662 };
663 let mut shell = Self {
664 app,
665 surfaces: vec![RootSurface::new(
666 RootId::Primary,
667 renderer,
668 buffer_size,
669 viewport,
670 )],
671 routing_scratch: SurfaceRoutingScratch::default(),
672 pending_dirty_nodes: Vec::new(),
673 pending_layer_property_nodes: Vec::new(),
674 geometry_scene_nodes: cranpose_ui::GeometrySceneNodes::default(),
675 };
676 shell.process_frame();
677 shell
678 }
679
680 pub fn app_context(&self) -> &Rc<cranpose_ui::AppContext> {
685 &self.app.app_context
686 }
687
688 pub fn surface(&mut self, root: RootId) -> Option<SurfaceMut<'_, R>> {
691 let index = self.surface_index(root)?;
692 Some(SurfaceMut::new(self, index))
693 }
694
695 pub fn primary(&mut self) -> SurfaceMut<'_, R> {
697 SurfaceMut::new(self, 0)
698 }
699
700 pub fn root_holding_the_press(&mut self) -> Option<RootId> {
709 let pressed = self
710 .surfaces
711 .iter()
712 .find_map(|surface| surface.hit_path_tracker.dispatch_order(PointerId::PRIMARY))?;
713 let primary_root = self.app.composition.root()?;
714 let mut applier = self.app.composition.applier_mut();
715 let holder = pressed.into_iter().find_map(|node| {
716 let node = applier.scene_node_attached_to(node, primary_root)?;
717 Some(cranpose_ui::nearest_window_root(&mut applier, node))
718 })?;
719 drop(applier);
720 self.surfaces
721 .iter()
722 .find(|surface| surface.owns_nodes_under(holder))
723 .map(|surface| surface.id)
724 }
725
726 fn surface_index(&self, root: RootId) -> Option<usize> {
727 self.surfaces.iter().position(|surface| surface.id == root)
728 }
729
730 pub fn add_window_surface(
740 &mut self,
741 window: u64,
742 renderer: R,
743 buffer_size: (u32, u32),
744 viewport: (f32, f32),
745 ) -> Option<R> {
746 let previous = self.remove_window_surface(window);
747 self.surfaces.push(RootSurface::new(
748 RootId::Window(window),
749 renderer,
750 buffer_size,
751 viewport,
752 ));
753 self.app.window_roots_seen = None;
754 self.sync_window_roots();
755 previous
756 }
757
758 pub fn remove_window_surface(&mut self, window: u64) -> Option<R> {
760 let root = RootId::Window(window);
761 let index = self.surface_index(root)?;
762 self.app.text_input_routes.borrow_mut().remove(root);
763 Some(self.surfaces.remove(index).renderer)
764 }
765
766 pub fn surface_ids(&self) -> Vec<RootId> {
768 self.surfaces.iter().map(|surface| surface.id).collect()
769 }
770
771 pub fn window_roots(&self) -> Vec<WindowRootEntry> {
775 let app_context = Rc::clone(&self.app.app_context);
776 app_context.enter(cranpose_ui::window_roots)
777 }
778
779 pub fn window_roots_revision(&self) -> u64 {
783 let app_context = Rc::clone(&self.app.app_context);
784 app_context.enter(cranpose_ui::window_roots_revision)
785 }
786
787 pub fn primary_has_content(&mut self) -> bool {
792 self.read_primary_boxes(cranpose_ui::has_placed_content)
793 .unwrap_or(false)
794 }
795
796 pub fn primary_content_size(&mut self) -> Option<Size> {
802 self.read_primary_boxes(cranpose_ui::placed_content_extent)
803 .flatten()
804 }
805
806 fn read_primary_boxes<T>(
810 &mut self,
811 read: impl FnOnce(&mut MemoryApplier, NodeId) -> Result<T, NodeError>,
812 ) -> Option<T> {
813 let app_context = Rc::clone(&self.app.app_context);
814 app_context.enter(|| {
815 let root = self.surfaces[0].root_node(&self.app)?;
816 let mut applier = self.app.composition.applier_mut();
817 read(&mut applier, root)
818 .inspect_err(|err| log::debug!("failed to read the primary root's boxes: {err}"))
819 .ok()
820 })
821 }
822
823 pub(crate) fn drag_and_drop_target_at(
829 &mut self,
830 point: cranpose_ui::DragAndDropPoint,
831 source: usize,
832 ) -> Option<(NodeId, cranpose_ui::Point)> {
833 let app_context = Rc::clone(&self.app.app_context);
834 let candidates: Vec<(usize, cranpose_ui::Point)> = match point.screen {
835 Some(screen) => (0..self.surfaces.len())
836 .rev()
837 .filter_map(|index| {
838 let local = self.surfaces[index].screen_point_inside(screen)?;
839 Some((index, local))
840 })
841 .collect(),
842 None => vec![(source, point.local)],
843 };
844 candidates.into_iter().find_map(|(index, local)| {
845 self.surfaces[index]
846 .renderer
847 .scene()
848 .hit_test_nodes(local.x, local.y)
849 .into_iter()
850 .find(|node| app_context.drag_and_drop().is_target(*node))
851 .map(|node| (node, local))
852 })
853 }
854
855 pub fn set_active_root(&mut self, root: RootId) {
859 self.app.text_input_routes.borrow_mut().set_active(root);
860 }
861
862 pub fn active_root(&self) -> RootId {
864 self.app.text_input_routes.borrow().active()
865 }
866
867 fn sync_window_roots(&mut self) {
868 let app_context = Rc::clone(&self.app.app_context);
869 app_context.enter(|| self.sync_window_roots_in_context());
870 }
871
872 pub(crate) fn sync_window_roots_in_context(&mut self) {
873 let revision = cranpose_ui::window_roots_revision();
874 if self.app.window_roots_seen == Some(revision) {
875 return;
876 }
877 self.app.window_roots_seen = Some(revision);
878 let entries = cranpose_ui::window_roots();
879 for surface in &mut self.surfaces {
880 let RootId::Window(id) = surface.id else {
881 continue;
882 };
883 let root = entries
884 .iter()
885 .find(|entry| entry.node as u64 == id)
886 .map(|entry| entry.node);
887 surface.set_root(root);
888 }
889 }
890
891 pub(crate) fn any_surface_dirty(&self) -> bool {
892 self.surfaces
893 .iter()
894 .any(|surface| surface.is_dirty || surface.scene_dirty)
895 }
896
897 pub(crate) fn mark_all_dirty(&mut self) {
898 for surface in &mut self.surfaces {
899 surface.is_dirty = true;
900 }
901 }
902
903 fn clear_surface_dirt(&mut self) {
904 for surface in &mut self.surfaces {
905 surface.is_dirty = false;
906 }
907 }
908
909 fn invalidate_dev_overlay_text(&mut self) {
910 for surface in &mut self.surfaces {
911 surface.invalidate_dev_overlay_text();
912 }
913 }
914
915 pub fn set_dev_options(&mut self, options: DevOptions) {
920 self.app.dev_options = options;
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_options(&self) -> &DevOptions {
929 &self.app.dev_options
930 }
931
932 pub fn frame_pacing_mode(&self) -> FramePacingMode {
933 self.app.dev_options.frame_pacing_mode
934 }
935
936 pub fn current_fps(&self) -> f32 {
937 self.app.fps_monitor.current_fps()
938 }
939
940 pub fn fps_stats(&self) -> FpsStats {
941 self.app.fps_monitor.stats()
942 }
943
944 pub fn reset_fps_stats(&mut self) {
945 self.app.fps_monitor.reset_stats();
946 self.invalidate_dev_overlay_text();
947 }
948
949 pub fn record_presented_frame(
950 &mut self,
951 frame_started_at: Instant,
952 frame_finished_at: Instant,
953 ) {
954 self.app
955 .fps_monitor
956 .record_frame_work(frame_started_at, frame_finished_at);
957 }
958
959 #[cfg(any(test, feature = "test-support"))]
960 #[doc(hidden)]
961 pub fn record_presented_frame_for_test(
962 &mut self,
963 frame_started_nanos: u64,
964 frame_finished_nanos: u64,
965 ) {
966 let started = self.app.start_time + std::time::Duration::from_nanos(frame_started_nanos);
967 let finished = self.app.start_time + std::time::Duration::from_nanos(frame_finished_nanos);
968 self.record_presented_frame(started, finished);
969 }
970
971 pub fn set_frame_pacing_mode(&mut self, mode: FramePacingMode) {
972 if self.app.dev_options.frame_pacing_mode == mode {
973 return;
974 }
975 self.app.dev_options.frame_pacing_mode = mode;
976 self.invalidate_dev_overlay_text();
977 let app_context = Rc::clone(&self.app.app_context);
978 app_context.enter(request_render_invalidation);
979 self.mark_all_dirty();
980 }
981
982 pub fn dev_overlay_control_center(&self, mode: FramePacingMode) -> Option<(f32, f32)> {
989 self.surfaces[0].dev_overlay_control_center(mode)
990 }
991
992 pub fn set_viewport(&mut self, width: f32, height: f32) {
995 if self.surfaces[0].viewport == (width, height) {
996 return;
997 }
998 self.primary().set_viewport(width, height);
999 self.process_frame();
1000 }
1001
1002 pub fn viewport_size(&self) -> (f32, f32) {
1003 self.surfaces[0].viewport
1004 }
1005
1006 pub fn set_screen_origin(&mut self, origin: Option<cranpose_ui_graphics::Point>) {
1009 self.surfaces[0].screen_origin = origin;
1010 }
1011
1012 pub fn set_buffer_size(&mut self, width: u32, height: u32) {
1013 self.surfaces[0].buffer_size = (width, height);
1014 }
1015
1016 pub fn buffer_size(&self) -> (u32, u32) {
1017 self.surfaces[0].buffer_size
1018 }
1019
1020 pub fn scene(&self) -> &R::Scene {
1021 self.surfaces[0].renderer.scene()
1022 }
1023
1024 pub fn renderer(&mut self) -> &mut R {
1025 &mut self.surfaces[0].renderer
1026 }
1027
1028 #[cfg(not(target_arch = "wasm32"))]
1029 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + Sync + 'static) {
1030 self.app.runtime.set_frame_waker(waker);
1031 }
1032
1033 #[cfg(target_arch = "wasm32")]
1034 pub fn set_frame_waker(&mut self, waker: impl Fn() + 'static) {
1035 self.app.runtime.set_frame_waker(waker);
1036 }
1037
1038 pub fn clear_frame_waker(&mut self) {
1039 self.app.runtime.clear_frame_waker();
1040 }
1041
1042 pub fn should_render(&self) -> bool {
1043 let app_context = Rc::clone(&self.app.app_context);
1044 app_context.enter(|| {
1045 self.app.wants_frame_in_context()
1046 || self.surfaces.iter().any(|surface| surface.scene_dirty)
1047 })
1048 }
1049
1050 pub fn needs_update(&self) -> bool {
1051 let app_context = Rc::clone(&self.app.app_context);
1052 app_context.enter(|| {
1053 self.app
1054 .needs_ui_update_in_context(self.any_surface_dirty())
1055 })
1056 }
1057
1058 pub fn needs_update_without_frame(&self) -> bool {
1061 let app_context = Rc::clone(&self.app.app_context);
1062 app_context.enter(|| {
1063 self.any_surface_dirty()
1064 || self.app.has_stale_work_in_context()
1065 || self.app.composition.should_recompose()
1066 || self.app.composition.runtime_handle().has_pending_ui()
1067 || has_pending_semantics_invalidations()
1068 || cranpose_ui::next_cursor_blink_time().is_some_and(|at| at <= Instant::now())
1069 || self
1070 .surfaces
1071 .iter()
1072 .any(|surface| surface.renderer_warmup_due(&self.app))
1073 })
1074 }
1075
1076 pub fn has_pending_ui(&self) -> bool {
1084 let app_context = Rc::clone(&self.app.app_context);
1085 app_context.enter(|| self.app.composition.runtime_handle().has_pending_ui())
1086 }
1087
1088 pub fn run_pending_tasks(&mut self) {
1095 let app_context = Rc::clone(&self.app.app_context);
1096 app_context.enter(|| {
1097 let runtime_handle = self.app.composition.runtime_handle();
1098 if runtime_handle.has_pending_ui() {
1099 runtime_handle.with_deferred_state_releases(|| runtime_handle.drain_ui());
1100 }
1101 });
1102 }
1103
1104 pub fn needs_redraw(&self) -> bool {
1117 let app_context = Rc::clone(&self.app.app_context);
1118 app_context.enter(|| self.surfaces[0].needs_redraw_in_context(&self.app))
1119 }
1120
1121 pub fn mark_dirty(&mut self) {
1123 self.surfaces[0].is_dirty = true;
1124 }
1125
1126 pub fn request_root_render(&mut self) {
1127 self.app.composition.request_root_render();
1128 self.app.request_forced_layout_pass();
1129 let app_context = Rc::clone(&self.app.app_context);
1130 app_context.enter(request_render_invalidation);
1131 self.mark_all_dirty();
1132 }
1133
1134 pub fn set_density(&mut self, density: f32) {
1135 let app_context = Rc::clone(&self.app.app_context);
1136 let changed = app_context.enter(|| {
1137 let previous = cranpose_ui::current_density().to_bits();
1138 cranpose_ui::set_density(density);
1139 previous != cranpose_ui::current_density().to_bits()
1140 });
1141 if changed {
1142 self.request_root_render();
1143 }
1144 }
1145
1146 pub fn set_font_scale(&mut self, font_scale: f32) {
1157 self.set_font_scale_curve(cranpose_ui::FontScaleCurve::linear(font_scale));
1158 }
1159
1160 pub fn set_font_scale_curve(&mut self, curve: cranpose_ui::FontScaleCurve) {
1167 let app_context = Rc::clone(&self.app.app_context);
1168 let changed = app_context.enter(|| {
1169 let previous = cranpose_ui::current_font_scale_curve();
1170 cranpose_ui::set_font_scale_curve(curve);
1171 previous != cranpose_ui::current_font_scale_curve()
1172 });
1173 if changed {
1174 self.request_root_render();
1175 }
1176 }
1177
1178 #[cfg(any(test, feature = "test-support"))]
1179 #[doc(hidden)]
1180 pub fn debug_current_density(&self) -> f32 {
1181 let app_context = Rc::clone(&self.app.app_context);
1182 app_context.enter(cranpose_ui::current_density)
1183 }
1184
1185 #[cfg(any(test, feature = "test-support"))]
1186 #[doc(hidden)]
1187 pub fn debug_current_font_scale(&self) -> f32 {
1188 let app_context = Rc::clone(&self.app.app_context);
1189 app_context.enter(cranpose_ui::current_font_scale)
1190 }
1191
1192 #[cfg(any(test, feature = "test-support"))]
1193 #[doc(hidden)]
1194 pub fn debug_current_font_scale_curve(&self) -> cranpose_ui::FontScaleCurve {
1195 let app_context = Rc::clone(&self.app.app_context);
1196 app_context.enter(cranpose_ui::current_font_scale_curve)
1197 }
1198
1199 #[cfg(any(test, feature = "test-support"))]
1200 #[doc(hidden)]
1201 pub fn debug_enter_app_context<T>(&self, block: impl FnOnce() -> T) -> T {
1202 let app_context = Rc::clone(&self.app.app_context);
1203 app_context.enter(block)
1204 }
1205
1206 pub fn has_active_animations(&self) -> bool {
1208 self.app.composition.should_render()
1209 }
1210
1211 pub fn has_transient_frame_callbacks(&self) -> bool {
1212 self.app
1213 .composition
1214 .runtime_handle()
1215 .has_transient_frame_callbacks()
1216 }
1217
1218 pub fn has_active_pointer_gesture(&self) -> bool {
1219 self.surfaces[0].has_active_pointer_gesture()
1220 }
1221
1222 pub fn frame_owed(&self) -> bool {
1224 self.surfaces[0].frame_owed
1225 }
1226
1227 pub fn take_frame_owed(&mut self) -> bool {
1229 std::mem::take(&mut self.surfaces[0].frame_owed)
1230 }
1231
1232 pub fn next_event_time(&self) -> Option<web_time::Instant> {
1235 self.app.next_event_time()
1236 }
1237
1238 fn compute_frame_schedule(&self) -> FrameSchedule {
1239 self.surfaces[0].compute_frame_schedule(&self.app, self.any_surface_dirty())
1240 }
1241
1242 pub fn frame_schedule(&self) -> FrameSchedule {
1243 let schedule = self.compute_frame_schedule();
1244 self.surfaces[0].frame_scheduler.record(schedule);
1245 schedule
1246 }
1247
1248 pub fn schedule_platform_frame<D>(&self, driver: &D) -> FrameSchedule
1249 where
1250 D: PlatformFrameDriver + ?Sized,
1251 {
1252 let schedule = self.compute_frame_schedule();
1253 self.surfaces[0].frame_scheduler.schedule(schedule, driver);
1254 schedule
1255 }
1256
1257 pub fn frame_scheduler_snapshot(&self) -> FrameSchedule {
1258 self.surfaces[0].frame_scheduler.snapshot()
1259 }
1260
1261 pub fn realtime_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
1263 self.app.realtime_pointer_event_time(platform_time_ms)
1264 }
1265
1266 pub fn exact_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
1268 PointerEventTime {
1269 platform_time_ms,
1270 animation_time_nanos: self.app.last_frame_time_nanos,
1271 }
1272 }
1273
1274 pub fn update_after_frame_interval(
1275 &mut self,
1276 frame_interval: std::time::Duration,
1277 ) -> FrameUpdateResult {
1278 let wall_frame_time = self.app.frame_time_nanos_at(Instant::now());
1279 let base_frame_time = self.app.last_frame_time_nanos.max(wall_frame_time);
1280 let frame_time = base_frame_time
1281 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1282 self.update_at_frame_time_nanos(frame_time)
1283 }
1284
1285 pub fn update_after_exact_interval(
1291 &mut self,
1292 frame_interval: std::time::Duration,
1293 ) -> FrameUpdateResult {
1294 let frame_time = self
1295 .app
1296 .last_frame_time_nanos
1297 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1298 self.update_at_frame_time_nanos(frame_time)
1299 }
1300
1301 pub fn dispatch_frame_at(&mut self, frame_time: Instant) {
1307 let frame_time = self.app.frame_time_nanos_at(frame_time);
1308 let app_context = Rc::clone(&self.app.app_context);
1309 app_context.enter(|| {
1310 let runtime_handle = self.app.runtime.runtime_handle();
1311 runtime_handle.with_deferred_state_releases(|| {
1312 self.app.last_frame_time_nanos = frame_time.max(self.app.last_frame_time_nanos);
1313 self.app
1314 .runtime
1315 .drain_frame_callbacks(self.app.last_frame_time_nanos);
1316 runtime_handle.drain_ui();
1317 self.dispatch_requested_mouse_moves(self.app.last_frame_time_nanos);
1318 });
1319 });
1320 }
1321
1322 pub fn update_without_frame(&mut self) -> FrameUpdateResult {
1325 self.update_with_frame_time(None)
1326 }
1327
1328 pub fn update_at_frame_time_nanos(&mut self, frame_time: u64) -> FrameUpdateResult {
1332 self.update_with_frame_time(Some(frame_time))
1333 }
1334
1335 fn update_with_frame_time(&mut self, frame_time: Option<u64>) -> FrameUpdateResult {
1336 let app_context = Rc::clone(&self.app.app_context);
1337 app_context.enter(|| {
1338 let update_started_at = Instant::now();
1339 let runtime_handle = self.app.runtime.runtime_handle();
1340 runtime_handle.with_deferred_state_releases(|| {
1341 if let Some(frame_time) = frame_time {
1342 self.app.last_frame_time_nanos = frame_time.max(self.app.last_frame_time_nanos);
1343 self.app.runtime.drain_frame_callbacks(self.app.last_frame_time_nanos);
1344 }
1345 let after_frame_callbacks = Instant::now();
1346 runtime_handle.drain_ui();
1347 self.dispatch_requested_mouse_moves(self.app.last_frame_time_nanos);
1348 let after_ui_drain = Instant::now();
1349 let should_render = self.app.composition.should_recompose();
1350 let mut reconcile_attempted = false;
1351 let mut reconcile_changed = false;
1352 if should_render {
1353 log::trace!(
1354 target: "cranpose::input",
1355 "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
1356 self.app.layout_requested,
1357 self.surfaces[0].scene_dirty,
1358 self.surfaces[0].is_dirty
1359 );
1360 (reconcile_attempted, reconcile_changed) = self.reconcile_in_context();
1361 }
1362 let after_reconcile = Instant::now();
1363 let result = self.process_frame_in_context(reconcile_changed);
1364 let after_process_frame = Instant::now();
1365 log_update_stage_telemetry(UpdateStageTelemetry {
1366 started_at: update_started_at,
1367 after_frame_callbacks,
1368 after_ui_drain,
1369 after_reconcile,
1370 after_process_frame,
1371 should_render,
1372 reconcile_attempted,
1373 reconcile_changed,
1374 });
1375 self.clear_surface_dirt();
1376 result
1377 })
1378 })
1379 }
1380
1381 fn dispatch_requested_mouse_moves(&mut self, frame_time: u64) {
1382 use cranpose_ui::mouse_input::MouseInputTarget;
1383 let pending = self.app.app_context.pending_mouse_moves();
1384 for _ in 0..pending {
1385 let Some(request) = self.app.app_context.take_mouse_move() else {
1386 break;
1387 };
1388 let root = match request.target {
1389 MouseInputTarget::Primary => RootId::Primary,
1390 MouseInputTarget::Window(id) => RootId::Window(id),
1391 };
1392 if let Some(mut surface) = self.surface(root) {
1393 surface.set_pointer_source(PointerSource::Mouse);
1394 surface.set_cursor_at_event_time(request.position.x, request.position.y, PointerEventTime {
1395 platform_time_ms: Some((frame_time / 1_000_000).min(i64::MAX as u64) as i64),
1396 animation_time_nanos: frame_time,
1397 });
1398 }
1399 }
1400 }
1401
1402 fn reconcile_in_context(&mut self) -> (bool, bool) {
1403 let Some(root_key) = self.app.composition.root_key() else {
1404 return (false, false);
1405 };
1406 match self
1407 .app
1408 .composition
1409 .reconcile(root_key, &mut *self.app.content)
1410 {
1411 Ok(changed) => {
1412 log::trace!(
1413 target: "cranpose::input",
1414 "reconcile changed={changed}"
1415 );
1416 if changed {
1417 self.app.fps_monitor.record_recomposition();
1418 if self.app.composition_tree_needs_layout() {
1419 self.app.request_layout_pass();
1420 }
1421 request_render_invalidation();
1422 }
1423 (true, changed)
1424 }
1425 Err(NodeError::Missing { id }) => {
1426 log::debug!("Recomposition skipped: node {id} no longer exists");
1427 self.app.request_layout_pass();
1428 request_render_invalidation();
1429 (true, false)
1430 }
1431 Err(err) => {
1432 log::error!("recomposition failed: {err}");
1433 self.app.request_layout_pass();
1434 request_render_invalidation();
1435 (true, false)
1436 }
1437 }
1438 }
1439
1440 pub fn update(&mut self) -> FrameUpdateResult {
1441 let frame_time = self.app.frame_time_nanos_at(Instant::now());
1442 self.update_at_frame_time_nanos(frame_time)
1443 }
1444}
1445
1446impl<R> Drop for AppShell<R>
1447where
1448 R: Renderer,
1449{
1450 fn drop(&mut self) {
1451 self.app.runtime.clear_frame_waker();
1452 }
1453}
1454
1455pub fn default_root_key() -> Key {
1456 location_key(file!(), line!(), column!())
1457}
1458
1459#[cfg(test)]
1460#[path = "tests/app_shell_frame_pacing_tests.rs"]
1461mod frame_pacing_tests;
1462
1463#[cfg(test)]
1464#[path = "tests/app_shell_tests.rs"]
1465mod tests;
1466
1467#[cfg(test)]
1468#[path = "tests/surface_tests.rs"]
1469mod surface_tests;