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