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