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    ///
878    /// This takes the setting as a plain multiplier. A host whose platform
879    /// converts `Sp` through a table of its own — Android 14 and up does — calls
880    /// [`AppShell::set_font_scale_curve`] with what the platform answered
881    /// instead, because the multiplication is not what that platform does.
882    pub fn set_font_scale(&mut self, font_scale: f32) {
883        self.set_font_scale_curve(cranpose_ui::FontScaleCurve::linear(font_scale));
884    }
885
886    /// Reports the system font scale together with the conversion behind it.
887    ///
888    /// See [`cranpose_ui::font_scale`]: above a threshold setting Android
889    /// resolves a size in `Sp` through a piecewise-linear table rather than by
890    /// multiplying, so a host that can read that table hands it over here and
891    /// every `Sp` in the app resolves the way the platform's own text does.
892    pub fn set_font_scale_curve(&mut self, curve: cranpose_ui::FontScaleCurve) {
893        let app_context = Rc::clone(&self.app_context);
894        let changed = app_context.enter(|| {
895            let previous = cranpose_ui::current_font_scale_curve();
896            cranpose_ui::set_font_scale_curve(curve);
897            previous != cranpose_ui::current_font_scale_curve()
898        });
899        if changed {
900            self.request_forced_layout_pass();
901            self.mark_dirty();
902        }
903    }
904
905    #[cfg(any(test, feature = "test-support"))]
906    #[doc(hidden)]
907    pub fn debug_current_density(&self) -> f32 {
908        let app_context = Rc::clone(&self.app_context);
909        app_context.enter(cranpose_ui::current_density)
910    }
911
912    #[cfg(any(test, feature = "test-support"))]
913    #[doc(hidden)]
914    pub fn debug_current_font_scale(&self) -> f32 {
915        let app_context = Rc::clone(&self.app_context);
916        app_context.enter(cranpose_ui::current_font_scale)
917    }
918
919    #[cfg(any(test, feature = "test-support"))]
920    #[doc(hidden)]
921    pub fn debug_current_font_scale_curve(&self) -> cranpose_ui::FontScaleCurve {
922        let app_context = Rc::clone(&self.app_context);
923        app_context.enter(cranpose_ui::current_font_scale_curve)
924    }
925
926    #[cfg(any(test, feature = "test-support"))]
927    #[doc(hidden)]
928    pub fn debug_enter_app_context<T>(&self, block: impl FnOnce() -> T) -> T {
929        let app_context = Rc::clone(&self.app_context);
930        app_context.enter(block)
931    }
932
933    fn request_layout_pass(&mut self) {
934        self.layout_requested = true;
935    }
936
937    fn request_forced_layout_pass(&mut self) {
938        self.layout_requested = true;
939        self.force_layout_pass = true;
940    }
941
942    fn composition_tree_needs_layout(&mut self) -> bool {
943        let Some(root) = self.composition.root() else {
944            return true;
945        };
946        let mut applier = self.composition.applier_mut();
947        cranpose_ui::tree_needs_layout(&mut *applier, root).unwrap_or_else(|err| {
948            log::warn!(
949                "Cannot check layout dirty status for root #{}: {}",
950                root,
951                err
952            );
953            true
954        })
955    }
956
957    /// Returns true if there are active animations or pending recompositions.
958    pub fn has_active_animations(&self) -> bool {
959        self.composition.should_render()
960    }
961
962    pub fn has_active_pointer_gesture(&self) -> bool {
963        self.buttons_pressed != PointerButtons::NONE
964            && self.hit_path_tracker.has_path(PointerId::PRIMARY)
965    }
966
967    /// Returns the next scheduled event time for cursor blink.
968    /// Use this for `ControlFlow::WaitUntil` scheduling.
969    pub fn next_event_time(&self) -> Option<web_time::Instant> {
970        let app_context = Rc::clone(&self.app_context);
971        app_context.enter(cranpose_ui::next_cursor_blink_time)
972    }
973
974    fn compute_frame_schedule(&self) -> FrameSchedule {
975        let needs_update = self.needs_update();
976        let needs_frame = self.is_dirty
977            || self.should_render()
978            || self.has_active_pointer_gesture()
979            || self.renderer.needs_frame_warmup();
980        FrameSchedule {
981            needs_update,
982            needs_frame,
983            next_deadline: self.next_event_time(),
984        }
985    }
986
987    pub fn frame_schedule(&self) -> FrameSchedule {
988        let schedule = self.compute_frame_schedule();
989        self.frame_scheduler.record(schedule);
990        schedule
991    }
992
993    pub fn schedule_platform_frame<D>(&self, driver: &D) -> FrameSchedule
994    where
995        D: PlatformFrameDriver + ?Sized,
996    {
997        let schedule = self.compute_frame_schedule();
998        self.frame_scheduler.schedule(schedule, driver);
999        schedule
1000    }
1001
1002    pub fn frame_scheduler_snapshot(&self) -> FrameSchedule {
1003        self.frame_scheduler.snapshot()
1004    }
1005
1006    fn frame_time_nanos_at(&self, now: Instant) -> u64 {
1007        now.checked_duration_since(self.start_time)
1008            .unwrap_or_default()
1009            .as_nanos()
1010            .min(u128::from(u64::MAX)) as u64
1011    }
1012
1013    /// Timestamp a live input sample against the current animation clock.
1014    pub fn realtime_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
1015        PointerEventTime {
1016            platform_time_ms,
1017            animation_time_nanos: self
1018                .frame_time_nanos_at(Instant::now())
1019                .max(self.last_frame_time_nanos),
1020        }
1021    }
1022
1023    /// Timestamp deterministic input at the most recently processed frame.
1024    pub fn exact_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
1025        PointerEventTime {
1026            platform_time_ms,
1027            animation_time_nanos: self.last_frame_time_nanos,
1028        }
1029    }
1030
1031    pub fn update_after_frame_interval(
1032        &mut self,
1033        frame_interval: std::time::Duration,
1034    ) -> FrameUpdateResult {
1035        let wall_frame_time = self.frame_time_nanos_at(Instant::now());
1036        let base_frame_time = self.last_frame_time_nanos.max(wall_frame_time);
1037        let frame_time = base_frame_time
1038            .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1039        self.update_at_frame_time_nanos(frame_time)
1040    }
1041
1042    /// Advance the frame clock by EXACTLY `frame_interval` past the last
1043    /// frame — no wall anchoring — and run one update there. Robot keyframe
1044    /// captures ride this to sample animations deterministically: while the
1045    /// advanced clock is ahead of wall time, interleaved wall-clocked
1046    /// updates clamp to it (dt 0) instead of fast-forwarding animations.
1047    pub fn update_after_exact_interval(
1048        &mut self,
1049        frame_interval: std::time::Duration,
1050    ) -> FrameUpdateResult {
1051        let frame_time = self
1052            .last_frame_time_nanos
1053            .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1054        self.update_at_frame_time_nanos(frame_time)
1055    }
1056
1057    pub fn update_at_frame_time_nanos(&mut self, frame_time: u64) -> FrameUpdateResult {
1058        let app_context = Rc::clone(&self.app_context);
1059        app_context.enter(|| {
1060            let update_started_at = Instant::now();
1061            let frame_time = frame_time.max(self.last_frame_time_nanos);
1062            self.last_frame_time_nanos = frame_time;
1063            let runtime_handle = self.runtime.runtime_handle();
1064            runtime_handle.with_deferred_state_releases(|| {
1065                self.runtime.drain_frame_callbacks(frame_time);
1066                let after_frame_callbacks = Instant::now();
1067                runtime_handle.drain_ui();
1068                let after_ui_drain = Instant::now();
1069                let should_render = self.composition.should_recompose();
1070                let mut reconcile_attempted = false;
1071                let mut reconcile_changed = false;
1072                if should_render {
1073                    log::trace!(
1074                        target: "cranpose::input",
1075                        "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
1076                        self.layout_requested,
1077                        self.scene_dirty,
1078                        self.is_dirty
1079                    );
1080                }
1081                if should_render {
1082                    let Some(root_key) = self.composition.root_key() else {
1083                        let result = self.process_frame_in_context(reconcile_changed);
1084                        let after_process_frame = Instant::now();
1085                        log_update_stage_telemetry(UpdateStageTelemetry {
1086                            started_at: update_started_at,
1087                            after_frame_callbacks,
1088                            after_ui_drain,
1089                            after_reconcile: after_ui_drain,
1090                            after_process_frame,
1091                            should_render,
1092                            reconcile_attempted,
1093                            reconcile_changed,
1094                        });
1095                        self.is_dirty = false;
1096                        return result;
1097                    };
1098                    reconcile_attempted = true;
1099                    match self.composition.reconcile(root_key, &mut *self.content) {
1100                        Ok(changed) => {
1101                            reconcile_changed = changed;
1102                            log::trace!(
1103                                target: "cranpose::input",
1104                                "reconcile changed={changed}"
1105                            );
1106                            if changed {
1107                                self.fps_monitor.record_recomposition();
1108                                if self.composition_tree_needs_layout() {
1109                                    self.request_layout_pass();
1110                                }
1111                                request_render_invalidation();
1112                            }
1113                        }
1114                        Err(NodeError::Missing { id }) => {
1115                            log::debug!("Recomposition skipped: node {} no longer exists", id);
1116                            self.request_layout_pass();
1117                            request_render_invalidation();
1118                        }
1119                        Err(err) => {
1120                            log::error!("recomposition failed: {err}");
1121                            self.request_layout_pass();
1122                            request_render_invalidation();
1123                        }
1124                    }
1125                }
1126                let after_reconcile = Instant::now();
1127                let result = self.process_frame_in_context(reconcile_changed);
1128                let after_process_frame = Instant::now();
1129                log_update_stage_telemetry(UpdateStageTelemetry {
1130                    started_at: update_started_at,
1131                    after_frame_callbacks,
1132                    after_ui_drain,
1133                    after_reconcile,
1134                    after_process_frame,
1135                    should_render,
1136                    reconcile_attempted,
1137                    reconcile_changed,
1138                });
1139                self.is_dirty = false;
1140                result
1141            })
1142        })
1143    }
1144
1145    pub fn update(&mut self) -> FrameUpdateResult {
1146        let frame_time = self.frame_time_nanos_at(Instant::now());
1147        self.update_at_frame_time_nanos(frame_time)
1148    }
1149}
1150
1151impl<R> Drop for AppShell<R>
1152where
1153    R: Renderer,
1154{
1155    fn drop(&mut self) {
1156        self.runtime.clear_frame_waker();
1157    }
1158}
1159
1160pub fn default_root_key() -> Key {
1161    location_key(file!(), line!(), column!())
1162}
1163
1164#[cfg(test)]
1165mod frame_pacing_tests {
1166    use super::{FramePacingMode, FrameSchedule, FrameScheduler, PlatformFrameDriver};
1167    use std::cell::RefCell;
1168    use std::panic::{catch_unwind, AssertUnwindSafe};
1169    use std::time::Duration;
1170    use web_time::Instant;
1171
1172    #[derive(Clone, Copy, Debug, PartialEq)]
1173    enum DriverCall {
1174        RequestFrame,
1175        RequestWakeAt(Instant),
1176        ClearWake,
1177    }
1178
1179    #[derive(Default)]
1180    struct RecordingFrameDriver {
1181        calls: RefCell<Vec<DriverCall>>,
1182    }
1183
1184    impl RecordingFrameDriver {
1185        fn calls(&self) -> Vec<DriverCall> {
1186            self.calls.borrow().clone()
1187        }
1188    }
1189
1190    impl PlatformFrameDriver for RecordingFrameDriver {
1191        fn request_frame(&self) {
1192            self.calls.borrow_mut().push(DriverCall::RequestFrame);
1193        }
1194
1195        fn request_wake_at(&self, deadline: Instant) {
1196            self.calls
1197                .borrow_mut()
1198                .push(DriverCall::RequestWakeAt(deadline));
1199        }
1200
1201        fn clear_wake(&self) {
1202            self.calls.borrow_mut().push(DriverCall::ClearWake);
1203        }
1204    }
1205
1206    #[test]
1207    fn frame_pacing_labels_match_overlay_modes() {
1208        assert_eq!(FramePacingMode::Vsync.label(), "VSync");
1209        assert_eq!(FramePacingMode::Hard60.label(), "60fps");
1210        assert_eq!(FramePacingMode::Hard120.label(), "120fps");
1211        assert_eq!(FramePacingMode::NoVsync.label(), "NoVSync");
1212    }
1213
1214    #[test]
1215    fn only_hard_modes_have_fixed_targets() {
1216        assert_eq!(FramePacingMode::Vsync.target_fps(), None);
1217        assert_eq!(FramePacingMode::Hard60.target_fps(), Some(60));
1218        assert_eq!(FramePacingMode::Hard120.target_fps(), Some(120));
1219        assert_eq!(FramePacingMode::NoVsync.target_fps(), None);
1220    }
1221
1222    #[test]
1223    fn frame_schedule_requests_immediate_frame_and_clears_deadline() {
1224        let driver = RecordingFrameDriver::default();
1225        let deadline = Instant::now() + Duration::from_millis(25);
1226
1227        FrameSchedule {
1228            needs_update: true,
1229            needs_frame: true,
1230            next_deadline: Some(deadline),
1231        }
1232        .apply_to(&driver);
1233
1234        assert_eq!(
1235            driver.calls(),
1236            vec![DriverCall::ClearWake, DriverCall::RequestFrame]
1237        );
1238    }
1239
1240    #[test]
1241    fn frame_schedule_requests_deadline_when_idle_until_timer() {
1242        let driver = RecordingFrameDriver::default();
1243        let deadline = Instant::now() + Duration::from_millis(25);
1244
1245        FrameSchedule {
1246            needs_update: false,
1247            needs_frame: false,
1248            next_deadline: Some(deadline),
1249        }
1250        .apply_to(&driver);
1251
1252        assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
1253    }
1254
1255    #[test]
1256    fn frame_schedule_wakes_without_requesting_frame_for_update_only_work() {
1257        let driver = RecordingFrameDriver::default();
1258        let before = Instant::now();
1259
1260        FrameSchedule {
1261            needs_update: true,
1262            needs_frame: false,
1263            next_deadline: None,
1264        }
1265        .apply_to(&driver);
1266
1267        let calls = driver.calls();
1268        assert_eq!(calls.len(), 1);
1269        match calls[0] {
1270            DriverCall::RequestWakeAt(deadline) => {
1271                assert!(deadline >= before);
1272            }
1273            other => panic!("update-only work must wake without requesting a frame: {other:?}"),
1274        }
1275    }
1276
1277    #[test]
1278    fn frame_schedule_clears_wake_when_fully_idle() {
1279        let driver = RecordingFrameDriver::default();
1280
1281        FrameSchedule {
1282            needs_update: false,
1283            needs_frame: false,
1284            next_deadline: None,
1285        }
1286        .apply_to(&driver);
1287
1288        assert_eq!(driver.calls(), vec![DriverCall::ClearWake]);
1289    }
1290
1291    #[test]
1292    fn frame_scheduler_records_latest_schedule_and_applies_driver() {
1293        let scheduler = FrameScheduler::default();
1294        let driver = RecordingFrameDriver::default();
1295        let deadline = Instant::now() + Duration::from_millis(25);
1296
1297        scheduler.schedule(
1298            FrameSchedule {
1299                needs_update: false,
1300                needs_frame: false,
1301                next_deadline: Some(deadline),
1302            },
1303            &driver,
1304        );
1305
1306        assert_eq!(
1307            scheduler.snapshot(),
1308            FrameSchedule {
1309                needs_update: false,
1310                needs_frame: false,
1311                next_deadline: Some(deadline),
1312            }
1313        );
1314        assert_eq!(driver.calls(), vec![DriverCall::RequestWakeAt(deadline)]);
1315    }
1316
1317    #[test]
1318    fn frame_scheduler_clears_deadline_for_immediate_frame() {
1319        let scheduler = FrameScheduler::default();
1320        let driver = RecordingFrameDriver::default();
1321        let deadline = Instant::now() + Duration::from_millis(25);
1322
1323        scheduler.schedule(
1324            FrameSchedule {
1325                needs_update: true,
1326                needs_frame: true,
1327                next_deadline: Some(deadline),
1328            },
1329            &driver,
1330        );
1331
1332        assert_eq!(
1333            scheduler.snapshot(),
1334            FrameSchedule {
1335                needs_update: true,
1336                needs_frame: true,
1337                next_deadline: None,
1338            }
1339        );
1340        assert_eq!(
1341            driver.calls(),
1342            vec![DriverCall::ClearWake, DriverCall::RequestFrame]
1343        );
1344    }
1345
1346    #[test]
1347    fn frame_scheduler_recovers_poisoned_deadline_lock() {
1348        let scheduler = FrameScheduler::default();
1349        let deadline = Instant::now() + Duration::from_millis(25);
1350
1351        let _ = catch_unwind(AssertUnwindSafe(|| {
1352            let _guard = scheduler.lock_deadline();
1353            panic!("poison frame scheduler deadline lock");
1354        }));
1355
1356        scheduler.record(FrameSchedule {
1357            needs_update: false,
1358            needs_frame: false,
1359            next_deadline: Some(deadline),
1360        });
1361
1362        assert_eq!(
1363            scheduler.snapshot(),
1364            FrameSchedule {
1365                needs_update: false,
1366                needs_frame: false,
1367                next_deadline: Some(deadline),
1368            }
1369        );
1370    }
1371}
1372
1373#[cfg(test)]
1374#[path = "tests/app_shell_tests.rs"]
1375mod tests;