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