1#![allow(clippy::type_complexity)]
2
3mod fps_monitor;
4mod hit_path_tracker;
5mod shell_debug;
6mod shell_frame;
7mod shell_input;
8#[cfg(test)]
9use shell_frame::build_draw_refresh_scope;
10
11pub use fps_monitor::{
13 current_fps, fps_display, fps_display_detailed, fps_stats, record_recomposition, FpsStats,
14};
15
16use std::fmt::{Debug, Write};
17use web_time::Instant;
19
20use cranpose_core::{
21 enter_event_handler, exit_event_handler, location_key, run_in_mutable_snapshot, Applier,
22 Composition, Key, MemoryApplier, NodeError, NodeId,
23};
24use cranpose_foundation::{PointerButton, PointerButtons, PointerEvent, PointerEventKind};
25use cranpose_render_common::{HitTestTarget, RenderScene, Renderer};
26use cranpose_runtime_std::StdRuntime;
27use cranpose_ui::{
28 format_layout_tree, format_render_scene, format_screen_summary,
29 has_pending_focus_invalidations, has_pending_pointer_repasses, peek_focus_invalidation,
30 peek_layout_invalidation, peek_pointer_invalidation, peek_render_invalidation,
31 process_focus_invalidations, process_pointer_repasses, request_render_invalidation,
32 take_draw_repass_nodes, take_focus_invalidation, take_layout_invalidation,
33 take_pointer_invalidation, take_render_invalidation, HeadlessRenderer, LayoutBox, LayoutNode,
34 LayoutTree, MeasureLayoutOptions, SemanticsTree, SubcomposeLayoutNode,
35};
36use cranpose_ui_graphics::{Point, Rect, Size};
37use hit_path_tracker::{HitPathTracker, PointerId};
38use std::collections::HashSet;
39
40pub use cranpose_ui::{KeyCode, KeyEvent, KeyEventType, Modifiers};
42
43#[cfg(any(test, feature = "test-support"))]
44use cranpose_core::{
45 debug_recompose_scope_registry_stats, MemoryApplierDebugStats,
46 RecomposeScopeRegistryDebugStats, SlotTableDebugStats,
47};
48#[cfg(any(test, feature = "test-support"))]
49use cranpose_core::{
50 runtime::{RuntimeDebugStats, StateArenaDebugStats},
51 snapshot_pinning::{debug_snapshot_pinning_stats, SnapshotPinningDebugStats},
52 snapshot_state_observer::SnapshotStateObserverDebugStats,
53 snapshot_v2::{debug_snapshot_v2_stats, SnapshotV2DebugStats},
54 CompositionPassDebugStats, SlotId,
55};
56
57pub struct AppShell<R>
58where
59 R: Renderer,
60{
61 runtime: StdRuntime,
62 composition: Composition<MemoryApplier>,
63 content: Box<dyn FnMut()>,
64 renderer: R,
65 cursor: (f32, f32),
66 viewport: (f32, f32),
67 buffer_size: (u32, u32),
68 start_time: Instant,
69 last_frame_time_nanos: u64,
70 layout_tree: Option<LayoutTree>,
71 semantics_tree: Option<SemanticsTree>,
72 semantics_enabled: bool,
73 layout_requested: bool,
74 force_layout_pass: bool,
75 scene_dirty: bool,
76 is_dirty: bool,
77 buttons_pressed: PointerButtons,
79 hit_path_tracker: HitPathTracker,
86 hovered_nodes: Vec<NodeId>,
89 #[cfg(all(
91 not(target_arch = "wasm32"),
92 not(target_os = "android"),
93 not(target_os = "ios")
94 ))]
95 clipboard: Option<arboard::Clipboard>,
96 dev_options: DevOptions,
98 dev_overlay_controls: Vec<DevOverlayControl>,
99}
100
101#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
102pub enum FramePacingMode {
103 Vsync,
104 Hard60,
105 Hard120,
106 #[default]
107 NoVsync,
108}
109
110impl FramePacingMode {
111 pub const ALL: [Self; 4] = [Self::Vsync, Self::Hard60, Self::Hard120, Self::NoVsync];
112
113 pub fn label(self) -> &'static str {
114 match self {
115 Self::Vsync => "VSync",
116 Self::Hard60 => "60fps",
117 Self::Hard120 => "120fps",
118 Self::NoVsync => "NoVSync",
119 }
120 }
121
122 pub fn target_fps(self) -> Option<u32> {
123 match self {
124 Self::Hard60 => Some(60),
125 Self::Hard120 => Some(120),
126 Self::Vsync | Self::NoVsync => None,
127 }
128 }
129}
130
131#[derive(Clone, Copy, Debug)]
132struct DevOverlayControl {
133 bounds: Rect,
134 mode: FramePacingMode,
135}
136
137#[derive(Clone, Debug, Default)]
142pub struct DevOptions {
143 pub fps_counter: bool,
145 pub recomposition_counter: bool,
147 pub layout_timing: bool,
149 pub frame_pacing_controls: bool,
150 pub frame_pacing_mode: FramePacingMode,
151}
152
153#[cfg(any(test, feature = "test-support"))]
154#[doc(hidden)]
155#[derive(Clone, Copy, Debug)]
156pub struct RuntimeLeakDebugStats {
157 pub applier_stats: MemoryApplierDebugStats,
158 pub live_node_heap_bytes: usize,
159 pub recycled_node_heap_bytes: usize,
160 pub slot_table_heap_bytes: usize,
161 pub pass_stats: CompositionPassDebugStats,
162 pub slot_stats: SlotTableDebugStats,
163 pub observer_stats: SnapshotStateObserverDebugStats,
164 pub runtime_stats: RuntimeDebugStats,
165 pub state_arena_stats: StateArenaDebugStats,
166 pub recompose_scope_stats: RecomposeScopeRegistryDebugStats,
167 pub snapshot_v2_stats: SnapshotV2DebugStats,
168 pub snapshot_pinning_stats: SnapshotPinningDebugStats,
169}
170
171impl<R> AppShell<R>
172where
173 R: Renderer,
174 R::Error: Debug,
175{
176 pub fn new(renderer: R, root_key: Key, content: impl FnMut() + 'static) -> Self {
177 Self::new_with_size(renderer, root_key, content, (800, 600), (800.0, 600.0))
178 }
179
180 pub fn new_with_size(
181 mut renderer: R,
182 root_key: Key,
183 content: impl FnMut() + 'static,
184 buffer_size: (u32, u32),
185 viewport: (f32, f32),
186 ) -> Self {
187 fps_monitor::init_fps_tracker();
189
190 let runtime = StdRuntime::new();
191 let mut composition = Composition::with_runtime(MemoryApplier::new(), runtime.runtime());
192 let mut build: Box<dyn FnMut()> = Box::new(content);
193 if let Err(err) = composition.render_stable(root_key, &mut *build) {
194 log::error!("initial render failed: {err}");
195 }
196 renderer.scene_mut().clear();
197 let mut shell = Self {
198 runtime,
199 composition,
200 content: build,
201 renderer,
202 cursor: (0.0, 0.0),
203 viewport,
204 buffer_size,
205 start_time: Instant::now(),
206 last_frame_time_nanos: 0,
207 layout_tree: None,
208 semantics_tree: None,
209 semantics_enabled: false,
210 layout_requested: true,
211 force_layout_pass: true,
212 scene_dirty: true,
213 is_dirty: true,
214 buttons_pressed: PointerButtons::NONE,
215 hit_path_tracker: HitPathTracker::new(),
216 hovered_nodes: Vec::new(),
217 #[cfg(all(
218 not(target_arch = "wasm32"),
219 not(target_os = "android"),
220 not(target_os = "ios")
221 ))]
222 clipboard: arboard::Clipboard::new().ok(),
223 dev_options: DevOptions::default(),
224 dev_overlay_controls: Vec::new(),
225 };
226 shell.process_frame();
227 shell
228 }
229
230 pub fn set_dev_options(&mut self, options: DevOptions) {
235 self.dev_options = options;
236 self.mark_dirty();
237 }
238
239 pub fn dev_options(&self) -> &DevOptions {
241 &self.dev_options
242 }
243
244 pub fn frame_pacing_mode(&self) -> FramePacingMode {
245 self.dev_options.frame_pacing_mode
246 }
247
248 pub fn set_frame_pacing_mode(&mut self, mode: FramePacingMode) {
249 if self.dev_options.frame_pacing_mode == mode {
250 return;
251 }
252 self.dev_options.frame_pacing_mode = mode;
253 request_render_invalidation();
254 self.mark_dirty();
255 }
256
257 pub fn handle_dev_overlay_click(&mut self, x: f32, y: f32) -> Option<FramePacingMode> {
258 if !self.dev_options.frame_pacing_controls {
259 return None;
260 }
261 let mode = self
262 .dev_overlay_controls
263 .iter()
264 .find(|control| control.bounds.contains(x, y))
265 .map(|control| control.mode)?;
266 self.set_frame_pacing_mode(mode);
267 Some(mode)
268 }
269
270 pub fn set_viewport(&mut self, width: f32, height: f32) {
271 self.viewport = (width, height);
272 self.request_forced_layout_pass();
273 self.mark_dirty();
274 self.process_frame();
275 }
276
277 pub fn viewport_size(&self) -> (f32, f32) {
278 self.viewport
279 }
280
281 pub fn set_buffer_size(&mut self, width: u32, height: u32) {
282 self.buffer_size = (width, height);
283 }
284
285 pub fn buffer_size(&self) -> (u32, u32) {
286 self.buffer_size
287 }
288
289 pub fn scene(&self) -> &R::Scene {
290 self.renderer.scene()
291 }
292
293 pub fn renderer(&mut self) -> &mut R {
294 &mut self.renderer
295 }
296
297 #[cfg(not(target_arch = "wasm32"))]
298 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + Sync + 'static) {
299 self.runtime.set_frame_waker(waker);
300 }
301
302 #[cfg(target_arch = "wasm32")]
303 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + 'static) {
304 self.runtime.set_frame_waker(waker);
305 }
306
307 pub fn clear_frame_waker(&mut self) {
308 self.runtime.clear_frame_waker();
309 }
310
311 pub fn should_render(&self) -> bool {
312 if self.layout_requested
313 || self.scene_dirty
314 || peek_render_invalidation()
315 || peek_pointer_invalidation()
316 || peek_focus_invalidation()
317 || peek_layout_invalidation()
318 {
319 return true;
320 }
321 self.composition.should_render()
322 }
323
324 pub fn needs_redraw(&self) -> bool {
327 if self.is_dirty
328 || self.layout_requested
329 || self.scene_dirty
330 || peek_render_invalidation()
331 || peek_pointer_invalidation()
332 || peek_focus_invalidation()
333 || peek_layout_invalidation()
334 || cranpose_ui::has_pending_layout_repasses()
335 || cranpose_ui::has_pending_draw_repasses()
336 || has_pending_pointer_repasses()
337 || has_pending_focus_invalidations()
338 {
339 return true;
340 }
341
342 self.composition.should_render()
343 }
344
345 pub fn mark_dirty(&mut self) {
347 self.is_dirty = true;
348 }
349
350 pub fn request_root_render(&mut self) {
351 self.composition.request_root_render();
352 self.request_forced_layout_pass();
353 self.mark_dirty();
354 }
355
356 fn request_layout_pass(&mut self) {
357 self.layout_requested = true;
358 }
359
360 fn request_forced_layout_pass(&mut self) {
361 self.layout_requested = true;
362 self.force_layout_pass = true;
363 }
364
365 pub fn has_active_animations(&self) -> bool {
367 self.composition.should_render()
368 }
369
370 pub fn next_event_time(&self) -> Option<web_time::Instant> {
373 cranpose_ui::next_cursor_blink_time()
374 }
375
376 fn frame_time_nanos_at(&self, now: Instant) -> u64 {
377 now.checked_duration_since(self.start_time)
378 .unwrap_or_default()
379 .as_nanos()
380 .min(u128::from(u64::MAX)) as u64
381 }
382
383 pub fn update_after_frame_interval(&mut self, frame_interval: std::time::Duration) {
384 let wall_frame_time = self.frame_time_nanos_at(Instant::now());
385 let base_frame_time = self.last_frame_time_nanos.max(wall_frame_time);
386 let frame_time = base_frame_time
387 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
388 self.update_at_frame_time_nanos(frame_time);
389 }
390
391 pub fn update_at_frame_time_nanos(&mut self, frame_time: u64) {
392 let frame_time = frame_time.max(self.last_frame_time_nanos);
393 self.last_frame_time_nanos = frame_time;
394 let runtime_handle = self.runtime.runtime_handle();
395 runtime_handle.with_deferred_state_releases(|| {
396 self.runtime.drain_frame_callbacks(frame_time);
397 runtime_handle.drain_ui();
398 let should_render = self.composition.should_render();
399 if should_render {
400 log::trace!(
401 target: "cranpose::input",
402 "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
403 self.layout_requested,
404 self.scene_dirty,
405 self.is_dirty
406 );
407 }
408 if should_render {
409 let Some(root_key) = self.composition.root_key() else {
410 self.process_frame();
411 self.is_dirty = false;
412 return;
413 };
414 match self.composition.reconcile(root_key, &mut *self.content) {
415 Ok(changed) => {
416 log::trace!(
417 target: "cranpose::input",
418 "reconcile changed={changed}"
419 );
420 if changed {
421 fps_monitor::record_recomposition();
422 self.request_layout_pass();
423 request_render_invalidation();
424 }
425 }
426 Err(NodeError::Missing { id }) => {
427 log::debug!("Recomposition skipped: node {} no longer exists", id);
430 self.request_layout_pass();
431 request_render_invalidation();
432 }
433 Err(err) => {
434 log::error!("recomposition failed: {err}");
435 self.request_layout_pass();
436 request_render_invalidation();
437 }
438 }
439 }
440 self.process_frame();
441 self.is_dirty = false;
443 });
444 }
445
446 pub fn update(&mut self) {
447 let frame_time = self.frame_time_nanos_at(Instant::now());
448 self.update_at_frame_time_nanos(frame_time);
449 }
450}
451
452impl<R> Drop for AppShell<R>
453where
454 R: Renderer,
455{
456 fn drop(&mut self) {
457 self.runtime.clear_frame_waker();
458 }
459}
460
461pub fn default_root_key() -> Key {
462 location_key(file!(), line!(), column!())
463}
464
465#[cfg(test)]
466mod frame_pacing_tests {
467 use super::FramePacingMode;
468
469 #[test]
470 fn frame_pacing_labels_match_overlay_modes() {
471 assert_eq!(FramePacingMode::Vsync.label(), "VSync");
472 assert_eq!(FramePacingMode::Hard60.label(), "60fps");
473 assert_eq!(FramePacingMode::Hard120.label(), "120fps");
474 assert_eq!(FramePacingMode::NoVsync.label(), "NoVSync");
475 }
476
477 #[test]
478 fn only_hard_modes_have_fixed_targets() {
479 assert_eq!(FramePacingMode::Vsync.target_fps(), None);
480 assert_eq!(FramePacingMode::Hard60.target_fps(), Some(60));
481 assert_eq!(FramePacingMode::Hard120.target_fps(), Some(120));
482 assert_eq!(FramePacingMode::NoVsync.target_fps(), None);
483 }
484}
485
486#[cfg(test)]
487#[path = "tests/app_shell_tests.rs"]
488mod tests;