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