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(mut renderer: R, root_key: Key, content: impl FnMut() + 'static) -> Self {
176 fps_monitor::init_fps_tracker();
178
179 let runtime = StdRuntime::new();
180 let mut composition = Composition::with_runtime(MemoryApplier::new(), runtime.runtime());
181 let mut build: Box<dyn FnMut()> = Box::new(content);
182 if let Err(err) = composition.render_stable(root_key, &mut *build) {
183 log::error!("initial render failed: {err}");
184 }
185 renderer.scene_mut().clear();
186 let mut shell = Self {
187 runtime,
188 composition,
189 content: build,
190 renderer,
191 cursor: (0.0, 0.0),
192 viewport: (800.0, 600.0),
193 buffer_size: (800, 600),
194 start_time: Instant::now(),
195 layout_tree: None,
196 semantics_tree: None,
197 semantics_enabled: false,
198 layout_requested: true,
199 force_layout_pass: true,
200 scene_dirty: true,
201 is_dirty: true,
202 buttons_pressed: PointerButtons::NONE,
203 hit_path_tracker: HitPathTracker::new(),
204 hovered_nodes: Vec::new(),
205 #[cfg(all(
206 not(target_arch = "wasm32"),
207 not(target_os = "android"),
208 not(target_os = "ios")
209 ))]
210 clipboard: arboard::Clipboard::new().ok(),
211 dev_options: DevOptions::default(),
212 dev_overlay_controls: Vec::new(),
213 };
214 shell.process_frame();
215 shell
216 }
217
218 pub fn set_dev_options(&mut self, options: DevOptions) {
223 self.dev_options = options;
224 self.mark_dirty();
225 }
226
227 pub fn dev_options(&self) -> &DevOptions {
229 &self.dev_options
230 }
231
232 pub fn frame_pacing_mode(&self) -> FramePacingMode {
233 self.dev_options.frame_pacing_mode
234 }
235
236 pub fn set_frame_pacing_mode(&mut self, mode: FramePacingMode) {
237 if self.dev_options.frame_pacing_mode == mode {
238 return;
239 }
240 self.dev_options.frame_pacing_mode = mode;
241 request_render_invalidation();
242 self.mark_dirty();
243 }
244
245 pub fn handle_dev_overlay_click(&mut self, x: f32, y: f32) -> Option<FramePacingMode> {
246 if !self.dev_options.frame_pacing_controls {
247 return None;
248 }
249 let mode = self
250 .dev_overlay_controls
251 .iter()
252 .find(|control| control.bounds.contains(x, y))
253 .map(|control| control.mode)?;
254 self.set_frame_pacing_mode(mode);
255 Some(mode)
256 }
257
258 pub fn set_viewport(&mut self, width: f32, height: f32) {
259 self.viewport = (width, height);
260 self.request_forced_layout_pass();
261 self.mark_dirty();
262 self.process_frame();
263 }
264
265 pub fn viewport_size(&self) -> (f32, f32) {
266 self.viewport
267 }
268
269 pub fn set_buffer_size(&mut self, width: u32, height: u32) {
270 self.buffer_size = (width, height);
271 }
272
273 pub fn buffer_size(&self) -> (u32, u32) {
274 self.buffer_size
275 }
276
277 pub fn scene(&self) -> &R::Scene {
278 self.renderer.scene()
279 }
280
281 pub fn renderer(&mut self) -> &mut R {
282 &mut self.renderer
283 }
284
285 #[cfg(not(target_arch = "wasm32"))]
286 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + Sync + 'static) {
287 self.runtime.set_frame_waker(waker);
288 }
289
290 #[cfg(target_arch = "wasm32")]
291 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + 'static) {
292 self.runtime.set_frame_waker(waker);
293 }
294
295 pub fn clear_frame_waker(&mut self) {
296 self.runtime.clear_frame_waker();
297 }
298
299 pub fn should_render(&self) -> bool {
300 if self.layout_requested
301 || self.scene_dirty
302 || peek_render_invalidation()
303 || peek_pointer_invalidation()
304 || peek_focus_invalidation()
305 || peek_layout_invalidation()
306 {
307 return true;
308 }
309 self.composition.should_render()
310 }
311
312 pub fn needs_redraw(&self) -> bool {
315 if self.is_dirty
316 || self.layout_requested
317 || self.scene_dirty
318 || peek_render_invalidation()
319 || peek_pointer_invalidation()
320 || peek_focus_invalidation()
321 || peek_layout_invalidation()
322 || cranpose_ui::has_pending_layout_repasses()
323 || cranpose_ui::has_pending_draw_repasses()
324 || has_pending_pointer_repasses()
325 || has_pending_focus_invalidations()
326 {
327 return true;
328 }
329
330 self.composition.should_render()
331 }
332
333 pub fn mark_dirty(&mut self) {
335 self.is_dirty = true;
336 }
337
338 fn request_layout_pass(&mut self) {
339 self.layout_requested = true;
340 }
341
342 fn request_forced_layout_pass(&mut self) {
343 self.layout_requested = true;
344 self.force_layout_pass = true;
345 }
346
347 pub fn has_active_animations(&self) -> bool {
349 self.composition.should_render()
350 }
351
352 pub fn next_event_time(&self) -> Option<web_time::Instant> {
355 cranpose_ui::next_cursor_blink_time()
356 }
357
358 pub fn update(&mut self) {
359 let runtime_handle = self.runtime.runtime_handle();
360 runtime_handle.with_deferred_state_releases(|| {
361 let now = Instant::now();
362 let frame_time = now
363 .checked_duration_since(self.start_time)
364 .unwrap_or_default()
365 .as_nanos() as u64;
366 self.runtime.drain_frame_callbacks(frame_time);
367 runtime_handle.drain_ui();
368 let should_render = self.composition.should_render();
369 if should_render {
370 log::trace!(
371 target: "cranpose::input",
372 "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
373 self.layout_requested,
374 self.scene_dirty,
375 self.is_dirty
376 );
377 }
378 if should_render {
379 let Some(root_key) = self.composition.root_key() else {
380 self.process_frame();
381 self.is_dirty = false;
382 return;
383 };
384 match self.composition.reconcile(root_key, &mut *self.content) {
385 Ok(changed) => {
386 log::trace!(
387 target: "cranpose::input",
388 "reconcile changed={changed}"
389 );
390 if changed {
391 fps_monitor::record_recomposition();
392 self.request_layout_pass();
393 request_render_invalidation();
394 }
395 }
396 Err(NodeError::Missing { id }) => {
397 log::debug!("Recomposition skipped: node {} no longer exists", id);
400 self.request_layout_pass();
401 request_render_invalidation();
402 }
403 Err(err) => {
404 log::error!("recomposition failed: {err}");
405 self.request_layout_pass();
406 request_render_invalidation();
407 }
408 }
409 }
410 self.process_frame();
411 self.is_dirty = false;
413 });
414 }
415}
416
417impl<R> Drop for AppShell<R>
418where
419 R: Renderer,
420{
421 fn drop(&mut self) {
422 self.runtime.clear_frame_waker();
423 }
424}
425
426pub fn default_root_key() -> Key {
427 location_key(file!(), line!(), column!())
428}
429
430#[cfg(test)]
431mod frame_pacing_tests {
432 use super::FramePacingMode;
433
434 #[test]
435 fn frame_pacing_labels_match_overlay_modes() {
436 assert_eq!(FramePacingMode::Vsync.label(), "VSync");
437 assert_eq!(FramePacingMode::Hard60.label(), "60fps");
438 assert_eq!(FramePacingMode::Hard120.label(), "120fps");
439 assert_eq!(FramePacingMode::NoVsync.label(), "NoVSync");
440 }
441
442 #[test]
443 fn only_hard_modes_have_fixed_targets() {
444 assert_eq!(FramePacingMode::Vsync.target_fps(), None);
445 assert_eq!(FramePacingMode::Hard60.target_fps(), Some(60));
446 assert_eq!(FramePacingMode::Hard120.target_fps(), Some(120));
447 assert_eq!(FramePacingMode::NoVsync.target_fps(), None);
448 }
449}
450
451#[cfg(test)]
452#[path = "tests/app_shell_tests.rs"]
453mod tests;