1#![deny(unsafe_code)]
2#![allow(clippy::type_complexity)]
3
4mod fps_monitor;
5mod hit_path_tracker;
6mod shell_debug;
7mod shell_frame;
8mod shell_input;
9#[cfg(test)]
10use shell_frame::build_draw_refresh_scope;
11
12pub use fps_monitor::FpsStats;
13
14use std::fmt::{Debug, Write};
15use std::rc::Rc;
16use std::sync::{
17 atomic::{AtomicBool, Ordering},
18 Mutex, MutexGuard,
19};
20use web_time::Instant;
22
23use cranpose_core::{
24 enter_event_handler_scope, location_key, run_in_mutable_snapshot, Applier, Composition, Key,
25 MemoryApplier, NodeError, NodeId,
26};
27use cranpose_foundation::{PointerButton, PointerButtons, PointerEvent, PointerEventKind};
28use cranpose_render_common::{HitTestTarget, RenderScene, Renderer};
29use cranpose_runtime_std::StdRuntime;
30use cranpose_ui::{
31 clear_transient_scroll_motion_contexts, format_layout_tree, format_render_scene,
32 format_screen_summary, has_pending_focus_invalidations, has_pending_pointer_repasses,
33 peek_focus_invalidation, peek_layout_invalidation, peek_pointer_invalidation,
34 peek_render_invalidation, process_focus_invalidations, process_pointer_repasses,
35 request_render_invalidation, take_draw_repass_nodes, take_focus_invalidation,
36 take_layout_invalidation, take_pointer_invalidation, take_render_invalidation,
37 HeadlessRenderer, LayoutBox, LayoutNode, LayoutTree, MeasureLayoutOptions, SemanticsTree,
38 SubcomposeLayoutNode,
39};
40use cranpose_ui_graphics::{Point, Rect, Size};
41use hit_path_tracker::{HitPathTracker, PointerId};
42use std::collections::HashSet;
43
44pub use cranpose_ui::{KeyCode, KeyEvent, KeyEventType, Modifiers};
46pub use cranpose_ui::PlatformTextInputHandler;
48pub use cranpose_ui::ImeEditorState;
50
51#[cfg(any(test, feature = "test-support"))]
52use cranpose_core::{
53 debug_recompose_scope_registry_stats, MemoryApplierDebugStats,
54 RecomposeScopeRegistryDebugStats, SlotTableDebugStats,
55};
56#[cfg(any(test, feature = "test-support"))]
57use cranpose_core::{
58 runtime::{RuntimeDebugStats, StateArenaDebugStats},
59 snapshot_pinning::{debug_snapshot_pinning_stats, SnapshotPinningDebugStats},
60 snapshot_state_observer::SnapshotStateObserverDebugStats,
61 snapshot_v2::{debug_snapshot_v2_stats, SnapshotV2DebugStats},
62 CompositionPassDebugStats, SlotId,
63};
64
65pub struct AppShell<R>
66where
67 R: Renderer,
68{
69 app_context: Rc<cranpose_ui::AppContext>,
70 runtime: StdRuntime,
71 composition: Composition<MemoryApplier>,
72 content: Box<dyn FnMut()>,
73 renderer: R,
74 cursor: (f32, f32),
75 viewport: (f32, f32),
76 buffer_size: (u32, u32),
77 start_time: Instant,
78 last_frame_time_nanos: u64,
79 layout_tree: Option<LayoutTree>,
80 semantics_tree: Option<SemanticsTree>,
81 semantics_enabled: bool,
82 layout_requested: bool,
83 force_layout_pass: bool,
84 scene_dirty: bool,
85 scoped_layout_scene_nodes: Vec<NodeId>,
86 is_dirty: bool,
87 buttons_pressed: PointerButtons,
89 hit_path_tracker: HitPathTracker,
96 hovered_nodes: Vec<NodeId>,
99 #[cfg(all(
101 feature = "clipboard-native",
102 not(target_arch = "wasm32"),
103 not(target_os = "android"),
104 not(target_os = "ios")
105 ))]
106 clipboard: Option<arboard::Clipboard>,
107 dev_options: DevOptions,
109 dev_overlay_controls: Vec<DevOverlayControl>,
110 dev_overlay_text: String,
111 dev_overlay_last_refresh: Option<Instant>,
112 dev_overlay_viewport: Option<Size>,
113 fps_monitor: fps_monitor::FpsMonitor,
114 frame_scheduler: FrameScheduler,
115}
116
117fn update_stage_telemetry_threshold_ms() -> Option<f64> {
118 static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
119 *THRESHOLD_MS.get_or_init(|| {
120 std::env::var("CRANPOSE_UPDATE_STAGE_TELEMETRY_MS")
121 .ok()
122 .and_then(|value| value.parse::<f64>().ok())
123 .filter(|value| value.is_finite() && *value >= 0.0)
124 })
125}
126
127#[derive(Clone, Copy, Debug)]
128struct UpdateStageTelemetry {
129 started_at: Instant,
130 after_frame_callbacks: Instant,
131 after_ui_drain: Instant,
132 after_reconcile: Instant,
133 after_process_frame: Instant,
134 should_render: bool,
135 reconcile_attempted: bool,
136 reconcile_changed: bool,
137}
138
139fn log_update_stage_telemetry(telemetry: UpdateStageTelemetry) {
140 let Some(threshold_ms) = update_stage_telemetry_threshold_ms() else {
141 return;
142 };
143 let total_ms = telemetry
144 .after_process_frame
145 .duration_since(telemetry.started_at)
146 .as_secs_f64()
147 * 1000.0;
148 if total_ms < threshold_ms {
149 return;
150 }
151
152 let frame_callbacks_ms = telemetry
153 .after_frame_callbacks
154 .duration_since(telemetry.started_at)
155 .as_secs_f64()
156 * 1000.0;
157 let ui_drain_ms = telemetry
158 .after_ui_drain
159 .duration_since(telemetry.after_frame_callbacks)
160 .as_secs_f64()
161 * 1000.0;
162 let reconcile_ms = telemetry
163 .after_reconcile
164 .duration_since(telemetry.after_ui_drain)
165 .as_secs_f64()
166 * 1000.0;
167 let process_frame_ms = telemetry
168 .after_process_frame
169 .duration_since(telemetry.after_reconcile)
170 .as_secs_f64()
171 * 1000.0;
172 eprintln!(
173 "[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={}",
174 telemetry.should_render,
175 telemetry.reconcile_attempted,
176 telemetry.reconcile_changed
177 );
178}
179
180#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
181pub enum FramePacingMode {
182 #[default]
185 Vsync,
186 Hard60,
187 Hard120,
188 NoVsync,
191}
192
193impl FramePacingMode {
194 pub const ALL: [Self; 4] = [Self::Vsync, Self::Hard60, Self::Hard120, Self::NoVsync];
195
196 pub fn label(self) -> &'static str {
197 match self {
198 Self::Vsync => "VSync",
199 Self::Hard60 => "60fps",
200 Self::Hard120 => "120fps",
201 Self::NoVsync => "NoVSync",
202 }
203 }
204
205 pub fn target_fps(self) -> Option<u32> {
206 match self {
207 Self::Hard60 => Some(60),
208 Self::Hard120 => Some(120),
209 Self::Vsync | Self::NoVsync => None,
210 }
211 }
212}
213
214#[derive(Clone, Copy, Debug, PartialEq)]
215pub struct FrameSchedule {
216 pub needs_update: bool,
217 pub needs_frame: bool,
218 pub next_deadline: Option<web_time::Instant>,
219}
220
221#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
222pub struct FrameUpdateResult {
223 pub visual_changed: bool,
224 pub structure_changed: bool,
225}
226
227pub trait PlatformFrameDriver {
228 fn request_frame(&self);
229 fn request_wake_at(&self, deadline: web_time::Instant);
230 fn clear_wake(&self);
231}
232
233#[derive(Debug)]
234pub struct FrameScheduler {
235 update_pending: AtomicBool,
236 frame_pending: AtomicBool,
237 next_deadline: Mutex<Option<web_time::Instant>>,
238}
239
240impl Default for FrameScheduler {
241 fn default() -> Self {
242 Self {
243 update_pending: AtomicBool::new(false),
244 frame_pending: AtomicBool::new(false),
245 next_deadline: Mutex::new(None),
246 }
247 }
248}
249
250impl FrameScheduler {
251 fn lock_deadline(&self) -> MutexGuard<'_, Option<web_time::Instant>> {
252 self.next_deadline
253 .lock()
254 .unwrap_or_else(|poisoned| poisoned.into_inner())
255 }
256
257 pub fn record(&self, schedule: FrameSchedule) {
258 self.update_pending
259 .store(schedule.needs_update, Ordering::SeqCst);
260 self.frame_pending
261 .store(schedule.needs_frame, Ordering::SeqCst);
262 let mut next_deadline = self.lock_deadline();
263 *next_deadline = if schedule.needs_update {
264 None
265 } else {
266 schedule.next_deadline
267 };
268 }
269
270 pub fn schedule<D>(&self, schedule: FrameSchedule, driver: &D)
271 where
272 D: PlatformFrameDriver + ?Sized,
273 {
274 self.record(schedule);
275 schedule.apply_to(driver);
276 }
277
278 pub fn snapshot(&self) -> FrameSchedule {
279 FrameSchedule {
280 needs_update: self.update_pending.load(Ordering::SeqCst),
281 needs_frame: self.frame_pending.load(Ordering::SeqCst),
282 next_deadline: *self.lock_deadline(),
283 }
284 }
285}
286
287impl FrameSchedule {
288 pub fn apply_to<D>(self, driver: &D)
289 where
290 D: PlatformFrameDriver + ?Sized,
291 {
292 if self.needs_frame {
293 driver.clear_wake();
294 driver.request_frame();
295 } else if self.needs_update {
296 driver.request_wake_at(web_time::Instant::now());
297 } else if let Some(deadline) = self.next_deadline {
298 driver.request_wake_at(deadline);
299 } else {
300 driver.clear_wake();
301 }
302 }
303}
304
305#[derive(Clone, Copy, Debug)]
306struct DevOverlayControl {
307 bounds: Rect,
308 mode: FramePacingMode,
309}
310
311#[derive(Clone, Debug, Default)]
316pub struct DevOptions {
317 pub fps_counter: bool,
319 pub recomposition_counter: bool,
321 pub layout_timing: bool,
323 pub frame_pacing_controls: bool,
324 pub frame_pacing_mode: FramePacingMode,
325}
326
327#[cfg(any(test, feature = "test-support"))]
328#[doc(hidden)]
329#[derive(Clone, Copy, Debug)]
330pub struct RuntimeLeakDebugStats {
331 pub applier_stats: MemoryApplierDebugStats,
332 pub live_node_heap_bytes: usize,
333 pub recycled_node_heap_bytes: usize,
334 pub slot_table_heap_bytes: usize,
335 pub pass_stats: CompositionPassDebugStats,
336 pub slot_stats: SlotTableDebugStats,
337 pub observer_stats: SnapshotStateObserverDebugStats,
338 pub runtime_stats: RuntimeDebugStats,
339 pub state_arena_stats: StateArenaDebugStats,
340 pub recompose_scope_stats: RecomposeScopeRegistryDebugStats,
341 pub snapshot_v2_stats: SnapshotV2DebugStats,
342 pub snapshot_pinning_stats: SnapshotPinningDebugStats,
343}
344
345impl<R> AppShell<R>
346where
347 R: Renderer,
348 R::Error: Debug,
349{
350 pub fn new(renderer: R, root_key: Key, content: impl FnMut() + 'static) -> Self {
351 Self::new_with_size(renderer, root_key, content, (800, 600), (800.0, 600.0))
352 }
353
354 pub fn new_with_size(
355 renderer: R,
356 root_key: Key,
357 content: impl FnMut() + 'static,
358 buffer_size: (u32, u32),
359 viewport: (f32, f32),
360 ) -> Self {
361 Self::new_with_size_and_density(renderer, root_key, content, buffer_size, viewport, 1.0)
362 }
363
364 pub fn new_with_size_and_density(
365 mut renderer: R,
366 root_key: Key,
367 content: impl FnMut() + 'static,
368 buffer_size: (u32, u32),
369 viewport: (f32, f32),
370 density: f32,
371 ) -> Self {
372 let app_context = cranpose_ui::AppContext::new_with_density(density);
373 let runtime = StdRuntime::new();
374 let mut composition = Composition::with_runtime(MemoryApplier::new(), runtime.runtime());
375 let mut build: Box<dyn FnMut()> = Box::new(content);
376 renderer.attach_app_context_services(&app_context);
377 app_context.enter(|| {
378 if let Err(err) = composition.render_stable(root_key, &mut *build) {
379 log::error!("initial render failed: {err}");
380 }
381 });
382 renderer.scene_mut().clear();
383 let mut shell = Self {
384 app_context,
385 runtime,
386 composition,
387 content: build,
388 renderer,
389 cursor: (0.0, 0.0),
390 viewport,
391 buffer_size,
392 start_time: Instant::now(),
393 last_frame_time_nanos: 0,
394 layout_tree: None,
395 semantics_tree: None,
396 semantics_enabled: false,
397 layout_requested: true,
398 force_layout_pass: true,
399 scene_dirty: true,
400 scoped_layout_scene_nodes: Vec::new(),
401 is_dirty: true,
402 buttons_pressed: PointerButtons::NONE,
403 hit_path_tracker: HitPathTracker::new(),
404 hovered_nodes: Vec::new(),
405 #[cfg(all(
406 feature = "clipboard-native",
407 not(target_arch = "wasm32"),
408 not(target_os = "android"),
409 not(target_os = "ios")
410 ))]
411 clipboard: arboard::Clipboard::new().ok(),
412 dev_options: DevOptions::default(),
413 dev_overlay_controls: Vec::new(),
414 dev_overlay_text: String::new(),
415 dev_overlay_last_refresh: None,
416 dev_overlay_viewport: None,
417 fps_monitor: fps_monitor::FpsMonitor::new(),
418 frame_scheduler: FrameScheduler::default(),
419 };
420 shell.process_frame();
421 shell
422 }
423
424 pub fn set_dev_options(&mut self, options: DevOptions) {
429 self.dev_options = options;
430 self.invalidate_dev_overlay_text();
431 let app_context = Rc::clone(&self.app_context);
432 app_context.enter(request_render_invalidation);
433 self.mark_dirty();
434 }
435
436 pub fn dev_options(&self) -> &DevOptions {
438 &self.dev_options
439 }
440
441 pub fn frame_pacing_mode(&self) -> FramePacingMode {
442 self.dev_options.frame_pacing_mode
443 }
444
445 pub fn current_fps(&self) -> f32 {
446 self.fps_monitor.current_fps()
447 }
448
449 pub fn fps_stats(&self) -> FpsStats {
450 self.fps_monitor.stats()
451 }
452
453 pub fn reset_fps_stats(&mut self) {
454 self.fps_monitor.reset_stats();
455 self.invalidate_dev_overlay_text();
456 }
457
458 pub fn record_presented_frame(
459 &mut self,
460 frame_started_at: Instant,
461 frame_finished_at: Instant,
462 ) {
463 self.fps_monitor
464 .record_frame_work(frame_started_at, frame_finished_at);
465 }
466
467 #[cfg(any(test, feature = "test-support"))]
468 #[doc(hidden)]
469 pub fn record_presented_frame_for_test(
470 &mut self,
471 frame_started_nanos: u64,
472 frame_finished_nanos: u64,
473 ) {
474 let started = self.start_time + std::time::Duration::from_nanos(frame_started_nanos);
475 let finished = self.start_time + std::time::Duration::from_nanos(frame_finished_nanos);
476 self.record_presented_frame(started, finished);
477 }
478
479 pub fn set_frame_pacing_mode(&mut self, mode: FramePacingMode) {
480 if self.dev_options.frame_pacing_mode == mode {
481 return;
482 }
483 self.dev_options.frame_pacing_mode = mode;
484 self.invalidate_dev_overlay_text();
485 let app_context = Rc::clone(&self.app_context);
486 app_context.enter(request_render_invalidation);
487 self.mark_dirty();
488 }
489
490 pub fn handle_dev_overlay_click(&mut self, x: f32, y: f32) -> Option<FramePacingMode> {
491 if !self.dev_options.frame_pacing_controls {
492 return None;
493 }
494 let mode = self
495 .dev_overlay_controls
496 .iter()
497 .find(|control| control.bounds.contains(x, y))
498 .map(|control| control.mode)?;
499 self.set_frame_pacing_mode(mode);
500 Some(mode)
501 }
502
503 fn invalidate_dev_overlay_text(&mut self) {
504 self.dev_overlay_text.clear();
505 self.dev_overlay_last_refresh = None;
506 self.dev_overlay_viewport = None;
507 }
508
509 pub fn set_viewport(&mut self, width: f32, height: f32) {
510 self.viewport = (width, height);
511 self.request_forced_layout_pass();
512 self.mark_dirty();
513 self.process_frame();
514 }
515
516 pub fn viewport_size(&self) -> (f32, f32) {
517 self.viewport
518 }
519
520 pub fn set_buffer_size(&mut self, width: u32, height: u32) {
521 self.buffer_size = (width, height);
522 }
523
524 pub fn buffer_size(&self) -> (u32, u32) {
525 self.buffer_size
526 }
527
528 pub fn scene(&self) -> &R::Scene {
529 self.renderer.scene()
530 }
531
532 pub fn renderer(&mut self) -> &mut R {
533 &mut self.renderer
534 }
535
536 #[cfg(not(target_arch = "wasm32"))]
537 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + Sync + 'static) {
538 self.runtime.set_frame_waker(waker);
539 }
540
541 #[cfg(target_arch = "wasm32")]
542 pub fn set_frame_waker(&mut self, waker: impl Fn() + 'static) {
543 self.runtime.set_frame_waker(waker);
544 }
545
546 pub fn clear_frame_waker(&mut self) {
547 self.runtime.clear_frame_waker();
548 }
549
550 pub fn should_render(&self) -> bool {
551 let app_context = Rc::clone(&self.app_context);
552 app_context.enter(|| {
553 if self.layout_requested
554 || self.scene_dirty
555 || peek_render_invalidation()
556 || peek_pointer_invalidation()
557 || peek_focus_invalidation()
558 || peek_layout_invalidation()
559 {
560 return true;
561 }
562 self.composition.should_render()
563 })
564 }
565
566 fn needs_ui_update_in_context(&self) -> bool {
567 if self.is_dirty
568 || self.layout_requested
569 || self.scene_dirty
570 || peek_render_invalidation()
571 || peek_pointer_invalidation()
572 || peek_focus_invalidation()
573 || peek_layout_invalidation()
574 || cranpose_ui::has_pending_layout_repasses()
575 || cranpose_ui::has_pending_draw_repasses()
576 || has_pending_pointer_repasses()
577 || has_pending_focus_invalidations()
578 {
579 return true;
580 }
581
582 self.composition.should_render()
583 }
584
585 pub fn needs_update(&self) -> bool {
586 let app_context = Rc::clone(&self.app_context);
587 app_context.enter(|| self.needs_ui_update_in_context())
588 }
589
590 pub fn needs_redraw(&self) -> bool {
593 let app_context = Rc::clone(&self.app_context);
594 app_context
595 .enter(|| self.needs_ui_update_in_context() || self.renderer.needs_frame_warmup())
596 }
597
598 pub fn mark_dirty(&mut self) {
600 self.is_dirty = true;
601 }
602
603 pub fn request_root_render(&mut self) {
604 self.composition.request_root_render();
605 self.request_forced_layout_pass();
606 let app_context = Rc::clone(&self.app_context);
607 app_context.enter(request_render_invalidation);
608 self.mark_dirty();
609 }
610
611 pub fn set_density(&mut self, density: f32) {
612 let app_context = Rc::clone(&self.app_context);
613 let changed = app_context.enter(|| {
614 let previous = cranpose_ui::current_density().to_bits();
615 cranpose_ui::set_density(density);
616 previous != cranpose_ui::current_density().to_bits()
617 });
618 if changed {
619 self.request_forced_layout_pass();
620 self.mark_dirty();
621 }
622 }
623
624 #[cfg(any(test, feature = "test-support"))]
625 #[doc(hidden)]
626 pub fn debug_current_density(&self) -> f32 {
627 let app_context = Rc::clone(&self.app_context);
628 app_context.enter(cranpose_ui::current_density)
629 }
630
631 #[cfg(any(test, feature = "test-support"))]
632 #[doc(hidden)]
633 pub fn debug_enter_app_context<T>(&self, block: impl FnOnce() -> T) -> T {
634 let app_context = Rc::clone(&self.app_context);
635 app_context.enter(block)
636 }
637
638 fn request_layout_pass(&mut self) {
639 self.layout_requested = true;
640 }
641
642 fn request_forced_layout_pass(&mut self) {
643 self.layout_requested = true;
644 self.force_layout_pass = true;
645 }
646
647 fn composition_tree_needs_layout(&mut self) -> bool {
648 let Some(root) = self.composition.root() else {
649 return true;
650 };
651 let mut applier = self.composition.applier_mut();
652 cranpose_ui::tree_needs_layout(&mut *applier, root).unwrap_or_else(|err| {
653 log::warn!(
654 "Cannot check layout dirty status for root #{}: {}",
655 root,
656 err
657 );
658 true
659 })
660 }
661
662 pub fn has_active_animations(&self) -> bool {
664 self.composition.should_render()
665 }
666
667 pub fn has_active_pointer_gesture(&self) -> bool {
668 self.buttons_pressed != PointerButtons::NONE
669 && self.hit_path_tracker.has_path(PointerId::PRIMARY)
670 }
671
672 pub fn next_event_time(&self) -> Option<web_time::Instant> {
675 let app_context = Rc::clone(&self.app_context);
676 app_context.enter(cranpose_ui::next_cursor_blink_time)
677 }
678
679 fn compute_frame_schedule(&self) -> FrameSchedule {
680 let needs_update = self.needs_update();
681 let needs_frame = needs_update
682 || self.has_active_animations()
683 || self.has_active_pointer_gesture()
684 || self.renderer.needs_frame_warmup();
685 FrameSchedule {
686 needs_update,
687 needs_frame,
688 next_deadline: self.next_event_time(),
689 }
690 }
691
692 pub fn frame_schedule(&self) -> FrameSchedule {
693 let schedule = self.compute_frame_schedule();
694 self.frame_scheduler.record(schedule);
695 schedule
696 }
697
698 pub fn schedule_platform_frame<D>(&self, driver: &D) -> FrameSchedule
699 where
700 D: PlatformFrameDriver + ?Sized,
701 {
702 let schedule = self.compute_frame_schedule();
703 self.frame_scheduler.schedule(schedule, driver);
704 schedule
705 }
706
707 pub fn frame_scheduler_snapshot(&self) -> FrameSchedule {
708 self.frame_scheduler.snapshot()
709 }
710
711 fn frame_time_nanos_at(&self, now: Instant) -> u64 {
712 now.checked_duration_since(self.start_time)
713 .unwrap_or_default()
714 .as_nanos()
715 .min(u128::from(u64::MAX)) as u64
716 }
717
718 pub fn update_after_frame_interval(
719 &mut self,
720 frame_interval: std::time::Duration,
721 ) -> FrameUpdateResult {
722 let wall_frame_time = self.frame_time_nanos_at(Instant::now());
723 let base_frame_time = self.last_frame_time_nanos.max(wall_frame_time);
724 let frame_time = base_frame_time
725 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
726 self.update_at_frame_time_nanos(frame_time)
727 }
728
729 pub fn update_at_frame_time_nanos(&mut self, frame_time: u64) -> FrameUpdateResult {
730 let app_context = Rc::clone(&self.app_context);
731 app_context.enter(|| {
732 let update_started_at = Instant::now();
733 let frame_time = frame_time.max(self.last_frame_time_nanos);
734 self.last_frame_time_nanos = frame_time;
735 let runtime_handle = self.runtime.runtime_handle();
736 runtime_handle.with_deferred_state_releases(|| {
737 self.runtime.drain_frame_callbacks(frame_time);
738 let after_frame_callbacks = Instant::now();
739 runtime_handle.drain_ui();
740 let after_ui_drain = Instant::now();
741 let should_render = self.composition.should_render();
742 let mut reconcile_attempted = false;
743 let mut reconcile_changed = false;
744 if should_render {
745 log::trace!(
746 target: "cranpose::input",
747 "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
748 self.layout_requested,
749 self.scene_dirty,
750 self.is_dirty
751 );
752 }
753 if should_render {
754 let Some(root_key) = self.composition.root_key() else {
755 let result = self.process_frame_in_context(reconcile_changed);
756 let after_process_frame = Instant::now();
757 log_update_stage_telemetry(UpdateStageTelemetry {
758 started_at: update_started_at,
759 after_frame_callbacks,
760 after_ui_drain,
761 after_reconcile: after_ui_drain,
762 after_process_frame,
763 should_render,
764 reconcile_attempted,
765 reconcile_changed,
766 });
767 self.is_dirty = false;
768 return result;
769 };
770 reconcile_attempted = true;
771 match self.composition.reconcile(root_key, &mut *self.content) {
772 Ok(changed) => {
773 reconcile_changed = changed;
774 log::trace!(
775 target: "cranpose::input",
776 "reconcile changed={changed}"
777 );
778 if changed {
779 self.fps_monitor.record_recomposition();
780 if self.composition_tree_needs_layout() {
781 self.request_layout_pass();
782 }
783 request_render_invalidation();
784 }
785 }
786 Err(NodeError::Missing { id }) => {
787 log::debug!("Recomposition skipped: node {} no longer exists", id);
788 self.request_layout_pass();
789 request_render_invalidation();
790 }
791 Err(err) => {
792 log::error!("recomposition failed: {err}");
793 self.request_layout_pass();
794 request_render_invalidation();
795 }
796 }
797 }
798 let after_reconcile = Instant::now();
799 let result = self.process_frame_in_context(reconcile_changed);
800 let after_process_frame = Instant::now();
801 log_update_stage_telemetry(UpdateStageTelemetry {
802 started_at: update_started_at,
803 after_frame_callbacks,
804 after_ui_drain,
805 after_reconcile,
806 after_process_frame,
807 should_render,
808 reconcile_attempted,
809 reconcile_changed,
810 });
811 self.is_dirty = false;
812 result
813 })
814 })
815 }
816
817 pub fn update(&mut self) -> FrameUpdateResult {
818 let frame_time = self.frame_time_nanos_at(Instant::now());
819 self.update_at_frame_time_nanos(frame_time)
820 }
821}
822
823impl<R> Drop for AppShell<R>
824where
825 R: Renderer,
826{
827 fn drop(&mut self) {
828 self.runtime.clear_frame_waker();
829 }
830}
831
832pub fn default_root_key() -> Key {
833 location_key(file!(), line!(), column!())
834}
835
836#[cfg(test)]
837mod frame_pacing_tests {
838 use super::{FramePacingMode, FrameSchedule, FrameScheduler, PlatformFrameDriver};
839 use std::cell::RefCell;
840 use std::panic::{catch_unwind, AssertUnwindSafe};
841 use std::time::Duration;
842 use web_time::Instant;
843
844 #[derive(Clone, Copy, Debug, PartialEq)]
845 enum DriverCall {
846 RequestFrame,
847 RequestWakeAt(Instant),
848 ClearWake,
849 }
850
851 #[derive(Default)]
852 struct RecordingFrameDriver {
853 calls: RefCell<Vec<DriverCall>>,
854 }
855
856 impl RecordingFrameDriver {
857 fn calls(&self) -> Vec<DriverCall> {
858 self.calls.borrow().clone()
859 }
860 }
861
862 impl PlatformFrameDriver for RecordingFrameDriver {
863 fn request_frame(&self) {
864 self.calls.borrow_mut().push(DriverCall::RequestFrame);
865 }
866
867 fn request_wake_at(&self, deadline: Instant) {
868 self.calls
869 .borrow_mut()
870 .push(DriverCall::RequestWakeAt(deadline));
871 }
872
873 fn clear_wake(&self) {
874 self.calls.borrow_mut().push(DriverCall::ClearWake);
875 }
876 }
877
878 #[test]
879 fn frame_pacing_labels_match_overlay_modes() {
880 assert_eq!(FramePacingMode::Vsync.label(), "VSync");
881 assert_eq!(FramePacingMode::Hard60.label(), "60fps");
882 assert_eq!(FramePacingMode::Hard120.label(), "120fps");
883 assert_eq!(FramePacingMode::NoVsync.label(), "NoVSync");
884 }
885
886 #[test]
887 fn only_hard_modes_have_fixed_targets() {
888 assert_eq!(FramePacingMode::Vsync.target_fps(), None);
889 assert_eq!(FramePacingMode::Hard60.target_fps(), Some(60));
890 assert_eq!(FramePacingMode::Hard120.target_fps(), Some(120));
891 assert_eq!(FramePacingMode::NoVsync.target_fps(), None);
892 }
893
894 #[test]
895 fn frame_schedule_requests_immediate_frame_and_clears_deadline() {
896 let driver = RecordingFrameDriver::default();
897 let deadline = Instant::now() + Duration::from_millis(25);
898
899 FrameSchedule {
900 needs_update: true,
901 needs_frame: true,
902 next_deadline: Some(deadline),
903 }
904 .apply_to(&driver);
905
906 assert_eq!(
907 driver.calls(),
908 vec![DriverCall::ClearWake, DriverCall::RequestFrame]
909 );
910 }
911
912 #[test]
913 fn frame_schedule_requests_deadline_when_idle_until_timer() {
914 let driver = RecordingFrameDriver::default();
915 let deadline = Instant::now() + Duration::from_millis(25);
916
917 FrameSchedule {
918 needs_update: false,
919 needs_frame: false,
920 next_deadline: Some(deadline),
921 }
922 .apply_to(&driver);
923
924 assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
925 }
926
927 #[test]
928 fn frame_schedule_wakes_without_requesting_frame_for_update_only_work() {
929 let driver = RecordingFrameDriver::default();
930 let before = Instant::now();
931
932 FrameSchedule {
933 needs_update: true,
934 needs_frame: false,
935 next_deadline: None,
936 }
937 .apply_to(&driver);
938
939 let calls = driver.calls();
940 assert_eq!(calls.len(), 1);
941 match calls[0] {
942 DriverCall::RequestWakeAt(deadline) => {
943 assert!(deadline >= before);
944 }
945 other => panic!("update-only work must wake without requesting a frame: {other:?}"),
946 }
947 }
948
949 #[test]
950 fn frame_schedule_clears_wake_when_fully_idle() {
951 let driver = RecordingFrameDriver::default();
952
953 FrameSchedule {
954 needs_update: false,
955 needs_frame: false,
956 next_deadline: None,
957 }
958 .apply_to(&driver);
959
960 assert_eq!(driver.calls(), vec![DriverCall::ClearWake]);
961 }
962
963 #[test]
964 fn frame_scheduler_records_latest_schedule_and_applies_driver() {
965 let scheduler = FrameScheduler::default();
966 let driver = RecordingFrameDriver::default();
967 let deadline = Instant::now() + Duration::from_millis(25);
968
969 scheduler.schedule(
970 FrameSchedule {
971 needs_update: false,
972 needs_frame: false,
973 next_deadline: Some(deadline),
974 },
975 &driver,
976 );
977
978 assert_eq!(
979 scheduler.snapshot(),
980 FrameSchedule {
981 needs_update: false,
982 needs_frame: false,
983 next_deadline: Some(deadline),
984 }
985 );
986 assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
987 }
988
989 #[test]
990 fn frame_scheduler_clears_deadline_for_immediate_frame() {
991 let scheduler = FrameScheduler::default();
992 let driver = RecordingFrameDriver::default();
993 let deadline = Instant::now() + Duration::from_millis(25);
994
995 scheduler.schedule(
996 FrameSchedule {
997 needs_update: true,
998 needs_frame: true,
999 next_deadline: Some(deadline),
1000 },
1001 &driver,
1002 );
1003
1004 assert_eq!(
1005 scheduler.snapshot(),
1006 FrameSchedule {
1007 needs_update: true,
1008 needs_frame: true,
1009 next_deadline: None,
1010 }
1011 );
1012 assert_eq!(
1013 driver.calls(),
1014 vec![DriverCall::ClearWake, DriverCall::RequestFrame]
1015 );
1016 }
1017
1018 #[test]
1019 fn frame_scheduler_recovers_poisoned_deadline_lock() {
1020 let scheduler = FrameScheduler::default();
1021 let deadline = Instant::now() + Duration::from_millis(25);
1022
1023 let _ = catch_unwind(AssertUnwindSafe(|| {
1024 let _guard = scheduler.lock_deadline();
1025 panic!("poison frame scheduler deadline lock");
1026 }));
1027
1028 scheduler.record(FrameSchedule {
1029 needs_update: false,
1030 needs_frame: false,
1031 next_deadline: Some(deadline),
1032 });
1033
1034 assert_eq!(
1035 scheduler.snapshot(),
1036 FrameSchedule {
1037 needs_update: false,
1038 needs_frame: false,
1039 next_deadline: Some(deadline),
1040 }
1041 );
1042 }
1043}
1044
1045#[cfg(test)]
1046#[path = "tests/app_shell_tests.rs"]
1047mod tests;