Skip to main content

cranpose_app_shell/
lib.rs

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