1#![allow(clippy::type_complexity)]
2
3mod fps_monitor;
4mod hit_path_tracker;
5mod shell_debug;
6mod shell_frame;
7mod shell_input;
8mod wheel;
9use std::{
10 fmt::{Debug, Write},
11 rc::Rc,
12 sync::{
13 Mutex, MutexGuard,
14 atomic::{AtomicBool, Ordering},
15 },
16};
17
18use cranpose_core::{
19 Applier, Composition, Key, MemoryApplier, NodeError, NodeId, collections::map::HashSet,
20 enter_event_handler_scope, location_key, run_in_mutable_snapshot,
21};
22pub use cranpose_foundation::{
23 DEFAULT_ROTARY_SCROLL_FACTOR_DP, Modifiers, PointerSource, RotaryScrollEvent,
24 rotary_scroll_pixels_from_detents,
25};
26use cranpose_foundation::{PointerButton, PointerButtons, PointerEvent, PointerEventKind};
27use cranpose_render_common::{HitTestTarget, RenderScene, Renderer};
28use cranpose_runtime_std::StdRuntime;
29use cranpose_ui::{
30 HeadlessRenderer, LayoutBox, LayoutNode, LayoutTree, MeasureLayoutOptions, SemanticsTree,
31 SubcomposeLayoutNode, clear_transient_scroll_motion_contexts, format_layout_tree,
32 format_render_scene, format_screen_summary, has_pending_focus_invalidations,
33 has_pending_pointer_repasses, has_pending_semantics_invalidations, peek_focus_invalidation,
34 peek_layout_invalidation, peek_pointer_invalidation, peek_render_invalidation,
35 process_focus_invalidations, process_pointer_repasses, process_semantics_invalidations,
36 request_render_invalidation, take_draw_repass_nodes, take_focus_invalidation,
37 take_layout_invalidation, take_pointer_invalidation, take_render_invalidation,
38};
39pub use cranpose_ui::{KeyCode, KeyEvent, KeyEventType};
40use cranpose_ui_graphics::{Point, Rect, Size};
41pub use fps_monitor::FpsStats;
42use hit_path_tracker::{HitPathTracker, PointerId};
43#[cfg(test)]
44use shell_frame::build_draw_refresh_scope;
45use web_time::Instant;
46pub use wheel::WheelScroll;
47
48#[cfg(all(
49 feature = "clipboard-native",
50 not(target_arch = "wasm32"),
51 not(target_os = "android"),
52 not(target_os = "ios")
53))]
54struct ShellClipboard {
55 inner: std::rc::Rc<std::cell::RefCell<Option<arboard::Clipboard>>>,
56}
57
58#[cfg(all(
59 feature = "clipboard-native",
60 not(target_arch = "wasm32"),
61 not(target_os = "android"),
62 not(target_os = "ios")
63))]
64impl cranpose_ui::clipboard_session::PlatformClipboard for ShellClipboard {
65 fn write_text(&self, text: &str) {
66 if let Some(clipboard) = self.inner.borrow_mut().as_mut() {
67 let _ = clipboard.set_text(text);
68 }
69 }
70
71 fn read_text(&self) -> Option<String> {
72 self.inner
73 .borrow_mut()
74 .as_mut()
75 .and_then(|clipboard| clipboard.get_text().ok())
76 }
77}
78#[cfg(any(test, feature = "test-support"))]
79use cranpose_core::{
80 CompositionPassDebugStats, SlotId,
81 runtime::{RuntimeDebugStats, StateArenaDebugStats},
82 snapshot_pinning::{SnapshotPinningDebugStats, debug_snapshot_pinning_stats},
83 snapshot_state_observer::SnapshotStateObserverDebugStats,
84 snapshot_v2::{SnapshotV2DebugStats, debug_snapshot_v2_stats},
85};
86#[cfg(any(test, feature = "test-support"))]
87use cranpose_core::{
88 MemoryApplierDebugStats, RecomposeScopeRegistryDebugStats, SlotTableDebugStats,
89 debug_recompose_scope_registry_stats,
90};
91pub use cranpose_ui::{ImeEditorState, PlatformTextInputHandler};
92
93#[derive(Debug, Clone, Copy, PartialEq, Default)]
103pub enum FrameRatePreference {
104 #[default]
108 Auto,
109 NoPreference,
111 Exact(f32),
114}
115
116impl FrameRatePreference {
117 pub const AUTO_QUIET_RATE_HZ: f32 = 60.0;
124
125 pub fn desired_rate_hz(
139 self,
140 producing_frames: bool,
141 interacting: bool,
142 panel_max_hz: Option<f32>,
143 ) -> f32 {
144 match self {
145 FrameRatePreference::Auto => {
146 if interacting {
147 panel_max_hz
148 .filter(|rate| *rate > 0.0)
149 .unwrap_or(Self::AUTO_QUIET_RATE_HZ)
150 } else if producing_frames {
151 Self::AUTO_QUIET_RATE_HZ
152 } else {
153 0.0
154 }
155 }
156 FrameRatePreference::NoPreference => 0.0,
157 FrameRatePreference::Exact(rate) if rate > 0.0 => rate,
158 FrameRatePreference::Exact(_) => 0.0,
159 }
160 }
161}
162
163pub struct AppShell<R>
164where
165 R: Renderer,
166{
167 app_context: Rc<cranpose_ui::AppContext>,
168 runtime: StdRuntime,
169 composition: Composition<MemoryApplier>,
170 content: Box<dyn FnMut()>,
171 renderer: R,
172 cursor: (f32, f32),
173 viewport: (f32, f32),
174 buffer_size: (u32, u32),
175 start_time: Instant,
176 last_frame_time_nanos: u64,
177 layout_tree: Option<LayoutTree>,
178 semantics_tree: Option<SemanticsTree>,
179 semantics_enabled: bool,
180 semantics_snapshot_revision: u64,
181 frame_rate_preference: FrameRatePreference,
182 layout_requested: bool,
183 force_layout_pass: bool,
184 scene_dirty: bool,
185 scoped_layout_scene_nodes: Vec<NodeId>,
186 retained_visual_nodes: HashSet<NodeId>,
187 is_dirty: bool,
188 buttons_pressed: PointerButtons,
189 pointer_source: PointerSource,
190 modifiers: Option<Modifiers>,
191 hit_path_tracker: HitPathTracker,
192 hovered_nodes: Vec<NodeId>,
193 on_rotary_scroll: Option<Rc<dyn Fn(RotaryScrollEvent) -> bool>>,
194 rotary_scroll_factor: f32,
195 #[cfg(all(feature = "clipboard-native", target_os = "linux"))]
196 clipboard: Option<arboard::Clipboard>,
197 dev_options: DevOptions,
198 dev_overlay_controls: Vec<DevOverlayControl>,
199 dev_overlay_text: String,
200 dev_overlay_last_refresh: Option<Instant>,
201 dev_overlay_viewport: Option<Size>,
202 fps_monitor: fps_monitor::FpsMonitor,
203 frame_scheduler: FrameScheduler,
204}
205
206#[derive(Clone, Copy, Debug, PartialEq, Eq)]
207pub struct PointerEventTime {
209 pub platform_time_ms: Option<i64>,
211 pub animation_time_nanos: u64,
213}
214
215fn update_stage_telemetry_threshold_ms() -> Option<f64> {
216 static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
217 *THRESHOLD_MS.get_or_init(|| {
218 std::env::var("CRANPOSE_UPDATE_STAGE_TELEMETRY_MS")
219 .ok()
220 .and_then(|value| value.parse::<f64>().ok())
221 .filter(|value| value.is_finite() && *value >= 0.0)
222 })
223}
224
225#[derive(Clone, Copy, Debug)]
226struct UpdateStageTelemetry {
227 started_at: Instant,
228 after_frame_callbacks: Instant,
229 after_ui_drain: Instant,
230 after_reconcile: Instant,
231 after_process_frame: Instant,
232 should_render: bool,
233 reconcile_attempted: bool,
234 reconcile_changed: bool,
235}
236
237fn log_update_stage_telemetry(telemetry: UpdateStageTelemetry) {
238 let Some(threshold_ms) = update_stage_telemetry_threshold_ms() else {
239 return;
240 };
241 let total_ms = telemetry
242 .after_process_frame
243 .duration_since(telemetry.started_at)
244 .as_secs_f64()
245 * 1000.0;
246 if total_ms < threshold_ms {
247 return;
248 }
249
250 let frame_callbacks_ms = telemetry
251 .after_frame_callbacks
252 .duration_since(telemetry.started_at)
253 .as_secs_f64()
254 * 1000.0;
255 let ui_drain_ms = telemetry
256 .after_ui_drain
257 .duration_since(telemetry.after_frame_callbacks)
258 .as_secs_f64()
259 * 1000.0;
260 let reconcile_ms = telemetry
261 .after_reconcile
262 .duration_since(telemetry.after_ui_drain)
263 .as_secs_f64()
264 * 1000.0;
265 let process_frame_ms = telemetry
266 .after_process_frame
267 .duration_since(telemetry.after_reconcile)
268 .as_secs_f64()
269 * 1000.0;
270 eprintln!(
271 "[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={}",
272 telemetry.should_render, telemetry.reconcile_attempted, telemetry.reconcile_changed
273 );
274}
275
276#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
277pub enum FramePacingMode {
278 #[default]
281 Vsync,
282 Hard60,
283 Hard120,
284 NoVsync,
287}
288
289impl FramePacingMode {
290 pub const ALL: [Self; 4] = [Self::Vsync, Self::Hard60, Self::Hard120, Self::NoVsync];
291
292 pub fn label(self) -> &'static str {
293 match self {
294 Self::Vsync => "VSync",
295 Self::Hard60 => "60fps",
296 Self::Hard120 => "120fps",
297 Self::NoVsync => "NoVSync",
298 }
299 }
300
301 pub fn target_fps(self) -> Option<u32> {
302 match self {
303 Self::Hard60 => Some(60),
304 Self::Hard120 => Some(120),
305 Self::Vsync | Self::NoVsync => None,
306 }
307 }
308}
309
310#[derive(Clone, Copy, Debug, PartialEq)]
311pub struct FrameSchedule {
312 pub needs_update: bool,
313 pub needs_frame: bool,
314 pub next_deadline: Option<web_time::Instant>,
315}
316
317#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
318pub struct FrameUpdateResult {
319 pub visual_changed: bool,
320 pub structure_changed: bool,
321}
322
323pub trait PlatformFrameDriver {
324 fn request_frame(&self);
325 fn request_wake_at(&self, deadline: web_time::Instant);
326 fn clear_wake(&self);
327}
328
329#[derive(Debug)]
330pub struct FrameScheduler {
331 update_pending: AtomicBool,
332 frame_pending: AtomicBool,
333 next_deadline: Mutex<Option<web_time::Instant>>,
334}
335
336impl Default for FrameScheduler {
337 fn default() -> Self {
338 Self {
339 update_pending: AtomicBool::new(false),
340 frame_pending: AtomicBool::new(false),
341 next_deadline: Mutex::new(None),
342 }
343 }
344}
345
346impl FrameScheduler {
347 fn lock_deadline(&self) -> MutexGuard<'_, Option<web_time::Instant>> {
348 self.next_deadline
349 .lock()
350 .unwrap_or_else(|poisoned| poisoned.into_inner())
351 }
352
353 pub fn record(&self, schedule: FrameSchedule) {
354 self.update_pending
355 .store(schedule.needs_update, Ordering::SeqCst);
356 self.frame_pending
357 .store(schedule.needs_frame, Ordering::SeqCst);
358 let mut next_deadline = self.lock_deadline();
359 *next_deadline = if schedule.needs_update {
360 None
361 } else {
362 schedule.next_deadline
363 };
364 }
365
366 pub fn schedule<D>(&self, schedule: FrameSchedule, driver: &D)
367 where
368 D: PlatformFrameDriver + ?Sized,
369 {
370 self.record(schedule);
371 schedule.apply_to(driver);
372 }
373
374 pub fn snapshot(&self) -> FrameSchedule {
375 FrameSchedule {
376 needs_update: self.update_pending.load(Ordering::SeqCst),
377 needs_frame: self.frame_pending.load(Ordering::SeqCst),
378 next_deadline: *self.lock_deadline(),
379 }
380 }
381}
382
383impl FrameSchedule {
384 pub fn apply_to<D>(self, driver: &D)
385 where
386 D: PlatformFrameDriver + ?Sized,
387 {
388 if self.needs_frame {
389 driver.clear_wake();
390 driver.request_frame();
391 } else if self.needs_update {
392 driver.request_wake_at(web_time::Instant::now());
393 } else if let Some(deadline) = self.next_deadline {
394 driver.request_wake_at(deadline);
395 } else {
396 driver.clear_wake();
397 }
398 }
399}
400
401#[derive(Clone, Copy, Debug)]
402struct DevOverlayControl {
403 bounds: Rect,
404 mode: FramePacingMode,
405}
406
407#[derive(Clone, Debug, Default)]
412pub struct DevOptions {
413 pub fps_counter: bool,
415 pub recomposition_counter: bool,
417 pub layout_timing: bool,
419 pub frame_pacing_controls: bool,
420 pub frame_pacing_mode: FramePacingMode,
421}
422
423#[cfg(any(test, feature = "test-support"))]
424#[doc(hidden)]
425#[derive(Clone, Copy, Debug)]
426pub struct RuntimeLeakDebugStats {
427 pub applier_stats: MemoryApplierDebugStats,
428 pub live_node_heap_bytes: usize,
429 pub recycled_node_heap_bytes: usize,
430 pub slot_table_heap_bytes: usize,
431 pub pass_stats: CompositionPassDebugStats,
432 pub slot_stats: SlotTableDebugStats,
433 pub observer_stats: SnapshotStateObserverDebugStats,
434 pub runtime_stats: RuntimeDebugStats,
435 pub state_arena_stats: StateArenaDebugStats,
436 pub recompose_scope_stats: RecomposeScopeRegistryDebugStats,
437 pub snapshot_v2_stats: SnapshotV2DebugStats,
438 pub snapshot_pinning_stats: SnapshotPinningDebugStats,
439}
440
441impl<R> AppShell<R>
442where
443 R: Renderer,
444 R::Error: Debug,
445{
446 pub fn new(renderer: R, root_key: Key, content: impl FnMut() + 'static) -> Self {
447 Self::new_with_size(renderer, root_key, content, (800, 600), (800.0, 600.0))
448 }
449
450 pub fn new_with_size(
451 renderer: R,
452 root_key: Key,
453 content: impl FnMut() + 'static,
454 buffer_size: (u32, u32),
455 viewport: (f32, f32),
456 ) -> Self {
457 Self::new_with_size_and_density(renderer, root_key, content, buffer_size, viewport, 1.0)
458 }
459
460 pub fn new_with_size_and_density(
461 mut renderer: R,
462 root_key: Key,
463 content: impl FnMut() + 'static,
464 buffer_size: (u32, u32),
465 viewport: (f32, f32),
466 density: f32,
467 ) -> Self {
468 let app_context = cranpose_ui::AppContext::new_with_density(density);
469 let runtime = StdRuntime::new();
470 let mut composition = Composition::with_runtime(MemoryApplier::new(), runtime.runtime());
471 let app_content = Rc::new(std::cell::RefCell::new(content));
472 let mut build: Box<dyn FnMut()> = Box::new(move || {
473 let app_content = Rc::clone(&app_content);
474 cranpose_ui::widgets::PopupHost(move || {
475 (app_content.borrow_mut())();
476 });
477 });
478 renderer.attach_app_context_services(&app_context);
479 app_context.enter(|| {
480 #[cfg(all(
481 feature = "clipboard-native",
482 not(target_arch = "wasm32"),
483 not(target_os = "android"),
484 not(target_os = "ios")
485 ))]
486 {
487 let clipboard =
488 std::rc::Rc::new(std::cell::RefCell::new(arboard::Clipboard::new().ok()));
489 cranpose_ui::clipboard_session::set_platform_clipboard(std::rc::Rc::new(
490 ShellClipboard { inner: clipboard },
491 ));
492 }
493 if let Err(err) = composition.render_stable(root_key, &mut *build) {
494 log::error!("initial render failed: {err}");
495 }
496 });
497 renderer.scene_mut().clear();
498 let mut shell = Self {
499 app_context,
500 runtime,
501 composition,
502 content: build,
503 renderer,
504 cursor: (0.0, 0.0),
505 viewport,
506 buffer_size,
507 start_time: Instant::now(),
508 last_frame_time_nanos: 0,
509 layout_tree: None,
510 semantics_tree: None,
511 semantics_enabled: false,
512 semantics_snapshot_revision: 0,
513 frame_rate_preference: FrameRatePreference::default(),
514 layout_requested: true,
515 force_layout_pass: true,
516 scene_dirty: true,
517 scoped_layout_scene_nodes: Vec::new(),
518 retained_visual_nodes: HashSet::new(),
519 is_dirty: true,
520 buttons_pressed: PointerButtons::NONE,
521 pointer_source: PointerSource::Unknown,
522 modifiers: None,
523 hit_path_tracker: HitPathTracker::new(),
524 hovered_nodes: Vec::new(),
525 on_rotary_scroll: None,
526 rotary_scroll_factor: DEFAULT_ROTARY_SCROLL_FACTOR_DP,
527 #[cfg(all(feature = "clipboard-native", target_os = "linux"))]
528 clipboard: arboard::Clipboard::new().ok(),
529 dev_options: DevOptions::default(),
530 dev_overlay_controls: Vec::new(),
531 dev_overlay_text: String::new(),
532 dev_overlay_last_refresh: None,
533 dev_overlay_viewport: None,
534 fps_monitor: fps_monitor::FpsMonitor::new(),
535 frame_scheduler: FrameScheduler::default(),
536 };
537 shell.process_frame();
538 shell
539 }
540
541 pub fn app_context(&self) -> &Rc<cranpose_ui::AppContext> {
546 &self.app_context
547 }
548
549 pub fn set_dev_options(&mut self, options: DevOptions) {
554 self.dev_options = options;
555 self.invalidate_dev_overlay_text();
556 let app_context = Rc::clone(&self.app_context);
557 app_context.enter(request_render_invalidation);
558 self.mark_dirty();
559 }
560
561 pub fn dev_options(&self) -> &DevOptions {
563 &self.dev_options
564 }
565
566 pub fn frame_pacing_mode(&self) -> FramePacingMode {
567 self.dev_options.frame_pacing_mode
568 }
569
570 pub fn current_fps(&self) -> f32 {
571 self.fps_monitor.current_fps()
572 }
573
574 pub fn fps_stats(&self) -> FpsStats {
575 self.fps_monitor.stats()
576 }
577
578 pub fn reset_fps_stats(&mut self) {
579 self.fps_monitor.reset_stats();
580 self.invalidate_dev_overlay_text();
581 }
582
583 pub fn record_presented_frame(
584 &mut self,
585 frame_started_at: Instant,
586 frame_finished_at: Instant,
587 ) {
588 self.fps_monitor
589 .record_frame_work(frame_started_at, frame_finished_at);
590 }
591
592 #[cfg(any(test, feature = "test-support"))]
593 #[doc(hidden)]
594 pub fn record_presented_frame_for_test(
595 &mut self,
596 frame_started_nanos: u64,
597 frame_finished_nanos: u64,
598 ) {
599 let started = self.start_time + std::time::Duration::from_nanos(frame_started_nanos);
600 let finished = self.start_time + std::time::Duration::from_nanos(frame_finished_nanos);
601 self.record_presented_frame(started, finished);
602 }
603
604 pub fn set_frame_pacing_mode(&mut self, mode: FramePacingMode) {
605 if self.dev_options.frame_pacing_mode == mode {
606 return;
607 }
608 self.dev_options.frame_pacing_mode = mode;
609 self.invalidate_dev_overlay_text();
610 let app_context = Rc::clone(&self.app_context);
611 app_context.enter(request_render_invalidation);
612 self.mark_dirty();
613 }
614
615 pub fn dev_overlay_control_center(&self, mode: FramePacingMode) -> Option<(f32, f32)> {
622 self.dev_overlay_controls
623 .iter()
624 .find(|control| control.mode == mode)
625 .map(|control| {
626 (
627 control.bounds.x + control.bounds.width * 0.5,
628 control.bounds.y + control.bounds.height * 0.5,
629 )
630 })
631 }
632
633 pub(crate) fn dev_overlay_press(&mut self, x: f32, y: f32) -> bool {
634 if !self.dev_options.frame_pacing_controls {
635 return false;
636 }
637 let Some(mode) = self
638 .dev_overlay_controls
639 .iter()
640 .find(|control| control.bounds.contains(x, y))
641 .map(|control| control.mode)
642 else {
643 return false;
644 };
645 self.set_frame_pacing_mode(mode);
646 true
647 }
648
649 fn invalidate_dev_overlay_text(&mut self) {
650 self.dev_overlay_text.clear();
651 self.dev_overlay_last_refresh = None;
652 self.dev_overlay_viewport = None;
653 }
654
655 pub fn set_viewport(&mut self, width: f32, height: f32) {
656 self.viewport = (width, height);
657 self.request_forced_layout_pass();
658 self.mark_dirty();
659 self.process_frame();
660 }
661
662 pub fn viewport_size(&self) -> (f32, f32) {
663 self.viewport
664 }
665
666 pub fn set_buffer_size(&mut self, width: u32, height: u32) {
667 self.buffer_size = (width, height);
668 }
669
670 pub fn buffer_size(&self) -> (u32, u32) {
671 self.buffer_size
672 }
673
674 pub fn scene(&self) -> &R::Scene {
675 self.renderer.scene()
676 }
677
678 pub fn renderer(&mut self) -> &mut R {
679 &mut self.renderer
680 }
681
682 #[cfg(not(target_arch = "wasm32"))]
683 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + Sync + 'static) {
684 self.runtime.set_frame_waker(waker);
685 }
686
687 #[cfg(target_arch = "wasm32")]
688 pub fn set_frame_waker(&mut self, waker: impl Fn() + 'static) {
689 self.runtime.set_frame_waker(waker);
690 }
691
692 pub fn clear_frame_waker(&mut self) {
693 self.runtime.clear_frame_waker();
694 }
695
696 pub fn should_render(&self) -> bool {
697 let app_context = Rc::clone(&self.app_context);
698 app_context.enter(|| {
699 if self.layout_requested
700 || self.scene_dirty
701 || peek_render_invalidation()
702 || peek_pointer_invalidation()
703 || peek_focus_invalidation()
704 || peek_layout_invalidation()
705 {
706 return true;
707 }
708 self.composition.should_render()
709 })
710 }
711
712 fn has_stale_pixels_in_context(&self) -> bool {
713 self.is_dirty
714 || self.layout_requested
715 || self.scene_dirty
716 || peek_render_invalidation()
717 || peek_pointer_invalidation()
718 || peek_focus_invalidation()
719 || peek_layout_invalidation()
720 || cranpose_ui::has_pending_layout_repasses()
721 || cranpose_ui::has_pending_measure_repasses()
722 || cranpose_ui::has_pending_draw_repasses()
723 || has_pending_pointer_repasses()
724 || has_pending_focus_invalidations()
725 }
726
727 fn needs_ui_update_in_context(&self) -> bool {
728 self.has_stale_pixels_in_context()
729 || self.composition.runtime_handle().has_pending_ui()
730 || has_pending_semantics_invalidations()
731 || self.composition.should_render()
732 }
733
734 pub fn needs_update(&self) -> bool {
735 let app_context = Rc::clone(&self.app_context);
736 app_context.enter(|| self.needs_ui_update_in_context())
737 }
738
739 pub fn has_pending_ui(&self) -> bool {
747 let app_context = Rc::clone(&self.app_context);
748 app_context.enter(|| self.composition.runtime_handle().has_pending_ui())
749 }
750
751 pub fn needs_redraw(&self) -> bool {
764 let app_context = Rc::clone(&self.app_context);
765 app_context.enter(|| self.has_stale_pixels_in_context() || self.renderer_warmup_due())
766 }
767
768 fn renderer_warmup_due(&self) -> bool {
773 self.renderer.needs_frame_warmup() && !self.runtime.runtime_handle().has_frame_callbacks()
774 }
775
776 pub fn mark_dirty(&mut self) {
778 self.is_dirty = true;
779 }
780
781 pub fn request_root_render(&mut self) {
782 self.composition.request_root_render();
783 self.request_forced_layout_pass();
784 let app_context = Rc::clone(&self.app_context);
785 app_context.enter(request_render_invalidation);
786 self.mark_dirty();
787 }
788
789 pub fn set_density(&mut self, density: f32) {
790 let app_context = Rc::clone(&self.app_context);
791 let changed = app_context.enter(|| {
792 let previous = cranpose_ui::current_density().to_bits();
793 cranpose_ui::set_density(density);
794 previous != cranpose_ui::current_density().to_bits()
795 });
796 if changed {
797 self.request_forced_layout_pass();
798 self.mark_dirty();
799 }
800 }
801
802 pub fn set_font_scale(&mut self, font_scale: f32) {
813 self.set_font_scale_curve(cranpose_ui::FontScaleCurve::linear(font_scale));
814 }
815
816 pub fn set_font_scale_curve(&mut self, curve: cranpose_ui::FontScaleCurve) {
823 let app_context = Rc::clone(&self.app_context);
824 let changed = app_context.enter(|| {
825 let previous = cranpose_ui::current_font_scale_curve();
826 cranpose_ui::set_font_scale_curve(curve);
827 previous != cranpose_ui::current_font_scale_curve()
828 });
829 if changed {
830 self.request_forced_layout_pass();
831 self.mark_dirty();
832 }
833 }
834
835 #[cfg(any(test, feature = "test-support"))]
836 #[doc(hidden)]
837 pub fn debug_current_density(&self) -> f32 {
838 let app_context = Rc::clone(&self.app_context);
839 app_context.enter(cranpose_ui::current_density)
840 }
841
842 #[cfg(any(test, feature = "test-support"))]
843 #[doc(hidden)]
844 pub fn debug_current_font_scale(&self) -> f32 {
845 let app_context = Rc::clone(&self.app_context);
846 app_context.enter(cranpose_ui::current_font_scale)
847 }
848
849 #[cfg(any(test, feature = "test-support"))]
850 #[doc(hidden)]
851 pub fn debug_current_font_scale_curve(&self) -> cranpose_ui::FontScaleCurve {
852 let app_context = Rc::clone(&self.app_context);
853 app_context.enter(cranpose_ui::current_font_scale_curve)
854 }
855
856 #[cfg(any(test, feature = "test-support"))]
857 #[doc(hidden)]
858 pub fn debug_enter_app_context<T>(&self, block: impl FnOnce() -> T) -> T {
859 let app_context = Rc::clone(&self.app_context);
860 app_context.enter(block)
861 }
862
863 fn request_layout_pass(&mut self) {
864 self.layout_requested = true;
865 }
866
867 fn request_forced_layout_pass(&mut self) {
868 self.layout_requested = true;
869 self.force_layout_pass = true;
870 }
871
872 fn composition_tree_needs_layout(&mut self) -> bool {
873 let Some(root) = self.composition.root() else {
874 return true;
875 };
876 let mut applier = self.composition.applier_mut();
877 cranpose_ui::tree_needs_layout(&mut *applier, root).unwrap_or_else(|err| {
878 log::warn!(
879 "Cannot check layout dirty status for root #{}: {}",
880 root,
881 err
882 );
883 true
884 })
885 }
886
887 pub fn has_active_animations(&self) -> bool {
889 self.composition.should_render()
890 }
891
892 pub fn has_transient_frame_callbacks(&self) -> bool {
893 self.composition
894 .runtime_handle()
895 .has_transient_frame_callbacks()
896 }
897
898 pub fn has_active_pointer_gesture(&self) -> bool {
899 self.buttons_pressed != PointerButtons::NONE
900 && self.hit_path_tracker.has_path(PointerId::PRIMARY)
901 }
902
903 pub fn next_event_time(&self) -> Option<web_time::Instant> {
906 let app_context = Rc::clone(&self.app_context);
907 app_context.enter(cranpose_ui::next_cursor_blink_time)
908 }
909
910 fn compute_frame_schedule(&self) -> FrameSchedule {
911 let needs_update = self.needs_update();
912 let needs_frame = self.is_dirty
913 || self.should_render()
914 || self.has_active_pointer_gesture()
915 || self.renderer_warmup_due();
916 FrameSchedule {
917 needs_update,
918 needs_frame,
919 next_deadline: self.next_event_time(),
920 }
921 }
922
923 pub fn frame_schedule(&self) -> FrameSchedule {
924 let schedule = self.compute_frame_schedule();
925 self.frame_scheduler.record(schedule);
926 schedule
927 }
928
929 pub fn schedule_platform_frame<D>(&self, driver: &D) -> FrameSchedule
930 where
931 D: PlatformFrameDriver + ?Sized,
932 {
933 let schedule = self.compute_frame_schedule();
934 self.frame_scheduler.schedule(schedule, driver);
935 schedule
936 }
937
938 pub fn frame_scheduler_snapshot(&self) -> FrameSchedule {
939 self.frame_scheduler.snapshot()
940 }
941
942 fn frame_time_nanos_at(&self, now: Instant) -> u64 {
943 now.checked_duration_since(self.start_time)
944 .unwrap_or_default()
945 .as_nanos()
946 .min(u128::from(u64::MAX)) as u64
947 }
948
949 pub fn realtime_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
951 PointerEventTime {
952 platform_time_ms,
953 animation_time_nanos: self
954 .frame_time_nanos_at(Instant::now())
955 .max(self.last_frame_time_nanos),
956 }
957 }
958
959 pub fn exact_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
961 PointerEventTime {
962 platform_time_ms,
963 animation_time_nanos: self.last_frame_time_nanos,
964 }
965 }
966
967 pub fn update_after_frame_interval(
968 &mut self,
969 frame_interval: std::time::Duration,
970 ) -> FrameUpdateResult {
971 let wall_frame_time = self.frame_time_nanos_at(Instant::now());
972 let base_frame_time = self.last_frame_time_nanos.max(wall_frame_time);
973 let frame_time = base_frame_time
974 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
975 self.update_at_frame_time_nanos(frame_time)
976 }
977
978 pub fn update_after_exact_interval(
984 &mut self,
985 frame_interval: std::time::Duration,
986 ) -> FrameUpdateResult {
987 let frame_time = self
988 .last_frame_time_nanos
989 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
990 self.update_at_frame_time_nanos(frame_time)
991 }
992
993 pub fn update_at_frame_time_nanos(&mut self, frame_time: u64) -> FrameUpdateResult {
994 let app_context = Rc::clone(&self.app_context);
995 app_context.enter(|| {
996 let update_started_at = Instant::now();
997 let frame_time = frame_time.max(self.last_frame_time_nanos);
998 self.last_frame_time_nanos = frame_time;
999 let runtime_handle = self.runtime.runtime_handle();
1000 runtime_handle.with_deferred_state_releases(|| {
1001 self.runtime.drain_frame_callbacks(frame_time);
1002 let after_frame_callbacks = Instant::now();
1003 runtime_handle.drain_ui();
1004 let after_ui_drain = Instant::now();
1005 let should_render = self.composition.should_recompose();
1006 let mut reconcile_attempted = false;
1007 let mut reconcile_changed = false;
1008 if should_render {
1009 log::trace!(
1010 target: "cranpose::input",
1011 "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
1012 self.layout_requested,
1013 self.scene_dirty,
1014 self.is_dirty
1015 );
1016 }
1017 if should_render {
1018 let Some(root_key) = self.composition.root_key() else {
1019 let result = self.process_frame_in_context(reconcile_changed);
1020 let after_process_frame = Instant::now();
1021 log_update_stage_telemetry(UpdateStageTelemetry {
1022 started_at: update_started_at,
1023 after_frame_callbacks,
1024 after_ui_drain,
1025 after_reconcile: after_ui_drain,
1026 after_process_frame,
1027 should_render,
1028 reconcile_attempted,
1029 reconcile_changed,
1030 });
1031 self.is_dirty = false;
1032 return result;
1033 };
1034 reconcile_attempted = true;
1035 match self.composition.reconcile(root_key, &mut *self.content) {
1036 Ok(changed) => {
1037 reconcile_changed = changed;
1038 log::trace!(
1039 target: "cranpose::input",
1040 "reconcile changed={changed}"
1041 );
1042 if changed {
1043 self.fps_monitor.record_recomposition();
1044 if self.composition_tree_needs_layout() {
1045 self.request_layout_pass();
1046 }
1047 request_render_invalidation();
1048 }
1049 }
1050 Err(NodeError::Missing { id }) => {
1051 log::debug!("Recomposition skipped: node {} no longer exists", id);
1052 self.request_layout_pass();
1053 request_render_invalidation();
1054 }
1055 Err(err) => {
1056 log::error!("recomposition failed: {err}");
1057 self.request_layout_pass();
1058 request_render_invalidation();
1059 }
1060 }
1061 }
1062 let after_reconcile = Instant::now();
1063 let result = self.process_frame_in_context(reconcile_changed);
1064 let after_process_frame = Instant::now();
1065 log_update_stage_telemetry(UpdateStageTelemetry {
1066 started_at: update_started_at,
1067 after_frame_callbacks,
1068 after_ui_drain,
1069 after_reconcile,
1070 after_process_frame,
1071 should_render,
1072 reconcile_attempted,
1073 reconcile_changed,
1074 });
1075 self.is_dirty = false;
1076 result
1077 })
1078 })
1079 }
1080
1081 pub fn update(&mut self) -> FrameUpdateResult {
1082 let frame_time = self.frame_time_nanos_at(Instant::now());
1083 self.update_at_frame_time_nanos(frame_time)
1084 }
1085}
1086
1087impl<R> Drop for AppShell<R>
1088where
1089 R: Renderer,
1090{
1091 fn drop(&mut self) {
1092 self.runtime.clear_frame_waker();
1093 }
1094}
1095
1096pub fn default_root_key() -> Key {
1097 location_key(file!(), line!(), column!())
1098}
1099
1100#[cfg(test)]
1101mod frame_pacing_tests {
1102 use std::{
1103 cell::RefCell,
1104 panic::{AssertUnwindSafe, catch_unwind},
1105 time::Duration,
1106 };
1107
1108 use web_time::Instant;
1109
1110 use super::{FramePacingMode, FrameSchedule, FrameScheduler, PlatformFrameDriver};
1111
1112 #[derive(Clone, Copy, Debug, PartialEq)]
1113 enum DriverCall {
1114 RequestFrame,
1115 RequestWakeAt(Instant),
1116 ClearWake,
1117 }
1118
1119 #[derive(Default)]
1120 struct RecordingFrameDriver {
1121 calls: RefCell<Vec<DriverCall>>,
1122 }
1123
1124 impl RecordingFrameDriver {
1125 fn calls(&self) -> Vec<DriverCall> {
1126 self.calls.borrow().clone()
1127 }
1128 }
1129
1130 impl PlatformFrameDriver for RecordingFrameDriver {
1131 fn request_frame(&self) {
1132 self.calls.borrow_mut().push(DriverCall::RequestFrame);
1133 }
1134
1135 fn request_wake_at(&self, deadline: Instant) {
1136 self.calls
1137 .borrow_mut()
1138 .push(DriverCall::RequestWakeAt(deadline));
1139 }
1140
1141 fn clear_wake(&self) {
1142 self.calls.borrow_mut().push(DriverCall::ClearWake);
1143 }
1144 }
1145
1146 #[test]
1147 fn frame_pacing_labels_match_overlay_modes() {
1148 assert_eq!(FramePacingMode::Vsync.label(), "VSync");
1149 assert_eq!(FramePacingMode::Hard60.label(), "60fps");
1150 assert_eq!(FramePacingMode::Hard120.label(), "120fps");
1151 assert_eq!(FramePacingMode::NoVsync.label(), "NoVSync");
1152 }
1153
1154 #[test]
1155 fn only_hard_modes_have_fixed_targets() {
1156 assert_eq!(FramePacingMode::Vsync.target_fps(), None);
1157 assert_eq!(FramePacingMode::Hard60.target_fps(), Some(60));
1158 assert_eq!(FramePacingMode::Hard120.target_fps(), Some(120));
1159 assert_eq!(FramePacingMode::NoVsync.target_fps(), None);
1160 }
1161
1162 #[test]
1163 fn frame_schedule_requests_immediate_frame_and_clears_deadline() {
1164 let driver = RecordingFrameDriver::default();
1165 let deadline = Instant::now() + Duration::from_millis(25);
1166
1167 FrameSchedule {
1168 needs_update: true,
1169 needs_frame: true,
1170 next_deadline: Some(deadline),
1171 }
1172 .apply_to(&driver);
1173
1174 assert_eq!(
1175 driver.calls(),
1176 vec![DriverCall::ClearWake, DriverCall::RequestFrame]
1177 );
1178 }
1179
1180 #[test]
1181 fn frame_schedule_requests_deadline_when_idle_until_timer() {
1182 let driver = RecordingFrameDriver::default();
1183 let deadline = Instant::now() + Duration::from_millis(25);
1184
1185 FrameSchedule {
1186 needs_update: false,
1187 needs_frame: false,
1188 next_deadline: Some(deadline),
1189 }
1190 .apply_to(&driver);
1191
1192 assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
1193 }
1194
1195 #[test]
1196 fn frame_schedule_wakes_without_requesting_frame_for_update_only_work() {
1197 let driver = RecordingFrameDriver::default();
1198 let before = Instant::now();
1199
1200 FrameSchedule {
1201 needs_update: true,
1202 needs_frame: false,
1203 next_deadline: None,
1204 }
1205 .apply_to(&driver);
1206
1207 let calls = driver.calls();
1208 assert_eq!(calls.len(), 1);
1209 match calls[0] {
1210 DriverCall::RequestWakeAt(deadline) => {
1211 assert!(deadline >= before);
1212 }
1213 other => panic!("update-only work must wake without requesting a frame: {other:?}"),
1214 }
1215 }
1216
1217 #[test]
1218 fn frame_schedule_clears_wake_when_fully_idle() {
1219 let driver = RecordingFrameDriver::default();
1220
1221 FrameSchedule {
1222 needs_update: false,
1223 needs_frame: false,
1224 next_deadline: None,
1225 }
1226 .apply_to(&driver);
1227
1228 assert_eq!(driver.calls(), vec![DriverCall::ClearWake]);
1229 }
1230
1231 #[test]
1232 fn frame_scheduler_records_latest_schedule_and_applies_driver() {
1233 let scheduler = FrameScheduler::default();
1234 let driver = RecordingFrameDriver::default();
1235 let deadline = Instant::now() + Duration::from_millis(25);
1236
1237 scheduler.schedule(
1238 FrameSchedule {
1239 needs_update: false,
1240 needs_frame: false,
1241 next_deadline: Some(deadline),
1242 },
1243 &driver,
1244 );
1245
1246 assert_eq!(
1247 scheduler.snapshot(),
1248 FrameSchedule {
1249 needs_update: false,
1250 needs_frame: false,
1251 next_deadline: Some(deadline),
1252 }
1253 );
1254 assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
1255 }
1256
1257 #[test]
1258 fn frame_scheduler_clears_deadline_for_immediate_frame() {
1259 let scheduler = FrameScheduler::default();
1260 let driver = RecordingFrameDriver::default();
1261 let deadline = Instant::now() + Duration::from_millis(25);
1262
1263 scheduler.schedule(
1264 FrameSchedule {
1265 needs_update: true,
1266 needs_frame: true,
1267 next_deadline: Some(deadline),
1268 },
1269 &driver,
1270 );
1271
1272 assert_eq!(
1273 scheduler.snapshot(),
1274 FrameSchedule {
1275 needs_update: true,
1276 needs_frame: true,
1277 next_deadline: None,
1278 }
1279 );
1280 assert_eq!(
1281 driver.calls(),
1282 vec![DriverCall::ClearWake, DriverCall::RequestFrame]
1283 );
1284 }
1285
1286 #[test]
1287 fn frame_scheduler_recovers_poisoned_deadline_lock() {
1288 let scheduler = FrameScheduler::default();
1289 let deadline = Instant::now() + Duration::from_millis(25);
1290
1291 let _ = catch_unwind(AssertUnwindSafe(|| {
1292 let _guard = scheduler.lock_deadline();
1293 panic!("poison frame scheduler deadline lock");
1294 }));
1295
1296 scheduler.record(FrameSchedule {
1297 needs_update: false,
1298 needs_frame: false,
1299 next_deadline: Some(deadline),
1300 });
1301
1302 assert_eq!(
1303 scheduler.snapshot(),
1304 FrameSchedule {
1305 needs_update: false,
1306 needs_frame: false,
1307 next_deadline: Some(deadline),
1308 }
1309 );
1310 }
1311}
1312
1313#[cfg(test)]
1314#[path = "tests/app_shell_tests.rs"]
1315mod tests;