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_active_pointer_gesture(&self) -> bool {
963 self.buttons_pressed != PointerButtons::NONE
964 && self.hit_path_tracker.has_path(PointerId::PRIMARY)
965 }
966
967 pub fn next_event_time(&self) -> Option<web_time::Instant> {
970 let app_context = Rc::clone(&self.app_context);
971 app_context.enter(cranpose_ui::next_cursor_blink_time)
972 }
973
974 fn compute_frame_schedule(&self) -> FrameSchedule {
975 let needs_update = self.needs_update();
976 let needs_frame = self.is_dirty
977 || self.should_render()
978 || self.has_active_pointer_gesture()
979 || self.renderer.needs_frame_warmup();
980 FrameSchedule {
981 needs_update,
982 needs_frame,
983 next_deadline: self.next_event_time(),
984 }
985 }
986
987 pub fn frame_schedule(&self) -> FrameSchedule {
988 let schedule = self.compute_frame_schedule();
989 self.frame_scheduler.record(schedule);
990 schedule
991 }
992
993 pub fn schedule_platform_frame<D>(&self, driver: &D) -> FrameSchedule
994 where
995 D: PlatformFrameDriver + ?Sized,
996 {
997 let schedule = self.compute_frame_schedule();
998 self.frame_scheduler.schedule(schedule, driver);
999 schedule
1000 }
1001
1002 pub fn frame_scheduler_snapshot(&self) -> FrameSchedule {
1003 self.frame_scheduler.snapshot()
1004 }
1005
1006 fn frame_time_nanos_at(&self, now: Instant) -> u64 {
1007 now.checked_duration_since(self.start_time)
1008 .unwrap_or_default()
1009 .as_nanos()
1010 .min(u128::from(u64::MAX)) as u64
1011 }
1012
1013 pub fn realtime_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
1015 PointerEventTime {
1016 platform_time_ms,
1017 animation_time_nanos: self
1018 .frame_time_nanos_at(Instant::now())
1019 .max(self.last_frame_time_nanos),
1020 }
1021 }
1022
1023 pub fn exact_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
1025 PointerEventTime {
1026 platform_time_ms,
1027 animation_time_nanos: self.last_frame_time_nanos,
1028 }
1029 }
1030
1031 pub fn update_after_frame_interval(
1032 &mut self,
1033 frame_interval: std::time::Duration,
1034 ) -> FrameUpdateResult {
1035 let wall_frame_time = self.frame_time_nanos_at(Instant::now());
1036 let base_frame_time = self.last_frame_time_nanos.max(wall_frame_time);
1037 let frame_time = base_frame_time
1038 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1039 self.update_at_frame_time_nanos(frame_time)
1040 }
1041
1042 pub fn update_after_exact_interval(
1048 &mut self,
1049 frame_interval: std::time::Duration,
1050 ) -> FrameUpdateResult {
1051 let frame_time = self
1052 .last_frame_time_nanos
1053 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1054 self.update_at_frame_time_nanos(frame_time)
1055 }
1056
1057 pub fn update_at_frame_time_nanos(&mut self, frame_time: u64) -> FrameUpdateResult {
1058 let app_context = Rc::clone(&self.app_context);
1059 app_context.enter(|| {
1060 let update_started_at = Instant::now();
1061 let frame_time = frame_time.max(self.last_frame_time_nanos);
1062 self.last_frame_time_nanos = frame_time;
1063 let runtime_handle = self.runtime.runtime_handle();
1064 runtime_handle.with_deferred_state_releases(|| {
1065 self.runtime.drain_frame_callbacks(frame_time);
1066 let after_frame_callbacks = Instant::now();
1067 runtime_handle.drain_ui();
1068 let after_ui_drain = Instant::now();
1069 let should_render = self.composition.should_recompose();
1070 let mut reconcile_attempted = false;
1071 let mut reconcile_changed = false;
1072 if should_render {
1073 log::trace!(
1074 target: "cranpose::input",
1075 "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
1076 self.layout_requested,
1077 self.scene_dirty,
1078 self.is_dirty
1079 );
1080 }
1081 if should_render {
1082 let Some(root_key) = self.composition.root_key() else {
1083 let result = self.process_frame_in_context(reconcile_changed);
1084 let after_process_frame = Instant::now();
1085 log_update_stage_telemetry(UpdateStageTelemetry {
1086 started_at: update_started_at,
1087 after_frame_callbacks,
1088 after_ui_drain,
1089 after_reconcile: after_ui_drain,
1090 after_process_frame,
1091 should_render,
1092 reconcile_attempted,
1093 reconcile_changed,
1094 });
1095 self.is_dirty = false;
1096 return result;
1097 };
1098 reconcile_attempted = true;
1099 match self.composition.reconcile(root_key, &mut *self.content) {
1100 Ok(changed) => {
1101 reconcile_changed = changed;
1102 log::trace!(
1103 target: "cranpose::input",
1104 "reconcile changed={changed}"
1105 );
1106 if changed {
1107 self.fps_monitor.record_recomposition();
1108 if self.composition_tree_needs_layout() {
1109 self.request_layout_pass();
1110 }
1111 request_render_invalidation();
1112 }
1113 }
1114 Err(NodeError::Missing { id }) => {
1115 log::debug!("Recomposition skipped: node {} no longer exists", id);
1116 self.request_layout_pass();
1117 request_render_invalidation();
1118 }
1119 Err(err) => {
1120 log::error!("recomposition failed: {err}");
1121 self.request_layout_pass();
1122 request_render_invalidation();
1123 }
1124 }
1125 }
1126 let after_reconcile = Instant::now();
1127 let result = self.process_frame_in_context(reconcile_changed);
1128 let after_process_frame = Instant::now();
1129 log_update_stage_telemetry(UpdateStageTelemetry {
1130 started_at: update_started_at,
1131 after_frame_callbacks,
1132 after_ui_drain,
1133 after_reconcile,
1134 after_process_frame,
1135 should_render,
1136 reconcile_attempted,
1137 reconcile_changed,
1138 });
1139 self.is_dirty = false;
1140 result
1141 })
1142 })
1143 }
1144
1145 pub fn update(&mut self) -> FrameUpdateResult {
1146 let frame_time = self.frame_time_nanos_at(Instant::now());
1147 self.update_at_frame_time_nanos(frame_time)
1148 }
1149}
1150
1151impl<R> Drop for AppShell<R>
1152where
1153 R: Renderer,
1154{
1155 fn drop(&mut self) {
1156 self.runtime.clear_frame_waker();
1157 }
1158}
1159
1160pub fn default_root_key() -> Key {
1161 location_key(file!(), line!(), column!())
1162}
1163
1164#[cfg(test)]
1165mod frame_pacing_tests {
1166 use super::{FramePacingMode, FrameSchedule, FrameScheduler, PlatformFrameDriver};
1167 use std::cell::RefCell;
1168 use std::panic::{catch_unwind, AssertUnwindSafe};
1169 use std::time::Duration;
1170 use web_time::Instant;
1171
1172 #[derive(Clone, Copy, Debug, PartialEq)]
1173 enum DriverCall {
1174 RequestFrame,
1175 RequestWakeAt(Instant),
1176 ClearWake,
1177 }
1178
1179 #[derive(Default)]
1180 struct RecordingFrameDriver {
1181 calls: RefCell<Vec<DriverCall>>,
1182 }
1183
1184 impl RecordingFrameDriver {
1185 fn calls(&self) -> Vec<DriverCall> {
1186 self.calls.borrow().clone()
1187 }
1188 }
1189
1190 impl PlatformFrameDriver for RecordingFrameDriver {
1191 fn request_frame(&self) {
1192 self.calls.borrow_mut().push(DriverCall::RequestFrame);
1193 }
1194
1195 fn request_wake_at(&self, deadline: Instant) {
1196 self.calls
1197 .borrow_mut()
1198 .push(DriverCall::RequestWakeAt(deadline));
1199 }
1200
1201 fn clear_wake(&self) {
1202 self.calls.borrow_mut().push(DriverCall::ClearWake);
1203 }
1204 }
1205
1206 #[test]
1207 fn frame_pacing_labels_match_overlay_modes() {
1208 assert_eq!(FramePacingMode::Vsync.label(), "VSync");
1209 assert_eq!(FramePacingMode::Hard60.label(), "60fps");
1210 assert_eq!(FramePacingMode::Hard120.label(), "120fps");
1211 assert_eq!(FramePacingMode::NoVsync.label(), "NoVSync");
1212 }
1213
1214 #[test]
1215 fn only_hard_modes_have_fixed_targets() {
1216 assert_eq!(FramePacingMode::Vsync.target_fps(), None);
1217 assert_eq!(FramePacingMode::Hard60.target_fps(), Some(60));
1218 assert_eq!(FramePacingMode::Hard120.target_fps(), Some(120));
1219 assert_eq!(FramePacingMode::NoVsync.target_fps(), None);
1220 }
1221
1222 #[test]
1223 fn frame_schedule_requests_immediate_frame_and_clears_deadline() {
1224 let driver = RecordingFrameDriver::default();
1225 let deadline = Instant::now() + Duration::from_millis(25);
1226
1227 FrameSchedule {
1228 needs_update: true,
1229 needs_frame: true,
1230 next_deadline: Some(deadline),
1231 }
1232 .apply_to(&driver);
1233
1234 assert_eq!(
1235 driver.calls(),
1236 vec![DriverCall::ClearWake, DriverCall::RequestFrame]
1237 );
1238 }
1239
1240 #[test]
1241 fn frame_schedule_requests_deadline_when_idle_until_timer() {
1242 let driver = RecordingFrameDriver::default();
1243 let deadline = Instant::now() + Duration::from_millis(25);
1244
1245 FrameSchedule {
1246 needs_update: false,
1247 needs_frame: false,
1248 next_deadline: Some(deadline),
1249 }
1250 .apply_to(&driver);
1251
1252 assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
1253 }
1254
1255 #[test]
1256 fn frame_schedule_wakes_without_requesting_frame_for_update_only_work() {
1257 let driver = RecordingFrameDriver::default();
1258 let before = Instant::now();
1259
1260 FrameSchedule {
1261 needs_update: true,
1262 needs_frame: false,
1263 next_deadline: None,
1264 }
1265 .apply_to(&driver);
1266
1267 let calls = driver.calls();
1268 assert_eq!(calls.len(), 1);
1269 match calls[0] {
1270 DriverCall::RequestWakeAt(deadline) => {
1271 assert!(deadline >= before);
1272 }
1273 other => panic!("update-only work must wake without requesting a frame: {other:?}"),
1274 }
1275 }
1276
1277 #[test]
1278 fn frame_schedule_clears_wake_when_fully_idle() {
1279 let driver = RecordingFrameDriver::default();
1280
1281 FrameSchedule {
1282 needs_update: false,
1283 needs_frame: false,
1284 next_deadline: None,
1285 }
1286 .apply_to(&driver);
1287
1288 assert_eq!(driver.calls(), vec![DriverCall::ClearWake]);
1289 }
1290
1291 #[test]
1292 fn frame_scheduler_records_latest_schedule_and_applies_driver() {
1293 let scheduler = FrameScheduler::default();
1294 let driver = RecordingFrameDriver::default();
1295 let deadline = Instant::now() + Duration::from_millis(25);
1296
1297 scheduler.schedule(
1298 FrameSchedule {
1299 needs_update: false,
1300 needs_frame: false,
1301 next_deadline: Some(deadline),
1302 },
1303 &driver,
1304 );
1305
1306 assert_eq!(
1307 scheduler.snapshot(),
1308 FrameSchedule {
1309 needs_update: false,
1310 needs_frame: false,
1311 next_deadline: Some(deadline),
1312 }
1313 );
1314 assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
1315 }
1316
1317 #[test]
1318 fn frame_scheduler_clears_deadline_for_immediate_frame() {
1319 let scheduler = FrameScheduler::default();
1320 let driver = RecordingFrameDriver::default();
1321 let deadline = Instant::now() + Duration::from_millis(25);
1322
1323 scheduler.schedule(
1324 FrameSchedule {
1325 needs_update: true,
1326 needs_frame: true,
1327 next_deadline: Some(deadline),
1328 },
1329 &driver,
1330 );
1331
1332 assert_eq!(
1333 scheduler.snapshot(),
1334 FrameSchedule {
1335 needs_update: true,
1336 needs_frame: true,
1337 next_deadline: None,
1338 }
1339 );
1340 assert_eq!(
1341 driver.calls(),
1342 vec![DriverCall::ClearWake, DriverCall::RequestFrame]
1343 );
1344 }
1345
1346 #[test]
1347 fn frame_scheduler_recovers_poisoned_deadline_lock() {
1348 let scheduler = FrameScheduler::default();
1349 let deadline = Instant::now() + Duration::from_millis(25);
1350
1351 let _ = catch_unwind(AssertUnwindSafe(|| {
1352 let _guard = scheduler.lock_deadline();
1353 panic!("poison frame scheduler deadline lock");
1354 }));
1355
1356 scheduler.record(FrameSchedule {
1357 needs_update: false,
1358 needs_frame: false,
1359 next_deadline: Some(deadline),
1360 });
1361
1362 assert_eq!(
1363 scheduler.snapshot(),
1364 FrameSchedule {
1365 needs_update: false,
1366 needs_frame: false,
1367 next_deadline: Some(deadline),
1368 }
1369 );
1370 }
1371}
1372
1373#[cfg(test)]
1374#[path = "tests/app_shell_tests.rs"]
1375mod tests;