Skip to main content

cranpose_app_shell/
lib.rs

1#![deny(unsafe_code)]
2#![allow(clippy::type_complexity)]
3
4mod fps_monitor;
5mod hit_path_tracker;
6mod shell_debug;
7mod shell_frame;
8mod shell_input;
9#[cfg(test)]
10use shell_frame::build_draw_refresh_scope;
11
12pub use fps_monitor::FpsStats;
13
14use std::fmt::{Debug, Write};
15use std::rc::Rc;
16use std::sync::{
17    atomic::{AtomicBool, Ordering},
18    Mutex, MutexGuard,
19};
20// Use web_time for cross-platform time support (native + WASM) - compatible with winit
21use web_time::Instant;
22
23use cranpose_core::{
24    enter_event_handler_scope, location_key, run_in_mutable_snapshot, Applier, Composition, Key,
25    MemoryApplier, NodeError, NodeId,
26};
27use cranpose_foundation::{PointerButton, PointerButtons, PointerEvent, PointerEventKind};
28use cranpose_render_common::{HitTestTarget, RenderScene, Renderer};
29use cranpose_runtime_std::StdRuntime;
30use cranpose_ui::{
31    clear_transient_scroll_motion_contexts, format_layout_tree, format_render_scene,
32    format_screen_summary, has_pending_focus_invalidations, has_pending_pointer_repasses,
33    peek_focus_invalidation, peek_layout_invalidation, peek_pointer_invalidation,
34    peek_render_invalidation, process_focus_invalidations, process_pointer_repasses,
35    request_render_invalidation, take_draw_repass_nodes, take_focus_invalidation,
36    take_layout_invalidation, take_pointer_invalidation, take_render_invalidation,
37    HeadlessRenderer, LayoutBox, LayoutNode, LayoutTree, MeasureLayoutOptions, SemanticsTree,
38    SubcomposeLayoutNode,
39};
40use cranpose_ui_graphics::{Point, Rect, Size};
41use hit_path_tracker::{HitPathTracker, PointerId};
42use std::collections::HashSet;
43
44// Re-export key event types for use by cranpose
45pub use cranpose_ui::{KeyCode, KeyEvent, KeyEventType, Modifiers};
46// Re-export the platform soft-keyboard hook so runtimes only depend on the shell
47pub use cranpose_ui::PlatformTextInputHandler;
48// Re-export the IME editable-state snapshot for platform text-input bridges
49pub use cranpose_ui::ImeEditorState;
50
51#[cfg(any(test, feature = "test-support"))]
52use cranpose_core::{
53    debug_recompose_scope_registry_stats, MemoryApplierDebugStats,
54    RecomposeScopeRegistryDebugStats, SlotTableDebugStats,
55};
56#[cfg(any(test, feature = "test-support"))]
57use cranpose_core::{
58    runtime::{RuntimeDebugStats, StateArenaDebugStats},
59    snapshot_pinning::{debug_snapshot_pinning_stats, SnapshotPinningDebugStats},
60    snapshot_state_observer::SnapshotStateObserverDebugStats,
61    snapshot_v2::{debug_snapshot_v2_stats, SnapshotV2DebugStats},
62    CompositionPassDebugStats, SlotId,
63};
64
65pub struct AppShell<R>
66where
67    R: Renderer,
68{
69    app_context: Rc<cranpose_ui::AppContext>,
70    runtime: StdRuntime,
71    composition: Composition<MemoryApplier>,
72    content: Box<dyn FnMut()>,
73    renderer: R,
74    cursor: (f32, f32),
75    viewport: (f32, f32),
76    buffer_size: (u32, u32),
77    start_time: Instant,
78    last_frame_time_nanos: u64,
79    layout_tree: Option<LayoutTree>,
80    semantics_tree: Option<SemanticsTree>,
81    semantics_enabled: bool,
82    layout_requested: bool,
83    force_layout_pass: bool,
84    scene_dirty: bool,
85    scoped_layout_scene_nodes: Vec<NodeId>,
86    is_dirty: bool,
87    /// Tracks which mouse buttons are currently pressed
88    buttons_pressed: PointerButtons,
89    /// Tracks which nodes were hit on PointerDown (by stable NodeId).
90    ///
91    /// This follows Jetpack Compose's HitPathTracker pattern:
92    /// - On Down: cache NodeIds, not geometry
93    /// - On Move/Up/Cancel: resolve fresh HitTargets from current scene
94    /// - Handler closures are preserved (same Rc), so internal state survives
95    hit_path_tracker: HitPathTracker,
96    /// Tracks which nodes the pointer is currently hovering over.
97    /// Used to synthesize Enter/Exit events when the hover set changes.
98    hovered_nodes: Vec<NodeId>,
99    /// Persistent clipboard for desktop (Linux X11 requires clipboard to stay alive)
100    #[cfg(all(
101        feature = "clipboard-native",
102        not(target_arch = "wasm32"),
103        not(target_os = "android"),
104        not(target_os = "ios")
105    ))]
106    clipboard: Option<arboard::Clipboard>,
107    /// Dev options for debugging and performance monitoring
108    dev_options: DevOptions,
109    dev_overlay_controls: Vec<DevOverlayControl>,
110    dev_overlay_text: String,
111    dev_overlay_last_refresh: Option<Instant>,
112    dev_overlay_viewport: Option<Size>,
113    fps_monitor: fps_monitor::FpsMonitor,
114    frame_scheduler: FrameScheduler,
115}
116
117fn update_stage_telemetry_threshold_ms() -> Option<f64> {
118    static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
119    *THRESHOLD_MS.get_or_init(|| {
120        std::env::var("CRANPOSE_UPDATE_STAGE_TELEMETRY_MS")
121            .ok()
122            .and_then(|value| value.parse::<f64>().ok())
123            .filter(|value| value.is_finite() && *value >= 0.0)
124    })
125}
126
127#[derive(Clone, Copy, Debug)]
128struct UpdateStageTelemetry {
129    started_at: Instant,
130    after_frame_callbacks: Instant,
131    after_ui_drain: Instant,
132    after_reconcile: Instant,
133    after_process_frame: Instant,
134    should_render: bool,
135    reconcile_attempted: bool,
136    reconcile_changed: bool,
137}
138
139fn log_update_stage_telemetry(telemetry: UpdateStageTelemetry) {
140    let Some(threshold_ms) = update_stage_telemetry_threshold_ms() else {
141        return;
142    };
143    let total_ms = telemetry
144        .after_process_frame
145        .duration_since(telemetry.started_at)
146        .as_secs_f64()
147        * 1000.0;
148    if total_ms < threshold_ms {
149        return;
150    }
151
152    let frame_callbacks_ms = telemetry
153        .after_frame_callbacks
154        .duration_since(telemetry.started_at)
155        .as_secs_f64()
156        * 1000.0;
157    let ui_drain_ms = telemetry
158        .after_ui_drain
159        .duration_since(telemetry.after_frame_callbacks)
160        .as_secs_f64()
161        * 1000.0;
162    let reconcile_ms = telemetry
163        .after_reconcile
164        .duration_since(telemetry.after_ui_drain)
165        .as_secs_f64()
166        * 1000.0;
167    let process_frame_ms = telemetry
168        .after_process_frame
169        .duration_since(telemetry.after_reconcile)
170        .as_secs_f64()
171        * 1000.0;
172    eprintln!(
173        "[update-stage-telemetry] total_ms={total_ms:.2} frame_callbacks_ms={frame_callbacks_ms:.2} ui_drain_ms={ui_drain_ms:.2} reconcile_ms={reconcile_ms:.2} process_frame_ms={process_frame_ms:.2} should_render={} reconcile_attempted={} reconcile_changed={}",
174        telemetry.should_render,
175        telemetry.reconcile_attempted,
176        telemetry.reconcile_changed
177    );
178}
179
180#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
181pub enum FramePacingMode {
182    /// Pace frames to the display refresh interval. The production default:
183    /// animations advance once per vsync instead of re-rendering uncapped.
184    #[default]
185    Vsync,
186    Hard60,
187    Hard120,
188    /// Render as fast as possible. For perf harnesses and robot drivers that
189    /// measure work throughput; saturates the GPU if used in a real app.
190    NoVsync,
191}
192
193impl FramePacingMode {
194    pub const ALL: [Self; 4] = [Self::Vsync, Self::Hard60, Self::Hard120, Self::NoVsync];
195
196    pub fn label(self) -> &'static str {
197        match self {
198            Self::Vsync => "VSync",
199            Self::Hard60 => "60fps",
200            Self::Hard120 => "120fps",
201            Self::NoVsync => "NoVSync",
202        }
203    }
204
205    pub fn target_fps(self) -> Option<u32> {
206        match self {
207            Self::Hard60 => Some(60),
208            Self::Hard120 => Some(120),
209            Self::Vsync | Self::NoVsync => None,
210        }
211    }
212}
213
214#[derive(Clone, Copy, Debug, PartialEq)]
215pub struct FrameSchedule {
216    pub needs_update: bool,
217    pub needs_frame: bool,
218    pub next_deadline: Option<web_time::Instant>,
219}
220
221#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
222pub struct FrameUpdateResult {
223    pub visual_changed: bool,
224    pub structure_changed: bool,
225}
226
227pub trait PlatformFrameDriver {
228    fn request_frame(&self);
229    fn request_wake_at(&self, deadline: web_time::Instant);
230    fn clear_wake(&self);
231}
232
233#[derive(Debug)]
234pub struct FrameScheduler {
235    update_pending: AtomicBool,
236    frame_pending: AtomicBool,
237    next_deadline: Mutex<Option<web_time::Instant>>,
238}
239
240impl Default for FrameScheduler {
241    fn default() -> Self {
242        Self {
243            update_pending: AtomicBool::new(false),
244            frame_pending: AtomicBool::new(false),
245            next_deadline: Mutex::new(None),
246        }
247    }
248}
249
250impl FrameScheduler {
251    fn lock_deadline(&self) -> MutexGuard<'_, Option<web_time::Instant>> {
252        self.next_deadline
253            .lock()
254            .unwrap_or_else(|poisoned| poisoned.into_inner())
255    }
256
257    pub fn record(&self, schedule: FrameSchedule) {
258        self.update_pending
259            .store(schedule.needs_update, Ordering::SeqCst);
260        self.frame_pending
261            .store(schedule.needs_frame, Ordering::SeqCst);
262        let mut next_deadline = self.lock_deadline();
263        *next_deadline = if schedule.needs_update {
264            None
265        } else {
266            schedule.next_deadline
267        };
268    }
269
270    pub fn schedule<D>(&self, schedule: FrameSchedule, driver: &D)
271    where
272        D: PlatformFrameDriver + ?Sized,
273    {
274        self.record(schedule);
275        schedule.apply_to(driver);
276    }
277
278    pub fn snapshot(&self) -> FrameSchedule {
279        FrameSchedule {
280            needs_update: self.update_pending.load(Ordering::SeqCst),
281            needs_frame: self.frame_pending.load(Ordering::SeqCst),
282            next_deadline: *self.lock_deadline(),
283        }
284    }
285}
286
287impl FrameSchedule {
288    pub fn apply_to<D>(self, driver: &D)
289    where
290        D: PlatformFrameDriver + ?Sized,
291    {
292        if self.needs_frame {
293            driver.clear_wake();
294            driver.request_frame();
295        } else if self.needs_update {
296            driver.request_wake_at(web_time::Instant::now());
297        } else if let Some(deadline) = self.next_deadline {
298            driver.request_wake_at(deadline);
299        } else {
300            driver.clear_wake();
301        }
302    }
303}
304
305#[derive(Clone, Copy, Debug)]
306struct DevOverlayControl {
307    bounds: Rect,
308    mode: FramePacingMode,
309}
310
311/// Development options for debugging and performance monitoring.
312///
313/// These are rendered directly by the renderer (not via composition)
314/// to avoid affecting performance measurements.
315#[derive(Clone, Debug, Default)]
316pub struct DevOptions {
317    /// Show FPS counter overlay
318    pub fps_counter: bool,
319    /// Show recomposition count
320    pub recomposition_counter: bool,
321    /// Show layout timing breakdown
322    pub layout_timing: bool,
323    pub frame_pacing_controls: bool,
324    pub frame_pacing_mode: FramePacingMode,
325}
326
327#[cfg(any(test, feature = "test-support"))]
328#[doc(hidden)]
329#[derive(Clone, Copy, Debug)]
330pub struct RuntimeLeakDebugStats {
331    pub applier_stats: MemoryApplierDebugStats,
332    pub live_node_heap_bytes: usize,
333    pub recycled_node_heap_bytes: usize,
334    pub slot_table_heap_bytes: usize,
335    pub pass_stats: CompositionPassDebugStats,
336    pub slot_stats: SlotTableDebugStats,
337    pub observer_stats: SnapshotStateObserverDebugStats,
338    pub runtime_stats: RuntimeDebugStats,
339    pub state_arena_stats: StateArenaDebugStats,
340    pub recompose_scope_stats: RecomposeScopeRegistryDebugStats,
341    pub snapshot_v2_stats: SnapshotV2DebugStats,
342    pub snapshot_pinning_stats: SnapshotPinningDebugStats,
343}
344
345impl<R> AppShell<R>
346where
347    R: Renderer,
348    R::Error: Debug,
349{
350    pub fn new(renderer: R, root_key: Key, content: impl FnMut() + 'static) -> Self {
351        Self::new_with_size(renderer, root_key, content, (800, 600), (800.0, 600.0))
352    }
353
354    pub fn new_with_size(
355        renderer: R,
356        root_key: Key,
357        content: impl FnMut() + 'static,
358        buffer_size: (u32, u32),
359        viewport: (f32, f32),
360    ) -> Self {
361        Self::new_with_size_and_density(renderer, root_key, content, buffer_size, viewport, 1.0)
362    }
363
364    pub fn new_with_size_and_density(
365        mut renderer: R,
366        root_key: Key,
367        content: impl FnMut() + 'static,
368        buffer_size: (u32, u32),
369        viewport: (f32, f32),
370        density: f32,
371    ) -> Self {
372        let app_context = cranpose_ui::AppContext::new_with_density(density);
373        let runtime = StdRuntime::new();
374        let mut composition = Composition::with_runtime(MemoryApplier::new(), runtime.runtime());
375        let mut build: Box<dyn FnMut()> = Box::new(content);
376        renderer.attach_app_context_services(&app_context);
377        app_context.enter(|| {
378            if let Err(err) = composition.render_stable(root_key, &mut *build) {
379                log::error!("initial render failed: {err}");
380            }
381        });
382        renderer.scene_mut().clear();
383        let mut shell = Self {
384            app_context,
385            runtime,
386            composition,
387            content: build,
388            renderer,
389            cursor: (0.0, 0.0),
390            viewport,
391            buffer_size,
392            start_time: Instant::now(),
393            last_frame_time_nanos: 0,
394            layout_tree: None,
395            semantics_tree: None,
396            semantics_enabled: false,
397            layout_requested: true,
398            force_layout_pass: true,
399            scene_dirty: true,
400            scoped_layout_scene_nodes: Vec::new(),
401            is_dirty: true,
402            buttons_pressed: PointerButtons::NONE,
403            hit_path_tracker: HitPathTracker::new(),
404            hovered_nodes: Vec::new(),
405            #[cfg(all(
406                feature = "clipboard-native",
407                not(target_arch = "wasm32"),
408                not(target_os = "android"),
409                not(target_os = "ios")
410            ))]
411            clipboard: arboard::Clipboard::new().ok(),
412            dev_options: DevOptions::default(),
413            dev_overlay_controls: Vec::new(),
414            dev_overlay_text: String::new(),
415            dev_overlay_last_refresh: None,
416            dev_overlay_viewport: None,
417            fps_monitor: fps_monitor::FpsMonitor::new(),
418            frame_scheduler: FrameScheduler::default(),
419        };
420        shell.process_frame();
421        shell
422    }
423
424    /// Set development options for debugging and performance monitoring.
425    ///
426    /// The FPS counter and other overlays are rendered directly by the renderer
427    /// (not via composition) to avoid affecting performance measurements.
428    pub fn set_dev_options(&mut self, options: DevOptions) {
429        self.dev_options = options;
430        self.invalidate_dev_overlay_text();
431        let app_context = Rc::clone(&self.app_context);
432        app_context.enter(request_render_invalidation);
433        self.mark_dirty();
434    }
435
436    /// Get a reference to the current dev options.
437    pub fn dev_options(&self) -> &DevOptions {
438        &self.dev_options
439    }
440
441    pub fn frame_pacing_mode(&self) -> FramePacingMode {
442        self.dev_options.frame_pacing_mode
443    }
444
445    pub fn current_fps(&self) -> f32 {
446        self.fps_monitor.current_fps()
447    }
448
449    pub fn fps_stats(&self) -> FpsStats {
450        self.fps_monitor.stats()
451    }
452
453    pub fn reset_fps_stats(&mut self) {
454        self.fps_monitor.reset_stats();
455        self.invalidate_dev_overlay_text();
456    }
457
458    pub fn record_presented_frame(
459        &mut self,
460        frame_started_at: Instant,
461        frame_finished_at: Instant,
462    ) {
463        self.fps_monitor
464            .record_frame_work(frame_started_at, frame_finished_at);
465    }
466
467    #[cfg(any(test, feature = "test-support"))]
468    #[doc(hidden)]
469    pub fn record_presented_frame_for_test(
470        &mut self,
471        frame_started_nanos: u64,
472        frame_finished_nanos: u64,
473    ) {
474        let started = self.start_time + std::time::Duration::from_nanos(frame_started_nanos);
475        let finished = self.start_time + std::time::Duration::from_nanos(frame_finished_nanos);
476        self.record_presented_frame(started, finished);
477    }
478
479    pub fn set_frame_pacing_mode(&mut self, mode: FramePacingMode) {
480        if self.dev_options.frame_pacing_mode == mode {
481            return;
482        }
483        self.dev_options.frame_pacing_mode = mode;
484        self.invalidate_dev_overlay_text();
485        let app_context = Rc::clone(&self.app_context);
486        app_context.enter(request_render_invalidation);
487        self.mark_dirty();
488    }
489
490    pub fn handle_dev_overlay_click(&mut self, x: f32, y: f32) -> Option<FramePacingMode> {
491        if !self.dev_options.frame_pacing_controls {
492            return None;
493        }
494        let mode = self
495            .dev_overlay_controls
496            .iter()
497            .find(|control| control.bounds.contains(x, y))
498            .map(|control| control.mode)?;
499        self.set_frame_pacing_mode(mode);
500        Some(mode)
501    }
502
503    fn invalidate_dev_overlay_text(&mut self) {
504        self.dev_overlay_text.clear();
505        self.dev_overlay_last_refresh = None;
506        self.dev_overlay_viewport = None;
507    }
508
509    pub fn set_viewport(&mut self, width: f32, height: f32) {
510        self.viewport = (width, height);
511        self.request_forced_layout_pass();
512        self.mark_dirty();
513        self.process_frame();
514    }
515
516    pub fn viewport_size(&self) -> (f32, f32) {
517        self.viewport
518    }
519
520    pub fn set_buffer_size(&mut self, width: u32, height: u32) {
521        self.buffer_size = (width, height);
522    }
523
524    pub fn buffer_size(&self) -> (u32, u32) {
525        self.buffer_size
526    }
527
528    pub fn scene(&self) -> &R::Scene {
529        self.renderer.scene()
530    }
531
532    pub fn renderer(&mut self) -> &mut R {
533        &mut self.renderer
534    }
535
536    #[cfg(not(target_arch = "wasm32"))]
537    pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + Sync + 'static) {
538        self.runtime.set_frame_waker(waker);
539    }
540
541    #[cfg(target_arch = "wasm32")]
542    pub fn set_frame_waker(&mut self, waker: impl Fn() + 'static) {
543        self.runtime.set_frame_waker(waker);
544    }
545
546    pub fn clear_frame_waker(&mut self) {
547        self.runtime.clear_frame_waker();
548    }
549
550    pub fn should_render(&self) -> bool {
551        let app_context = Rc::clone(&self.app_context);
552        app_context.enter(|| {
553            if self.layout_requested
554                || self.scene_dirty
555                || peek_render_invalidation()
556                || peek_pointer_invalidation()
557                || peek_focus_invalidation()
558                || peek_layout_invalidation()
559            {
560                return true;
561            }
562            self.composition.should_render()
563        })
564    }
565
566    fn needs_ui_update_in_context(&self) -> bool {
567        if self.is_dirty
568            || self.layout_requested
569            || self.scene_dirty
570            || peek_render_invalidation()
571            || peek_pointer_invalidation()
572            || peek_focus_invalidation()
573            || peek_layout_invalidation()
574            || cranpose_ui::has_pending_layout_repasses()
575            || cranpose_ui::has_pending_draw_repasses()
576            || has_pending_pointer_repasses()
577            || has_pending_focus_invalidations()
578        {
579            return true;
580        }
581
582        self.composition.should_render()
583    }
584
585    pub fn needs_update(&self) -> bool {
586        let app_context = Rc::clone(&self.app_context);
587        app_context.enter(|| self.needs_ui_update_in_context())
588    }
589
590    /// Returns true if the shell needs to redraw (dirty flag, layout dirty, active animations).
591    /// Note: Cursor blink is now timer-based and uses WaitUntil scheduling, not continuous redraw.
592    pub fn needs_redraw(&self) -> bool {
593        let app_context = Rc::clone(&self.app_context);
594        app_context
595            .enter(|| self.needs_ui_update_in_context() || self.renderer.needs_frame_warmup())
596    }
597
598    /// Marks the shell as dirty, indicating a redraw is needed.
599    pub fn mark_dirty(&mut self) {
600        self.is_dirty = true;
601    }
602
603    pub fn request_root_render(&mut self) {
604        self.composition.request_root_render();
605        self.request_forced_layout_pass();
606        let app_context = Rc::clone(&self.app_context);
607        app_context.enter(request_render_invalidation);
608        self.mark_dirty();
609    }
610
611    pub fn set_density(&mut self, density: f32) {
612        let app_context = Rc::clone(&self.app_context);
613        let changed = app_context.enter(|| {
614            let previous = cranpose_ui::current_density().to_bits();
615            cranpose_ui::set_density(density);
616            previous != cranpose_ui::current_density().to_bits()
617        });
618        if changed {
619            self.request_forced_layout_pass();
620            self.mark_dirty();
621        }
622    }
623
624    #[cfg(any(test, feature = "test-support"))]
625    #[doc(hidden)]
626    pub fn debug_current_density(&self) -> f32 {
627        let app_context = Rc::clone(&self.app_context);
628        app_context.enter(cranpose_ui::current_density)
629    }
630
631    #[cfg(any(test, feature = "test-support"))]
632    #[doc(hidden)]
633    pub fn debug_enter_app_context<T>(&self, block: impl FnOnce() -> T) -> T {
634        let app_context = Rc::clone(&self.app_context);
635        app_context.enter(block)
636    }
637
638    fn request_layout_pass(&mut self) {
639        self.layout_requested = true;
640    }
641
642    fn request_forced_layout_pass(&mut self) {
643        self.layout_requested = true;
644        self.force_layout_pass = true;
645    }
646
647    fn composition_tree_needs_layout(&mut self) -> bool {
648        let Some(root) = self.composition.root() else {
649            return true;
650        };
651        let mut applier = self.composition.applier_mut();
652        cranpose_ui::tree_needs_layout(&mut *applier, root).unwrap_or_else(|err| {
653            log::warn!(
654                "Cannot check layout dirty status for root #{}: {}",
655                root,
656                err
657            );
658            true
659        })
660    }
661
662    /// Returns true if there are active animations or pending recompositions.
663    pub fn has_active_animations(&self) -> bool {
664        self.composition.should_render()
665    }
666
667    pub fn has_active_pointer_gesture(&self) -> bool {
668        self.buttons_pressed != PointerButtons::NONE
669            && self.hit_path_tracker.has_path(PointerId::PRIMARY)
670    }
671
672    /// Returns the next scheduled event time for cursor blink.
673    /// Use this for `ControlFlow::WaitUntil` scheduling.
674    pub fn next_event_time(&self) -> Option<web_time::Instant> {
675        let app_context = Rc::clone(&self.app_context);
676        app_context.enter(cranpose_ui::next_cursor_blink_time)
677    }
678
679    fn compute_frame_schedule(&self) -> FrameSchedule {
680        let needs_update = self.needs_update();
681        let needs_frame = needs_update
682            || self.has_active_animations()
683            || self.has_active_pointer_gesture()
684            || self.renderer.needs_frame_warmup();
685        FrameSchedule {
686            needs_update,
687            needs_frame,
688            next_deadline: self.next_event_time(),
689        }
690    }
691
692    pub fn frame_schedule(&self) -> FrameSchedule {
693        let schedule = self.compute_frame_schedule();
694        self.frame_scheduler.record(schedule);
695        schedule
696    }
697
698    pub fn schedule_platform_frame<D>(&self, driver: &D) -> FrameSchedule
699    where
700        D: PlatformFrameDriver + ?Sized,
701    {
702        let schedule = self.compute_frame_schedule();
703        self.frame_scheduler.schedule(schedule, driver);
704        schedule
705    }
706
707    pub fn frame_scheduler_snapshot(&self) -> FrameSchedule {
708        self.frame_scheduler.snapshot()
709    }
710
711    fn frame_time_nanos_at(&self, now: Instant) -> u64 {
712        now.checked_duration_since(self.start_time)
713            .unwrap_or_default()
714            .as_nanos()
715            .min(u128::from(u64::MAX)) as u64
716    }
717
718    pub fn update_after_frame_interval(
719        &mut self,
720        frame_interval: std::time::Duration,
721    ) -> FrameUpdateResult {
722        let wall_frame_time = self.frame_time_nanos_at(Instant::now());
723        let base_frame_time = self.last_frame_time_nanos.max(wall_frame_time);
724        let frame_time = base_frame_time
725            .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
726        self.update_at_frame_time_nanos(frame_time)
727    }
728
729    pub fn update_at_frame_time_nanos(&mut self, frame_time: u64) -> FrameUpdateResult {
730        let app_context = Rc::clone(&self.app_context);
731        app_context.enter(|| {
732            let update_started_at = Instant::now();
733            let frame_time = frame_time.max(self.last_frame_time_nanos);
734            self.last_frame_time_nanos = frame_time;
735            let runtime_handle = self.runtime.runtime_handle();
736            runtime_handle.with_deferred_state_releases(|| {
737                self.runtime.drain_frame_callbacks(frame_time);
738                let after_frame_callbacks = Instant::now();
739                runtime_handle.drain_ui();
740                let after_ui_drain = Instant::now();
741                let should_render = self.composition.should_render();
742                let mut reconcile_attempted = false;
743                let mut reconcile_changed = false;
744                if should_render {
745                    log::trace!(
746                        target: "cranpose::input",
747                        "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
748                        self.layout_requested,
749                        self.scene_dirty,
750                        self.is_dirty
751                    );
752                }
753                if should_render {
754                    let Some(root_key) = self.composition.root_key() else {
755                        let result = self.process_frame_in_context(reconcile_changed);
756                        let after_process_frame = Instant::now();
757                        log_update_stage_telemetry(UpdateStageTelemetry {
758                            started_at: update_started_at,
759                            after_frame_callbacks,
760                            after_ui_drain,
761                            after_reconcile: after_ui_drain,
762                            after_process_frame,
763                            should_render,
764                            reconcile_attempted,
765                            reconcile_changed,
766                        });
767                        self.is_dirty = false;
768                        return result;
769                    };
770                    reconcile_attempted = true;
771                    match self.composition.reconcile(root_key, &mut *self.content) {
772                        Ok(changed) => {
773                            reconcile_changed = changed;
774                            log::trace!(
775                                target: "cranpose::input",
776                                "reconcile changed={changed}"
777                            );
778                            if changed {
779                                self.fps_monitor.record_recomposition();
780                                if self.composition_tree_needs_layout() {
781                                    self.request_layout_pass();
782                                }
783                                request_render_invalidation();
784                            }
785                        }
786                        Err(NodeError::Missing { id }) => {
787                            log::debug!("Recomposition skipped: node {} no longer exists", id);
788                            self.request_layout_pass();
789                            request_render_invalidation();
790                        }
791                        Err(err) => {
792                            log::error!("recomposition failed: {err}");
793                            self.request_layout_pass();
794                            request_render_invalidation();
795                        }
796                    }
797                }
798                let after_reconcile = Instant::now();
799                let result = self.process_frame_in_context(reconcile_changed);
800                let after_process_frame = Instant::now();
801                log_update_stage_telemetry(UpdateStageTelemetry {
802                    started_at: update_started_at,
803                    after_frame_callbacks,
804                    after_ui_drain,
805                    after_reconcile,
806                    after_process_frame,
807                    should_render,
808                    reconcile_attempted,
809                    reconcile_changed,
810                });
811                self.is_dirty = false;
812                result
813            })
814        })
815    }
816
817    pub fn update(&mut self) -> FrameUpdateResult {
818        let frame_time = self.frame_time_nanos_at(Instant::now());
819        self.update_at_frame_time_nanos(frame_time)
820    }
821}
822
823impl<R> Drop for AppShell<R>
824where
825    R: Renderer,
826{
827    fn drop(&mut self) {
828        self.runtime.clear_frame_waker();
829    }
830}
831
832pub fn default_root_key() -> Key {
833    location_key(file!(), line!(), column!())
834}
835
836#[cfg(test)]
837mod frame_pacing_tests {
838    use super::{FramePacingMode, FrameSchedule, FrameScheduler, PlatformFrameDriver};
839    use std::cell::RefCell;
840    use std::panic::{catch_unwind, AssertUnwindSafe};
841    use std::time::Duration;
842    use web_time::Instant;
843
844    #[derive(Clone, Copy, Debug, PartialEq)]
845    enum DriverCall {
846        RequestFrame,
847        RequestWakeAt(Instant),
848        ClearWake,
849    }
850
851    #[derive(Default)]
852    struct RecordingFrameDriver {
853        calls: RefCell<Vec<DriverCall>>,
854    }
855
856    impl RecordingFrameDriver {
857        fn calls(&self) -> Vec<DriverCall> {
858            self.calls.borrow().clone()
859        }
860    }
861
862    impl PlatformFrameDriver for RecordingFrameDriver {
863        fn request_frame(&self) {
864            self.calls.borrow_mut().push(DriverCall::RequestFrame);
865        }
866
867        fn request_wake_at(&self, deadline: Instant) {
868            self.calls
869                .borrow_mut()
870                .push(DriverCall::RequestWakeAt(deadline));
871        }
872
873        fn clear_wake(&self) {
874            self.calls.borrow_mut().push(DriverCall::ClearWake);
875        }
876    }
877
878    #[test]
879    fn frame_pacing_labels_match_overlay_modes() {
880        assert_eq!(FramePacingMode::Vsync.label(), "VSync");
881        assert_eq!(FramePacingMode::Hard60.label(), "60fps");
882        assert_eq!(FramePacingMode::Hard120.label(), "120fps");
883        assert_eq!(FramePacingMode::NoVsync.label(), "NoVSync");
884    }
885
886    #[test]
887    fn only_hard_modes_have_fixed_targets() {
888        assert_eq!(FramePacingMode::Vsync.target_fps(), None);
889        assert_eq!(FramePacingMode::Hard60.target_fps(), Some(60));
890        assert_eq!(FramePacingMode::Hard120.target_fps(), Some(120));
891        assert_eq!(FramePacingMode::NoVsync.target_fps(), None);
892    }
893
894    #[test]
895    fn frame_schedule_requests_immediate_frame_and_clears_deadline() {
896        let driver = RecordingFrameDriver::default();
897        let deadline = Instant::now() + Duration::from_millis(25);
898
899        FrameSchedule {
900            needs_update: true,
901            needs_frame: true,
902            next_deadline: Some(deadline),
903        }
904        .apply_to(&driver);
905
906        assert_eq!(
907            driver.calls(),
908            vec![DriverCall::ClearWake, DriverCall::RequestFrame]
909        );
910    }
911
912    #[test]
913    fn frame_schedule_requests_deadline_when_idle_until_timer() {
914        let driver = RecordingFrameDriver::default();
915        let deadline = Instant::now() + Duration::from_millis(25);
916
917        FrameSchedule {
918            needs_update: false,
919            needs_frame: false,
920            next_deadline: Some(deadline),
921        }
922        .apply_to(&driver);
923
924        assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
925    }
926
927    #[test]
928    fn frame_schedule_wakes_without_requesting_frame_for_update_only_work() {
929        let driver = RecordingFrameDriver::default();
930        let before = Instant::now();
931
932        FrameSchedule {
933            needs_update: true,
934            needs_frame: false,
935            next_deadline: None,
936        }
937        .apply_to(&driver);
938
939        let calls = driver.calls();
940        assert_eq!(calls.len(), 1);
941        match calls[0] {
942            DriverCall::RequestWakeAt(deadline) => {
943                assert!(deadline >= before);
944            }
945            other => panic!("update-only work must wake without requesting a frame: {other:?}"),
946        }
947    }
948
949    #[test]
950    fn frame_schedule_clears_wake_when_fully_idle() {
951        let driver = RecordingFrameDriver::default();
952
953        FrameSchedule {
954            needs_update: false,
955            needs_frame: false,
956            next_deadline: None,
957        }
958        .apply_to(&driver);
959
960        assert_eq!(driver.calls(), vec![DriverCall::ClearWake]);
961    }
962
963    #[test]
964    fn frame_scheduler_records_latest_schedule_and_applies_driver() {
965        let scheduler = FrameScheduler::default();
966        let driver = RecordingFrameDriver::default();
967        let deadline = Instant::now() + Duration::from_millis(25);
968
969        scheduler.schedule(
970            FrameSchedule {
971                needs_update: false,
972                needs_frame: false,
973                next_deadline: Some(deadline),
974            },
975            &driver,
976        );
977
978        assert_eq!(
979            scheduler.snapshot(),
980            FrameSchedule {
981                needs_update: false,
982                needs_frame: false,
983                next_deadline: Some(deadline),
984            }
985        );
986        assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
987    }
988
989    #[test]
990    fn frame_scheduler_clears_deadline_for_immediate_frame() {
991        let scheduler = FrameScheduler::default();
992        let driver = RecordingFrameDriver::default();
993        let deadline = Instant::now() + Duration::from_millis(25);
994
995        scheduler.schedule(
996            FrameSchedule {
997                needs_update: true,
998                needs_frame: true,
999                next_deadline: Some(deadline),
1000            },
1001            &driver,
1002        );
1003
1004        assert_eq!(
1005            scheduler.snapshot(),
1006            FrameSchedule {
1007                needs_update: true,
1008                needs_frame: true,
1009                next_deadline: None,
1010            }
1011        );
1012        assert_eq!(
1013            driver.calls(),
1014            vec![DriverCall::ClearWake, DriverCall::RequestFrame]
1015        );
1016    }
1017
1018    #[test]
1019    fn frame_scheduler_recovers_poisoned_deadline_lock() {
1020        let scheduler = FrameScheduler::default();
1021        let deadline = Instant::now() + Duration::from_millis(25);
1022
1023        let _ = catch_unwind(AssertUnwindSafe(|| {
1024            let _guard = scheduler.lock_deadline();
1025            panic!("poison frame scheduler deadline lock");
1026        }));
1027
1028        scheduler.record(FrameSchedule {
1029            needs_update: false,
1030            needs_frame: false,
1031            next_deadline: Some(deadline),
1032        });
1033
1034        assert_eq!(
1035            scheduler.snapshot(),
1036            FrameSchedule {
1037                needs_update: false,
1038                needs_frame: false,
1039                next_deadline: Some(deadline),
1040            }
1041        );
1042    }
1043}
1044
1045#[cfg(test)]
1046#[path = "tests/app_shell_tests.rs"]
1047mod tests;