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, 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, slot_table::SlotTableDebugStats, MemoryApplierDebugStats,
46 RecomposeScopeRegistryDebugStats,
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}
98
99#[derive(Clone, Debug, Default)]
104pub struct DevOptions {
105 pub fps_counter: bool,
107 pub recomposition_counter: bool,
109 pub layout_timing: bool,
111}
112
113#[cfg(any(test, feature = "test-support"))]
114#[doc(hidden)]
115#[derive(Clone, Copy, Debug)]
116pub struct RuntimeLeakDebugStats {
117 pub applier_stats: MemoryApplierDebugStats,
118 pub live_node_heap_bytes: usize,
119 pub recycled_node_heap_bytes: usize,
120 pub slot_table_heap_bytes: usize,
121 pub pass_stats: CompositionPassDebugStats,
122 pub slot_stats: SlotTableDebugStats,
123 pub observer_stats: SnapshotStateObserverDebugStats,
124 pub runtime_stats: RuntimeDebugStats,
125 pub state_arena_stats: StateArenaDebugStats,
126 pub recompose_scope_stats: RecomposeScopeRegistryDebugStats,
127 pub snapshot_v2_stats: SnapshotV2DebugStats,
128 pub snapshot_pinning_stats: SnapshotPinningDebugStats,
129}
130
131impl<R> AppShell<R>
132where
133 R: Renderer,
134 R::Error: Debug,
135{
136 pub fn new(mut renderer: R, root_key: Key, content: impl FnMut() + 'static) -> Self {
137 fps_monitor::init_fps_tracker();
139
140 let runtime = StdRuntime::new();
141 let mut composition = Composition::with_runtime(MemoryApplier::new(), runtime.runtime());
142 let mut build: Box<dyn FnMut()> = Box::new(content);
143 if let Err(err) = composition.render_stable(root_key, &mut *build) {
144 log::error!("initial render failed: {err}");
145 }
146 renderer.scene_mut().clear();
147 let mut shell = Self {
148 runtime,
149 composition,
150 content: build,
151 renderer,
152 cursor: (0.0, 0.0),
153 viewport: (800.0, 600.0),
154 buffer_size: (800, 600),
155 start_time: Instant::now(),
156 layout_tree: None,
157 semantics_tree: None,
158 semantics_enabled: false,
159 layout_requested: true,
160 force_layout_pass: true,
161 scene_dirty: true,
162 is_dirty: true,
163 buttons_pressed: PointerButtons::NONE,
164 hit_path_tracker: HitPathTracker::new(),
165 hovered_nodes: Vec::new(),
166 #[cfg(all(
167 not(target_arch = "wasm32"),
168 not(target_os = "android"),
169 not(target_os = "ios")
170 ))]
171 clipboard: arboard::Clipboard::new().ok(),
172 dev_options: DevOptions::default(),
173 };
174 shell.process_frame();
175 shell
176 }
177
178 pub fn set_dev_options(&mut self, options: DevOptions) {
183 self.dev_options = options;
184 }
185
186 pub fn dev_options(&self) -> &DevOptions {
188 &self.dev_options
189 }
190
191 pub fn set_viewport(&mut self, width: f32, height: f32) {
192 self.viewport = (width, height);
193 self.request_forced_layout_pass();
194 self.mark_dirty();
195 self.process_frame();
196 }
197
198 pub fn set_buffer_size(&mut self, width: u32, height: u32) {
199 self.buffer_size = (width, height);
200 }
201
202 pub fn buffer_size(&self) -> (u32, u32) {
203 self.buffer_size
204 }
205
206 pub fn scene(&self) -> &R::Scene {
207 self.renderer.scene()
208 }
209
210 pub fn renderer(&mut self) -> &mut R {
211 &mut self.renderer
212 }
213
214 #[cfg(not(target_arch = "wasm32"))]
215 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + Sync + 'static) {
216 self.runtime.set_frame_waker(waker);
217 }
218
219 #[cfg(target_arch = "wasm32")]
220 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + 'static) {
221 self.runtime.set_frame_waker(waker);
222 }
223
224 pub fn clear_frame_waker(&mut self) {
225 self.runtime.clear_frame_waker();
226 }
227
228 pub fn should_render(&self) -> bool {
229 if self.layout_requested
230 || self.scene_dirty
231 || peek_render_invalidation()
232 || peek_pointer_invalidation()
233 || peek_focus_invalidation()
234 || peek_layout_invalidation()
235 {
236 return true;
237 }
238 self.composition.should_render()
239 }
240
241 pub fn needs_redraw(&self) -> bool {
244 if self.is_dirty
245 || self.layout_requested
246 || self.scene_dirty
247 || peek_render_invalidation()
248 || peek_pointer_invalidation()
249 || peek_focus_invalidation()
250 || peek_layout_invalidation()
251 || cranpose_ui::has_pending_layout_repasses()
252 || cranpose_ui::has_pending_draw_repasses()
253 || has_pending_pointer_repasses()
254 || has_pending_focus_invalidations()
255 {
256 return true;
257 }
258
259 self.composition.should_render()
260 }
261
262 pub fn mark_dirty(&mut self) {
264 self.is_dirty = true;
265 }
266
267 fn request_layout_pass(&mut self) {
268 self.layout_requested = true;
269 }
270
271 fn request_forced_layout_pass(&mut self) {
272 self.layout_requested = true;
273 self.force_layout_pass = true;
274 }
275
276 pub fn has_active_animations(&self) -> bool {
278 self.composition.should_render()
279 }
280
281 pub fn next_event_time(&self) -> Option<web_time::Instant> {
284 cranpose_ui::next_cursor_blink_time()
285 }
286
287 pub fn update(&mut self) {
288 let runtime_handle = self.runtime.runtime_handle();
289 runtime_handle.with_deferred_state_releases(|| {
290 let now = Instant::now();
291 let frame_time = now
292 .checked_duration_since(self.start_time)
293 .unwrap_or_default()
294 .as_nanos() as u64;
295 self.runtime.drain_frame_callbacks(frame_time);
296 runtime_handle.drain_ui();
297 let should_render = self.composition.should_render();
298 if should_render {
299 log::trace!(
300 target: "cranpose::input",
301 "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
302 self.layout_requested,
303 self.scene_dirty,
304 self.is_dirty
305 );
306 }
307 if should_render {
308 let Some(root_key) = self.composition.root_key() else {
309 self.process_frame();
310 self.is_dirty = false;
311 return;
312 };
313 match self.composition.reconcile(root_key, &mut *self.content) {
314 Ok(changed) => {
315 log::trace!(
316 target: "cranpose::input",
317 "reconcile changed={changed}"
318 );
319 if changed {
320 fps_monitor::record_recomposition();
321 self.request_layout_pass();
322 request_render_invalidation();
323 }
324 }
325 Err(NodeError::Missing { id }) => {
326 log::debug!("Recomposition skipped: node {} no longer exists", id);
329 self.request_layout_pass();
330 request_render_invalidation();
331 }
332 Err(err) => {
333 log::error!("recomposition failed: {err}");
334 self.request_layout_pass();
335 request_render_invalidation();
336 }
337 }
338 }
339 self.process_frame();
340 self.is_dirty = false;
342 });
343 }
344}
345
346impl<R> Drop for AppShell<R>
347where
348 R: Renderer,
349{
350 fn drop(&mut self) {
351 self.runtime.clear_frame_waker();
352 }
353}
354
355pub fn default_root_key() -> Key {
356 location_key(file!(), line!(), column!())
357}
358
359#[cfg(test)]
360#[path = "tests/app_shell_tests.rs"]
361mod tests;