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
296        // If the document title is set prior to the window being created then it will
297        // have been sent to a dummy ShellProvider and won't get picked up.
298        // So we look for it here and set it if present.
299        let title = inner.find_title_node().map(|node| node.text_content());
300        if let Some(title) = title {
301            winit_window.set_title(&title);
302        }
303
304        drop(inner);
305
306        Self {
307            renderer: config.renderer,
308            waker: None,
309            poll_requested: Arc::new(AtomicBool::new(false)),
310            animation_timer: None,
311            keyboard_modifiers: Default::default(),
312            proxy: proxy.clone(),
313            window: winit_window.clone(),
314            doc,
315            theme_override: None,
316            buttons: MouseEventButtons::None,
317            active_events: Arc::new(AtomicRefCell::new(Vec::new())),
318            safe_area_insets,
319            #[cfg(target_arch = "wasm32")]
320            pending_resize: None,
321            #[cfg(target_arch = "wasm32")]
322            last_resize_at: None,
323            #[cfg(target_arch = "wasm32")]
324            resize_timer_scheduled: false,
325            pointer_pos: Default::default(),
326            is_visible: winit_window.is_visible().unwrap_or(true),
327            redraw_pending: std::cell::Cell::new(false),
328            frame_stats: FrameStats::new(&*winit_window),
329            #[cfg(feature = "accessibility")]
330            accessibility,
331
332            #[cfg(target_os = "ios")]
333            ios_request_redraw: std::cell::Cell::new(false),
334
335            animation_frame_due: std::cell::Cell::new(None),
336        }
337    }
338
339    pub fn replace_document(&mut self, new_doc: Box<dyn Document>, retain_scroll_position: bool) {
340        let inner = self.doc.inner();
341        let scroll = inner.viewport_scroll();
342        let viewport = inner.viewport().clone();
343        let shell_provider = inner.shell_provider.clone();
344        drop(inner);
345
346        self.doc = new_doc;
347
348        let mut inner = self.doc.inner_mut();
349        inner.set_viewport(viewport);
350        inner.set_shell_provider(shell_provider);
351        drop(inner);
352
353        self.poll();
354        self.request_redraw();
355
356        if retain_scroll_position {
357            self.doc.inner_mut().set_viewport_scroll(scroll);
358        }
359    }
360
361    pub fn theme_override(&self) -> Option<Theme> {
362        self.theme_override
363    }
364
365    pub fn current_theme(&self) -> Theme {
366        color_scheme_to_theme(self.doc.inner().viewport().color_scheme)
367    }
368
369    pub fn set_theme_override(&mut self, theme: Option<Theme>) {
370        self.theme_override = theme;
371        let theme = theme.or(self.window.theme()).unwrap_or(Theme::Light);
372        self.with_viewport(|v| v.color_scheme = theme_to_color_scheme(theme));
373    }
374
375    pub fn downcast_doc_mut<T: 'static>(&mut self) -> &mut T {
376        (&mut *self.doc as &mut dyn Any)
377            .downcast_mut::<T>()
378            .unwrap()
379    }
380
381    pub fn try_downcast_doc_mut<T: 'static>(&mut self) -> Option<&mut T> {
382        (&mut *self.doc as &mut dyn Any).downcast_mut::<T>()
383    }
384
385    pub fn current_animation_time(&mut self) -> f64 {
386        match &self.animation_timer {
387            Some(start) => Instant::now().duration_since(*start).as_secs_f64(),
388            None => {
389                self.animation_timer = Some(Instant::now());
390                0.0
391            }
392        }
393    }
394}
395
396impl<Rend: WindowRenderer> View<Rend> {
397    /// Start resuming the renderer. Dispatches [`BlitzShellEvent::ResumeReady`]
398    /// when initialization completes — synchronously on native, asynchronously
399    /// on wasm32. The embedder must call [`complete_resume`](Self::complete_resume)
400    /// in response.
401    pub fn resume(&mut self) {
402        let window_id = self.window_id();
403        let animation_time = self.current_animation_time();
404
405        let (width, height) = {
406            let mut inner = self.doc.inner_mut();
407            inner.resolve(animation_time);
408            inner.viewport().window_size
409        };
410
411        let proxy = self.proxy.clone();
412        self.renderer
413            .resume(Arc::new(self.window.clone()), width, height, move || {
414                proxy.send_event(BlitzShellEvent::ResumeReady { window_id });
415            });
416    }
417
418    /// Finalize a previously-started resume. Should be called in response to a
419    /// [`BlitzShellEvent::ResumeReady`] event. Paints the first frame and
420    /// installs the doc poll waker. Returns `true` if the renderer is now active.
421    pub fn complete_resume(&mut self) -> bool {
422        if !self.renderer.complete_resume() {
423            return false;
424        }
425
426        // Resync the renderer to the current viewport. Resize/scale events that
427        // arrived while the renderer was Pending were no-ops on the renderer
428        // (its `set_size` only matches Active), so the surface created during
429        // resume could be at a stale size by the time we get here.
430        let animation_time = self.current_animation_time();
431        let mut inner = self.doc.inner_mut();
432        inner.resolve(animation_time);
433        let (width, height) = inner.viewport().window_size;
434        let scale = inner.viewport().scale_f64();
435        // Device pixels: `paint_scene`'s initial_x/initial_y are the document's
436        // origin in the scene, and everything downstream of them — the viewport
437        // cull, the root element's translate, and `draw_sub_document` for an
438        // embedded document — is already scaled. Passing the logical value here
439        // halved the offset on a HiDPI display.
440        let insets = self.safe_area_insets;
441
442        #[cfg(feature = "custom-widget")]
443        inner.can_create_surfaces(&mut self.renderer as _);
444
445        self.renderer.set_size(width, height);
446
447        self.renderer.render(|scene| {
448            paint_scene(
449                scene,
450                &mut inner,
451                scale,
452                width,
453                height,
454                insets.left,
455                insets.top,
456            )
457        });
458        drop(inner);
459        self.redraw_pending.set(false);
460
461        self.waker = Some(create_waker(&self.proxy, Arc::clone(&self.poll_requested)));
462        // Scripts can schedule timers before the native surface exists. Their timer thread has
463        // nothing to wake until this point, so poll once after installing the event-loop waker
464        // to run already-due work and re-arm future deadlines.
465        self.poll();
466        true
467    }
468
469    pub fn suspend(&mut self) {
470        self.waker = None;
471        self.redraw_pending.set(false);
472        self.renderer.suspend();
473
474        #[cfg(feature = "custom-widget")]
475        self.doc.inner_mut().destroy_surfaces();
476    }
477
478    /// Ask for a poll before the event loop next sleeps.
479    ///
480    /// Costs one relaxed store when the flag is already set, which is the
481    /// common case during a drag or a scroll.
482    pub fn request_poll(&self) {
483        self.poll_requested.store(true, Ordering::Release);
484    }
485
486    /// Poll iff a poll was asked for since the last drain, clearing the request.
487    pub fn poll_if_requested(&mut self) -> bool {
488        if self.poll_requested.swap(false, Ordering::AcqRel) {
489            self.poll()
490        } else {
491            false
492        }
493    }
494
495    pub fn poll(&mut self) -> bool {
496        if let Some(waker) = &self.waker {
497            let cx = std::task::Context::from_waker(waker);
498            if self.doc.poll(Some(cx)) {
499                #[cfg(feature = "accessibility")]
500                {
501                    let inner = self.doc.inner();
502                    // `poll()` already answered that the document changed.
503                    // The former `changed_nodes` guard was both inverted and
504                    // never cleared, so it suppressed this update while
505                    // retaining every node id the document had ever created.
506                    self.accessibility.update_tree(&inner);
507                }
508
509                self.request_redraw();
510                return true;
511            }
512        }
513
514        false
515    }
516
517    pub fn request_redraw(&self) {
518        if self.renderer.is_active() && !self.redraw_pending.replace(true) {
519            self.window.request_redraw();
520            #[cfg(target_os = "ios")]
521            self.ios_request_redraw.set(true);
522        }
523    }
524
525    /// Render the requested frame and report whether it was submitted.
526    pub fn redraw(&mut self) -> bool {
527        /*
528         * Permission, not an attached consumer.
529         *
530         * `deep_profiling_enabled` means permitted *and* somebody is reading,
531         * which is the right gate for the intrusive collectors: they cost
532         * something per section and nobody should pay for a reader who is not
533         * there. Frame timing is not that. It is four `Instant::now()` calls
534         * per frame feeding a bounded ring, and its readers are the
535         * `[blitz-frame]` log line, which writes to a local file, and the
536         * diagnostics endpoint, which connects per request and holds nothing.
537         *
538         * Gating it on a consumer meant the owner could turn both switches on
539         * and still see an empty ring: `blitz-bench` reported "no frames in
540         * window" from an application that was rendering at 120Hz, and the log
541         * file never got past its refresh-rate line.
542         */
543        let profiling = blitz_traits::profiling::deep_profiling_permitted();
544        let frame_started = Instant::now();
545        self.redraw_pending.set(false);
546        #[cfg(target_os = "ios")]
547        self.ios_request_redraw.set(false);
548        let animation_time = self.current_animation_time();
549        let is_visible = self.is_visible;
550
551        let resolve_started = profiling.then(Instant::now);
552        let mut inner = self.doc.inner_mut();
553        inner.resolve(animation_time);
554        let resolve_time = resolve_started.map_or(Duration::ZERO, |started| started.elapsed());
555
556        // Unregister resources (e.g. textures) from dropped custom widget nodes
557        #[cfg(feature = "custom-widget")]
558        for id in inner.take_pending_resource_deallocations() {
559            self.renderer.unregister_resource(id);
560        }
561
562        let (width, height) = inner.viewport().window_size;
563        let scale = inner.viewport().scale_f64();
564        let animation_pacing = inner.animation_pacing();
565        let is_animating = animation_pacing != blitz_dom::AnimationPacing::Idle;
566        let is_blocked = inner.has_pending_critical_resources();
567        // Device pixels: `paint_scene`'s initial_x/initial_y are the document's
568        // origin in the scene, and everything downstream of them — the viewport
569        // cull, the root element's translate, and `draw_sub_document` for an
570        // embedded document — is already scaled. Passing the logical value here
571        // halved the offset on a HiDPI display.
572        let insets = self.safe_area_insets;
573
574        let mut paint_time = Duration::ZERO;
575        let render_started = profiling.then(Instant::now);
576        let committed = !is_blocked && is_visible;
577        if committed {
578            self.renderer.render(|scene| {
579                let paint_started = profiling.then(Instant::now);
580                blitz_paint::paint_scene_at_time(
581                    scene,
582                    &mut inner,
583                    scale,
584                    width,
585                    height,
586                    insets.left,
587                    insets.top,
588                    animation_time,
589                );
590                paint_time = paint_started.map_or(Duration::ZERO, |started| started.elapsed());
591            });
592        }
593        let renderer_time = render_started
594            .map_or(Duration::ZERO, |started| started.elapsed())
595            .saturating_sub(paint_time);
596
597        drop(inner);
598
599        if profiling {
600            self.frame_stats
601                .record(frame_started, resolve_time, paint_time, renderer_time);
602        }
603
604        if !is_blocked && is_visible && is_animating {
605            // Due rather than requested. Requesting here is what runs an
606            // animation at the display's rate; `about_to_wait` waits out the
607            // remainder of the interval and asks then.
608            //
609            // Measured from when this frame *started*, not from now, so the
610            // interval covers the frame's own cost instead of following it. The
611            // other way round, a 6ms frame plus a 33ms wait is a 39ms cadence,
612            // and the clamp silently runs slower than it claims: 24fps measured
613            // where 30 was asked for.
614            self.animation_frame_due.set(Some(
615                frame_started + animation_frame_interval(animation_pacing),
616            ));
617        } else {
618            self.animation_frame_due.set(None);
619        }
620        committed
621    }
622
623    /// Ask for the pending animation frame if it is due, and report when the
624    /// next one falls due so the event loop can sleep until then.
625    ///
626    /// Returns `None` when nothing is animating, which lets the loop wait for
627    /// input instead of on a clock.
628    pub fn poll_animation_frame(&self, now: Instant) -> Option<Instant> {
629        let due = self.animation_frame_due.get()?;
630        if now >= due {
631            self.animation_frame_due.set(None);
632            self.request_redraw();
633            None
634        } else {
635            Some(due)
636        }
637    }
638
639    pub fn pointer_coords(&self, position: PhysicalPosition<f64>) -> PointerCoords {
640        let inner = self.doc.inner();
641        let scale = inner.viewport().scale_f64();
642        let LogicalPosition::<f32> {
643            x: screen_x,
644            y: screen_y,
645        } = position.to_logical(scale);
646        let viewport_scroll_offset = inner.viewport_scroll();
647        let client_x = screen_x - (self.safe_area_insets.left as f64 / scale) as f32;
648        let client_y = screen_y - (self.safe_area_insets.top as f64 / scale) as f32;
649        let page_x = client_x + viewport_scroll_offset.x as f32;
650        let page_y = client_y + viewport_scroll_offset.y as f32;
651
652        PointerCoords {
653            screen_x,
654            screen_y,
655            client_x,
656            client_y,
657            page_x,
658            page_y,
659        }
660    }
661
662    pub fn window_id(&self) -> WindowId {
663        self.window.id()
664    }
665
666    /// Store `event` as an active pointer, replacing any existing entry with the
667    /// same id. The stored event has an empty `active_pointers` list to avoid a
668    /// reference cycle.
669    fn set_active_pointer(&self, event: &BlitzPointerEvent) {
670        let mut stored = event.clone();
671        stored.active_pointers = Default::default();
672
673        let mut active = self.active_events.borrow_mut();
674        if let Some(existing) = active.iter_mut().find(|e| e.id == stored.id) {
675            *existing = stored;
676        } else {
677            active.push(stored);
678        }
679    }
680
681    /// Update the stored position/state of an already-active pointer. Does
682    /// nothing if the pointer is not currently active (e.g. a hovering pen).
683    fn update_active_pointer(&self, event: &BlitzPointerEvent) {
684        let mut active = self.active_events.borrow_mut();
685        if let Some(existing) = active.iter_mut().find(|e| e.id == event.id) {
686            let mut stored = event.clone();
687            stored.active_pointers = Default::default();
688            *existing = stored;
689        }
690    }
691
692    /// Remove an active pointer by id. Returns `true` if it was present.
693    fn remove_active_pointer(&self, id: BlitzPointerId) -> bool {
694        let mut active = self.active_events.borrow_mut();
695        let len_before = active.len();
696        active.retain(|e| e.id != id);
697        active.len() != len_before
698    }
699
700    #[inline]
701    pub fn with_viewport(&mut self, cb: impl FnOnce(&mut Viewport)) {
702        let mut inner = self.doc.inner_mut();
703        let mut viewport = inner.viewport_mut();
704        cb(&mut viewport);
705        let (width, height) = viewport.window_size;
706        drop(viewport);
707        drop(inner);
708        if width > 0 && height > 0 {
709            let insets = self.safe_area_insets;
710            self.renderer.set_size(
711                width + insets.left + insets.right,
712                height + insets.top + insets.bottom,
713            );
714            self.request_redraw();
715        }
716    }
717
718    #[cfg(feature = "accessibility")]
719    pub fn build_accessibility_tree(&mut self) {
720        let inner = self.doc.inner();
721        self.accessibility.update_tree(&inner);
722    }
723
724    #[cfg(target_arch = "wasm32")]
725    const RESIZE_DEBOUNCE_MS: u32 = 100;
726
727    #[cfg(target_arch = "wasm32")]
728    fn schedule_resize_settle_check(&mut self, delay_ms: u32) {
729        use wasm_bindgen::JsCast;
730        use wasm_bindgen::closure::Closure;
731
732        let proxy = self.proxy.clone();
733        let window_id = self.window_id();
734        let cb = Closure::once_into_js(move || {
735            proxy.send_event(BlitzShellEvent::ResizeSettleCheck { window_id });
736        });
737        if let Some(win) = web_sys::window() {
738            let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(
739                cb.unchecked_ref(),
740                delay_ms as i32,
741            );
742            self.resize_timer_scheduled = true;
743        }
744    }
745
746    /// Applies the pending resize iff motion has been quiet for the debounce
747    /// window; otherwise re-arms the timer for the remaining time. Called
748    /// when a previously scheduled timer fires.
749    #[cfg(target_arch = "wasm32")]
750    pub fn apply_pending_resize_if_settled(&mut self) {
751        self.resize_timer_scheduled = false;
752        let Some(last) = self.last_resize_at else {
753            return;
754        };
755        let debounce = std::time::Duration::from_millis(Self::RESIZE_DEBOUNCE_MS as u64);
756        let elapsed = web_time::Instant::now().saturating_duration_since(last);
757        if elapsed < debounce {
758            // Motion ongoing — wait out the rest of the window before re-checking.
759            let remaining_ms = (debounce - elapsed).as_millis() as u32;
760            self.schedule_resize_settle_check(remaining_ms);
761            return;
762        }
763        let Some(size) = self.pending_resize.take() else {
764            return;
765        };
766        self.last_resize_at = None;
767
768        let insets = self.safe_area_insets;
769        let width = size.width.saturating_sub(insets.left + insets.right);
770        let height = size.height.saturating_sub(insets.top + insets.bottom);
771        self.with_viewport(|v| v.window_size = (width, height));
772        self.request_redraw();
773    }
774
775    #[cfg(target_os = "macos")]
776    pub fn handle_apple_standard_keybinding(&mut self, command: &str) {
777        use blitz_traits::SmolStr;
778        let event = UiEvent::AppleStandardKeybinding(SmolStr::new(command));
779        self.doc.handle_ui_event(event);
780    }
781
782    /// Handle a window event and report an actual rendered frame commit.
783    pub fn handle_winit_event(&mut self, event: WindowEvent) -> bool {
784        // Update accessibility focus and window size state in response to a Winit WindowEvent
785        #[cfg(feature = "accessibility")]
786        self.accessibility
787            .process_window_event(&*self.window, &event);
788
789        let mut paint_committed = false;
790        match event {
791            WindowEvent::Destroyed => {}
792            WindowEvent::ActivationTokenDone { .. } => {},
793            WindowEvent::CloseRequested => {
794                // Currently handled at the level above in application.rs
795            }
796            WindowEvent::RedrawRequested => {
797                paint_committed = self.redraw();
798            }
799            WindowEvent::Moved(_) => {}
800            WindowEvent::Occluded(is_occluded) => {
801                self.is_visible = !is_occluded;
802                if self.is_visible {
803                    self.request_redraw();
804                }
805            },
806            WindowEvent::SurfaceResized(physical_size) => {
807                self.safe_area_insets = get_safe_area_insets(&*self.window);
808                // On WASM, defer the apply: wgpu's surface.configure clears the canvas,
809                // so running it every frame flickers during a drag. The browser stretches
810                // the stale backing store until the debounce timer settles.
811                #[cfg(target_arch = "wasm32")]
812                {
813                    self.pending_resize = Some(physical_size);
814                    self.last_resize_at = Some(web_time::Instant::now());
815                    if !self.resize_timer_scheduled {
816                        self.schedule_resize_settle_check(Self::RESIZE_DEBOUNCE_MS);
817                    }
818                }
819                #[cfg(not(target_arch = "wasm32"))]
820                {
821                    let insets = self.safe_area_insets;
822                    let width = physical_size.width - insets.left - insets.right;
823                    let height = physical_size.height - insets.top - insets.bottom;
824                    self.with_viewport(|v| v.window_size = (width, height));
825                    self.request_redraw();
826                }
827            }
828            WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
829                self.with_viewport(|v| v.set_hidpi_scale(scale_factor as f32));
830                self.request_redraw();
831            }
832            WindowEvent::ThemeChanged(theme) => {
833                let color_scheme = theme_to_color_scheme(self.theme_override.unwrap_or(theme));
834                let mut inner = self.doc.inner_mut();
835                inner.viewport_mut().color_scheme = color_scheme;
836            }
837            WindowEvent::Ime(ime_event) => {
838                self.doc.handle_ui_event(UiEvent::Ime(winit_ime_to_blitz(ime_event)));
839                self.request_redraw();
840            },
841            WindowEvent::ModifiersChanged(new_state) => {
842                // Store new keyboard modifier (ctrl, shift, etc) state for later use
843                self.keyboard_modifiers = new_state;
844            }
845            WindowEvent::KeyboardInput { event, .. } => {
846                if let PhysicalKey::Code(key_code) = event.physical_key && event.state.is_pressed() {
847                        let ctrl = self.keyboard_modifiers.state().control_key();
848                        let meta = self.keyboard_modifiers.state().meta_key();
849                        let alt = self.keyboard_modifiers.state().alt_key();
850
851                        // Ctrl/Super keyboard shortcuts
852                        if ctrl | meta {
853                            match key_code {
854                                KeyCode::Equal => {
855                                    self.doc.inner_mut().viewport_mut().zoom_by(0.1);
856                                },
857                                KeyCode::Minus => {
858                                    self.doc.inner_mut().viewport_mut().zoom_by(-0.1);
859                                },
860                                KeyCode::Digit0 => {
861                                    self.doc.inner_mut().viewport_mut().set_zoom(1.0);
862                                }
863                                _ => {}
864                            };
865                        }
866
867                        // Alt keyboard shortcuts
868                        if alt {
869                            match key_code {
870                                KeyCode::KeyD => {
871                                    let mut inner = self.doc.inner_mut();
872                                    inner.devtools_mut().toggle_show_layout();
873                                    drop(inner);
874                                    self.request_redraw();
875                                }
876                                KeyCode::KeyH => {
877                                    let mut inner = self.doc.inner_mut();
878                                    inner.devtools_mut().toggle_highlight_hover();
879                                    drop(inner);
880                                    self.request_redraw();
881                                }
882                                KeyCode::KeyT => self.doc.inner().print_taffy_tree(),
883                                _ => {}
884                            };
885                        }
886
887                }
888
889                // Unmodified keypresses
890                let key_event_data = winit_key_event_to_blitz(&event, self.keyboard_modifiers.state());
891                let event = if event.state.is_pressed() {
892                    UiEvent::KeyDown(key_event_data)
893                } else {
894                    UiEvent::KeyUp(key_event_data)
895                };
896
897                self.doc.handle_ui_event(event);
898            }
899            WindowEvent::PointerEntered { /*device_id*/.. } => {}
900            WindowEvent::PointerLeft { position, primary, kind, .. } => {
901                let id = pointer_kind_to_blitz(&kind);
902
903                // A `PointerLeft` for a non-mouse pointer that is still pressed
904                // (i.e. we never saw a `PointerButton` with `Released` for it)
905                // means the system cancelled tracking of this touch/pen. Emit a
906                // pointercancel in that case. A mouse simply leaving the window,
907                // or a touch that was already released, is not a cancellation.
908                // Remove from the active list first so the cancelled pointer is
909                // excluded from this event's `touches`. `remove_active_pointer`
910                // reports whether the pointer was actually active.
911                if id != BlitzPointerId::Mouse && self.remove_active_pointer(id) {
912                    let position = position.unwrap_or(self.pointer_pos);
913                    self.pointer_pos = position;
914
915                    // The pointer is no longer pressed.
916                    self.buttons ^= MouseEventButton::Main.into();
917
918                    let event = BlitzPointerEvent {
919                        id,
920                        is_primary: primary,
921                        coords: self.pointer_coords(position),
922                        button: MouseEventButton::Main,
923                        buttons: self.buttons,
924                        mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
925                        details: PointerDetails::default(),
926                        element: Default::default(),
927                        active_pointers: Arc::clone(&self.active_events),
928                    };
929
930                    self.doc.handle_ui_event(UiEvent::PointerCancel(event));
931                    self.request_redraw();
932                }
933            }
934            WindowEvent::PointerMoved { position, source, primary, .. } => {
935                self.pointer_pos = position;
936                let id = pointer_source_to_blitz(&source);
937                let event = BlitzPointerEvent {
938                    id,
939                    is_primary: primary,
940                    coords: self.pointer_coords(position),
941                    button: Default::default(),
942                    buttons: self.buttons,
943                    mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
944                    details: pointer_source_to_blitz_details(&source),
945                    element: Default::default(),
946                    active_pointers: Arc::clone(&self.active_events),
947                };
948                // Keep multi-touch positions current (no-op for non-active pointers).
949                if id != BlitzPointerId::Mouse {
950                    self.update_active_pointer(&event);
951                }
952                self.doc.handle_ui_event(UiEvent::PointerMove(event));
953                // Same omission as the wheel arm below: dispatched without ever
954                // asking for a frame. A pointer move is what drives hover
955                // feedback and, more visibly, a drag: a slider being dragged is
956                // a stream of these and nothing else, so the thumb only moved
957                // when some unrelated event happened to wake the loop.
958                self.request_redraw();
959            }
960            WindowEvent::PointerButton { button, state, primary, position, .. } => {
961                let id = button_source_to_blitz(&button);
962                let coords = self.pointer_coords(position);
963                self.pointer_pos = position;
964                let button = match &button {
965                    ButtonSource::Mouse(mouse_button) => match mouse_button {
966                        MouseButton::Left => MouseEventButton::Main,
967                        MouseButton::Right => MouseEventButton::Secondary,
968                        MouseButton::Middle => MouseEventButton::Auxiliary,
969                        // TODO: handle other button types
970                        _ => MouseEventButton::Auxiliary,
971                    }
972                    _ => MouseEventButton::Main,
973                };
974
975                match state {
976                    ElementState::Pressed => self.buttons |= button.into(),
977                    ElementState::Released => self.buttons ^= button.into(),
978                }
979
980                let pointer_event = BlitzPointerEvent {
981                    id,
982                    is_primary: primary,
983                    coords,
984                    button,
985                    buttons: self.buttons,
986                    mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
987
988                    // TODO: details for pointer up/down events
989                    details: PointerDetails::default(),
990                    element: Default::default(),
991                    active_pointers: Arc::clone(&self.active_events),
992                };
993
994                // Maintain the list of active (pressed) non-mouse pointers. A
995                // press adds the pointer *before* dispatch (so touchstart's
996                // `touches` includes it). A release is handled after the
997                // synthetic move below so the move still sees it, but before the
998                // pointerup so touchend's `touches` excludes it.
999                if id != BlitzPointerId::Mouse && state == ElementState::Pressed {
1000                    self.set_active_pointer(&pointer_event);
1001                }
1002
1003                // Touch input doesn't emit a `PointerMoved` before the button
1004                // event the way a mouse does, so synthesise a move to update the
1005                // hover/hit position to the touch location.
1006                if id != BlitzPointerId::Mouse {
1007                    let event = BlitzPointerEvent {
1008                        id,
1009                        is_primary: primary,
1010                        coords,
1011                        button: Default::default(),
1012                        buttons: self.buttons,
1013                        mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
1014                        details: PointerDetails::default(),
1015                        element: Default::default(),
1016                        active_pointers: Arc::clone(&self.active_events),
1017                    };
1018                    self.doc.handle_ui_event(UiEvent::PointerMove(event));
1019                }
1020
1021                if id != BlitzPointerId::Mouse && state == ElementState::Released {
1022                    self.remove_active_pointer(id);
1023                }
1024
1025                let event = pointer_event;
1026
1027                let event = match state {
1028                    ElementState::Pressed => UiEvent::PointerDown(event),
1029                    ElementState::Released => UiEvent::PointerUp(event),
1030                };
1031
1032                self.doc.handle_ui_event(event);
1033                self.request_redraw();
1034            }
1035            WindowEvent::MouseWheel { delta, .. } => {
1036                let blitz_delta = match delta {
1037                    winit::event::MouseScrollDelta::LineDelta(x, y) => BlitzWheelDelta::Lines(x as f64, y as f64),
1038                    winit::event::MouseScrollDelta::PixelDelta(pos) => BlitzWheelDelta::Pixels(pos.x, pos.y),
1039                };
1040
1041                let event = BlitzWheelEvent {
1042                    delta: blitz_delta,
1043                    coords: self.pointer_coords(self.pointer_pos),
1044                    buttons: self.buttons,
1045                    mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
1046                    element: Default::default()
1047                };
1048
1049                self.doc.handle_ui_event(UiEvent::Wheel(event));
1050                // Every other input arm asks for a frame; this one did not.
1051                //
1052                // A wheel event changes `scroll_offset` on the document and
1053                // nothing told the loop about it, so `about_to_wait` found no
1054                // poll request and no animation deadline, set
1055                // `ControlFlow::Wait`, and the window slept with the pre-scroll
1056                // frame still on screen. The content is laid out correctly the
1057                // whole time; it is simply never painted.
1058                //
1059                // It reads as "the pane went blank", because a scroll that ends
1060                // on fresh content leaves the last painted frame showing
1061                // whatever was there before, and it comes back the moment any
1062                // other event arrives, since those arms do request a redraw.
1063                // Measured on a wedged window: layout correct and on-screen
1064                // (the "Appearance" heading at viewport y=159), 0% CPU, every
1065                // thread parked in `nextEventMatchingMask`, and a single 1px
1066                // synthetic scroll restored it.
1067                self.request_redraw();
1068            }
1069            WindowEvent::Focused(_) => {}
1070            WindowEvent::TouchpadPressure { .. } => {}
1071            WindowEvent::PinchGesture { .. } => {},
1072            WindowEvent::PanGesture { .. } => {},
1073            WindowEvent::DoubleTapGesture { .. } => {},
1074            WindowEvent::RotationGesture { .. } => {},
1075            WindowEvent::DragEntered { .. } => {},
1076            WindowEvent::DragMoved { .. } => {},
1077            WindowEvent::DragDropped { .. } => {},
1078            WindowEvent::DragLeft { .. } => {},
1079        }
1080        paint_committed
1081    }
1082}
1083
1084struct FrameStats {
1085    enabled: bool,
1086    output_path: Option<PathBuf>,
1087    refresh_millihertz: Option<u32>,
1088    last_frame_started: Option<Instant>,
1089    sample_started: Instant,
1090    frames: u32,
1091    active_intervals: u32,
1092    missed_refreshes: u32,
1093    interval_total: Duration,
1094    interval_max: Duration,
1095    resolve_total: Duration,
1096    paint_total: Duration,
1097    renderer_total: Duration,
1098    /// Worst scene in the sample window, not the last one. A per-second line
1099    /// that averaged layer counts would hide the one dense frame that decides
1100    /// what the rasteriser has to composite.
1101    layers: blitz_paint::SceneLayerCounts,
1102}
1103
1104impl FrameStats {
1105    fn emit(output_path: Option<&PathBuf>, message: &str) {
1106        eprintln!("{message}");
1107        #[cfg(not(target_arch = "wasm32"))]
1108        if let Some(path) = output_path
1109            && let Ok(mut output) = std::fs::OpenOptions::new()
1110                .create(true)
1111                .append(true)
1112                .open(path)
1113        {
1114            let _ = std::io::Write::write_all(&mut output, message.as_bytes());
1115            let _ = std::io::Write::write_all(&mut output, b"\n");
1116        }
1117    }
1118
1119    fn new(window: &dyn Window) -> Self {
1120        #[cfg(not(target_arch = "wasm32"))]
1121        let enabled = std::env::var_os("BLITZ_FRAME_STATS").is_some();
1122        #[cfg(target_arch = "wasm32")]
1123        let enabled = false;
1124        #[cfg(not(target_arch = "wasm32"))]
1125        let output_path = std::env::var_os("BLITZ_FRAME_STATS_FILE").map(PathBuf::from);
1126        #[cfg(target_arch = "wasm32")]
1127        let output_path = None;
1128
1129        let refresh_millihertz = window
1130            .current_monitor()
1131            .and_then(|monitor| monitor.current_video_mode())
1132            .and_then(|mode| mode.refresh_rate_millihertz())
1133            .map(std::num::NonZeroU32::get);
1134
1135        // Publish the refresh rate even when the log line is off. The shared frame
1136        // log needs it to tell a late frame from an on-time one, and that readout
1137        // is not gated on BLITZ_FRAME_STATS.
1138        crate::frame_stats::set_display_refresh_millihertz(refresh_millihertz);
1139
1140        if enabled {
1141            let message = match refresh_millihertz {
1142                Some(rate) => format!(
1143                    "[blitz-frame] display_refresh_hz={:.3}",
1144                    f64::from(rate) / 1000.0
1145                ),
1146                None => "[blitz-frame] display_refresh_hz=unknown".to_owned(),
1147            };
1148            Self::emit(output_path.as_ref(), &message);
1149        }
1150
1151        Self {
1152            enabled,
1153            output_path,
1154            refresh_millihertz,
1155            last_frame_started: None,
1156            sample_started: Instant::now(),
1157            frames: 0,
1158            active_intervals: 0,
1159            missed_refreshes: 0,
1160            interval_total: Duration::ZERO,
1161            interval_max: Duration::ZERO,
1162            resolve_total: Duration::ZERO,
1163            paint_total: Duration::ZERO,
1164            renderer_total: Duration::ZERO,
1165            layers: blitz_paint::SceneLayerCounts::default(),
1166        }
1167    }
1168
1169    fn record(
1170        &mut self,
1171        frame_started: Instant,
1172        resolve: Duration,
1173        paint: Duration,
1174        renderer: Duration,
1175    ) {
1176        // Publish every frame to the process-global log before the enabled check.
1177        // Out-of-band readers (the MCP diagnostics endpoint) need real numbers from
1178        // a normally launched app; gating this on BLITZ_FRAME_STATS would leave them
1179        // with nothing to report, which is what previously drove that endpoint to
1180        // time its own snapshot collection and present it as frame cost.
1181        crate::frame_stats::record_frame(frame_started, resolve, paint, renderer);
1182
1183        if !self.enabled {
1184            return;
1185        }
1186
1187        if let Some(previous) = self.last_frame_started.replace(frame_started) {
1188            let interval = frame_started.duration_since(previous);
1189            // Ignore idle gaps. These statistics describe active interaction bursts,
1190            // not the intentional zero-FPS idle state.
1191            if interval <= Duration::from_millis(100) {
1192                self.active_intervals += 1;
1193                self.interval_total += interval;
1194                self.interval_max = self.interval_max.max(interval);
1195
1196                if let Some(rate) = self.refresh_millihertz {
1197                    let target = Duration::from_secs_f64(1000.0 / f64::from(rate));
1198                    if interval > target.mul_f64(1.5) {
1199                        self.missed_refreshes += 1;
1200                    }
1201                }
1202            }
1203        }
1204
1205        // The scene for this frame has already been painted by the time a frame
1206        // is recorded, so these counts describe it.
1207        let layers = blitz_paint::latest_scene_layers();
1208        self.layers.wanted = self.layers.wanted.max(layers.wanted);
1209        self.layers.used = self.layers.used.max(layers.used);
1210        self.layers.max_depth = self.layers.max_depth.max(layers.max_depth);
1211        for (worst, seen) in self.layers.by_site.iter_mut().zip(layers.by_site) {
1212            *worst = (*worst).max(seen);
1213        }
1214
1215        self.frames += 1;
1216        self.resolve_total += resolve;
1217        self.paint_total += paint;
1218        self.renderer_total += renderer;
1219
1220        let sample_elapsed = self.sample_started.elapsed();
1221        if sample_elapsed < Duration::from_secs(1) || self.frames < 2 {
1222            return;
1223        }
1224
1225        let active_fps = if self.interval_total.is_zero() {
1226            0.0
1227        } else {
1228            f64::from(self.active_intervals) / self.interval_total.as_secs_f64()
1229        };
1230        let frames = f64::from(self.frames);
1231        // Per-site counts include the sites that bypass the layer manager, so
1232        // this sum is larger than `layers_used_max` rather than a split of it.
1233        let by_site = blitz_paint::LayerSite::ALL
1234            .iter()
1235            .zip(self.layers.by_site)
1236            .map(|(site, count)| format!("{}:{count}", site.name()))
1237            .collect::<Vec<_>>()
1238            .join(",");
1239        let message = format!(
1240            "[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}",
1241            self.frames,
1242            self.active_intervals,
1243            self.missed_refreshes,
1244            self.interval_max.as_secs_f64() * 1000.0,
1245            self.resolve_total.as_secs_f64() * 1000.0 / frames,
1246            self.paint_total.as_secs_f64() * 1000.0 / frames,
1247            self.renderer_total.as_secs_f64() * 1000.0 / frames,
1248            self.layers.wanted,
1249            self.layers.used,
1250            self.layers.max_depth,
1251        );
1252        Self::emit(self.output_path.as_ref(), &message);
1253
1254        self.sample_started = frame_started;
1255        self.frames = 0;
1256        self.active_intervals = 0;
1257        self.missed_refreshes = 0;
1258        self.interval_total = Duration::ZERO;
1259        self.interval_max = Duration::ZERO;
1260        self.resolve_total = Duration::ZERO;
1261        self.paint_total = Duration::ZERO;
1262        self.renderer_total = Duration::ZERO;
1263        self.layers = blitz_paint::SceneLayerCounts::default();
1264    }
1265}
1266
1267#[cfg(test)]
1268mod animation_pacing_tests {
1269    use super::*;
1270
1271    #[test]
1272    fn css_animation_frames_are_limited_to_fifteen_fps() {
1273        assert_eq!(
1274            animation_frame_interval_for_refresh(
1275                blitz_dom::AnimationPacing::SlowCss,
1276                Some(120_000),
1277            ),
1278            Duration::from_secs_f64(1.0 / 15.0),
1279        );
1280        assert_eq!(
1281            animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::SlowCss, Some(60_000)),
1282            Duration::from_secs_f64(1.0 / 15.0),
1283        );
1284        assert_eq!(
1285            animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::SlowCss, None),
1286            Duration::from_millis(67),
1287        );
1288    }
1289
1290    #[test]
1291    fn interactive_animation_frames_remain_at_thirty_fps() {
1292        assert_eq!(
1293            animation_frame_interval_for_refresh(
1294                blitz_dom::AnimationPacing::Interactive,
1295                Some(120_000),
1296            ),
1297            Duration::from_secs_f64(1.0 / 30.0),
1298        );
1299        assert_eq!(
1300            animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::Interactive, None),
1301            Duration::from_millis(33),
1302        );
1303    }
1304
1305    #[test]
1306    fn caret_only_frames_run_at_blink_boundaries() {
1307        assert_eq!(
1308            animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::Caret, Some(120_000)),
1309            Duration::from_millis(500),
1310        );
1311    }
1312}