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                if let Some(ime_event) = winit_ime_to_blitz(ime_event) {
839                    self.doc.handle_ui_event(UiEvent::Ime(ime_event));
840                }
841                self.request_redraw();
842            },
843            WindowEvent::ModifiersChanged(new_state) => {
844                // Store new keyboard modifier (ctrl, shift, etc) state for later use
845                self.keyboard_modifiers = new_state;
846            }
847            WindowEvent::KeyboardInput { event, .. } => {
848                if let PhysicalKey::Code(key_code) = event.physical_key && event.state.is_pressed() {
849                        let ctrl = self.keyboard_modifiers.state().control_key();
850                        let meta = self.keyboard_modifiers.state().meta_key();
851                        let alt = self.keyboard_modifiers.state().alt_key();
852
853                        // Ctrl/Super keyboard shortcuts
854                        if ctrl | meta {
855                            match key_code {
856                                KeyCode::Equal => {
857                                    self.doc.inner_mut().viewport_mut().zoom_by(0.1);
858                                },
859                                KeyCode::Minus => {
860                                    self.doc.inner_mut().viewport_mut().zoom_by(-0.1);
861                                },
862                                KeyCode::Digit0 => {
863                                    self.doc.inner_mut().viewport_mut().set_zoom(1.0);
864                                }
865                                _ => {}
866                            };
867                        }
868
869                        // Alt keyboard shortcuts
870                        if alt {
871                            match key_code {
872                                KeyCode::KeyD => {
873                                    let mut inner = self.doc.inner_mut();
874                                    inner.devtools_mut().toggle_show_layout();
875                                    drop(inner);
876                                    self.request_redraw();
877                                }
878                                KeyCode::KeyH => {
879                                    let mut inner = self.doc.inner_mut();
880                                    inner.devtools_mut().toggle_highlight_hover();
881                                    drop(inner);
882                                    self.request_redraw();
883                                }
884                                KeyCode::KeyT => self.doc.inner().print_taffy_tree(),
885                                _ => {}
886                            };
887                        }
888
889                }
890
891                // Unmodified keypresses
892                let key_event_data = winit_key_event_to_blitz(&event, self.keyboard_modifiers.state());
893                let event = if event.state.is_pressed() {
894                    UiEvent::KeyDown(key_event_data)
895                } else {
896                    UiEvent::KeyUp(key_event_data)
897                };
898
899                self.doc.handle_ui_event(event);
900            }
901            WindowEvent::PointerEntered { /*device_id*/.. } => {}
902            WindowEvent::PointerLeft { position, primary, kind, .. } => {
903                let id = pointer_kind_to_blitz(&kind);
904
905                // A `PointerLeft` for a non-mouse pointer that is still pressed
906                // (i.e. we never saw a `PointerButton` with `Released` for it)
907                // means the system cancelled tracking of this touch/pen. Emit a
908                // pointercancel in that case. A mouse simply leaving the window,
909                // or a touch that was already released, is not a cancellation.
910                // Remove from the active list first so the cancelled pointer is
911                // excluded from this event's `touches`. `remove_active_pointer`
912                // reports whether the pointer was actually active.
913                if id != BlitzPointerId::Mouse && self.remove_active_pointer(id) {
914                    let position = position.unwrap_or(self.pointer_pos);
915                    self.pointer_pos = position;
916
917                    // The pointer is no longer pressed.
918                    self.buttons ^= MouseEventButton::Main.into();
919
920                    let event = BlitzPointerEvent {
921                        id,
922                        is_primary: primary,
923                        coords: self.pointer_coords(position),
924                        button: MouseEventButton::Main,
925                        buttons: self.buttons,
926                        mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
927                        details: PointerDetails::default(),
928                        element: Default::default(),
929                        active_pointers: Arc::clone(&self.active_events),
930                    };
931
932                    self.doc.handle_ui_event(UiEvent::PointerCancel(event));
933                    self.request_redraw();
934                }
935            }
936            WindowEvent::PointerMoved { position, source, primary, .. } => {
937                self.pointer_pos = position;
938                let id = pointer_source_to_blitz(&source);
939                let event = BlitzPointerEvent {
940                    id,
941                    is_primary: primary,
942                    coords: self.pointer_coords(position),
943                    button: Default::default(),
944                    buttons: self.buttons,
945                    mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
946                    details: pointer_source_to_blitz_details(&source),
947                    element: Default::default(),
948                    active_pointers: Arc::clone(&self.active_events),
949                };
950                // Keep multi-touch positions current (no-op for non-active pointers).
951                if id != BlitzPointerId::Mouse {
952                    self.update_active_pointer(&event);
953                }
954                self.doc.handle_ui_event(UiEvent::PointerMove(event));
955                // Same omission as the wheel arm below: dispatched without ever
956                // asking for a frame. A pointer move is what drives hover
957                // feedback and, more visibly, a drag: a slider being dragged is
958                // a stream of these and nothing else, so the thumb only moved
959                // when some unrelated event happened to wake the loop.
960                self.request_redraw();
961            }
962            WindowEvent::PointerButton { button, state, primary, position, .. } => {
963                let id = button_source_to_blitz(&button);
964                let coords = self.pointer_coords(position);
965                self.pointer_pos = position;
966                let button = match &button {
967                    ButtonSource::Mouse(mouse_button) => match mouse_button {
968                        MouseButton::Left => MouseEventButton::Main,
969                        MouseButton::Right => MouseEventButton::Secondary,
970                        MouseButton::Middle => MouseEventButton::Auxiliary,
971                        // TODO: handle other button types
972                        _ => MouseEventButton::Auxiliary,
973                    }
974                    _ => MouseEventButton::Main,
975                };
976
977                match state {
978                    ElementState::Pressed => self.buttons |= button.into(),
979                    ElementState::Released => self.buttons ^= button.into(),
980                }
981
982                let pointer_event = BlitzPointerEvent {
983                    id,
984                    is_primary: primary,
985                    coords,
986                    button,
987                    buttons: self.buttons,
988                    mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
989
990                    // TODO: details for pointer up/down events
991                    details: PointerDetails::default(),
992                    element: Default::default(),
993                    active_pointers: Arc::clone(&self.active_events),
994                };
995
996                // Maintain the list of active (pressed) non-mouse pointers. A
997                // press adds the pointer *before* dispatch (so touchstart's
998                // `touches` includes it). A release is handled after the
999                // synthetic move below so the move still sees it, but before the
1000                // pointerup so touchend's `touches` excludes it.
1001                if id != BlitzPointerId::Mouse && state == ElementState::Pressed {
1002                    self.set_active_pointer(&pointer_event);
1003                }
1004
1005                // Touch input doesn't emit a `PointerMoved` before the button
1006                // event the way a mouse does, so synthesise a move to update the
1007                // hover/hit position to the touch location.
1008                if id != BlitzPointerId::Mouse {
1009                    let event = BlitzPointerEvent {
1010                        id,
1011                        is_primary: primary,
1012                        coords,
1013                        button: Default::default(),
1014                        buttons: self.buttons,
1015                        mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
1016                        details: PointerDetails::default(),
1017                        element: Default::default(),
1018                        active_pointers: Arc::clone(&self.active_events),
1019                    };
1020                    self.doc.handle_ui_event(UiEvent::PointerMove(event));
1021                }
1022
1023                if id != BlitzPointerId::Mouse && state == ElementState::Released {
1024                    self.remove_active_pointer(id);
1025                }
1026
1027                let event = pointer_event;
1028
1029                let event = match state {
1030                    ElementState::Pressed => UiEvent::PointerDown(event),
1031                    ElementState::Released => UiEvent::PointerUp(event),
1032                };
1033
1034                self.doc.handle_ui_event(event);
1035                self.request_redraw();
1036            }
1037            WindowEvent::MouseWheel { delta, .. } => {
1038                let blitz_delta = match delta {
1039                    winit::event::MouseScrollDelta::LineDelta(x, y) => BlitzWheelDelta::Lines(x as f64, y as f64),
1040                    winit::event::MouseScrollDelta::PixelDelta(pos) => BlitzWheelDelta::Pixels(pos.x, pos.y),
1041                    _ => return paint_committed,
1042                };
1043
1044                let event = BlitzWheelEvent {
1045                    delta: blitz_delta,
1046                    coords: self.pointer_coords(self.pointer_pos),
1047                    buttons: self.buttons,
1048                    mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
1049                    element: Default::default()
1050                };
1051
1052                self.doc.handle_ui_event(UiEvent::Wheel(event));
1053                // Every other input arm asks for a frame; this one did not.
1054                //
1055                // A wheel event changes `scroll_offset` on the document and
1056                // nothing told the loop about it, so `about_to_wait` found no
1057                // poll request and no animation deadline, set
1058                // `ControlFlow::Wait`, and the window slept with the pre-scroll
1059                // frame still on screen. The content is laid out correctly the
1060                // whole time; it is simply never painted.
1061                //
1062                // It reads as "the pane went blank", because a scroll that ends
1063                // on fresh content leaves the last painted frame showing
1064                // whatever was there before, and it comes back the moment any
1065                // other event arrives, since those arms do request a redraw.
1066                // Measured on a wedged window: layout correct and on-screen
1067                // (the "Appearance" heading at viewport y=159), 0% CPU, every
1068                // thread parked in `nextEventMatchingMask`, and a single 1px
1069                // synthetic scroll restored it.
1070                self.request_redraw();
1071            }
1072            WindowEvent::Focused(_) => {}
1073            WindowEvent::TouchpadPressure { .. } => {}
1074            WindowEvent::PinchGesture { .. } => {},
1075            WindowEvent::PanGesture { .. } => {},
1076            WindowEvent::DoubleTapGesture { .. } => {},
1077            WindowEvent::RotationGesture { .. } => {},
1078            WindowEvent::DragEntered { .. } => {},
1079            WindowEvent::DragDropped { .. } => {},
1080            WindowEvent::DragLeft { .. } => {},
1081            _ => {},
1082        }
1083        paint_committed
1084    }
1085}
1086
1087struct FrameStats {
1088    enabled: bool,
1089    output_path: Option<PathBuf>,
1090    refresh_millihertz: Option<u32>,
1091    last_frame_started: Option<Instant>,
1092    sample_started: Instant,
1093    frames: u32,
1094    active_intervals: u32,
1095    missed_refreshes: u32,
1096    interval_total: Duration,
1097    interval_max: Duration,
1098    resolve_total: Duration,
1099    paint_total: Duration,
1100    renderer_total: Duration,
1101    /// Worst scene in the sample window, not the last one. A per-second line
1102    /// that averaged layer counts would hide the one dense frame that decides
1103    /// what the rasteriser has to composite.
1104    layers: blitz_paint::SceneLayerCounts,
1105}
1106
1107impl FrameStats {
1108    fn emit(output_path: Option<&PathBuf>, message: &str) {
1109        eprintln!("{message}");
1110        #[cfg(not(target_arch = "wasm32"))]
1111        if let Some(path) = output_path
1112            && let Ok(mut output) = std::fs::OpenOptions::new()
1113                .create(true)
1114                .append(true)
1115                .open(path)
1116        {
1117            let _ = std::io::Write::write_all(&mut output, message.as_bytes());
1118            let _ = std::io::Write::write_all(&mut output, b"\n");
1119        }
1120    }
1121
1122    fn new(window: &dyn Window) -> Self {
1123        #[cfg(not(target_arch = "wasm32"))]
1124        let enabled = std::env::var_os("BLITZ_FRAME_STATS").is_some();
1125        #[cfg(target_arch = "wasm32")]
1126        let enabled = false;
1127        #[cfg(not(target_arch = "wasm32"))]
1128        let output_path = std::env::var_os("BLITZ_FRAME_STATS_FILE").map(PathBuf::from);
1129        #[cfg(target_arch = "wasm32")]
1130        let output_path = None;
1131
1132        let refresh_millihertz = window
1133            .current_monitor()
1134            .and_then(|monitor| monitor.current_video_mode())
1135            .and_then(|mode| mode.refresh_rate_millihertz())
1136            .map(std::num::NonZeroU32::get);
1137
1138        // Publish the refresh rate even when the log line is off. The shared frame
1139        // log needs it to tell a late frame from an on-time one, and that readout
1140        // is not gated on BLITZ_FRAME_STATS.
1141        crate::frame_stats::set_display_refresh_millihertz(refresh_millihertz);
1142
1143        if enabled {
1144            let message = match refresh_millihertz {
1145                Some(rate) => format!(
1146                    "[blitz-frame] display_refresh_hz={:.3}",
1147                    f64::from(rate) / 1000.0
1148                ),
1149                None => "[blitz-frame] display_refresh_hz=unknown".to_owned(),
1150            };
1151            Self::emit(output_path.as_ref(), &message);
1152        }
1153
1154        Self {
1155            enabled,
1156            output_path,
1157            refresh_millihertz,
1158            last_frame_started: None,
1159            sample_started: Instant::now(),
1160            frames: 0,
1161            active_intervals: 0,
1162            missed_refreshes: 0,
1163            interval_total: Duration::ZERO,
1164            interval_max: Duration::ZERO,
1165            resolve_total: Duration::ZERO,
1166            paint_total: Duration::ZERO,
1167            renderer_total: Duration::ZERO,
1168            layers: blitz_paint::SceneLayerCounts::default(),
1169        }
1170    }
1171
1172    fn record(
1173        &mut self,
1174        frame_started: Instant,
1175        resolve: Duration,
1176        paint: Duration,
1177        renderer: Duration,
1178    ) {
1179        // Publish every frame to the process-global log before the enabled check.
1180        // Out-of-band readers (the MCP diagnostics endpoint) need real numbers from
1181        // a normally launched app; gating this on BLITZ_FRAME_STATS would leave them
1182        // with nothing to report, which is what previously drove that endpoint to
1183        // time its own snapshot collection and present it as frame cost.
1184        crate::frame_stats::record_frame(frame_started, resolve, paint, renderer);
1185
1186        if !self.enabled {
1187            return;
1188        }
1189
1190        if let Some(previous) = self.last_frame_started.replace(frame_started) {
1191            let interval = frame_started.duration_since(previous);
1192            // Ignore idle gaps. These statistics describe active interaction bursts,
1193            // not the intentional zero-FPS idle state.
1194            if interval <= Duration::from_millis(100) {
1195                self.active_intervals += 1;
1196                self.interval_total += interval;
1197                self.interval_max = self.interval_max.max(interval);
1198
1199                if let Some(rate) = self.refresh_millihertz {
1200                    let target = Duration::from_secs_f64(1000.0 / f64::from(rate));
1201                    if interval > target.mul_f64(1.5) {
1202                        self.missed_refreshes += 1;
1203                    }
1204                }
1205            }
1206        }
1207
1208        // The scene for this frame has already been painted by the time a frame
1209        // is recorded, so these counts describe it.
1210        let layers = blitz_paint::latest_scene_layers();
1211        self.layers.wanted = self.layers.wanted.max(layers.wanted);
1212        self.layers.used = self.layers.used.max(layers.used);
1213        self.layers.max_depth = self.layers.max_depth.max(layers.max_depth);
1214        for (worst, seen) in self.layers.by_site.iter_mut().zip(layers.by_site) {
1215            *worst = (*worst).max(seen);
1216        }
1217
1218        self.frames += 1;
1219        self.resolve_total += resolve;
1220        self.paint_total += paint;
1221        self.renderer_total += renderer;
1222
1223        let sample_elapsed = self.sample_started.elapsed();
1224        if sample_elapsed < Duration::from_secs(1) || self.frames < 2 {
1225            return;
1226        }
1227
1228        let active_fps = if self.interval_total.is_zero() {
1229            0.0
1230        } else {
1231            f64::from(self.active_intervals) / self.interval_total.as_secs_f64()
1232        };
1233        let frames = f64::from(self.frames);
1234        // Per-site counts include the sites that bypass the layer manager, so
1235        // this sum is larger than `layers_used_max` rather than a split of it.
1236        let by_site = blitz_paint::LayerSite::ALL
1237            .iter()
1238            .zip(self.layers.by_site)
1239            .map(|(site, count)| format!("{}:{count}", site.name()))
1240            .collect::<Vec<_>>()
1241            .join(",");
1242        let message = format!(
1243            "[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}",
1244            self.frames,
1245            self.active_intervals,
1246            self.missed_refreshes,
1247            self.interval_max.as_secs_f64() * 1000.0,
1248            self.resolve_total.as_secs_f64() * 1000.0 / frames,
1249            self.paint_total.as_secs_f64() * 1000.0 / frames,
1250            self.renderer_total.as_secs_f64() * 1000.0 / frames,
1251            self.layers.wanted,
1252            self.layers.used,
1253            self.layers.max_depth,
1254        );
1255        Self::emit(self.output_path.as_ref(), &message);
1256
1257        self.sample_started = frame_started;
1258        self.frames = 0;
1259        self.active_intervals = 0;
1260        self.missed_refreshes = 0;
1261        self.interval_total = Duration::ZERO;
1262        self.interval_max = Duration::ZERO;
1263        self.resolve_total = Duration::ZERO;
1264        self.paint_total = Duration::ZERO;
1265        self.renderer_total = Duration::ZERO;
1266        self.layers = blitz_paint::SceneLayerCounts::default();
1267    }
1268}
1269
1270#[cfg(test)]
1271mod animation_pacing_tests {
1272    use super::*;
1273
1274    #[test]
1275    fn css_animation_frames_are_limited_to_fifteen_fps() {
1276        assert_eq!(
1277            animation_frame_interval_for_refresh(
1278                blitz_dom::AnimationPacing::SlowCss,
1279                Some(120_000),
1280            ),
1281            Duration::from_secs_f64(1.0 / 15.0),
1282        );
1283        assert_eq!(
1284            animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::SlowCss, Some(60_000)),
1285            Duration::from_secs_f64(1.0 / 15.0),
1286        );
1287        assert_eq!(
1288            animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::SlowCss, None),
1289            Duration::from_millis(67),
1290        );
1291    }
1292
1293    #[test]
1294    fn interactive_animation_frames_remain_at_thirty_fps() {
1295        assert_eq!(
1296            animation_frame_interval_for_refresh(
1297                blitz_dom::AnimationPacing::Interactive,
1298                Some(120_000),
1299            ),
1300            Duration::from_secs_f64(1.0 / 30.0),
1301        );
1302        assert_eq!(
1303            animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::Interactive, None),
1304            Duration::from_millis(33),
1305        );
1306    }
1307
1308    #[test]
1309    fn caret_only_frames_run_at_blink_boundaries() {
1310        assert_eq!(
1311            animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::Caret, Some(120_000)),
1312            Duration::from_millis(500),
1313        );
1314    }
1315}