Skip to main content

blitz_shell/
window.rs

1use crate::BlitzShellProvider;
2use crate::convert_events::{
3    button_source_to_blitz, color_scheme_to_theme, pointer_kind_to_blitz, pointer_source_to_blitz,
4    pointer_source_to_blitz_details, theme_to_color_scheme, winit_ime_to_blitz,
5    winit_key_event_to_blitz, winit_modifiers_to_kbt_modifiers,
6};
7use crate::event::{BlitzShellEvent, BlitzShellProxy, create_waker};
8use anyrender::WindowRenderer;
9use blitz_dom::Document;
10use blitz_paint::paint_scene;
11use blitz_traits::events::{
12    BlitzPointerEvent, BlitzPointerId, BlitzWheelDelta, BlitzWheelEvent, MouseEventButton,
13    MouseEventButtons, PointerCoords, PointerDetails, UiEvent,
14};
15use blitz_traits::shell::Viewport;
16use winit::dpi::{LogicalPosition, PhysicalInsets, PhysicalPosition};
17use winit::keyboard::PhysicalKey;
18
19use atomic_refcell::AtomicRefCell;
20use std::any::Any;
21use std::path::PathBuf;
22use std::sync::Arc;
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::task::Waker;
25use std::time::Duration;
26use web_time::Instant;
27use winit::event::{ButtonSource, ElementState, MouseButton};
28use winit::event_loop::ActiveEventLoop;
29use winit::window::{Theme, WindowAttributes, WindowId};
30use winit::{event::Modifiers, event::WindowEvent, keyboard::KeyCode, window::Window};
31
32#[cfg(feature = "accessibility")]
33use crate::accessibility::AccessibilityState;
34
35// Ignore safe_area_insets on macOS because we don't want to avoid
36// drawing in the titlebar.
37#[cfg(target_os = "macos")]
38fn get_safe_area_insets(_window: &dyn Window) -> PhysicalInsets<u32> {
39    Default::default()
40}
41#[cfg(not(target_os = "macos"))]
42fn get_safe_area_insets(window: &dyn Window) -> PhysicalInsets<u32> {
43    window.safe_area()
44}
45
46pub struct WindowConfig<Rend: WindowRenderer> {
47    doc: Box<dyn Document>,
48    pub(crate) attributes: WindowAttributes,
49    renderer: Rend,
50    on_created: Option<WindowCreatedCallback>,
51}
52
53type WindowCreatedCallback = Box<dyn FnOnce(Arc<dyn Window>) + 'static>;
54
55impl<Rend: WindowRenderer> WindowConfig<Rend> {
56    pub fn new(doc: Box<dyn Document>, renderer: Rend) -> Self {
57        Self::with_attributes(doc, renderer, WindowAttributes::default())
58    }
59
60    pub fn with_attributes(
61        doc: Box<dyn Document>,
62        renderer: Rend,
63        attributes: WindowAttributes,
64    ) -> Self {
65        WindowConfig {
66            doc,
67            attributes,
68            renderer,
69            on_created: None,
70        }
71    }
72
73    /// Run a callback after the native window is created and before the first frame is prepared.
74    pub fn with_on_created(mut self, callback: impl FnOnce(Arc<dyn Window>) + 'static) -> Self {
75        self.on_created = Some(Box::new(callback));
76        self
77    }
78}
79
80pub struct View<Rend: WindowRenderer> {
81    pub doc: Box<dyn Document>,
82
83    pub renderer: Rend,
84    pub waker: Option<Waker>,
85
86    /// Set when something wants this document polled: an input event the shell
87    /// just handled, or a future waking on another thread. The event loop
88    /// drains it once before it sleeps, so a burst of pointer moves costs one
89    /// poll rather than one each, and nothing is queued or allocated to say so.
90    poll_requested: Arc<AtomicBool>,
91
92    pub proxy: BlitzShellProxy,
93    pub window: Arc<dyn Window>,
94
95    /// The state of the keyboard modifiers (ctrl, shift, etc). Winit/Tao don't track these for us so we
96    /// need to store them in order to have access to them when processing keypress events
97    pub theme_override: Option<Theme>,
98    pub keyboard_modifiers: Modifiers,
99    pub buttons: MouseEventButtons,
100    pub pointer_pos: PhysicalPosition<f64>,
101    /// The non-mouse pointers (touch/pen) that are currently pressed, in the
102    /// order they were pressed.
103    ///
104    /// This serves two purposes:
105    /// - Multi-touch: it is cloned (cheaply, via [`Arc`]) into every dispatched
106    ///   [`BlitzPointerEvent`] so that touch events can report all concurrent
107    ///   touches via their `touches` list.
108    /// - Cancellation detection: winit signals a cancelled touch with a
109    ///   [`WindowEvent::PointerLeft`] that is *not* preceded by a
110    ///   [`WindowEvent::PointerButton`] with [`ElementState::Released`]. If a
111    ///   pointer is still in this list when it leaves, it was cancelled.
112    ///
113    /// The events stored here always have an empty `active_pointers` list to
114    /// avoid a reference cycle.
115    pub active_events: Arc<AtomicRefCell<Vec<BlitzPointerEvent>>>,
116    pub animation_timer: Option<Instant>,
117    pub is_visible: bool,
118    pub safe_area_insets: PhysicalInsets<u32>,
119
120    /// Whether a platform redraw has already been requested and has not yet
121    /// entered [`Self::redraw`]. DOM mutations can invalidate a window many
122    /// times during one input burst; the platform only needs one frame request.
123    redraw_pending: std::cell::Cell<bool>,
124
125    frame_stats: FrameStats,
126
127    #[cfg(target_arch = "wasm32")]
128    pending_resize: Option<winit::dpi::PhysicalSize<u32>>,
129    #[cfg(target_arch = "wasm32")]
130    last_resize_at: Option<web_time::Instant>,
131    /// True iff a setTimeout has been scheduled and not yet observed by
132    /// `apply_pending_resize_if_settled`. Prevents the timer storm that would
133    /// otherwise allocate a fresh `Closure` per resize event during a drag.
134    #[cfg(target_arch = "wasm32")]
135    resize_timer_scheduled: bool,
136
137    #[cfg(feature = "accessibility")]
138    /// Accessibility adapter for `accesskit`.
139    pub accessibility: AccessibilityState,
140
141    // Calling request_redraw within a WindowEvent doesn't work on iOS. So on iOS we track the state
142    // with a boolean and call request_redraw in about_to_wait
143    //
144    // See https://github.com/rust-windowing/winit/issues/3406
145    #[cfg(target_os = "ios")]
146    pub ios_request_redraw: std::cell::Cell<bool>,
147
148    /// When the next animation-only frame is due, if one is.
149    ///
150    /// An animation drives frames by asking for the next redraw at the end of
151    /// the last one, which runs it at the display's rate. Set this instead of
152    /// asking immediately, and `about_to_wait` turns it into a
153    /// `ControlFlow::WaitUntil`, so the loop sleeps in between rather than
154    /// spinning. `None` means nothing is animating and the loop can wait
155    /// indefinitely for input.
156    pub animation_frame_due: std::cell::Cell<Option<Instant>>,
157}
158
159/// Frames per second to aim for on CSS-only animation frames.
160///
161/// A browser cannot negotiate with the pages it renders: an arbitrary site's
162/// `animation: fade 2s infinite` otherwise pins the process at the display's
163/// refresh rate, repainting the whole window each time, for as long as the tab
164/// is open. 15fps is sufficient for the slow decorative animations this is
165/// aimed at, and halves the full-window paint and render work compared with
166/// 30fps.
167///
168/// This governs *animation-only* frames. Input, resize, navigation and every
169/// other event still redraw immediately, so nothing this clamps is something a
170/// user is waiting on.
171const CSS_ANIMATION_TARGET_FPS: u32 = 15;
172
173/// Canvas, scrolling, custom widgets and other interactive animation sources
174/// keep the previous animation-only cadence.
175const INTERACTIVE_ANIMATION_TARGET_FPS: u32 = 30;
176const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
177
178/// Used only when the display will not say what its refresh rate is.
179const CSS_ANIMATION_FALLBACK_INTERVAL: Duration = Duration::from_millis(67);
180const INTERACTIVE_ANIMATION_FALLBACK_INTERVAL: Duration = Duration::from_millis(33);
181
182/// The gap between animation-only frames, as a whole number of the display's
183/// own refresh intervals.
184///
185/// Rounding to a multiple of the refresh rate rather than picking a wall-clock
186/// constant: a fixed 33ms against an 8.3ms refresh is a period the display
187/// cannot hit, so frames land one refresh late at an irregular beat, and the
188/// clamp reads as jitter rather than as a lower frame rate. On a 120Hz display
189/// this is every 8th refresh, on 60Hz every 4th, and both are exactly 15fps.
190fn animation_frame_interval(pacing: blitz_dom::AnimationPacing) -> Duration {
191    animation_frame_interval_for_refresh(pacing, crate::frame_stats::display_refresh_millihertz())
192}
193
194fn animation_frame_interval_for_refresh(
195    pacing: blitz_dom::AnimationPacing,
196    millihertz: Option<u32>,
197) -> Duration {
198    let (target_fps, fallback_interval) = match pacing {
199        blitz_dom::AnimationPacing::Idle => return Duration::ZERO,
200        blitz_dom::AnimationPacing::Caret => return CARET_BLINK_INTERVAL,
201        blitz_dom::AnimationPacing::SlowCss => {
202            (CSS_ANIMATION_TARGET_FPS, CSS_ANIMATION_FALLBACK_INTERVAL)
203        }
204        blitz_dom::AnimationPacing::Interactive => (
205            INTERACTIVE_ANIMATION_TARGET_FPS,
206            INTERACTIVE_ANIMATION_FALLBACK_INTERVAL,
207        ),
208    };
209    let Some(millihertz) = millihertz else {
210        return fallback_interval;
211    };
212    let refresh_hz = f64::from(millihertz) / 1000.0;
213    if refresh_hz <= f64::from(target_fps) {
214        // A display slower than the target cannot be clamped toward it, and
215        // asking for every refresh is what it would already be doing.
216        return Duration::from_secs_f64(1.0 / refresh_hz);
217    }
218    let every_nth = (refresh_hz / f64::from(target_fps)).round().max(1.0);
219    Duration::from_secs_f64(every_nth / refresh_hz)
220}
221
222impl<Rend: WindowRenderer> Drop for View<Rend> {
223    fn drop(&mut self) {
224        // Release the renderer's window surface before the window is dropped.
225        // The renderer may be shared (e.g. provided as a context to user code),
226        // in which case it can outlive the `View`. A GPU surface must not
227        // outlive the window/display it is attached to: dropping it after the
228        // event loop has shut down segfaults on Wayland.
229        self.renderer.suspend();
230    }
231}
232
233impl<Rend: WindowRenderer> View<Rend> {
234    pub fn init(
235        mut config: WindowConfig<Rend>,
236        event_loop: &dyn ActiveEventLoop,
237        proxy: &BlitzShellProxy,
238    ) -> Self {
239        // We create window as invisble and then later make window visible
240        // after AccessKit has initialised to avoid AccessKit panics
241        let is_visible = config.attributes.visible;
242        // Capture the requested surface size before consuming `attributes`, so we can
243        // seed the viewport on platforms (winit-web) that report `surface_size() == 0×0`
244        // until a layout pass fires.
245        let requested_surface_size = config.attributes.surface_size;
246        let attrs = config.attributes.with_visible(false);
247
248        let winit_window: Arc<dyn Window> = Arc::from(event_loop.create_window(attrs).unwrap());
249        if let Some(on_created) = config.on_created.take() {
250            on_created(Arc::clone(&winit_window));
251        }
252        #[cfg(feature = "accessibility")]
253        let accessibility = AccessibilityState::new(&*winit_window, proxy.clone());
254
255        if is_visible {
256            winit_window.set_visible(true);
257        }
258
259        // Create viewport
260        // TODO: account for the "safe area"
261        let scale = winit_window.scale_factor() as f32;
262        let mut size = winit_window.surface_size();
263        if (size.width == 0 || size.height == 0)
264            && let Some(requested) = requested_surface_size
265        {
266            size = requested.to_physical(scale as f64);
267        }
268        // On wasm, when the embedder didn't call `with_surface_size`, winit-web's
269        // initial `surface_size()` is 0×0 — its ResizeObserver hasn't fired yet.
270        // Resuming the renderer at 0×0 trips a wgpu swapchain-size-0 error, so
271        // seed from the canvas element's CSS layout box (host-stylesheet result).
272        #[cfg(target_arch = "wasm32")]
273        if size.width == 0 || size.height == 0 {
274            use winit::platform::web::WindowExtWeb;
275            if let Some(canvas) = winit_window.canvas() {
276                let css_w = canvas.offset_width().max(0) as u32;
277                let css_h = canvas.offset_height().max(0) as u32;
278                if css_w > 0 && css_h > 0 {
279                    size = winit::dpi::LogicalSize::new(css_w, css_h).to_physical(scale as f64);
280                }
281            }
282        }
283        let safe_area_insets = get_safe_area_insets(&*winit_window);
284        let theme = winit_window.theme().unwrap_or(Theme::Light);
285        let color_scheme = theme_to_color_scheme(theme);
286        let viewport = Viewport::new(size.width, size.height, scale, color_scheme);
287
288        // Create shell provider
289        let shell_provider = BlitzShellProvider::new(winit_window.clone(), proxy.clone());
290
291        let mut doc = config.doc;
292        let mut inner = doc.inner_mut();
293        inner.set_viewport(viewport);
294        inner.set_shell_provider(Arc::new(shell_provider));
295        inner.set_window_focused(winit_window.has_focus());
296
297        // If the document title is set prior to the window being created then it will
298        // have been sent to a dummy ShellProvider and won't get picked up.
299        // So we look for it here and set it if present.
300        let title = inner.find_title_node().map(|node| node.text_content());
301        if let Some(title) = title {
302            winit_window.set_title(&title);
303        }
304
305        drop(inner);
306
307        Self {
308            renderer: config.renderer,
309            waker: None,
310            poll_requested: Arc::new(AtomicBool::new(false)),
311            animation_timer: None,
312            keyboard_modifiers: Default::default(),
313            proxy: proxy.clone(),
314            window: winit_window.clone(),
315            doc,
316            theme_override: None,
317            buttons: MouseEventButtons::None,
318            active_events: Arc::new(AtomicRefCell::new(Vec::new())),
319            safe_area_insets,
320            #[cfg(target_arch = "wasm32")]
321            pending_resize: None,
322            #[cfg(target_arch = "wasm32")]
323            last_resize_at: None,
324            #[cfg(target_arch = "wasm32")]
325            resize_timer_scheduled: false,
326            pointer_pos: Default::default(),
327            is_visible: winit_window.is_visible().unwrap_or(true),
328            redraw_pending: std::cell::Cell::new(false),
329            frame_stats: FrameStats::new(&*winit_window),
330            #[cfg(feature = "accessibility")]
331            accessibility,
332
333            #[cfg(target_os = "ios")]
334            ios_request_redraw: std::cell::Cell::new(false),
335
336            animation_frame_due: std::cell::Cell::new(None),
337        }
338    }
339
340    pub fn replace_document(&mut self, new_doc: Box<dyn Document>, retain_scroll_position: bool) {
341        let inner = self.doc.inner();
342        let scroll = inner.viewport_scroll();
343        let viewport = inner.viewport().clone();
344        let shell_provider = inner.shell_provider.clone();
345        let focused = inner.window_focused();
346        drop(inner);
347
348        self.doc = new_doc;
349
350        let mut inner = self.doc.inner_mut();
351        inner.set_viewport(viewport);
352        inner.set_shell_provider(shell_provider);
353        inner.set_window_focused(focused);
354        drop(inner);
355
356        self.poll();
357        self.request_redraw();
358
359        if retain_scroll_position {
360            self.doc.inner_mut().set_viewport_scroll(scroll);
361        }
362    }
363
364    pub fn theme_override(&self) -> Option<Theme> {
365        self.theme_override
366    }
367
368    pub fn current_theme(&self) -> Theme {
369        color_scheme_to_theme(self.doc.inner().viewport().color_scheme)
370    }
371
372    pub fn set_theme_override(&mut self, theme: Option<Theme>) {
373        self.theme_override = theme;
374        let theme = theme.or(self.window.theme()).unwrap_or(Theme::Light);
375        self.with_viewport(|v| v.color_scheme = theme_to_color_scheme(theme));
376    }
377
378    pub fn downcast_doc_mut<T: 'static>(&mut self) -> &mut T {
379        (&mut *self.doc as &mut dyn Any)
380            .downcast_mut::<T>()
381            .unwrap()
382    }
383
384    pub fn try_downcast_doc_mut<T: 'static>(&mut self) -> Option<&mut T> {
385        (&mut *self.doc as &mut dyn Any).downcast_mut::<T>()
386    }
387
388    pub fn current_animation_time(&mut self) -> f64 {
389        match &self.animation_timer {
390            Some(start) => Instant::now().duration_since(*start).as_secs_f64(),
391            None => {
392                self.animation_timer = Some(Instant::now());
393                0.0
394            }
395        }
396    }
397}
398
399impl<Rend: WindowRenderer> View<Rend> {
400    /// Start resuming the renderer. Dispatches [`BlitzShellEvent::ResumeReady`]
401    /// when initialization completes — synchronously on native, asynchronously
402    /// on wasm32. The embedder must call [`complete_resume`](Self::complete_resume)
403    /// in response.
404    pub fn resume(&mut self) {
405        let window_id = self.window_id();
406        let animation_time = self.current_animation_time();
407
408        let (width, height) = {
409            let mut inner = self.doc.inner_mut();
410            inner.resolve(animation_time);
411            inner.viewport().window_size
412        };
413
414        let proxy = self.proxy.clone();
415        self.renderer
416            .resume(Arc::new(self.window.clone()), width, height, move || {
417                proxy.send_event(BlitzShellEvent::ResumeReady { window_id });
418            });
419    }
420
421    /// Finalize a previously-started resume. Should be called in response to a
422    /// [`BlitzShellEvent::ResumeReady`] event. Paints the first frame and
423    /// installs the doc poll waker. Returns `true` if the renderer is now active.
424    pub fn complete_resume(&mut self) -> bool {
425        if !self.renderer.complete_resume() {
426            return false;
427        }
428
429        // Resync the renderer to the current viewport. Resize/scale events that
430        // arrived while the renderer was Pending were no-ops on the renderer
431        // (its `set_size` only matches Active), so the surface created during
432        // resume could be at a stale size by the time we get here.
433        let animation_time = self.current_animation_time();
434        let mut inner = self.doc.inner_mut();
435        inner.resolve(animation_time);
436        let (width, height) = inner.viewport().window_size;
437        let scale = inner.viewport().scale_f64();
438        // Device pixels: `paint_scene`'s initial_x/initial_y are the document's
439        // origin in the scene, and everything downstream of them — the viewport
440        // cull, the root element's translate, and `draw_sub_document` for an
441        // embedded document — is already scaled. Passing the logical value here
442        // halved the offset on a HiDPI display.
443        let insets = self.safe_area_insets;
444
445        #[cfg(feature = "custom-widget")]
446        inner.can_create_surfaces(&mut self.renderer as _);
447
448        self.renderer.set_size(width, height);
449
450        self.renderer.render(|scene| {
451            paint_scene(
452                scene,
453                &mut inner,
454                scale,
455                width,
456                height,
457                insets.left,
458                insets.top,
459            )
460        });
461        drop(inner);
462        self.redraw_pending.set(false);
463
464        self.waker = Some(create_waker(&self.proxy, Arc::clone(&self.poll_requested)));
465        // Scripts can schedule timers before the native surface exists. Their timer thread has
466        // nothing to wake until this point, so poll once after installing the event-loop waker
467        // to run already-due work and re-arm future deadlines.
468        self.poll();
469        true
470    }
471
472    pub fn suspend(&mut self) {
473        self.waker = None;
474        self.redraw_pending.set(false);
475        self.renderer.suspend();
476
477        #[cfg(feature = "custom-widget")]
478        self.doc.inner_mut().destroy_surfaces();
479    }
480
481    /// Ask for a poll before the event loop next sleeps.
482    ///
483    /// Costs one relaxed store when the flag is already set, which is the
484    /// common case during a drag or a scroll.
485    pub fn request_poll(&self) {
486        self.poll_requested.store(true, Ordering::Release);
487    }
488
489    /// Poll iff a poll was asked for since the last drain, clearing the request.
490    pub fn poll_if_requested(&mut self) -> bool {
491        if self.poll_requested.swap(false, Ordering::AcqRel) {
492            self.poll()
493        } else {
494            false
495        }
496    }
497
498    pub fn poll(&mut self) -> bool {
499        if let Some(waker) = &self.waker {
500            let cx = std::task::Context::from_waker(waker);
501            if self.doc.poll(Some(cx)) {
502                #[cfg(feature = "accessibility")]
503                {
504                    let inner = self.doc.inner();
505                    // `poll()` already answered that the document changed.
506                    // The former `changed_nodes` guard was both inverted and
507                    // never cleared, so it suppressed this update while
508                    // retaining every node id the document had ever created.
509                    self.accessibility.update_tree(&inner);
510                }
511
512                self.request_redraw();
513                return true;
514            }
515        }
516
517        false
518    }
519
520    pub fn request_redraw(&self) {
521        if self.renderer.is_active() && !self.redraw_pending.replace(true) {
522            self.window.request_redraw();
523            #[cfg(target_os = "ios")]
524            self.ios_request_redraw.set(true);
525        }
526    }
527
528    /// Render the requested frame and report whether it was submitted.
529    pub fn redraw(&mut self) -> bool {
530        /*
531         * Permission, not an attached consumer.
532         *
533         * `deep_profiling_enabled` means permitted *and* somebody is reading,
534         * which is the right gate for the intrusive collectors: they cost
535         * something per section and nobody should pay for a reader who is not
536         * there. Frame timing is not that. It is four `Instant::now()` calls
537         * per frame feeding a bounded ring, and its readers are the
538         * `[blitz-frame]` log line, which writes to a local file, and the
539         * diagnostics endpoint, which connects per request and holds nothing.
540         *
541         * Gating it on a consumer meant the owner could turn both switches on
542         * and still see an empty ring: `blitz-bench` reported "no frames in
543         * window" from an application that was rendering at 120Hz, and the log
544         * file never got past its refresh-rate line.
545         */
546        let profiling = blitz_traits::profiling::deep_profiling_permitted();
547        let frame_started = Instant::now();
548        self.redraw_pending.set(false);
549        #[cfg(target_os = "ios")]
550        self.ios_request_redraw.set(false);
551        let animation_time = self.current_animation_time();
552        let is_visible = self.is_visible;
553
554        let resolve_started = profiling.then(Instant::now);
555        let mut inner = self.doc.inner_mut();
556        inner.resolve(animation_time);
557        let resolve_time = resolve_started.map_or(Duration::ZERO, |started| started.elapsed());
558
559        // Unregister resources (e.g. textures) from dropped custom widget nodes
560        #[cfg(feature = "custom-widget")]
561        for id in inner.take_pending_resource_deallocations() {
562            self.renderer.unregister_resource(id);
563        }
564
565        let (width, height) = inner.viewport().window_size;
566        let scale = inner.viewport().scale_f64();
567        let animation_pacing = inner.animation_pacing();
568        let is_animating = animation_pacing != blitz_dom::AnimationPacing::Idle;
569        let is_blocked = inner.has_pending_critical_resources();
570        // Device pixels: `paint_scene`'s initial_x/initial_y are the document's
571        // origin in the scene, and everything downstream of them — the viewport
572        // cull, the root element's translate, and `draw_sub_document` for an
573        // embedded document — is already scaled. Passing the logical value here
574        // halved the offset on a HiDPI display.
575        let insets = self.safe_area_insets;
576
577        let mut paint_time = Duration::ZERO;
578        let render_started = profiling.then(Instant::now);
579        let committed = !is_blocked && is_visible;
580        if committed {
581            self.renderer.render(|scene| {
582                let paint_started = profiling.then(Instant::now);
583                blitz_paint::paint_scene_at_time(
584                    scene,
585                    &mut inner,
586                    scale,
587                    width,
588                    height,
589                    insets.left,
590                    insets.top,
591                    animation_time,
592                );
593                paint_time = paint_started.map_or(Duration::ZERO, |started| started.elapsed());
594            });
595        }
596        let renderer_time = render_started
597            .map_or(Duration::ZERO, |started| started.elapsed())
598            .saturating_sub(paint_time);
599
600        drop(inner);
601
602        if profiling {
603            self.frame_stats
604                .record(frame_started, resolve_time, paint_time, renderer_time);
605        }
606
607        if !is_blocked && is_visible && is_animating {
608            // Due rather than requested. Requesting here is what runs an
609            // animation at the display's rate; `about_to_wait` waits out the
610            // remainder of the interval and asks then.
611            //
612            // Measured from when this frame *started*, not from now, so the
613            // interval covers the frame's own cost instead of following it. The
614            // other way round, a 6ms frame plus a 33ms wait is a 39ms cadence,
615            // and the clamp silently runs slower than it claims: 24fps measured
616            // where 30 was asked for.
617            self.animation_frame_due.set(Some(
618                frame_started + animation_frame_interval(animation_pacing),
619            ));
620        } else {
621            self.animation_frame_due.set(None);
622        }
623        committed
624    }
625
626    /// Ask for the pending animation frame if it is due, and report when the
627    /// next one falls due so the event loop can sleep until then.
628    ///
629    /// Returns `None` when nothing is animating, which lets the loop wait for
630    /// input instead of on a clock.
631    pub fn poll_animation_frame(&self, now: Instant) -> Option<Instant> {
632        let due = self.animation_frame_due.get()?;
633        if now >= due {
634            self.animation_frame_due.set(None);
635            self.request_redraw();
636            None
637        } else {
638            Some(due)
639        }
640    }
641
642    pub fn pointer_coords(&self, position: PhysicalPosition<f64>) -> PointerCoords {
643        let inner = self.doc.inner();
644        let scale = inner.viewport().scale_f64();
645        let LogicalPosition::<f32> {
646            x: screen_x,
647            y: screen_y,
648        } = position.to_logical(scale);
649        let viewport_scroll_offset = inner.viewport_scroll();
650        let client_x = screen_x - (self.safe_area_insets.left as f64 / scale) as f32;
651        let client_y = screen_y - (self.safe_area_insets.top as f64 / scale) as f32;
652        let page_x = client_x + viewport_scroll_offset.x as f32;
653        let page_y = client_y + viewport_scroll_offset.y as f32;
654
655        PointerCoords {
656            screen_x,
657            screen_y,
658            client_x,
659            client_y,
660            page_x,
661            page_y,
662        }
663    }
664
665    pub fn window_id(&self) -> WindowId {
666        self.window.id()
667    }
668
669    /// Store `event` as an active pointer, replacing any existing entry with the
670    /// same id. The stored event has an empty `active_pointers` list to avoid a
671    /// reference cycle.
672    fn set_active_pointer(&self, event: &BlitzPointerEvent) {
673        let mut stored = event.clone();
674        stored.active_pointers = Default::default();
675
676        let mut active = self.active_events.borrow_mut();
677        if let Some(existing) = active.iter_mut().find(|e| e.id == stored.id) {
678            *existing = stored;
679        } else {
680            active.push(stored);
681        }
682    }
683
684    /// Update the stored position/state of an already-active pointer. Does
685    /// nothing if the pointer is not currently active (e.g. a hovering pen).
686    fn update_active_pointer(&self, event: &BlitzPointerEvent) {
687        let mut active = self.active_events.borrow_mut();
688        if let Some(existing) = active.iter_mut().find(|e| e.id == event.id) {
689            let mut stored = event.clone();
690            stored.active_pointers = Default::default();
691            *existing = stored;
692        }
693    }
694
695    /// Remove an active pointer by id. Returns `true` if it was present.
696    fn remove_active_pointer(&self, id: BlitzPointerId) -> bool {
697        let mut active = self.active_events.borrow_mut();
698        let len_before = active.len();
699        active.retain(|e| e.id != id);
700        active.len() != len_before
701    }
702
703    #[inline]
704    pub fn with_viewport(&mut self, cb: impl FnOnce(&mut Viewport)) {
705        let mut inner = self.doc.inner_mut();
706        let mut viewport = inner.viewport_mut();
707        cb(&mut viewport);
708        let (width, height) = viewport.window_size;
709        drop(viewport);
710        drop(inner);
711        if width > 0 && height > 0 {
712            let insets = self.safe_area_insets;
713            self.renderer.set_size(
714                width + insets.left + insets.right,
715                height + insets.top + insets.bottom,
716            );
717            self.request_redraw();
718        }
719    }
720
721    #[cfg(feature = "accessibility")]
722    pub fn build_accessibility_tree(&mut self) {
723        let inner = self.doc.inner();
724        self.accessibility.update_tree(&inner);
725    }
726
727    #[cfg(target_arch = "wasm32")]
728    const RESIZE_DEBOUNCE_MS: u32 = 100;
729
730    #[cfg(target_arch = "wasm32")]
731    fn schedule_resize_settle_check(&mut self, delay_ms: u32) {
732        use wasm_bindgen::JsCast;
733        use wasm_bindgen::closure::Closure;
734
735        let proxy = self.proxy.clone();
736        let window_id = self.window_id();
737        let cb = Closure::once_into_js(move || {
738            proxy.send_event(BlitzShellEvent::ResizeSettleCheck { window_id });
739        });
740        if let Some(win) = web_sys::window() {
741            let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(
742                cb.unchecked_ref(),
743                delay_ms as i32,
744            );
745            self.resize_timer_scheduled = true;
746        }
747    }
748
749    /// Applies the pending resize iff motion has been quiet for the debounce
750    /// window; otherwise re-arms the timer for the remaining time. Called
751    /// when a previously scheduled timer fires.
752    #[cfg(target_arch = "wasm32")]
753    pub fn apply_pending_resize_if_settled(&mut self) {
754        self.resize_timer_scheduled = false;
755        let Some(last) = self.last_resize_at else {
756            return;
757        };
758        let debounce = std::time::Duration::from_millis(Self::RESIZE_DEBOUNCE_MS as u64);
759        let elapsed = web_time::Instant::now().saturating_duration_since(last);
760        if elapsed < debounce {
761            // Motion ongoing — wait out the rest of the window before re-checking.
762            let remaining_ms = (debounce - elapsed).as_millis() as u32;
763            self.schedule_resize_settle_check(remaining_ms);
764            return;
765        }
766        let Some(size) = self.pending_resize.take() else {
767            return;
768        };
769        self.last_resize_at = None;
770
771        let insets = self.safe_area_insets;
772        let width = size.width.saturating_sub(insets.left + insets.right);
773        let height = size.height.saturating_sub(insets.top + insets.bottom);
774        self.with_viewport(|v| v.window_size = (width, height));
775        self.request_redraw();
776    }
777
778    #[cfg(target_os = "macos")]
779    pub fn handle_apple_standard_keybinding(&mut self, command: &str) {
780        use blitz_traits::SmolStr;
781        let event = UiEvent::AppleStandardKeybinding(SmolStr::new(command));
782        self.doc.handle_ui_event(event);
783    }
784
785    /// Handle a window event and report an actual rendered frame commit.
786    pub fn handle_winit_event(&mut self, event: WindowEvent) -> bool {
787        // Update accessibility focus and window size state in response to a Winit WindowEvent
788        #[cfg(feature = "accessibility")]
789        self.accessibility
790            .process_window_event(&*self.window, &event);
791
792        let mut paint_committed = false;
793        match event {
794            WindowEvent::Destroyed => {}
795            WindowEvent::ActivationTokenDone { .. } => {},
796            WindowEvent::CloseRequested => {
797                // Currently handled at the level above in application.rs
798            }
799            WindowEvent::RedrawRequested => {
800                paint_committed = self.redraw();
801            }
802            WindowEvent::Moved(_) => {}
803            WindowEvent::Occluded(is_occluded) => {
804                self.is_visible = !is_occluded;
805                if self.is_visible {
806                    self.request_redraw();
807                }
808            },
809            WindowEvent::SurfaceResized(physical_size) => {
810                self.safe_area_insets = get_safe_area_insets(&*self.window);
811                // On WASM, defer the apply: wgpu's surface.configure clears the canvas,
812                // so running it every frame flickers during a drag. The browser stretches
813                // the stale backing store until the debounce timer settles.
814                #[cfg(target_arch = "wasm32")]
815                {
816                    self.pending_resize = Some(physical_size);
817                    self.last_resize_at = Some(web_time::Instant::now());
818                    if !self.resize_timer_scheduled {
819                        self.schedule_resize_settle_check(Self::RESIZE_DEBOUNCE_MS);
820                    }
821                }
822                #[cfg(not(target_arch = "wasm32"))]
823                {
824                    let insets = self.safe_area_insets;
825                    let width = physical_size.width - insets.left - insets.right;
826                    let height = physical_size.height - insets.top - insets.bottom;
827                    self.with_viewport(|v| v.window_size = (width, height));
828                    self.request_redraw();
829                }
830            }
831            WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
832                self.with_viewport(|v| v.set_hidpi_scale(scale_factor as f32));
833                self.request_redraw();
834            }
835            WindowEvent::ThemeChanged(theme) => {
836                let color_scheme = theme_to_color_scheme(self.theme_override.unwrap_or(theme));
837                let mut inner = self.doc.inner_mut();
838                inner.viewport_mut().color_scheme = color_scheme;
839            }
840            WindowEvent::Ime(ime_event) => {
841                if let Some(ime_event) = winit_ime_to_blitz(ime_event) {
842                    self.doc.handle_ui_event(UiEvent::Ime(ime_event));
843                }
844                self.request_redraw();
845            },
846            WindowEvent::ModifiersChanged(new_state) => {
847                // Store new keyboard modifier (ctrl, shift, etc) state for later use
848                self.keyboard_modifiers = new_state;
849            }
850            WindowEvent::KeyboardInput { event, .. } => {
851                if let PhysicalKey::Code(key_code) = event.physical_key && event.state.is_pressed() {
852                        let ctrl = self.keyboard_modifiers.state().control_key();
853                        let meta = self.keyboard_modifiers.state().meta_key();
854                        let alt = self.keyboard_modifiers.state().alt_key();
855
856                        // Ctrl/Super keyboard shortcuts
857                        if ctrl | meta {
858                            match key_code {
859                                KeyCode::Equal => {
860                                    self.doc.inner_mut().viewport_mut().zoom_by(0.1);
861                                },
862                                KeyCode::Minus => {
863                                    self.doc.inner_mut().viewport_mut().zoom_by(-0.1);
864                                },
865                                KeyCode::Digit0 => {
866                                    self.doc.inner_mut().viewport_mut().set_zoom(1.0);
867                                }
868                                _ => {}
869                            };
870                        }
871
872                        // Alt keyboard shortcuts
873                        if alt {
874                            match key_code {
875                                KeyCode::KeyD => {
876                                    let mut inner = self.doc.inner_mut();
877                                    inner.devtools_mut().toggle_show_layout();
878                                    drop(inner);
879                                    self.request_redraw();
880                                }
881                                KeyCode::KeyH => {
882                                    let mut inner = self.doc.inner_mut();
883                                    inner.devtools_mut().toggle_highlight_hover();
884                                    drop(inner);
885                                    self.request_redraw();
886                                }
887                                KeyCode::KeyT => self.doc.inner().print_taffy_tree(),
888                                _ => {}
889                            };
890                        }
891
892                }
893
894                // Unmodified keypresses
895                let key_event_data = winit_key_event_to_blitz(&event, self.keyboard_modifiers.state());
896                let event = if event.state.is_pressed() {
897                    UiEvent::KeyDown(key_event_data)
898                } else {
899                    UiEvent::KeyUp(key_event_data)
900                };
901
902                self.doc.handle_ui_event(event);
903            }
904            WindowEvent::PointerEntered { /*device_id*/.. } => {}
905            WindowEvent::PointerLeft { position, primary, kind, .. } => {
906                let id = pointer_kind_to_blitz(&kind);
907
908                // A `PointerLeft` for a non-mouse pointer that is still pressed
909                // (i.e. we never saw a `PointerButton` with `Released` for it)
910                // means the system cancelled tracking of this touch/pen. Emit a
911                // pointercancel in that case. A mouse simply leaving the window,
912                // or a touch that was already released, is not a cancellation.
913                // Remove from the active list first so the cancelled pointer is
914                // excluded from this event's `touches`. `remove_active_pointer`
915                // reports whether the pointer was actually active.
916                if id != BlitzPointerId::Mouse && self.remove_active_pointer(id) {
917                    let position = position.unwrap_or(self.pointer_pos);
918                    self.pointer_pos = position;
919
920                    // The pointer is no longer pressed.
921                    self.buttons ^= MouseEventButton::Main.into();
922
923                    let event = BlitzPointerEvent {
924                        id,
925                        is_primary: primary,
926                        coords: self.pointer_coords(position),
927                        button: MouseEventButton::Main,
928                        buttons: self.buttons,
929                        mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
930                        details: PointerDetails::default(),
931                        element: Default::default(),
932                        active_pointers: Arc::clone(&self.active_events),
933                    };
934
935                    self.doc.handle_ui_event(UiEvent::PointerCancel(event));
936                    self.request_redraw();
937                }
938            }
939            WindowEvent::PointerMoved { position, source, primary, .. } => {
940                self.pointer_pos = position;
941                let id = pointer_source_to_blitz(&source);
942                let event = BlitzPointerEvent {
943                    id,
944                    is_primary: primary,
945                    coords: self.pointer_coords(position),
946                    button: Default::default(),
947                    buttons: self.buttons,
948                    mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
949                    details: pointer_source_to_blitz_details(&source),
950                    element: Default::default(),
951                    active_pointers: Arc::clone(&self.active_events),
952                };
953                // Keep multi-touch positions current (no-op for non-active pointers).
954                if id != BlitzPointerId::Mouse {
955                    self.update_active_pointer(&event);
956                }
957                self.doc.handle_ui_event(UiEvent::PointerMove(event));
958                // Same omission as the wheel arm below: dispatched without ever
959                // asking for a frame. A pointer move is what drives hover
960                // feedback and, more visibly, a drag: a slider being dragged is
961                // a stream of these and nothing else, so the thumb only moved
962                // when some unrelated event happened to wake the loop.
963                self.request_redraw();
964            }
965            WindowEvent::PointerButton { button, state, primary, position, .. } => {
966                let id = button_source_to_blitz(&button);
967                let coords = self.pointer_coords(position);
968                self.pointer_pos = position;
969                let button = match &button {
970                    ButtonSource::Mouse(mouse_button) => match mouse_button {
971                        MouseButton::Left => MouseEventButton::Main,
972                        MouseButton::Right => MouseEventButton::Secondary,
973                        MouseButton::Middle => MouseEventButton::Auxiliary,
974                        // TODO: handle other button types
975                        _ => MouseEventButton::Auxiliary,
976                    }
977                    _ => MouseEventButton::Main,
978                };
979
980                match state {
981                    ElementState::Pressed => self.buttons |= button.into(),
982                    ElementState::Released => self.buttons ^= button.into(),
983                }
984
985                let pointer_event = BlitzPointerEvent {
986                    id,
987                    is_primary: primary,
988                    coords,
989                    button,
990                    buttons: self.buttons,
991                    mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
992
993                    // TODO: details for pointer up/down events
994                    details: PointerDetails::default(),
995                    element: Default::default(),
996                    active_pointers: Arc::clone(&self.active_events),
997                };
998
999                // Maintain the list of active (pressed) non-mouse pointers. A
1000                // press adds the pointer *before* dispatch (so touchstart's
1001                // `touches` includes it). A release is handled after the
1002                // synthetic move below so the move still sees it, but before the
1003                // pointerup so touchend's `touches` excludes it.
1004                if id != BlitzPointerId::Mouse && state == ElementState::Pressed {
1005                    self.set_active_pointer(&pointer_event);
1006                }
1007
1008                // Touch input doesn't emit a `PointerMoved` before the button
1009                // event the way a mouse does, so synthesise a move to update the
1010                // hover/hit position to the touch location.
1011                if id != BlitzPointerId::Mouse {
1012                    let event = BlitzPointerEvent {
1013                        id,
1014                        is_primary: primary,
1015                        coords,
1016                        button: Default::default(),
1017                        buttons: self.buttons,
1018                        mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
1019                        details: PointerDetails::default(),
1020                        element: Default::default(),
1021                        active_pointers: Arc::clone(&self.active_events),
1022                    };
1023                    self.doc.handle_ui_event(UiEvent::PointerMove(event));
1024                }
1025
1026                if id != BlitzPointerId::Mouse && state == ElementState::Released {
1027                    self.remove_active_pointer(id);
1028                }
1029
1030                let event = pointer_event;
1031
1032                let event = match state {
1033                    ElementState::Pressed => UiEvent::PointerDown(event),
1034                    ElementState::Released => UiEvent::PointerUp(event),
1035                };
1036
1037                self.doc.handle_ui_event(event);
1038                self.request_redraw();
1039            }
1040            WindowEvent::MouseWheel { delta, .. } => {
1041                let blitz_delta = match delta {
1042                    winit::event::MouseScrollDelta::LineDelta(x, y) => BlitzWheelDelta::Lines(x as f64, y as f64),
1043                    winit::event::MouseScrollDelta::PixelDelta(pos) => BlitzWheelDelta::Pixels(pos.x, pos.y),
1044                    _ => return paint_committed,
1045                };
1046
1047                let event = BlitzWheelEvent {
1048                    delta: blitz_delta,
1049                    coords: self.pointer_coords(self.pointer_pos),
1050                    buttons: self.buttons,
1051                    mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
1052                    element: Default::default()
1053                };
1054
1055                self.doc.handle_ui_event(UiEvent::Wheel(event));
1056                // Every other input arm asks for a frame; this one did not.
1057                //
1058                // A wheel event changes `scroll_offset` on the document and
1059                // nothing told the loop about it, so `about_to_wait` found no
1060                // poll request and no animation deadline, set
1061                // `ControlFlow::Wait`, and the window slept with the pre-scroll
1062                // frame still on screen. The content is laid out correctly the
1063                // whole time; it is simply never painted.
1064                //
1065                // It reads as "the pane went blank", because a scroll that ends
1066                // on fresh content leaves the last painted frame showing
1067                // whatever was there before, and it comes back the moment any
1068                // other event arrives, since those arms do request a redraw.
1069                // Measured on a wedged window: layout correct and on-screen
1070                // (the "Appearance" heading at viewport y=159), 0% CPU, every
1071                // thread parked in `nextEventMatchingMask`, and a single 1px
1072                // synthetic scroll restored it.
1073                self.request_redraw();
1074            }
1075            WindowEvent::Focused(focused) => {
1076                self.doc.inner_mut().set_window_focused(focused);
1077                self.request_redraw();
1078            }
1079            WindowEvent::TouchpadPressure { .. } => {}
1080            WindowEvent::PinchGesture { .. } => {},
1081            WindowEvent::PanGesture { .. } => {},
1082            WindowEvent::DoubleTapGesture { .. } => {},
1083            WindowEvent::RotationGesture { .. } => {},
1084            WindowEvent::DragEntered { .. } => {},
1085            WindowEvent::DragDropped { .. } => {},
1086            WindowEvent::DragLeft { .. } => {},
1087            _ => {},
1088        }
1089        paint_committed
1090    }
1091}
1092
1093struct FrameStats {
1094    enabled: bool,
1095    output_path: Option<PathBuf>,
1096    refresh_millihertz: Option<u32>,
1097    last_frame_started: Option<Instant>,
1098    sample_started: Instant,
1099    frames: u32,
1100    active_intervals: u32,
1101    missed_refreshes: u32,
1102    interval_total: Duration,
1103    interval_max: Duration,
1104    resolve_total: Duration,
1105    paint_total: Duration,
1106    renderer_total: Duration,
1107    /// Worst scene in the sample window, not the last one. A per-second line
1108    /// that averaged layer counts would hide the one dense frame that decides
1109    /// what the rasteriser has to composite.
1110    layers: blitz_paint::SceneLayerCounts,
1111}
1112
1113impl FrameStats {
1114    fn emit(output_path: Option<&PathBuf>, message: &str) {
1115        eprintln!("{message}");
1116        #[cfg(not(target_arch = "wasm32"))]
1117        if let Some(path) = output_path
1118            && let Ok(mut output) = std::fs::OpenOptions::new()
1119                .create(true)
1120                .append(true)
1121                .open(path)
1122        {
1123            let _ = std::io::Write::write_all(&mut output, message.as_bytes());
1124            let _ = std::io::Write::write_all(&mut output, b"\n");
1125        }
1126    }
1127
1128    fn new(window: &dyn Window) -> Self {
1129        #[cfg(not(target_arch = "wasm32"))]
1130        let enabled = std::env::var_os("BLITZ_FRAME_STATS").is_some();
1131        #[cfg(target_arch = "wasm32")]
1132        let enabled = false;
1133        #[cfg(not(target_arch = "wasm32"))]
1134        let output_path = std::env::var_os("BLITZ_FRAME_STATS_FILE").map(PathBuf::from);
1135        #[cfg(target_arch = "wasm32")]
1136        let output_path = None;
1137
1138        let refresh_millihertz = window
1139            .current_monitor()
1140            .and_then(|monitor| monitor.current_video_mode())
1141            .and_then(|mode| mode.refresh_rate_millihertz())
1142            .map(std::num::NonZeroU32::get);
1143
1144        // Publish the refresh rate even when the log line is off. The shared frame
1145        // log needs it to tell a late frame from an on-time one, and that readout
1146        // is not gated on BLITZ_FRAME_STATS.
1147        crate::frame_stats::set_display_refresh_millihertz(refresh_millihertz);
1148
1149        if enabled {
1150            let message = match refresh_millihertz {
1151                Some(rate) => format!(
1152                    "[blitz-frame] display_refresh_hz={:.3}",
1153                    f64::from(rate) / 1000.0
1154                ),
1155                None => "[blitz-frame] display_refresh_hz=unknown".to_owned(),
1156            };
1157            Self::emit(output_path.as_ref(), &message);
1158        }
1159
1160        Self {
1161            enabled,
1162            output_path,
1163            refresh_millihertz,
1164            last_frame_started: None,
1165            sample_started: Instant::now(),
1166            frames: 0,
1167            active_intervals: 0,
1168            missed_refreshes: 0,
1169            interval_total: Duration::ZERO,
1170            interval_max: Duration::ZERO,
1171            resolve_total: Duration::ZERO,
1172            paint_total: Duration::ZERO,
1173            renderer_total: Duration::ZERO,
1174            layers: blitz_paint::SceneLayerCounts::default(),
1175        }
1176    }
1177
1178    fn record(
1179        &mut self,
1180        frame_started: Instant,
1181        resolve: Duration,
1182        paint: Duration,
1183        renderer: Duration,
1184    ) {
1185        // Publish every frame to the process-global log before the enabled check.
1186        // Out-of-band readers (the MCP diagnostics endpoint) need real numbers from
1187        // a normally launched app; gating this on BLITZ_FRAME_STATS would leave them
1188        // with nothing to report, which is what previously drove that endpoint to
1189        // time its own snapshot collection and present it as frame cost.
1190        crate::frame_stats::record_frame(frame_started, resolve, paint, renderer);
1191
1192        if !self.enabled {
1193            return;
1194        }
1195
1196        if let Some(previous) = self.last_frame_started.replace(frame_started) {
1197            let interval = frame_started.duration_since(previous);
1198            // Ignore idle gaps. These statistics describe active interaction bursts,
1199            // not the intentional zero-FPS idle state.
1200            if interval <= Duration::from_millis(100) {
1201                self.active_intervals += 1;
1202                self.interval_total += interval;
1203                self.interval_max = self.interval_max.max(interval);
1204
1205                if let Some(rate) = self.refresh_millihertz {
1206                    let target = Duration::from_secs_f64(1000.0 / f64::from(rate));
1207                    if interval > target.mul_f64(1.5) {
1208                        self.missed_refreshes += 1;
1209                    }
1210                }
1211            }
1212        }
1213
1214        // The scene for this frame has already been painted by the time a frame
1215        // is recorded, so these counts describe it.
1216        let layers = blitz_paint::latest_scene_layers();
1217        self.layers.wanted = self.layers.wanted.max(layers.wanted);
1218        self.layers.used = self.layers.used.max(layers.used);
1219        self.layers.max_depth = self.layers.max_depth.max(layers.max_depth);
1220        for (worst, seen) in self.layers.by_site.iter_mut().zip(layers.by_site) {
1221            *worst = (*worst).max(seen);
1222        }
1223
1224        self.frames += 1;
1225        self.resolve_total += resolve;
1226        self.paint_total += paint;
1227        self.renderer_total += renderer;
1228
1229        let sample_elapsed = self.sample_started.elapsed();
1230        if sample_elapsed < Duration::from_secs(1) || self.frames < 2 {
1231            return;
1232        }
1233
1234        let active_fps = if self.interval_total.is_zero() {
1235            0.0
1236        } else {
1237            f64::from(self.active_intervals) / self.interval_total.as_secs_f64()
1238        };
1239        let frames = f64::from(self.frames);
1240        // Per-site counts include the sites that bypass the layer manager, so
1241        // this sum is larger than `layers_used_max` rather than a split of it.
1242        let by_site = blitz_paint::LayerSite::ALL
1243            .iter()
1244            .zip(self.layers.by_site)
1245            .map(|(site, count)| format!("{}:{count}", site.name()))
1246            .collect::<Vec<_>>()
1247            .join(",");
1248        let message = format!(
1249            "[blitz-frame] active_fps={active_fps:.1} frames={} active_intervals={} missed_refreshes={} max_interval_ms={:.2} resolve_avg_ms={:.2} paint_avg_ms={:.2} renderer_avg_ms={:.2} layers_wanted_max={} layers_used_max={} layer_depth_max={} layers_by_site={by_site}",
1250            self.frames,
1251            self.active_intervals,
1252            self.missed_refreshes,
1253            self.interval_max.as_secs_f64() * 1000.0,
1254            self.resolve_total.as_secs_f64() * 1000.0 / frames,
1255            self.paint_total.as_secs_f64() * 1000.0 / frames,
1256            self.renderer_total.as_secs_f64() * 1000.0 / frames,
1257            self.layers.wanted,
1258            self.layers.used,
1259            self.layers.max_depth,
1260        );
1261        Self::emit(self.output_path.as_ref(), &message);
1262
1263        self.sample_started = frame_started;
1264        self.frames = 0;
1265        self.active_intervals = 0;
1266        self.missed_refreshes = 0;
1267        self.interval_total = Duration::ZERO;
1268        self.interval_max = Duration::ZERO;
1269        self.resolve_total = Duration::ZERO;
1270        self.paint_total = Duration::ZERO;
1271        self.renderer_total = Duration::ZERO;
1272        self.layers = blitz_paint::SceneLayerCounts::default();
1273    }
1274}
1275
1276#[cfg(test)]
1277mod animation_pacing_tests {
1278    use super::*;
1279
1280    #[test]
1281    fn css_animation_frames_are_limited_to_fifteen_fps() {
1282        assert_eq!(
1283            animation_frame_interval_for_refresh(
1284                blitz_dom::AnimationPacing::SlowCss,
1285                Some(120_000),
1286            ),
1287            Duration::from_secs_f64(1.0 / 15.0),
1288        );
1289        assert_eq!(
1290            animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::SlowCss, Some(60_000)),
1291            Duration::from_secs_f64(1.0 / 15.0),
1292        );
1293        assert_eq!(
1294            animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::SlowCss, None),
1295            Duration::from_millis(67),
1296        );
1297    }
1298
1299    #[test]
1300    fn interactive_animation_frames_remain_at_thirty_fps() {
1301        assert_eq!(
1302            animation_frame_interval_for_refresh(
1303                blitz_dom::AnimationPacing::Interactive,
1304                Some(120_000),
1305            ),
1306            Duration::from_secs_f64(1.0 / 30.0),
1307        );
1308        assert_eq!(
1309            animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::Interactive, None),
1310            Duration::from_millis(33),
1311        );
1312    }
1313
1314    #[test]
1315    fn caret_only_frames_run_at_blink_boundaries() {
1316        assert_eq!(
1317            animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::Caret, Some(120_000)),
1318            Duration::from_millis(500),
1319        );
1320    }
1321}