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