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;
9mod wheel;
10#[cfg(test)]
11use shell_frame::build_draw_refresh_scope;
12
13pub use fps_monitor::FpsStats;
14
15use std::fmt::{Debug, Write};
16use std::rc::Rc;
17use std::sync::{
18    atomic::{AtomicBool, Ordering},
19    Mutex, MutexGuard,
20};
21// Use web_time for cross-platform time support (native + WASM) - compatible with winit
22use web_time::Instant;
23
24use cranpose_core::{
25    enter_event_handler_scope, location_key, run_in_mutable_snapshot, Applier, Composition, Key,
26    MemoryApplier, NodeError, NodeId,
27};
28use cranpose_foundation::{PointerButton, PointerButtons, PointerEvent, PointerEventKind};
29use cranpose_render_common::{HitTestTarget, RenderScene, Renderer};
30use cranpose_runtime_std::StdRuntime;
31use cranpose_ui::{
32    clear_transient_scroll_motion_contexts, format_layout_tree, format_render_scene,
33    format_screen_summary, has_pending_focus_invalidations, has_pending_pointer_repasses,
34    has_pending_semantics_invalidations, peek_focus_invalidation, peek_layout_invalidation,
35    peek_pointer_invalidation, peek_render_invalidation, process_focus_invalidations,
36    process_pointer_repasses, process_semantics_invalidations, request_render_invalidation,
37    take_draw_repass_nodes, take_focus_invalidation, take_layout_invalidation,
38    take_pointer_invalidation, take_render_invalidation, HeadlessRenderer, LayoutBox, LayoutNode,
39    LayoutTree, MeasureLayoutOptions, SemanticsTree, SubcomposeLayoutNode,
40};
41use cranpose_ui_graphics::{Point, Rect, Size};
42use hit_path_tracker::{HitPathTracker, PointerId};
43use std::collections::HashSet;
44
45// Re-export key event types for use by cranpose
46pub use cranpose_ui::{KeyCode, KeyEvent, KeyEventType, Modifiers};
47// Re-export the pointer device source so platform backends can stamp it.
48pub use cranpose_foundation::PointerSource;
49// Re-export the rotary (Wear OS crown / rotating bezel) event so platform
50// backends can build one and apps can type their window-level handler.
51pub use cranpose_foundation::{
52    rotary_scroll_pixels_from_detents, RotaryScrollEvent, DEFAULT_ROTARY_SCROLL_FACTOR_DP,
53};
54// The wheel sample every host normalizes into, and the convention it carries.
55pub use wheel::WheelScroll;
56
57/// Bridges the in-tree selection menu's clipboard actions to the desktop OS
58/// clipboard (`arboard`). Holds a persistent clipboard handle (Linux X11 loses
59/// clipboard contents when the last owning handle drops), shared behind an
60/// `Rc<RefCell<..>>` so it stays alive for the app-context's lifetime.
61#[cfg(all(
62    feature = "clipboard-native",
63    not(target_arch = "wasm32"),
64    not(target_os = "android"),
65    not(target_os = "ios")
66))]
67struct ShellClipboard {
68    inner: std::rc::Rc<std::cell::RefCell<Option<arboard::Clipboard>>>,
69}
70
71#[cfg(all(
72    feature = "clipboard-native",
73    not(target_arch = "wasm32"),
74    not(target_os = "android"),
75    not(target_os = "ios")
76))]
77impl cranpose_ui::clipboard_session::PlatformClipboard for ShellClipboard {
78    fn write_text(&self, text: &str) {
79        if let Some(clipboard) = self.inner.borrow_mut().as_mut() {
80            let _ = clipboard.set_text(text);
81        }
82    }
83
84    fn read_text(&self) -> Option<String> {
85        self.inner
86            .borrow_mut()
87            .as_mut()
88            .and_then(|clipboard| clipboard.get_text().ok())
89    }
90}
91// Re-export the platform soft-keyboard hook so runtimes only depend on the shell
92pub use cranpose_ui::PlatformTextInputHandler;
93// Re-export the IME editable-state snapshot for platform text-input bridges
94pub use cranpose_ui::ImeEditorState;
95
96#[cfg(any(test, feature = "test-support"))]
97use cranpose_core::{
98    debug_recompose_scope_registry_stats, MemoryApplierDebugStats,
99    RecomposeScopeRegistryDebugStats, SlotTableDebugStats,
100};
101#[cfg(any(test, feature = "test-support"))]
102use cranpose_core::{
103    runtime::{RuntimeDebugStats, StateArenaDebugStats},
104    snapshot_pinning::{debug_snapshot_pinning_stats, SnapshotPinningDebugStats},
105    snapshot_state_observer::SnapshotStateObserverDebugStats,
106    snapshot_v2::{debug_snapshot_v2_stats, SnapshotV2DebugStats},
107    CompositionPassDebugStats, SlotId,
108};
109
110/// How the platform should vote the display's frame rate on behalf of the app.
111///
112/// Compose apps get 120 Hz gameplay on a 120 Hz panel not by presenting faster
113/// but because HWUI votes a rate on the window while animations and gestures
114/// run, and clears it when they stop. A window that never votes is pinned by
115/// SurfaceFlinger's cadence inference instead — which also throttles the app's
116/// choreographer, so the inference reinforces itself. `Auto` reproduces the
117/// HWUI behaviour; the platform backends read it every frame and vote through
118/// the native window when the desired rate changes.
119#[derive(Debug, Clone, Copy, PartialEq, Default)]
120pub enum FrameRatePreference {
121    /// Ask for the panel's fastest rate while frames are being produced, no
122    /// preference when the scene is still. This is the default, matching what
123    /// Compose/HWUI do for every app without the app's involvement.
124    #[default]
125    Auto,
126    /// Never vote; the OS infers a rate from presentation cadence.
127    NoPreference,
128    /// Always vote exactly this rate in Hz. Values `<= 0` behave like
129    /// [`FrameRatePreference::NoPreference`].
130    Exact(f32),
131}
132
133impl FrameRatePreference {
134    /// The baseline `Auto` votes while animating without interaction — the
135    /// same rate HWUI's NORMAL frame-rate category resolves to on phone
136    /// panels. The quiet vote cannot simply be "no vote": SurfaceFlinger
137    /// infers a non-voting window's rate from whatever cadence it last
138    /// observed and pins it, so an app that ever ran the panel's fast rate
139    /// would stay there forever (measured on a Pixel 9 Pro, both directions).
140    pub const AUTO_QUIET_RATE_HZ: f32 = 60.0;
141
142    /// The rate the platform should vote right now, in Hz, where `0.0` means
143    /// "clear the vote". `producing_frames` is whether the frame loop has a
144    /// frame scheduled, `interacting` whether input arrived within the
145    /// platform's boost hold-off, and `panel_max_hz` the display's fastest
146    /// supported rate when the platform knows it.
147    ///
148    /// While interacting, `Auto` holds the boost even through moments with no
149    /// frame scheduled: a gesture sequence crosses still screens (a tap lands,
150    /// the old scene stops animating, the new one hasn't started), and letting
151    /// each of those instantly clear the vote flapped the display between the
152    /// boost rate and no-vote several times per second on device. This mirrors
153    /// SurfaceFlinger's own touch boost, which also outlives the touch by
154    /// seconds regardless of what the app presents in between.
155    pub fn desired_rate_hz(
156        self,
157        producing_frames: bool,
158        interacting: bool,
159        panel_max_hz: Option<f32>,
160    ) -> f32 {
161        match self {
162            FrameRatePreference::Auto => {
163                if interacting {
164                    panel_max_hz
165                        .filter(|rate| *rate > 0.0)
166                        .unwrap_or(Self::AUTO_QUIET_RATE_HZ)
167                } else if producing_frames {
168                    Self::AUTO_QUIET_RATE_HZ
169                } else {
170                    0.0
171                }
172            }
173            FrameRatePreference::NoPreference => 0.0,
174            FrameRatePreference::Exact(rate) if rate > 0.0 => rate,
175            FrameRatePreference::Exact(_) => 0.0,
176        }
177    }
178}
179
180pub struct AppShell<R>
181where
182    R: Renderer,
183{
184    app_context: Rc<cranpose_ui::AppContext>,
185    runtime: StdRuntime,
186    composition: Composition<MemoryApplier>,
187    content: Box<dyn FnMut()>,
188    renderer: R,
189    cursor: (f32, f32),
190    viewport: (f32, f32),
191    buffer_size: (u32, u32),
192    start_time: Instant,
193    last_frame_time_nanos: u64,
194    layout_tree: Option<LayoutTree>,
195    semantics_tree: Option<SemanticsTree>,
196    semantics_enabled: bool,
197    /// Monotonic counter that moves whenever the cached layout/semantics
198    /// snapshots are invalidated or semantics tracking is toggled. Accessibility
199    /// bridges compare it against the revision they last projected so a frame
200    /// that changed nothing semantic costs them one integer compare instead of
201    /// a full tree walk. See [`AppShell::semantics_snapshot_revision`].
202    semantics_snapshot_revision: u64,
203    /// The app's display frame-rate preference, applied by platform backends
204    /// that own a native window. See [`FrameRatePreference`].
205    frame_rate_preference: FrameRatePreference,
206    layout_requested: bool,
207    force_layout_pass: bool,
208    scene_dirty: bool,
209    scoped_layout_scene_nodes: Vec<NodeId>,
210    is_dirty: bool,
211    /// Tracks which mouse buttons are currently pressed
212    buttons_pressed: PointerButtons,
213    /// Device source (touch/mouse/stylus) of the most recent pointer sample,
214    /// set by the platform before dispatching and stamped onto every
215    /// `PointerEvent` the shell constructs. The pointer model is stateful
216    /// (`set_cursor` then `pointer_pressed`), so a single per-shell cell mirrors
217    /// the existing cursor/buttons state without churning every method signature.
218    pointer_source: PointerSource,
219    /// Tracks which nodes were hit on PointerDown (by stable NodeId).
220    ///
221    /// This follows Jetpack Compose's HitPathTracker pattern:
222    /// - On Down: cache NodeIds, not geometry
223    /// - On Move/Up/Cancel: resolve fresh HitTargets from current scene
224    /// - Handler closures are preserved (same Rc), so internal state survives
225    hit_path_tracker: HitPathTracker,
226    /// Tracks which nodes the pointer is currently hovering over.
227    /// Used to synthesize Enter/Exit events when the hover set changes.
228    hovered_nodes: Vec<NodeId>,
229    /// Window-level rotary (crown/bezel) fallback handler.
230    ///
231    /// Invoked only when no modifier in the routed chain consumed the event, so
232    /// an app that draws everything into one canvas can read raw rotary deltas
233    /// without participating in focus. See
234    /// [`AppShell::set_on_rotary_scroll`](AppShell::set_on_rotary_scroll).
235    on_rotary_scroll: Option<Rc<dyn Fn(RotaryScrollEvent) -> bool>>,
236    /// Pixels per rotary detent used when a platform reports the crown delta in
237    /// detents. Defaults to `DEFAULT_ROTARY_SCROLL_FACTOR_DP * density`.
238    rotary_scroll_factor: f32,
239    /// Persistent clipboard for desktop (Linux X11 requires clipboard to stay alive)
240    #[cfg(all(feature = "clipboard-native", target_os = "linux"))]
241    clipboard: Option<arboard::Clipboard>,
242    /// Dev options for debugging and performance monitoring
243    dev_options: DevOptions,
244    dev_overlay_controls: Vec<DevOverlayControl>,
245    dev_overlay_text: String,
246    dev_overlay_last_refresh: Option<Instant>,
247    dev_overlay_viewport: Option<Size>,
248    fps_monitor: fps_monitor::FpsMonitor,
249    frame_scheduler: FrameScheduler,
250}
251
252#[derive(Clone, Copy, Debug, PartialEq, Eq)]
253/// Platform and animation-clock timestamps for one pointer sample.
254pub struct PointerEventTime {
255    /// Timestamp supplied by the platform, in its millisecond clock domain.
256    pub platform_time_ms: Option<i64>,
257    /// Timestamp in the animation frame-clock domain.
258    pub animation_time_nanos: u64,
259}
260
261fn update_stage_telemetry_threshold_ms() -> Option<f64> {
262    static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
263    *THRESHOLD_MS.get_or_init(|| {
264        std::env::var("CRANPOSE_UPDATE_STAGE_TELEMETRY_MS")
265            .ok()
266            .and_then(|value| value.parse::<f64>().ok())
267            .filter(|value| value.is_finite() && *value >= 0.0)
268    })
269}
270
271#[derive(Clone, Copy, Debug)]
272struct UpdateStageTelemetry {
273    started_at: Instant,
274    after_frame_callbacks: Instant,
275    after_ui_drain: Instant,
276    after_reconcile: Instant,
277    after_process_frame: Instant,
278    should_render: bool,
279    reconcile_attempted: bool,
280    reconcile_changed: bool,
281}
282
283fn log_update_stage_telemetry(telemetry: UpdateStageTelemetry) {
284    let Some(threshold_ms) = update_stage_telemetry_threshold_ms() else {
285        return;
286    };
287    let total_ms = telemetry
288        .after_process_frame
289        .duration_since(telemetry.started_at)
290        .as_secs_f64()
291        * 1000.0;
292    if total_ms < threshold_ms {
293        return;
294    }
295
296    let frame_callbacks_ms = telemetry
297        .after_frame_callbacks
298        .duration_since(telemetry.started_at)
299        .as_secs_f64()
300        * 1000.0;
301    let ui_drain_ms = telemetry
302        .after_ui_drain
303        .duration_since(telemetry.after_frame_callbacks)
304        .as_secs_f64()
305        * 1000.0;
306    let reconcile_ms = telemetry
307        .after_reconcile
308        .duration_since(telemetry.after_ui_drain)
309        .as_secs_f64()
310        * 1000.0;
311    let process_frame_ms = telemetry
312        .after_process_frame
313        .duration_since(telemetry.after_reconcile)
314        .as_secs_f64()
315        * 1000.0;
316    eprintln!(
317        "[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={}",
318        telemetry.should_render,
319        telemetry.reconcile_attempted,
320        telemetry.reconcile_changed
321    );
322}
323
324#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
325pub enum FramePacingMode {
326    /// Pace frames to the display refresh interval. The production default:
327    /// animations advance once per vsync instead of re-rendering uncapped.
328    #[default]
329    Vsync,
330    Hard60,
331    Hard120,
332    /// Render as fast as possible. For perf harnesses and robot drivers that
333    /// measure work throughput; saturates the GPU if used in a real app.
334    NoVsync,
335}
336
337impl FramePacingMode {
338    pub const ALL: [Self; 4] = [Self::Vsync, Self::Hard60, Self::Hard120, Self::NoVsync];
339
340    pub fn label(self) -> &'static str {
341        match self {
342            Self::Vsync => "VSync",
343            Self::Hard60 => "60fps",
344            Self::Hard120 => "120fps",
345            Self::NoVsync => "NoVSync",
346        }
347    }
348
349    pub fn target_fps(self) -> Option<u32> {
350        match self {
351            Self::Hard60 => Some(60),
352            Self::Hard120 => Some(120),
353            Self::Vsync | Self::NoVsync => None,
354        }
355    }
356}
357
358#[derive(Clone, Copy, Debug, PartialEq)]
359pub struct FrameSchedule {
360    pub needs_update: bool,
361    pub needs_frame: bool,
362    pub next_deadline: Option<web_time::Instant>,
363}
364
365#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
366pub struct FrameUpdateResult {
367    pub visual_changed: bool,
368    pub structure_changed: bool,
369}
370
371pub trait PlatformFrameDriver {
372    fn request_frame(&self);
373    fn request_wake_at(&self, deadline: web_time::Instant);
374    fn clear_wake(&self);
375}
376
377#[derive(Debug)]
378pub struct FrameScheduler {
379    update_pending: AtomicBool,
380    frame_pending: AtomicBool,
381    next_deadline: Mutex<Option<web_time::Instant>>,
382}
383
384impl Default for FrameScheduler {
385    fn default() -> Self {
386        Self {
387            update_pending: AtomicBool::new(false),
388            frame_pending: AtomicBool::new(false),
389            next_deadline: Mutex::new(None),
390        }
391    }
392}
393
394impl FrameScheduler {
395    fn lock_deadline(&self) -> MutexGuard<'_, Option<web_time::Instant>> {
396        self.next_deadline
397            .lock()
398            .unwrap_or_else(|poisoned| poisoned.into_inner())
399    }
400
401    pub fn record(&self, schedule: FrameSchedule) {
402        self.update_pending
403            .store(schedule.needs_update, Ordering::SeqCst);
404        self.frame_pending
405            .store(schedule.needs_frame, Ordering::SeqCst);
406        let mut next_deadline = self.lock_deadline();
407        *next_deadline = if schedule.needs_update {
408            None
409        } else {
410            schedule.next_deadline
411        };
412    }
413
414    pub fn schedule<D>(&self, schedule: FrameSchedule, driver: &D)
415    where
416        D: PlatformFrameDriver + ?Sized,
417    {
418        self.record(schedule);
419        schedule.apply_to(driver);
420    }
421
422    pub fn snapshot(&self) -> FrameSchedule {
423        FrameSchedule {
424            needs_update: self.update_pending.load(Ordering::SeqCst),
425            needs_frame: self.frame_pending.load(Ordering::SeqCst),
426            next_deadline: *self.lock_deadline(),
427        }
428    }
429}
430
431impl FrameSchedule {
432    pub fn apply_to<D>(self, driver: &D)
433    where
434        D: PlatformFrameDriver + ?Sized,
435    {
436        if self.needs_frame {
437            driver.clear_wake();
438            driver.request_frame();
439        } else if self.needs_update {
440            driver.request_wake_at(web_time::Instant::now());
441        } else if let Some(deadline) = self.next_deadline {
442            driver.request_wake_at(deadline);
443        } else {
444            driver.clear_wake();
445        }
446    }
447}
448
449#[derive(Clone, Copy, Debug)]
450struct DevOverlayControl {
451    bounds: Rect,
452    mode: FramePacingMode,
453}
454
455/// Development options for debugging and performance monitoring.
456///
457/// These are rendered directly by the renderer (not via composition)
458/// to avoid affecting performance measurements.
459#[derive(Clone, Debug, Default)]
460pub struct DevOptions {
461    /// Show FPS counter overlay
462    pub fps_counter: bool,
463    /// Show recomposition count
464    pub recomposition_counter: bool,
465    /// Show layout timing breakdown
466    pub layout_timing: bool,
467    pub frame_pacing_controls: bool,
468    pub frame_pacing_mode: FramePacingMode,
469}
470
471#[cfg(any(test, feature = "test-support"))]
472#[doc(hidden)]
473#[derive(Clone, Copy, Debug)]
474pub struct RuntimeLeakDebugStats {
475    pub applier_stats: MemoryApplierDebugStats,
476    pub live_node_heap_bytes: usize,
477    pub recycled_node_heap_bytes: usize,
478    pub slot_table_heap_bytes: usize,
479    pub pass_stats: CompositionPassDebugStats,
480    pub slot_stats: SlotTableDebugStats,
481    pub observer_stats: SnapshotStateObserverDebugStats,
482    pub runtime_stats: RuntimeDebugStats,
483    pub state_arena_stats: StateArenaDebugStats,
484    pub recompose_scope_stats: RecomposeScopeRegistryDebugStats,
485    pub snapshot_v2_stats: SnapshotV2DebugStats,
486    pub snapshot_pinning_stats: SnapshotPinningDebugStats,
487}
488
489impl<R> AppShell<R>
490where
491    R: Renderer,
492    R::Error: Debug,
493{
494    pub fn new(renderer: R, root_key: Key, content: impl FnMut() + 'static) -> Self {
495        Self::new_with_size(renderer, root_key, content, (800, 600), (800.0, 600.0))
496    }
497
498    pub fn new_with_size(
499        renderer: R,
500        root_key: Key,
501        content: impl FnMut() + 'static,
502        buffer_size: (u32, u32),
503        viewport: (f32, f32),
504    ) -> Self {
505        Self::new_with_size_and_density(renderer, root_key, content, buffer_size, viewport, 1.0)
506    }
507
508    pub fn new_with_size_and_density(
509        mut renderer: R,
510        root_key: Key,
511        content: impl FnMut() + 'static,
512        buffer_size: (u32, u32),
513        viewport: (f32, f32),
514        density: f32,
515    ) -> Self {
516        let app_context = cranpose_ui::AppContext::new_with_density(density);
517        let runtime = StdRuntime::new();
518        let mut composition = Composition::with_runtime(MemoryApplier::new(), runtime.runtime());
519        // Install the top-level overlay layer once, at the root, so every app
520        // gets `Popup`/selection-handle/context-menu support for free and the
521        // overlay is composed (and thus painted and hit-tested) last. The app's
522        // content is called through a shared handle so this wrapper can be
523        // re-created cheaply on every recomposition without moving `content`.
524        let app_content = Rc::new(std::cell::RefCell::new(content));
525        let mut build: Box<dyn FnMut()> = Box::new(move || {
526            let app_content = Rc::clone(&app_content);
527            cranpose_ui::widgets::PopupHost(move || {
528                (app_content.borrow_mut())();
529            });
530        });
531        renderer.attach_app_context_services(&app_context);
532        app_context.enter(|| {
533            // Route the selection menu's Copy/Cut/Paste through the OS clipboard
534            // on desktop; other platforms fall back to the in-process clipboard.
535            #[cfg(all(
536                feature = "clipboard-native",
537                not(target_arch = "wasm32"),
538                not(target_os = "android"),
539                not(target_os = "ios")
540            ))]
541            {
542                let clipboard =
543                    std::rc::Rc::new(std::cell::RefCell::new(arboard::Clipboard::new().ok()));
544                cranpose_ui::clipboard_session::set_platform_clipboard(std::rc::Rc::new(
545                    ShellClipboard { inner: clipboard },
546                ));
547            }
548            if let Err(err) = composition.render_stable(root_key, &mut *build) {
549                log::error!("initial render failed: {err}");
550            }
551        });
552        renderer.scene_mut().clear();
553        let mut shell = Self {
554            app_context,
555            runtime,
556            composition,
557            content: build,
558            renderer,
559            cursor: (0.0, 0.0),
560            viewport,
561            buffer_size,
562            start_time: Instant::now(),
563            last_frame_time_nanos: 0,
564            layout_tree: None,
565            semantics_tree: None,
566            semantics_enabled: false,
567            semantics_snapshot_revision: 0,
568            frame_rate_preference: FrameRatePreference::default(),
569            layout_requested: true,
570            force_layout_pass: true,
571            scene_dirty: true,
572            scoped_layout_scene_nodes: Vec::new(),
573            is_dirty: true,
574            buttons_pressed: PointerButtons::NONE,
575            pointer_source: PointerSource::Unknown,
576            hit_path_tracker: HitPathTracker::new(),
577            hovered_nodes: Vec::new(),
578            on_rotary_scroll: None,
579            rotary_scroll_factor: DEFAULT_ROTARY_SCROLL_FACTOR_DP,
580            #[cfg(all(feature = "clipboard-native", target_os = "linux"))]
581            clipboard: arboard::Clipboard::new().ok(),
582            dev_options: DevOptions::default(),
583            dev_overlay_controls: Vec::new(),
584            dev_overlay_text: String::new(),
585            dev_overlay_last_refresh: None,
586            dev_overlay_viewport: None,
587            fps_monitor: fps_monitor::FpsMonitor::new(),
588            frame_scheduler: FrameScheduler::default(),
589        };
590        shell.process_frame();
591        shell
592    }
593
594    /// The shell's [`AppContext`](cranpose_ui::AppContext). Platform backends
595    /// use it to register per-context services (such as the OS clipboard) that
596    /// need UIKit/JNI access the shell itself does not have: enter the context
597    /// and call the relevant `set_platform_*` installer.
598    pub fn app_context(&self) -> &Rc<cranpose_ui::AppContext> {
599        &self.app_context
600    }
601
602    /// Set development options for debugging and performance monitoring.
603    ///
604    /// The FPS counter and other overlays are rendered directly by the renderer
605    /// (not via composition) to avoid affecting performance measurements.
606    pub fn set_dev_options(&mut self, options: DevOptions) {
607        self.dev_options = options;
608        self.invalidate_dev_overlay_text();
609        let app_context = Rc::clone(&self.app_context);
610        app_context.enter(request_render_invalidation);
611        self.mark_dirty();
612    }
613
614    /// Get a reference to the current dev options.
615    pub fn dev_options(&self) -> &DevOptions {
616        &self.dev_options
617    }
618
619    pub fn frame_pacing_mode(&self) -> FramePacingMode {
620        self.dev_options.frame_pacing_mode
621    }
622
623    pub fn current_fps(&self) -> f32 {
624        self.fps_monitor.current_fps()
625    }
626
627    pub fn fps_stats(&self) -> FpsStats {
628        self.fps_monitor.stats()
629    }
630
631    pub fn reset_fps_stats(&mut self) {
632        self.fps_monitor.reset_stats();
633        self.invalidate_dev_overlay_text();
634    }
635
636    pub fn record_presented_frame(
637        &mut self,
638        frame_started_at: Instant,
639        frame_finished_at: Instant,
640    ) {
641        self.fps_monitor
642            .record_frame_work(frame_started_at, frame_finished_at);
643    }
644
645    #[cfg(any(test, feature = "test-support"))]
646    #[doc(hidden)]
647    pub fn record_presented_frame_for_test(
648        &mut self,
649        frame_started_nanos: u64,
650        frame_finished_nanos: u64,
651    ) {
652        let started = self.start_time + std::time::Duration::from_nanos(frame_started_nanos);
653        let finished = self.start_time + std::time::Duration::from_nanos(frame_finished_nanos);
654        self.record_presented_frame(started, finished);
655    }
656
657    pub fn set_frame_pacing_mode(&mut self, mode: FramePacingMode) {
658        if self.dev_options.frame_pacing_mode == mode {
659            return;
660        }
661        self.dev_options.frame_pacing_mode = mode;
662        self.invalidate_dev_overlay_text();
663        let app_context = Rc::clone(&self.app_context);
664        app_context.enter(request_render_invalidation);
665        self.mark_dirty();
666    }
667
668    /// Where the dev overlay draws the control for `mode`, in logical pixels.
669    ///
670    /// The overlay is drawn by the renderer rather than composed, so it has no
671    /// semantics for a test to search. Without this a test that wants to press
672    /// a pacing control has to hard-code a coordinate and silently starts
673    /// passing against empty space the moment the overlay's text changes.
674    pub fn dev_overlay_control_center(&self, mode: FramePacingMode) -> Option<(f32, f32)> {
675        self.dev_overlay_controls
676            .iter()
677            .find(|control| control.mode == mode)
678            .map(|control| {
679                (
680                    control.bounds.x + control.bounds.width * 0.5,
681                    control.bounds.y + control.bounds.height * 0.5,
682                )
683            })
684    }
685
686    /// Take a press on a dev-overlay pacing control, if it lands on one.
687    ///
688    /// Every pointer press runs through here before the composition sees it, so
689    /// the controls answer to whatever produced the press: a mouse, a finger,
690    /// or a robot injecting one. A platform shell that hit-tested the overlay
691    /// itself would only make the controls work for the one input path it owns
692    /// -- which is how they came to do nothing under a robot at all.
693    pub(crate) fn dev_overlay_press(&mut self, x: f32, y: f32) -> bool {
694        if !self.dev_options.frame_pacing_controls {
695            return false;
696        }
697        let Some(mode) = self
698            .dev_overlay_controls
699            .iter()
700            .find(|control| control.bounds.contains(x, y))
701            .map(|control| control.mode)
702        else {
703            return false;
704        };
705        self.set_frame_pacing_mode(mode);
706        true
707    }
708
709    fn invalidate_dev_overlay_text(&mut self) {
710        self.dev_overlay_text.clear();
711        self.dev_overlay_last_refresh = None;
712        self.dev_overlay_viewport = None;
713    }
714
715    pub fn set_viewport(&mut self, width: f32, height: f32) {
716        self.viewport = (width, height);
717        self.request_forced_layout_pass();
718        self.mark_dirty();
719        self.process_frame();
720    }
721
722    pub fn viewport_size(&self) -> (f32, f32) {
723        self.viewport
724    }
725
726    pub fn set_buffer_size(&mut self, width: u32, height: u32) {
727        self.buffer_size = (width, height);
728    }
729
730    pub fn buffer_size(&self) -> (u32, u32) {
731        self.buffer_size
732    }
733
734    pub fn scene(&self) -> &R::Scene {
735        self.renderer.scene()
736    }
737
738    pub fn renderer(&mut self) -> &mut R {
739        &mut self.renderer
740    }
741
742    #[cfg(not(target_arch = "wasm32"))]
743    pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + Sync + 'static) {
744        self.runtime.set_frame_waker(waker);
745    }
746
747    #[cfg(target_arch = "wasm32")]
748    pub fn set_frame_waker(&mut self, waker: impl Fn() + 'static) {
749        self.runtime.set_frame_waker(waker);
750    }
751
752    pub fn clear_frame_waker(&mut self) {
753        self.runtime.clear_frame_waker();
754    }
755
756    pub fn should_render(&self) -> bool {
757        let app_context = Rc::clone(&self.app_context);
758        app_context.enter(|| {
759            if self.layout_requested
760                || self.scene_dirty
761                || peek_render_invalidation()
762                || peek_pointer_invalidation()
763                || peek_focus_invalidation()
764                || peek_layout_invalidation()
765            {
766                return true;
767            }
768            self.composition.should_render()
769        })
770    }
771
772    /// The invalidations that mean the pixels on screen are stale.
773    ///
774    /// Every one of these ends in a scene rebuild, so every one of them is a
775    /// reason to put a new frame on the display. Deliberately *excludes*
776    /// [`Composition::should_render`]: an armed frame callback means the
777    /// composition owes the app a tick, which is not the same as owing the
778    /// display a frame, and conflating the two is what made
779    /// [`Self::needs_redraw`] indistinguishable from [`Self::needs_update`].
780    fn has_stale_pixels_in_context(&self) -> bool {
781        self.is_dirty
782            || self.layout_requested
783            || self.scene_dirty
784            || peek_render_invalidation()
785            || peek_pointer_invalidation()
786            || peek_focus_invalidation()
787            || peek_layout_invalidation()
788            || cranpose_ui::has_pending_layout_repasses()
789            || cranpose_ui::has_pending_measure_repasses()
790            || cranpose_ui::has_pending_draw_repasses()
791            || has_pending_pointer_repasses()
792            || has_pending_focus_invalidations()
793    }
794
795    fn needs_ui_update_in_context(&self) -> bool {
796        // Stale pixels, a queued UI continuation (which wakes an update but
797        // must never schedule a frame - see `needs_redraw`), or a
798        // composition that wants to render.
799        self.has_stale_pixels_in_context()
800            || self.composition.runtime_handle().has_pending_ui()
801            // A semantics invalidation is work for the UI thread, not a reason
802            // to repaint: it is serviced by `run_dispatch_queues` and changes no
803            // pixel. It belongs here rather than in `has_stale_pixels_in_context`
804            // so that an app asleep on a still screen still wakes far enough to
805            // republish its tree for a screen reader, without that wake being
806            // counted as a frame the display owes.
807            || has_pending_semantics_invalidations()
808            || self.composition.should_render()
809    }
810
811    pub fn needs_update(&self) -> bool {
812        let app_context = Rc::clone(&self.app_context);
813        app_context.enter(|| self.needs_ui_update_in_context())
814    }
815
816    /// Returns true when the runtime holds work for the UI thread: posted tasks,
817    /// continuations from worker threads, or async tasks ready to poll.
818    ///
819    /// This is the part of [`needs_update`](Self::needs_update) that another
820    /// thread is waiting on, told apart from the part that only wants the screen
821    /// redrawn. A platform backend running with no surface uses it to compose
822    /// for work and stay asleep for animation.
823    pub fn has_pending_ui(&self) -> bool {
824        let app_context = Rc::clone(&self.app_context);
825        app_context.enter(|| self.composition.runtime_handle().has_pending_ui())
826    }
827
828    /// Returns true if the shell owes the display a frame: stale pixels, or a
829    /// renderer that has not warmed its swapchain yet.
830    ///
831    /// An app that merely holds an open `next_frame()` await - a game loop, a
832    /// polling effect - keeps [`Self::needs_update`] true forever without
833    /// changing a single pixel. Such an app must still be *ticked* every frame,
834    /// but the frame it produces is byte-identical to the last one, and
835    /// presenting it costs a full swapchain rotation and pins the panel at its
836    /// maximum refresh rate. Callers pair this with
837    /// [`FrameUpdateResult::visual_changed`] from the update they just ran,
838    /// which reports the work that update actually did.
839    /// Note: Cursor blink is now timer-based and uses WaitUntil scheduling, not continuous redraw.
840    pub fn needs_redraw(&self) -> bool {
841        let app_context = Rc::clone(&self.app_context);
842        app_context
843            .enter(|| self.has_stale_pixels_in_context() || self.renderer.needs_frame_warmup())
844    }
845
846    /// Marks the shell as dirty, indicating a redraw is needed.
847    pub fn mark_dirty(&mut self) {
848        self.is_dirty = true;
849    }
850
851    pub fn request_root_render(&mut self) {
852        self.composition.request_root_render();
853        self.request_forced_layout_pass();
854        let app_context = Rc::clone(&self.app_context);
855        app_context.enter(request_render_invalidation);
856        self.mark_dirty();
857    }
858
859    pub fn set_density(&mut self, density: f32) {
860        let app_context = Rc::clone(&self.app_context);
861        let changed = app_context.enter(|| {
862            let previous = cranpose_ui::current_density().to_bits();
863            cranpose_ui::set_density(density);
864            previous != cranpose_ui::current_density().to_bits()
865        });
866        if changed {
867            self.request_forced_layout_pass();
868            self.mark_dirty();
869        }
870    }
871
872    /// Reports the system font scale the platform is showing text at.
873    ///
874    /// Hosts call this at startup and on every configuration change. Sizes in
875    /// `Sp` follow it, sizes in `Dp` do not, which is what lets an app grow its
876    /// text with the user's setting while its layout stays where it was.
877    pub fn set_font_scale(&mut self, font_scale: f32) {
878        let app_context = Rc::clone(&self.app_context);
879        let changed = app_context.enter(|| {
880            let previous = cranpose_ui::current_font_scale().to_bits();
881            cranpose_ui::set_font_scale(font_scale);
882            previous != cranpose_ui::current_font_scale().to_bits()
883        });
884        if changed {
885            self.request_forced_layout_pass();
886            self.mark_dirty();
887        }
888    }
889
890    #[cfg(any(test, feature = "test-support"))]
891    #[doc(hidden)]
892    pub fn debug_current_density(&self) -> f32 {
893        let app_context = Rc::clone(&self.app_context);
894        app_context.enter(cranpose_ui::current_density)
895    }
896
897    #[cfg(any(test, feature = "test-support"))]
898    #[doc(hidden)]
899    pub fn debug_current_font_scale(&self) -> f32 {
900        let app_context = Rc::clone(&self.app_context);
901        app_context.enter(cranpose_ui::current_font_scale)
902    }
903
904    #[cfg(any(test, feature = "test-support"))]
905    #[doc(hidden)]
906    pub fn debug_enter_app_context<T>(&self, block: impl FnOnce() -> T) -> T {
907        let app_context = Rc::clone(&self.app_context);
908        app_context.enter(block)
909    }
910
911    fn request_layout_pass(&mut self) {
912        self.layout_requested = true;
913    }
914
915    fn request_forced_layout_pass(&mut self) {
916        self.layout_requested = true;
917        self.force_layout_pass = true;
918    }
919
920    fn composition_tree_needs_layout(&mut self) -> bool {
921        let Some(root) = self.composition.root() else {
922            return true;
923        };
924        let mut applier = self.composition.applier_mut();
925        cranpose_ui::tree_needs_layout(&mut *applier, root).unwrap_or_else(|err| {
926            log::warn!(
927                "Cannot check layout dirty status for root #{}: {}",
928                root,
929                err
930            );
931            true
932        })
933    }
934
935    /// Returns true if there are active animations or pending recompositions.
936    pub fn has_active_animations(&self) -> bool {
937        self.composition.should_render()
938    }
939
940    pub fn has_active_pointer_gesture(&self) -> bool {
941        self.buttons_pressed != PointerButtons::NONE
942            && self.hit_path_tracker.has_path(PointerId::PRIMARY)
943    }
944
945    /// Returns the next scheduled event time for cursor blink.
946    /// Use this for `ControlFlow::WaitUntil` scheduling.
947    pub fn next_event_time(&self) -> Option<web_time::Instant> {
948        let app_context = Rc::clone(&self.app_context);
949        app_context.enter(cranpose_ui::next_cursor_blink_time)
950    }
951
952    fn compute_frame_schedule(&self) -> FrameSchedule {
953        let needs_update = self.needs_update();
954        let needs_frame = self.is_dirty
955            || self.should_render()
956            || self.has_active_pointer_gesture()
957            || self.renderer.needs_frame_warmup();
958        FrameSchedule {
959            needs_update,
960            needs_frame,
961            next_deadline: self.next_event_time(),
962        }
963    }
964
965    pub fn frame_schedule(&self) -> FrameSchedule {
966        let schedule = self.compute_frame_schedule();
967        self.frame_scheduler.record(schedule);
968        schedule
969    }
970
971    pub fn schedule_platform_frame<D>(&self, driver: &D) -> FrameSchedule
972    where
973        D: PlatformFrameDriver + ?Sized,
974    {
975        let schedule = self.compute_frame_schedule();
976        self.frame_scheduler.schedule(schedule, driver);
977        schedule
978    }
979
980    pub fn frame_scheduler_snapshot(&self) -> FrameSchedule {
981        self.frame_scheduler.snapshot()
982    }
983
984    fn frame_time_nanos_at(&self, now: Instant) -> u64 {
985        now.checked_duration_since(self.start_time)
986            .unwrap_or_default()
987            .as_nanos()
988            .min(u128::from(u64::MAX)) as u64
989    }
990
991    /// Timestamp a live input sample against the current animation clock.
992    pub fn realtime_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
993        PointerEventTime {
994            platform_time_ms,
995            animation_time_nanos: self
996                .frame_time_nanos_at(Instant::now())
997                .max(self.last_frame_time_nanos),
998        }
999    }
1000
1001    /// Timestamp deterministic input at the most recently processed frame.
1002    pub fn exact_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
1003        PointerEventTime {
1004            platform_time_ms,
1005            animation_time_nanos: self.last_frame_time_nanos,
1006        }
1007    }
1008
1009    pub fn update_after_frame_interval(
1010        &mut self,
1011        frame_interval: std::time::Duration,
1012    ) -> FrameUpdateResult {
1013        let wall_frame_time = self.frame_time_nanos_at(Instant::now());
1014        let base_frame_time = self.last_frame_time_nanos.max(wall_frame_time);
1015        let frame_time = base_frame_time
1016            .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1017        self.update_at_frame_time_nanos(frame_time)
1018    }
1019
1020    /// Advance the frame clock by EXACTLY `frame_interval` past the last
1021    /// frame — no wall anchoring — and run one update there. Robot keyframe
1022    /// captures ride this to sample animations deterministically: while the
1023    /// advanced clock is ahead of wall time, interleaved wall-clocked
1024    /// updates clamp to it (dt 0) instead of fast-forwarding animations.
1025    pub fn update_after_exact_interval(
1026        &mut self,
1027        frame_interval: std::time::Duration,
1028    ) -> FrameUpdateResult {
1029        let frame_time = self
1030            .last_frame_time_nanos
1031            .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1032        self.update_at_frame_time_nanos(frame_time)
1033    }
1034
1035    pub fn update_at_frame_time_nanos(&mut self, frame_time: u64) -> FrameUpdateResult {
1036        let app_context = Rc::clone(&self.app_context);
1037        app_context.enter(|| {
1038            let update_started_at = Instant::now();
1039            let frame_time = frame_time.max(self.last_frame_time_nanos);
1040            self.last_frame_time_nanos = frame_time;
1041            let runtime_handle = self.runtime.runtime_handle();
1042            runtime_handle.with_deferred_state_releases(|| {
1043                self.runtime.drain_frame_callbacks(frame_time);
1044                let after_frame_callbacks = Instant::now();
1045                runtime_handle.drain_ui();
1046                let after_ui_drain = Instant::now();
1047                let should_render = self.composition.should_recompose();
1048                let mut reconcile_attempted = false;
1049                let mut reconcile_changed = false;
1050                if should_render {
1051                    log::trace!(
1052                        target: "cranpose::input",
1053                        "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
1054                        self.layout_requested,
1055                        self.scene_dirty,
1056                        self.is_dirty
1057                    );
1058                }
1059                if should_render {
1060                    let Some(root_key) = self.composition.root_key() else {
1061                        let result = self.process_frame_in_context(reconcile_changed);
1062                        let after_process_frame = Instant::now();
1063                        log_update_stage_telemetry(UpdateStageTelemetry {
1064                            started_at: update_started_at,
1065                            after_frame_callbacks,
1066                            after_ui_drain,
1067                            after_reconcile: after_ui_drain,
1068                            after_process_frame,
1069                            should_render,
1070                            reconcile_attempted,
1071                            reconcile_changed,
1072                        });
1073                        self.is_dirty = false;
1074                        return result;
1075                    };
1076                    reconcile_attempted = true;
1077                    match self.composition.reconcile(root_key, &mut *self.content) {
1078                        Ok(changed) => {
1079                            reconcile_changed = changed;
1080                            log::trace!(
1081                                target: "cranpose::input",
1082                                "reconcile changed={changed}"
1083                            );
1084                            if changed {
1085                                self.fps_monitor.record_recomposition();
1086                                if self.composition_tree_needs_layout() {
1087                                    self.request_layout_pass();
1088                                }
1089                                request_render_invalidation();
1090                            }
1091                        }
1092                        Err(NodeError::Missing { id }) => {
1093                            log::debug!("Recomposition skipped: node {} no longer exists", id);
1094                            self.request_layout_pass();
1095                            request_render_invalidation();
1096                        }
1097                        Err(err) => {
1098                            log::error!("recomposition failed: {err}");
1099                            self.request_layout_pass();
1100                            request_render_invalidation();
1101                        }
1102                    }
1103                }
1104                let after_reconcile = Instant::now();
1105                let result = self.process_frame_in_context(reconcile_changed);
1106                let after_process_frame = Instant::now();
1107                log_update_stage_telemetry(UpdateStageTelemetry {
1108                    started_at: update_started_at,
1109                    after_frame_callbacks,
1110                    after_ui_drain,
1111                    after_reconcile,
1112                    after_process_frame,
1113                    should_render,
1114                    reconcile_attempted,
1115                    reconcile_changed,
1116                });
1117                self.is_dirty = false;
1118                result
1119            })
1120        })
1121    }
1122
1123    pub fn update(&mut self) -> FrameUpdateResult {
1124        let frame_time = self.frame_time_nanos_at(Instant::now());
1125        self.update_at_frame_time_nanos(frame_time)
1126    }
1127}
1128
1129impl<R> Drop for AppShell<R>
1130where
1131    R: Renderer,
1132{
1133    fn drop(&mut self) {
1134        self.runtime.clear_frame_waker();
1135    }
1136}
1137
1138pub fn default_root_key() -> Key {
1139    location_key(file!(), line!(), column!())
1140}
1141
1142#[cfg(test)]
1143mod frame_pacing_tests {
1144    use super::{FramePacingMode, FrameSchedule, FrameScheduler, PlatformFrameDriver};
1145    use std::cell::RefCell;
1146    use std::panic::{catch_unwind, AssertUnwindSafe};
1147    use std::time::Duration;
1148    use web_time::Instant;
1149
1150    #[derive(Clone, Copy, Debug, PartialEq)]
1151    enum DriverCall {
1152        RequestFrame,
1153        RequestWakeAt(Instant),
1154        ClearWake,
1155    }
1156
1157    #[derive(Default)]
1158    struct RecordingFrameDriver {
1159        calls: RefCell<Vec<DriverCall>>,
1160    }
1161
1162    impl RecordingFrameDriver {
1163        fn calls(&self) -> Vec<DriverCall> {
1164            self.calls.borrow().clone()
1165        }
1166    }
1167
1168    impl PlatformFrameDriver for RecordingFrameDriver {
1169        fn request_frame(&self) {
1170            self.calls.borrow_mut().push(DriverCall::RequestFrame);
1171        }
1172
1173        fn request_wake_at(&self, deadline: Instant) {
1174            self.calls
1175                .borrow_mut()
1176                .push(DriverCall::RequestWakeAt(deadline));
1177        }
1178
1179        fn clear_wake(&self) {
1180            self.calls.borrow_mut().push(DriverCall::ClearWake);
1181        }
1182    }
1183
1184    #[test]
1185    fn frame_pacing_labels_match_overlay_modes() {
1186        assert_eq!(FramePacingMode::Vsync.label(), "VSync");
1187        assert_eq!(FramePacingMode::Hard60.label(), "60fps");
1188        assert_eq!(FramePacingMode::Hard120.label(), "120fps");
1189        assert_eq!(FramePacingMode::NoVsync.label(), "NoVSync");
1190    }
1191
1192    #[test]
1193    fn only_hard_modes_have_fixed_targets() {
1194        assert_eq!(FramePacingMode::Vsync.target_fps(), None);
1195        assert_eq!(FramePacingMode::Hard60.target_fps(), Some(60));
1196        assert_eq!(FramePacingMode::Hard120.target_fps(), Some(120));
1197        assert_eq!(FramePacingMode::NoVsync.target_fps(), None);
1198    }
1199
1200    #[test]
1201    fn frame_schedule_requests_immediate_frame_and_clears_deadline() {
1202        let driver = RecordingFrameDriver::default();
1203        let deadline = Instant::now() + Duration::from_millis(25);
1204
1205        FrameSchedule {
1206            needs_update: true,
1207            needs_frame: true,
1208            next_deadline: Some(deadline),
1209        }
1210        .apply_to(&driver);
1211
1212        assert_eq!(
1213            driver.calls(),
1214            vec![DriverCall::ClearWake, DriverCall::RequestFrame]
1215        );
1216    }
1217
1218    #[test]
1219    fn frame_schedule_requests_deadline_when_idle_until_timer() {
1220        let driver = RecordingFrameDriver::default();
1221        let deadline = Instant::now() + Duration::from_millis(25);
1222
1223        FrameSchedule {
1224            needs_update: false,
1225            needs_frame: false,
1226            next_deadline: Some(deadline),
1227        }
1228        .apply_to(&driver);
1229
1230        assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
1231    }
1232
1233    #[test]
1234    fn frame_schedule_wakes_without_requesting_frame_for_update_only_work() {
1235        let driver = RecordingFrameDriver::default();
1236        let before = Instant::now();
1237
1238        FrameSchedule {
1239            needs_update: true,
1240            needs_frame: false,
1241            next_deadline: None,
1242        }
1243        .apply_to(&driver);
1244
1245        let calls = driver.calls();
1246        assert_eq!(calls.len(), 1);
1247        match calls[0] {
1248            DriverCall::RequestWakeAt(deadline) => {
1249                assert!(deadline >= before);
1250            }
1251            other => panic!("update-only work must wake without requesting a frame: {other:?}"),
1252        }
1253    }
1254
1255    #[test]
1256    fn frame_schedule_clears_wake_when_fully_idle() {
1257        let driver = RecordingFrameDriver::default();
1258
1259        FrameSchedule {
1260            needs_update: false,
1261            needs_frame: false,
1262            next_deadline: None,
1263        }
1264        .apply_to(&driver);
1265
1266        assert_eq!(driver.calls(), vec![DriverCall::ClearWake]);
1267    }
1268
1269    #[test]
1270    fn frame_scheduler_records_latest_schedule_and_applies_driver() {
1271        let scheduler = FrameScheduler::default();
1272        let driver = RecordingFrameDriver::default();
1273        let deadline = Instant::now() + Duration::from_millis(25);
1274
1275        scheduler.schedule(
1276            FrameSchedule {
1277                needs_update: false,
1278                needs_frame: false,
1279                next_deadline: Some(deadline),
1280            },
1281            &driver,
1282        );
1283
1284        assert_eq!(
1285            scheduler.snapshot(),
1286            FrameSchedule {
1287                needs_update: false,
1288                needs_frame: false,
1289                next_deadline: Some(deadline),
1290            }
1291        );
1292        assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
1293    }
1294
1295    #[test]
1296    fn frame_scheduler_clears_deadline_for_immediate_frame() {
1297        let scheduler = FrameScheduler::default();
1298        let driver = RecordingFrameDriver::default();
1299        let deadline = Instant::now() + Duration::from_millis(25);
1300
1301        scheduler.schedule(
1302            FrameSchedule {
1303                needs_update: true,
1304                needs_frame: true,
1305                next_deadline: Some(deadline),
1306            },
1307            &driver,
1308        );
1309
1310        assert_eq!(
1311            scheduler.snapshot(),
1312            FrameSchedule {
1313                needs_update: true,
1314                needs_frame: true,
1315                next_deadline: None,
1316            }
1317        );
1318        assert_eq!(
1319            driver.calls(),
1320            vec![DriverCall::ClearWake, DriverCall::RequestFrame]
1321        );
1322    }
1323
1324    #[test]
1325    fn frame_scheduler_recovers_poisoned_deadline_lock() {
1326        let scheduler = FrameScheduler::default();
1327        let deadline = Instant::now() + Duration::from_millis(25);
1328
1329        let _ = catch_unwind(AssertUnwindSafe(|| {
1330            let _guard = scheduler.lock_deadline();
1331            panic!("poison frame scheduler deadline lock");
1332        }));
1333
1334        scheduler.record(FrameSchedule {
1335            needs_update: false,
1336            needs_frame: false,
1337            next_deadline: Some(deadline),
1338        });
1339
1340        assert_eq!(
1341            scheduler.snapshot(),
1342            FrameSchedule {
1343                needs_update: false,
1344                needs_frame: false,
1345                next_deadline: Some(deadline),
1346            }
1347        );
1348    }
1349}
1350
1351#[cfg(test)]
1352#[path = "tests/app_shell_tests.rs"]
1353mod tests;