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