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