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 layout_tree: Option<LayoutTree>,
70 semantics_tree: Option<SemanticsTree>,
71 semantics_enabled: bool,
72 layout_requested: bool,
73 force_layout_pass: bool,
74 scene_dirty: bool,
75 is_dirty: bool,
76 buttons_pressed: PointerButtons,
78 hit_path_tracker: HitPathTracker,
85 hovered_nodes: Vec<NodeId>,
88 #[cfg(all(
90 not(target_arch = "wasm32"),
91 not(target_os = "android"),
92 not(target_os = "ios")
93 ))]
94 clipboard: Option<arboard::Clipboard>,
95 dev_options: DevOptions,
97 dev_overlay_controls: Vec<DevOverlayControl>,
98}
99
100#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
101pub enum FramePacingMode {
102 Vsync,
103 Hard60,
104 Hard120,
105 #[default]
106 NoVsync,
107}
108
109impl FramePacingMode {
110 pub const ALL: [Self; 4] = [Self::Vsync, Self::Hard60, Self::Hard120, Self::NoVsync];
111
112 pub fn label(self) -> &'static str {
113 match self {
114 Self::Vsync => "VSync",
115 Self::Hard60 => "60fps",
116 Self::Hard120 => "120fps",
117 Self::NoVsync => "NoVSync",
118 }
119 }
120
121 pub fn target_fps(self) -> Option<u32> {
122 match self {
123 Self::Hard60 => Some(60),
124 Self::Hard120 => Some(120),
125 Self::Vsync | Self::NoVsync => None,
126 }
127 }
128}
129
130#[derive(Clone, Copy, Debug)]
131struct DevOverlayControl {
132 bounds: Rect,
133 mode: FramePacingMode,
134}
135
136#[derive(Clone, Debug, Default)]
141pub struct DevOptions {
142 pub fps_counter: bool,
144 pub recomposition_counter: bool,
146 pub layout_timing: bool,
148 pub frame_pacing_controls: bool,
149 pub frame_pacing_mode: FramePacingMode,
150}
151
152#[cfg(any(test, feature = "test-support"))]
153#[doc(hidden)]
154#[derive(Clone, Copy, Debug)]
155pub struct RuntimeLeakDebugStats {
156 pub applier_stats: MemoryApplierDebugStats,
157 pub live_node_heap_bytes: usize,
158 pub recycled_node_heap_bytes: usize,
159 pub slot_table_heap_bytes: usize,
160 pub pass_stats: CompositionPassDebugStats,
161 pub slot_stats: SlotTableDebugStats,
162 pub observer_stats: SnapshotStateObserverDebugStats,
163 pub runtime_stats: RuntimeDebugStats,
164 pub state_arena_stats: StateArenaDebugStats,
165 pub recompose_scope_stats: RecomposeScopeRegistryDebugStats,
166 pub snapshot_v2_stats: SnapshotV2DebugStats,
167 pub snapshot_pinning_stats: SnapshotPinningDebugStats,
168}
169
170impl<R> AppShell<R>
171where
172 R: Renderer,
173 R::Error: Debug,
174{
175 pub fn new(renderer: R, root_key: Key, content: impl FnMut() + 'static) -> Self {
176 Self::new_with_size(renderer, root_key, content, (800, 600), (800.0, 600.0))
177 }
178
179 pub fn new_with_size(
180 mut renderer: R,
181 root_key: Key,
182 content: impl FnMut() + 'static,
183 buffer_size: (u32, u32),
184 viewport: (f32, f32),
185 ) -> Self {
186 fps_monitor::init_fps_tracker();
188
189 let runtime = StdRuntime::new();
190 let mut composition = Composition::with_runtime(MemoryApplier::new(), runtime.runtime());
191 let mut build: Box<dyn FnMut()> = Box::new(content);
192 if let Err(err) = composition.render_stable(root_key, &mut *build) {
193 log::error!("initial render failed: {err}");
194 }
195 renderer.scene_mut().clear();
196 let mut shell = Self {
197 runtime,
198 composition,
199 content: build,
200 renderer,
201 cursor: (0.0, 0.0),
202 viewport,
203 buffer_size,
204 start_time: Instant::now(),
205 layout_tree: None,
206 semantics_tree: None,
207 semantics_enabled: false,
208 layout_requested: true,
209 force_layout_pass: true,
210 scene_dirty: true,
211 is_dirty: true,
212 buttons_pressed: PointerButtons::NONE,
213 hit_path_tracker: HitPathTracker::new(),
214 hovered_nodes: Vec::new(),
215 #[cfg(all(
216 not(target_arch = "wasm32"),
217 not(target_os = "android"),
218 not(target_os = "ios")
219 ))]
220 clipboard: arboard::Clipboard::new().ok(),
221 dev_options: DevOptions::default(),
222 dev_overlay_controls: Vec::new(),
223 };
224 shell.process_frame();
225 shell
226 }
227
228 pub fn set_dev_options(&mut self, options: DevOptions) {
233 self.dev_options = options;
234 self.mark_dirty();
235 }
236
237 pub fn dev_options(&self) -> &DevOptions {
239 &self.dev_options
240 }
241
242 pub fn frame_pacing_mode(&self) -> FramePacingMode {
243 self.dev_options.frame_pacing_mode
244 }
245
246 pub fn set_frame_pacing_mode(&mut self, mode: FramePacingMode) {
247 if self.dev_options.frame_pacing_mode == mode {
248 return;
249 }
250 self.dev_options.frame_pacing_mode = mode;
251 request_render_invalidation();
252 self.mark_dirty();
253 }
254
255 pub fn handle_dev_overlay_click(&mut self, x: f32, y: f32) -> Option<FramePacingMode> {
256 if !self.dev_options.frame_pacing_controls {
257 return None;
258 }
259 let mode = self
260 .dev_overlay_controls
261 .iter()
262 .find(|control| control.bounds.contains(x, y))
263 .map(|control| control.mode)?;
264 self.set_frame_pacing_mode(mode);
265 Some(mode)
266 }
267
268 pub fn set_viewport(&mut self, width: f32, height: f32) {
269 self.viewport = (width, height);
270 self.request_forced_layout_pass();
271 self.mark_dirty();
272 self.process_frame();
273 }
274
275 pub fn viewport_size(&self) -> (f32, f32) {
276 self.viewport
277 }
278
279 pub fn set_buffer_size(&mut self, width: u32, height: u32) {
280 self.buffer_size = (width, height);
281 }
282
283 pub fn buffer_size(&self) -> (u32, u32) {
284 self.buffer_size
285 }
286
287 pub fn scene(&self) -> &R::Scene {
288 self.renderer.scene()
289 }
290
291 pub fn renderer(&mut self) -> &mut R {
292 &mut self.renderer
293 }
294
295 #[cfg(not(target_arch = "wasm32"))]
296 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + Sync + 'static) {
297 self.runtime.set_frame_waker(waker);
298 }
299
300 #[cfg(target_arch = "wasm32")]
301 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + 'static) {
302 self.runtime.set_frame_waker(waker);
303 }
304
305 pub fn clear_frame_waker(&mut self) {
306 self.runtime.clear_frame_waker();
307 }
308
309 pub fn should_render(&self) -> bool {
310 if self.layout_requested
311 || self.scene_dirty
312 || peek_render_invalidation()
313 || peek_pointer_invalidation()
314 || peek_focus_invalidation()
315 || peek_layout_invalidation()
316 {
317 return true;
318 }
319 self.composition.should_render()
320 }
321
322 pub fn needs_redraw(&self) -> bool {
325 if self.is_dirty
326 || self.layout_requested
327 || self.scene_dirty
328 || peek_render_invalidation()
329 || peek_pointer_invalidation()
330 || peek_focus_invalidation()
331 || peek_layout_invalidation()
332 || cranpose_ui::has_pending_layout_repasses()
333 || cranpose_ui::has_pending_draw_repasses()
334 || has_pending_pointer_repasses()
335 || has_pending_focus_invalidations()
336 {
337 return true;
338 }
339
340 self.composition.should_render()
341 }
342
343 pub fn mark_dirty(&mut self) {
345 self.is_dirty = true;
346 }
347
348 pub fn request_root_render(&mut self) {
349 self.composition.request_root_render();
350 self.request_forced_layout_pass();
351 self.mark_dirty();
352 }
353
354 fn request_layout_pass(&mut self) {
355 self.layout_requested = true;
356 }
357
358 fn request_forced_layout_pass(&mut self) {
359 self.layout_requested = true;
360 self.force_layout_pass = true;
361 }
362
363 pub fn has_active_animations(&self) -> bool {
365 self.composition.should_render()
366 }
367
368 pub fn next_event_time(&self) -> Option<web_time::Instant> {
371 cranpose_ui::next_cursor_blink_time()
372 }
373
374 pub fn update(&mut self) {
375 let runtime_handle = self.runtime.runtime_handle();
376 runtime_handle.with_deferred_state_releases(|| {
377 let now = Instant::now();
378 let frame_time = now
379 .checked_duration_since(self.start_time)
380 .unwrap_or_default()
381 .as_nanos() as u64;
382 self.runtime.drain_frame_callbacks(frame_time);
383 runtime_handle.drain_ui();
384 let should_render = self.composition.should_render();
385 if should_render {
386 log::trace!(
387 target: "cranpose::input",
388 "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
389 self.layout_requested,
390 self.scene_dirty,
391 self.is_dirty
392 );
393 }
394 if should_render {
395 let Some(root_key) = self.composition.root_key() else {
396 self.process_frame();
397 self.is_dirty = false;
398 return;
399 };
400 match self.composition.reconcile(root_key, &mut *self.content) {
401 Ok(changed) => {
402 log::trace!(
403 target: "cranpose::input",
404 "reconcile changed={changed}"
405 );
406 if changed {
407 fps_monitor::record_recomposition();
408 self.request_layout_pass();
409 request_render_invalidation();
410 }
411 }
412 Err(NodeError::Missing { id }) => {
413 log::debug!("Recomposition skipped: node {} no longer exists", id);
416 self.request_layout_pass();
417 request_render_invalidation();
418 }
419 Err(err) => {
420 log::error!("recomposition failed: {err}");
421 self.request_layout_pass();
422 request_render_invalidation();
423 }
424 }
425 }
426 self.process_frame();
427 self.is_dirty = false;
429 });
430 }
431}
432
433impl<R> Drop for AppShell<R>
434where
435 R: Renderer,
436{
437 fn drop(&mut self) {
438 self.runtime.clear_frame_waker();
439 }
440}
441
442pub fn default_root_key() -> Key {
443 location_key(file!(), line!(), column!())
444}
445
446#[cfg(test)]
447mod frame_pacing_tests {
448 use super::FramePacingMode;
449
450 #[test]
451 fn frame_pacing_labels_match_overlay_modes() {
452 assert_eq!(FramePacingMode::Vsync.label(), "VSync");
453 assert_eq!(FramePacingMode::Hard60.label(), "60fps");
454 assert_eq!(FramePacingMode::Hard120.label(), "120fps");
455 assert_eq!(FramePacingMode::NoVsync.label(), "NoVSync");
456 }
457
458 #[test]
459 fn only_hard_modes_have_fixed_targets() {
460 assert_eq!(FramePacingMode::Vsync.target_fps(), None);
461 assert_eq!(FramePacingMode::Hard60.target_fps(), Some(60));
462 assert_eq!(FramePacingMode::Hard120.target_fps(), Some(120));
463 assert_eq!(FramePacingMode::NoVsync.target_fps(), None);
464 }
465}
466
467#[cfg(test)]
468#[path = "tests/app_shell_tests.rs"]
469mod tests;