Skip to main content

cranpose_app_shell/
lib.rs

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
11// Re-export FPS monitoring API
12pub use fps_monitor::{
13    current_fps, fps_display, fps_display_detailed, fps_stats, record_recomposition, FpsStats,
14};
15
16use std::fmt::{Debug, Write};
17// Use web_time for cross-platform time support (native + WASM) - compatible with winit
18use 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
40// Re-export key event types for use by cranpose
41pub 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    /// Tracks which mouse buttons are currently pressed
78    buttons_pressed: PointerButtons,
79    /// Tracks which nodes were hit on PointerDown (by stable NodeId).
80    ///
81    /// This follows Jetpack Compose's HitPathTracker pattern:
82    /// - On Down: cache NodeIds, not geometry
83    /// - On Move/Up/Cancel: resolve fresh HitTargets from current scene
84    /// - Handler closures are preserved (same Rc), so internal state survives
85    hit_path_tracker: HitPathTracker,
86    /// Tracks which nodes the pointer is currently hovering over.
87    /// Used to synthesize Enter/Exit events when the hover set changes.
88    hovered_nodes: Vec<NodeId>,
89    /// Persistent clipboard for desktop (Linux X11 requires clipboard to stay alive)
90    #[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 for debugging and performance monitoring
97    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/// Development options for debugging and performance monitoring.
138///
139/// These are rendered directly by the renderer (not via composition)
140/// to avoid affecting performance measurements.
141#[derive(Clone, Debug, Default)]
142pub struct DevOptions {
143    /// Show FPS counter overlay
144    pub fps_counter: bool,
145    /// Show recomposition count
146    pub recomposition_counter: bool,
147    /// Show layout timing breakdown
148    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        // Initialize FPS tracking
188        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    /// Set development options for debugging and performance monitoring.
231    ///
232    /// The FPS counter and other overlays are rendered directly by the renderer
233    /// (not via composition) to avoid affecting performance measurements.
234    pub fn set_dev_options(&mut self, options: DevOptions) {
235        self.dev_options = options;
236        self.mark_dirty();
237    }
238
239    /// Get a reference to the current dev options.
240    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    /// Returns true if the shell needs to redraw (dirty flag, layout dirty, active animations).
325    /// Note: Cursor blink is now timer-based and uses WaitUntil scheduling, not continuous redraw.
326    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    /// Marks the shell as dirty, indicating a redraw is needed.
346    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    /// Returns true if there are active animations or pending recompositions.
366    pub fn has_active_animations(&self) -> bool {
367        self.composition.should_render()
368    }
369
370    /// Returns the next scheduled event time for cursor blink.
371    /// Use this for `ControlFlow::WaitUntil` scheduling.
372    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                        // Node was removed (likely due to conditional render or tab switch)
428                        // This is expected when scopes try to recompose after their nodes are gone
429                        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            // Clear dirty flag after update (frame has been processed)
442            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;