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.enter(|| self.has_stale_pixels_in_context() || self.renderer_warmup_due())
765 }
766
767 fn renderer_warmup_due(&self) -> bool {
772 self.renderer.needs_frame_warmup() && !self.runtime.runtime_handle().has_frame_callbacks()
773 }
774
775 pub fn mark_dirty(&mut self) {
777 self.is_dirty = true;
778 }
779
780 pub fn request_root_render(&mut self) {
781 self.composition.request_root_render();
782 self.request_forced_layout_pass();
783 let app_context = Rc::clone(&self.app_context);
784 app_context.enter(request_render_invalidation);
785 self.mark_dirty();
786 }
787
788 pub fn set_density(&mut self, density: f32) {
789 let app_context = Rc::clone(&self.app_context);
790 let changed = app_context.enter(|| {
791 let previous = cranpose_ui::current_density().to_bits();
792 cranpose_ui::set_density(density);
793 previous != cranpose_ui::current_density().to_bits()
794 });
795 if changed {
796 self.request_forced_layout_pass();
797 self.mark_dirty();
798 }
799 }
800
801 pub fn set_font_scale(&mut self, font_scale: f32) {
812 self.set_font_scale_curve(cranpose_ui::FontScaleCurve::linear(font_scale));
813 }
814
815 pub fn set_font_scale_curve(&mut self, curve: cranpose_ui::FontScaleCurve) {
822 let app_context = Rc::clone(&self.app_context);
823 let changed = app_context.enter(|| {
824 let previous = cranpose_ui::current_font_scale_curve();
825 cranpose_ui::set_font_scale_curve(curve);
826 previous != cranpose_ui::current_font_scale_curve()
827 });
828 if changed {
829 self.request_forced_layout_pass();
830 self.mark_dirty();
831 }
832 }
833
834 #[cfg(any(test, feature = "test-support"))]
835 #[doc(hidden)]
836 pub fn debug_current_density(&self) -> f32 {
837 let app_context = Rc::clone(&self.app_context);
838 app_context.enter(cranpose_ui::current_density)
839 }
840
841 #[cfg(any(test, feature = "test-support"))]
842 #[doc(hidden)]
843 pub fn debug_current_font_scale(&self) -> f32 {
844 let app_context = Rc::clone(&self.app_context);
845 app_context.enter(cranpose_ui::current_font_scale)
846 }
847
848 #[cfg(any(test, feature = "test-support"))]
849 #[doc(hidden)]
850 pub fn debug_current_font_scale_curve(&self) -> cranpose_ui::FontScaleCurve {
851 let app_context = Rc::clone(&self.app_context);
852 app_context.enter(cranpose_ui::current_font_scale_curve)
853 }
854
855 #[cfg(any(test, feature = "test-support"))]
856 #[doc(hidden)]
857 pub fn debug_enter_app_context<T>(&self, block: impl FnOnce() -> T) -> T {
858 let app_context = Rc::clone(&self.app_context);
859 app_context.enter(block)
860 }
861
862 fn request_layout_pass(&mut self) {
863 self.layout_requested = true;
864 }
865
866 fn request_forced_layout_pass(&mut self) {
867 self.layout_requested = true;
868 self.force_layout_pass = true;
869 }
870
871 fn composition_tree_needs_layout(&mut self) -> bool {
872 let Some(root) = self.composition.root() else {
873 return true;
874 };
875 let mut applier = self.composition.applier_mut();
876 cranpose_ui::tree_needs_layout(&mut *applier, root).unwrap_or_else(|err| {
877 log::warn!(
878 "Cannot check layout dirty status for root #{}: {}",
879 root,
880 err
881 );
882 true
883 })
884 }
885
886 pub fn has_active_animations(&self) -> bool {
888 self.composition.should_render()
889 }
890
891 pub fn has_transient_frame_callbacks(&self) -> bool {
892 self.composition
893 .runtime_handle()
894 .has_transient_frame_callbacks()
895 }
896
897 pub fn has_active_pointer_gesture(&self) -> bool {
898 self.buttons_pressed != PointerButtons::NONE
899 && self.hit_path_tracker.has_path(PointerId::PRIMARY)
900 }
901
902 pub fn next_event_time(&self) -> Option<web_time::Instant> {
905 let app_context = Rc::clone(&self.app_context);
906 app_context.enter(cranpose_ui::next_cursor_blink_time)
907 }
908
909 fn compute_frame_schedule(&self) -> FrameSchedule {
910 let needs_update = self.needs_update();
911 let needs_frame = self.is_dirty
912 || self.should_render()
913 || self.has_active_pointer_gesture()
914 || self.renderer_warmup_due();
915 FrameSchedule {
916 needs_update,
917 needs_frame,
918 next_deadline: self.next_event_time(),
919 }
920 }
921
922 pub fn frame_schedule(&self) -> FrameSchedule {
923 let schedule = self.compute_frame_schedule();
924 self.frame_scheduler.record(schedule);
925 schedule
926 }
927
928 pub fn schedule_platform_frame<D>(&self, driver: &D) -> FrameSchedule
929 where
930 D: PlatformFrameDriver + ?Sized,
931 {
932 let schedule = self.compute_frame_schedule();
933 self.frame_scheduler.schedule(schedule, driver);
934 schedule
935 }
936
937 pub fn frame_scheduler_snapshot(&self) -> FrameSchedule {
938 self.frame_scheduler.snapshot()
939 }
940
941 fn frame_time_nanos_at(&self, now: Instant) -> u64 {
942 now.checked_duration_since(self.start_time)
943 .unwrap_or_default()
944 .as_nanos()
945 .min(u128::from(u64::MAX)) as u64
946 }
947
948 pub fn realtime_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
950 PointerEventTime {
951 platform_time_ms,
952 animation_time_nanos: self
953 .frame_time_nanos_at(Instant::now())
954 .max(self.last_frame_time_nanos),
955 }
956 }
957
958 pub fn exact_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
960 PointerEventTime {
961 platform_time_ms,
962 animation_time_nanos: self.last_frame_time_nanos,
963 }
964 }
965
966 pub fn update_after_frame_interval(
967 &mut self,
968 frame_interval: std::time::Duration,
969 ) -> FrameUpdateResult {
970 let wall_frame_time = self.frame_time_nanos_at(Instant::now());
971 let base_frame_time = self.last_frame_time_nanos.max(wall_frame_time);
972 let frame_time = base_frame_time
973 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
974 self.update_at_frame_time_nanos(frame_time)
975 }
976
977 pub fn update_after_exact_interval(
983 &mut self,
984 frame_interval: std::time::Duration,
985 ) -> FrameUpdateResult {
986 let frame_time = self
987 .last_frame_time_nanos
988 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
989 self.update_at_frame_time_nanos(frame_time)
990 }
991
992 pub fn update_at_frame_time_nanos(&mut self, frame_time: u64) -> FrameUpdateResult {
993 let app_context = Rc::clone(&self.app_context);
994 app_context.enter(|| {
995 let update_started_at = Instant::now();
996 let frame_time = frame_time.max(self.last_frame_time_nanos);
997 self.last_frame_time_nanos = frame_time;
998 let runtime_handle = self.runtime.runtime_handle();
999 runtime_handle.with_deferred_state_releases(|| {
1000 self.runtime.drain_frame_callbacks(frame_time);
1001 let after_frame_callbacks = Instant::now();
1002 runtime_handle.drain_ui();
1003 let after_ui_drain = Instant::now();
1004 let should_render = self.composition.should_recompose();
1005 let mut reconcile_attempted = false;
1006 let mut reconcile_changed = false;
1007 if should_render {
1008 log::trace!(
1009 target: "cranpose::input",
1010 "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
1011 self.layout_requested,
1012 self.scene_dirty,
1013 self.is_dirty
1014 );
1015 }
1016 if should_render {
1017 let Some(root_key) = self.composition.root_key() else {
1018 let result = self.process_frame_in_context(reconcile_changed);
1019 let after_process_frame = Instant::now();
1020 log_update_stage_telemetry(UpdateStageTelemetry {
1021 started_at: update_started_at,
1022 after_frame_callbacks,
1023 after_ui_drain,
1024 after_reconcile: after_ui_drain,
1025 after_process_frame,
1026 should_render,
1027 reconcile_attempted,
1028 reconcile_changed,
1029 });
1030 self.is_dirty = false;
1031 return result;
1032 };
1033 reconcile_attempted = true;
1034 match self.composition.reconcile(root_key, &mut *self.content) {
1035 Ok(changed) => {
1036 reconcile_changed = changed;
1037 log::trace!(
1038 target: "cranpose::input",
1039 "reconcile changed={changed}"
1040 );
1041 if changed {
1042 self.fps_monitor.record_recomposition();
1043 if self.composition_tree_needs_layout() {
1044 self.request_layout_pass();
1045 }
1046 request_render_invalidation();
1047 }
1048 }
1049 Err(NodeError::Missing { id }) => {
1050 log::debug!("Recomposition skipped: node {} no longer exists", id);
1051 self.request_layout_pass();
1052 request_render_invalidation();
1053 }
1054 Err(err) => {
1055 log::error!("recomposition failed: {err}");
1056 self.request_layout_pass();
1057 request_render_invalidation();
1058 }
1059 }
1060 }
1061 let after_reconcile = Instant::now();
1062 let result = self.process_frame_in_context(reconcile_changed);
1063 let after_process_frame = Instant::now();
1064 log_update_stage_telemetry(UpdateStageTelemetry {
1065 started_at: update_started_at,
1066 after_frame_callbacks,
1067 after_ui_drain,
1068 after_reconcile,
1069 after_process_frame,
1070 should_render,
1071 reconcile_attempted,
1072 reconcile_changed,
1073 });
1074 self.is_dirty = false;
1075 result
1076 })
1077 })
1078 }
1079
1080 pub fn update(&mut self) -> FrameUpdateResult {
1081 let frame_time = self.frame_time_nanos_at(Instant::now());
1082 self.update_at_frame_time_nanos(frame_time)
1083 }
1084}
1085
1086impl<R> Drop for AppShell<R>
1087where
1088 R: Renderer,
1089{
1090 fn drop(&mut self) {
1091 self.runtime.clear_frame_waker();
1092 }
1093}
1094
1095pub fn default_root_key() -> Key {
1096 location_key(file!(), line!(), column!())
1097}
1098
1099#[cfg(test)]
1100mod frame_pacing_tests {
1101 use std::{
1102 cell::RefCell,
1103 panic::{AssertUnwindSafe, catch_unwind},
1104 time::Duration,
1105 };
1106
1107 use web_time::Instant;
1108
1109 use super::{FramePacingMode, FrameSchedule, FrameScheduler, PlatformFrameDriver};
1110
1111 #[derive(Clone, Copy, Debug, PartialEq)]
1112 enum DriverCall {
1113 RequestFrame,
1114 RequestWakeAt(Instant),
1115 ClearWake,
1116 }
1117
1118 #[derive(Default)]
1119 struct RecordingFrameDriver {
1120 calls: RefCell<Vec<DriverCall>>,
1121 }
1122
1123 impl RecordingFrameDriver {
1124 fn calls(&self) -> Vec<DriverCall> {
1125 self.calls.borrow().clone()
1126 }
1127 }
1128
1129 impl PlatformFrameDriver for RecordingFrameDriver {
1130 fn request_frame(&self) {
1131 self.calls.borrow_mut().push(DriverCall::RequestFrame);
1132 }
1133
1134 fn request_wake_at(&self, deadline: Instant) {
1135 self.calls
1136 .borrow_mut()
1137 .push(DriverCall::RequestWakeAt(deadline));
1138 }
1139
1140 fn clear_wake(&self) {
1141 self.calls.borrow_mut().push(DriverCall::ClearWake);
1142 }
1143 }
1144
1145 #[test]
1146 fn frame_pacing_labels_match_overlay_modes() {
1147 assert_eq!(FramePacingMode::Vsync.label(), "VSync");
1148 assert_eq!(FramePacingMode::Hard60.label(), "60fps");
1149 assert_eq!(FramePacingMode::Hard120.label(), "120fps");
1150 assert_eq!(FramePacingMode::NoVsync.label(), "NoVSync");
1151 }
1152
1153 #[test]
1154 fn only_hard_modes_have_fixed_targets() {
1155 assert_eq!(FramePacingMode::Vsync.target_fps(), None);
1156 assert_eq!(FramePacingMode::Hard60.target_fps(), Some(60));
1157 assert_eq!(FramePacingMode::Hard120.target_fps(), Some(120));
1158 assert_eq!(FramePacingMode::NoVsync.target_fps(), None);
1159 }
1160
1161 #[test]
1162 fn frame_schedule_requests_immediate_frame_and_clears_deadline() {
1163 let driver = RecordingFrameDriver::default();
1164 let deadline = Instant::now() + Duration::from_millis(25);
1165
1166 FrameSchedule {
1167 needs_update: true,
1168 needs_frame: true,
1169 next_deadline: Some(deadline),
1170 }
1171 .apply_to(&driver);
1172
1173 assert_eq!(
1174 driver.calls(),
1175 vec![DriverCall::ClearWake, DriverCall::RequestFrame]
1176 );
1177 }
1178
1179 #[test]
1180 fn frame_schedule_requests_deadline_when_idle_until_timer() {
1181 let driver = RecordingFrameDriver::default();
1182 let deadline = Instant::now() + Duration::from_millis(25);
1183
1184 FrameSchedule {
1185 needs_update: false,
1186 needs_frame: false,
1187 next_deadline: Some(deadline),
1188 }
1189 .apply_to(&driver);
1190
1191 assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
1192 }
1193
1194 #[test]
1195 fn frame_schedule_wakes_without_requesting_frame_for_update_only_work() {
1196 let driver = RecordingFrameDriver::default();
1197 let before = Instant::now();
1198
1199 FrameSchedule {
1200 needs_update: true,
1201 needs_frame: false,
1202 next_deadline: None,
1203 }
1204 .apply_to(&driver);
1205
1206 let calls = driver.calls();
1207 assert_eq!(calls.len(), 1);
1208 match calls[0] {
1209 DriverCall::RequestWakeAt(deadline) => {
1210 assert!(deadline >= before);
1211 }
1212 other => panic!("update-only work must wake without requesting a frame: {other:?}"),
1213 }
1214 }
1215
1216 #[test]
1217 fn frame_schedule_clears_wake_when_fully_idle() {
1218 let driver = RecordingFrameDriver::default();
1219
1220 FrameSchedule {
1221 needs_update: false,
1222 needs_frame: false,
1223 next_deadline: None,
1224 }
1225 .apply_to(&driver);
1226
1227 assert_eq!(driver.calls(), vec![DriverCall::ClearWake]);
1228 }
1229
1230 #[test]
1231 fn frame_scheduler_records_latest_schedule_and_applies_driver() {
1232 let scheduler = FrameScheduler::default();
1233 let driver = RecordingFrameDriver::default();
1234 let deadline = Instant::now() + Duration::from_millis(25);
1235
1236 scheduler.schedule(
1237 FrameSchedule {
1238 needs_update: false,
1239 needs_frame: false,
1240 next_deadline: Some(deadline),
1241 },
1242 &driver,
1243 );
1244
1245 assert_eq!(
1246 scheduler.snapshot(),
1247 FrameSchedule {
1248 needs_update: false,
1249 needs_frame: false,
1250 next_deadline: Some(deadline),
1251 }
1252 );
1253 assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
1254 }
1255
1256 #[test]
1257 fn frame_scheduler_clears_deadline_for_immediate_frame() {
1258 let scheduler = FrameScheduler::default();
1259 let driver = RecordingFrameDriver::default();
1260 let deadline = Instant::now() + Duration::from_millis(25);
1261
1262 scheduler.schedule(
1263 FrameSchedule {
1264 needs_update: true,
1265 needs_frame: true,
1266 next_deadline: Some(deadline),
1267 },
1268 &driver,
1269 );
1270
1271 assert_eq!(
1272 scheduler.snapshot(),
1273 FrameSchedule {
1274 needs_update: true,
1275 needs_frame: true,
1276 next_deadline: None,
1277 }
1278 );
1279 assert_eq!(
1280 driver.calls(),
1281 vec![DriverCall::ClearWake, DriverCall::RequestFrame]
1282 );
1283 }
1284
1285 #[test]
1286 fn frame_scheduler_recovers_poisoned_deadline_lock() {
1287 let scheduler = FrameScheduler::default();
1288 let deadline = Instant::now() + Duration::from_millis(25);
1289
1290 let _ = catch_unwind(AssertUnwindSafe(|| {
1291 let _guard = scheduler.lock_deadline();
1292 panic!("poison frame scheduler deadline lock");
1293 }));
1294
1295 scheduler.record(FrameSchedule {
1296 needs_update: false,
1297 needs_frame: false,
1298 next_deadline: Some(deadline),
1299 });
1300
1301 assert_eq!(
1302 scheduler.snapshot(),
1303 FrameSchedule {
1304 needs_update: false,
1305 needs_frame: false,
1306 next_deadline: Some(deadline),
1307 }
1308 );
1309 }
1310}
1311
1312#[cfg(test)]
1313#[path = "tests/app_shell_tests.rs"]
1314mod tests;