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#[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 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 poll_requested: Arc<AtomicBool>,
91
92 pub proxy: BlitzShellProxy,
93 pub window: Arc<dyn Window>,
94
95 pub theme_override: Option<Theme>,
98 pub keyboard_modifiers: Modifiers,
99 pub buttons: MouseEventButtons,
100 pub pointer_pos: PhysicalPosition<f64>,
101 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 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 #[cfg(target_arch = "wasm32")]
135 resize_timer_scheduled: bool,
136
137 #[cfg(feature = "accessibility")]
138 pub accessibility: AccessibilityState,
140
141 #[cfg(target_os = "ios")]
146 pub ios_request_redraw: std::cell::Cell<bool>,
147
148 pub animation_frame_due: std::cell::Cell<Option<Instant>>,
157}
158
159const CSS_ANIMATION_TARGET_FPS: u32 = 15;
172
173const INTERACTIVE_ANIMATION_TARGET_FPS: u32 = 30;
176const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
177
178const CSS_ANIMATION_FALLBACK_INTERVAL: Duration = Duration::from_millis(67);
180const INTERACTIVE_ANIMATION_FALLBACK_INTERVAL: Duration = Duration::from_millis(33);
181
182fn 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 return Duration::from_secs_f64(1.0 / refresh_hz);
217 }
218 let every_nth = (refresh_hz / f64::from(target_fps)).round().max(1.0);
219 Duration::from_secs_f64(every_nth / refresh_hz)
220}
221
222impl<Rend: WindowRenderer> Drop for View<Rend> {
223 fn drop(&mut self) {
224 self.renderer.suspend();
230 }
231}
232
233impl<Rend: WindowRenderer> View<Rend> {
234 pub fn init(
235 mut config: WindowConfig<Rend>,
236 event_loop: &dyn ActiveEventLoop,
237 proxy: &BlitzShellProxy,
238 ) -> Self {
239 let is_visible = config.attributes.visible;
242 let requested_surface_size = config.attributes.surface_size;
246 let attrs = config.attributes.with_visible(false);
247
248 let winit_window: Arc<dyn Window> = Arc::from(event_loop.create_window(attrs).unwrap());
249 if let Some(on_created) = config.on_created.take() {
250 on_created(Arc::clone(&winit_window));
251 }
252 #[cfg(feature = "accessibility")]
253 let accessibility = AccessibilityState::new(&*winit_window, proxy.clone());
254
255 if is_visible {
256 winit_window.set_visible(true);
257 }
258
259 let scale = winit_window.scale_factor() as f32;
262 let mut size = winit_window.surface_size();
263 if (size.width == 0 || size.height == 0)
264 && let Some(requested) = requested_surface_size
265 {
266 size = requested.to_physical(scale as f64);
267 }
268 #[cfg(target_arch = "wasm32")]
273 if size.width == 0 || size.height == 0 {
274 use winit::platform::web::WindowExtWeb;
275 if let Some(canvas) = winit_window.canvas() {
276 let css_w = canvas.offset_width().max(0) as u32;
277 let css_h = canvas.offset_height().max(0) as u32;
278 if css_w > 0 && css_h > 0 {
279 size = winit::dpi::LogicalSize::new(css_w, css_h).to_physical(scale as f64);
280 }
281 }
282 }
283 let safe_area_insets = get_safe_area_insets(&*winit_window);
284 let theme = winit_window.theme().unwrap_or(Theme::Light);
285 let color_scheme = theme_to_color_scheme(theme);
286 let viewport = Viewport::new(size.width, size.height, scale, color_scheme);
287
288 let shell_provider = BlitzShellProvider::new(winit_window.clone(), proxy.clone());
290
291 let mut doc = config.doc;
292 let mut inner = doc.inner_mut();
293 inner.set_viewport(viewport);
294 inner.set_shell_provider(Arc::new(shell_provider));
295
296 let title = inner.find_title_node().map(|node| node.text_content());
300 if let Some(title) = title {
301 winit_window.set_title(&title);
302 }
303
304 drop(inner);
305
306 Self {
307 renderer: config.renderer,
308 waker: None,
309 poll_requested: Arc::new(AtomicBool::new(false)),
310 animation_timer: None,
311 keyboard_modifiers: Default::default(),
312 proxy: proxy.clone(),
313 window: winit_window.clone(),
314 doc,
315 theme_override: None,
316 buttons: MouseEventButtons::None,
317 active_events: Arc::new(AtomicRefCell::new(Vec::new())),
318 safe_area_insets,
319 #[cfg(target_arch = "wasm32")]
320 pending_resize: None,
321 #[cfg(target_arch = "wasm32")]
322 last_resize_at: None,
323 #[cfg(target_arch = "wasm32")]
324 resize_timer_scheduled: false,
325 pointer_pos: Default::default(),
326 is_visible: winit_window.is_visible().unwrap_or(true),
327 redraw_pending: std::cell::Cell::new(false),
328 frame_stats: FrameStats::new(&*winit_window),
329 #[cfg(feature = "accessibility")]
330 accessibility,
331
332 #[cfg(target_os = "ios")]
333 ios_request_redraw: std::cell::Cell::new(false),
334
335 animation_frame_due: std::cell::Cell::new(None),
336 }
337 }
338
339 pub fn replace_document(&mut self, new_doc: Box<dyn Document>, retain_scroll_position: bool) {
340 let inner = self.doc.inner();
341 let scroll = inner.viewport_scroll();
342 let viewport = inner.viewport().clone();
343 let shell_provider = inner.shell_provider.clone();
344 drop(inner);
345
346 self.doc = new_doc;
347
348 let mut inner = self.doc.inner_mut();
349 inner.set_viewport(viewport);
350 inner.set_shell_provider(shell_provider);
351 drop(inner);
352
353 self.poll();
354 self.request_redraw();
355
356 if retain_scroll_position {
357 self.doc.inner_mut().set_viewport_scroll(scroll);
358 }
359 }
360
361 pub fn theme_override(&self) -> Option<Theme> {
362 self.theme_override
363 }
364
365 pub fn current_theme(&self) -> Theme {
366 color_scheme_to_theme(self.doc.inner().viewport().color_scheme)
367 }
368
369 pub fn set_theme_override(&mut self, theme: Option<Theme>) {
370 self.theme_override = theme;
371 let theme = theme.or(self.window.theme()).unwrap_or(Theme::Light);
372 self.with_viewport(|v| v.color_scheme = theme_to_color_scheme(theme));
373 }
374
375 pub fn downcast_doc_mut<T: 'static>(&mut self) -> &mut T {
376 (&mut *self.doc as &mut dyn Any)
377 .downcast_mut::<T>()
378 .unwrap()
379 }
380
381 pub fn try_downcast_doc_mut<T: 'static>(&mut self) -> Option<&mut T> {
382 (&mut *self.doc as &mut dyn Any).downcast_mut::<T>()
383 }
384
385 pub fn current_animation_time(&mut self) -> f64 {
386 match &self.animation_timer {
387 Some(start) => Instant::now().duration_since(*start).as_secs_f64(),
388 None => {
389 self.animation_timer = Some(Instant::now());
390 0.0
391 }
392 }
393 }
394}
395
396impl<Rend: WindowRenderer> View<Rend> {
397 pub fn resume(&mut self) {
402 let window_id = self.window_id();
403 let animation_time = self.current_animation_time();
404
405 let (width, height) = {
406 let mut inner = self.doc.inner_mut();
407 inner.resolve(animation_time);
408 inner.viewport().window_size
409 };
410
411 let proxy = self.proxy.clone();
412 self.renderer
413 .resume(Arc::new(self.window.clone()), width, height, move || {
414 proxy.send_event(BlitzShellEvent::ResumeReady { window_id });
415 });
416 }
417
418 pub fn complete_resume(&mut self) -> bool {
422 if !self.renderer.complete_resume() {
423 return false;
424 }
425
426 let animation_time = self.current_animation_time();
431 let mut inner = self.doc.inner_mut();
432 inner.resolve(animation_time);
433 let (width, height) = inner.viewport().window_size;
434 let scale = inner.viewport().scale_f64();
435 let insets = self.safe_area_insets;
441
442 #[cfg(feature = "custom-widget")]
443 inner.can_create_surfaces(&mut self.renderer as _);
444
445 self.renderer.set_size(width, height);
446
447 self.renderer.render(|scene| {
448 paint_scene(
449 scene,
450 &mut inner,
451 scale,
452 width,
453 height,
454 insets.left,
455 insets.top,
456 )
457 });
458 drop(inner);
459 self.redraw_pending.set(false);
460
461 self.waker = Some(create_waker(&self.proxy, Arc::clone(&self.poll_requested)));
462 self.poll();
466 true
467 }
468
469 pub fn suspend(&mut self) {
470 self.waker = None;
471 self.redraw_pending.set(false);
472 self.renderer.suspend();
473
474 #[cfg(feature = "custom-widget")]
475 self.doc.inner_mut().destroy_surfaces();
476 }
477
478 pub fn request_poll(&self) {
483 self.poll_requested.store(true, Ordering::Release);
484 }
485
486 pub fn poll_if_requested(&mut self) -> bool {
488 if self.poll_requested.swap(false, Ordering::AcqRel) {
489 self.poll()
490 } else {
491 false
492 }
493 }
494
495 pub fn poll(&mut self) -> bool {
496 if let Some(waker) = &self.waker {
497 let cx = std::task::Context::from_waker(waker);
498 if self.doc.poll(Some(cx)) {
499 #[cfg(feature = "accessibility")]
500 {
501 let inner = self.doc.inner();
502 self.accessibility.update_tree(&inner);
507 }
508
509 self.request_redraw();
510 return true;
511 }
512 }
513
514 false
515 }
516
517 pub fn request_redraw(&self) {
518 if self.renderer.is_active() && !self.redraw_pending.replace(true) {
519 self.window.request_redraw();
520 #[cfg(target_os = "ios")]
521 self.ios_request_redraw.set(true);
522 }
523 }
524
525 pub fn redraw(&mut self) -> bool {
527 let profiling = blitz_traits::profiling::deep_profiling_permitted();
544 let frame_started = Instant::now();
545 self.redraw_pending.set(false);
546 #[cfg(target_os = "ios")]
547 self.ios_request_redraw.set(false);
548 let animation_time = self.current_animation_time();
549 let is_visible = self.is_visible;
550
551 let resolve_started = profiling.then(Instant::now);
552 let mut inner = self.doc.inner_mut();
553 inner.resolve(animation_time);
554 let resolve_time = resolve_started.map_or(Duration::ZERO, |started| started.elapsed());
555
556 #[cfg(feature = "custom-widget")]
558 for id in inner.take_pending_resource_deallocations() {
559 self.renderer.unregister_resource(id);
560 }
561
562 let (width, height) = inner.viewport().window_size;
563 let scale = inner.viewport().scale_f64();
564 let animation_pacing = inner.animation_pacing();
565 let is_animating = animation_pacing != blitz_dom::AnimationPacing::Idle;
566 let is_blocked = inner.has_pending_critical_resources();
567 let insets = self.safe_area_insets;
573
574 let mut paint_time = Duration::ZERO;
575 let render_started = profiling.then(Instant::now);
576 let committed = !is_blocked && is_visible;
577 if committed {
578 self.renderer.render(|scene| {
579 let paint_started = profiling.then(Instant::now);
580 blitz_paint::paint_scene_at_time(
581 scene,
582 &mut inner,
583 scale,
584 width,
585 height,
586 insets.left,
587 insets.top,
588 animation_time,
589 );
590 paint_time = paint_started.map_or(Duration::ZERO, |started| started.elapsed());
591 });
592 }
593 let renderer_time = render_started
594 .map_or(Duration::ZERO, |started| started.elapsed())
595 .saturating_sub(paint_time);
596
597 drop(inner);
598
599 if profiling {
600 self.frame_stats
601 .record(frame_started, resolve_time, paint_time, renderer_time);
602 }
603
604 if !is_blocked && is_visible && is_animating {
605 self.animation_frame_due.set(Some(
615 frame_started + animation_frame_interval(animation_pacing),
616 ));
617 } else {
618 self.animation_frame_due.set(None);
619 }
620 committed
621 }
622
623 pub fn poll_animation_frame(&self, now: Instant) -> Option<Instant> {
629 let due = self.animation_frame_due.get()?;
630 if now >= due {
631 self.animation_frame_due.set(None);
632 self.request_redraw();
633 None
634 } else {
635 Some(due)
636 }
637 }
638
639 pub fn pointer_coords(&self, position: PhysicalPosition<f64>) -> PointerCoords {
640 let inner = self.doc.inner();
641 let scale = inner.viewport().scale_f64();
642 let LogicalPosition::<f32> {
643 x: screen_x,
644 y: screen_y,
645 } = position.to_logical(scale);
646 let viewport_scroll_offset = inner.viewport_scroll();
647 let client_x = screen_x - (self.safe_area_insets.left as f64 / scale) as f32;
648 let client_y = screen_y - (self.safe_area_insets.top as f64 / scale) as f32;
649 let page_x = client_x + viewport_scroll_offset.x as f32;
650 let page_y = client_y + viewport_scroll_offset.y as f32;
651
652 PointerCoords {
653 screen_x,
654 screen_y,
655 client_x,
656 client_y,
657 page_x,
658 page_y,
659 }
660 }
661
662 pub fn window_id(&self) -> WindowId {
663 self.window.id()
664 }
665
666 fn set_active_pointer(&self, event: &BlitzPointerEvent) {
670 let mut stored = event.clone();
671 stored.active_pointers = Default::default();
672
673 let mut active = self.active_events.borrow_mut();
674 if let Some(existing) = active.iter_mut().find(|e| e.id == stored.id) {
675 *existing = stored;
676 } else {
677 active.push(stored);
678 }
679 }
680
681 fn update_active_pointer(&self, event: &BlitzPointerEvent) {
684 let mut active = self.active_events.borrow_mut();
685 if let Some(existing) = active.iter_mut().find(|e| e.id == event.id) {
686 let mut stored = event.clone();
687 stored.active_pointers = Default::default();
688 *existing = stored;
689 }
690 }
691
692 fn remove_active_pointer(&self, id: BlitzPointerId) -> bool {
694 let mut active = self.active_events.borrow_mut();
695 let len_before = active.len();
696 active.retain(|e| e.id != id);
697 active.len() != len_before
698 }
699
700 #[inline]
701 pub fn with_viewport(&mut self, cb: impl FnOnce(&mut Viewport)) {
702 let mut inner = self.doc.inner_mut();
703 let mut viewport = inner.viewport_mut();
704 cb(&mut viewport);
705 let (width, height) = viewport.window_size;
706 drop(viewport);
707 drop(inner);
708 if width > 0 && height > 0 {
709 let insets = self.safe_area_insets;
710 self.renderer.set_size(
711 width + insets.left + insets.right,
712 height + insets.top + insets.bottom,
713 );
714 self.request_redraw();
715 }
716 }
717
718 #[cfg(feature = "accessibility")]
719 pub fn build_accessibility_tree(&mut self) {
720 let inner = self.doc.inner();
721 self.accessibility.update_tree(&inner);
722 }
723
724 #[cfg(target_arch = "wasm32")]
725 const RESIZE_DEBOUNCE_MS: u32 = 100;
726
727 #[cfg(target_arch = "wasm32")]
728 fn schedule_resize_settle_check(&mut self, delay_ms: u32) {
729 use wasm_bindgen::JsCast;
730 use wasm_bindgen::closure::Closure;
731
732 let proxy = self.proxy.clone();
733 let window_id = self.window_id();
734 let cb = Closure::once_into_js(move || {
735 proxy.send_event(BlitzShellEvent::ResizeSettleCheck { window_id });
736 });
737 if let Some(win) = web_sys::window() {
738 let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(
739 cb.unchecked_ref(),
740 delay_ms as i32,
741 );
742 self.resize_timer_scheduled = true;
743 }
744 }
745
746 #[cfg(target_arch = "wasm32")]
750 pub fn apply_pending_resize_if_settled(&mut self) {
751 self.resize_timer_scheduled = false;
752 let Some(last) = self.last_resize_at else {
753 return;
754 };
755 let debounce = std::time::Duration::from_millis(Self::RESIZE_DEBOUNCE_MS as u64);
756 let elapsed = web_time::Instant::now().saturating_duration_since(last);
757 if elapsed < debounce {
758 let remaining_ms = (debounce - elapsed).as_millis() as u32;
760 self.schedule_resize_settle_check(remaining_ms);
761 return;
762 }
763 let Some(size) = self.pending_resize.take() else {
764 return;
765 };
766 self.last_resize_at = None;
767
768 let insets = self.safe_area_insets;
769 let width = size.width.saturating_sub(insets.left + insets.right);
770 let height = size.height.saturating_sub(insets.top + insets.bottom);
771 self.with_viewport(|v| v.window_size = (width, height));
772 self.request_redraw();
773 }
774
775 #[cfg(target_os = "macos")]
776 pub fn handle_apple_standard_keybinding(&mut self, command: &str) {
777 use blitz_traits::SmolStr;
778 let event = UiEvent::AppleStandardKeybinding(SmolStr::new(command));
779 self.doc.handle_ui_event(event);
780 }
781
782 pub fn handle_winit_event(&mut self, event: WindowEvent) -> bool {
784 #[cfg(feature = "accessibility")]
786 self.accessibility
787 .process_window_event(&*self.window, &event);
788
789 let mut paint_committed = false;
790 match event {
791 WindowEvent::Destroyed => {}
792 WindowEvent::ActivationTokenDone { .. } => {},
793 WindowEvent::CloseRequested => {
794 }
796 WindowEvent::RedrawRequested => {
797 paint_committed = self.redraw();
798 }
799 WindowEvent::Moved(_) => {}
800 WindowEvent::Occluded(is_occluded) => {
801 self.is_visible = !is_occluded;
802 if self.is_visible {
803 self.request_redraw();
804 }
805 },
806 WindowEvent::SurfaceResized(physical_size) => {
807 self.safe_area_insets = get_safe_area_insets(&*self.window);
808 #[cfg(target_arch = "wasm32")]
812 {
813 self.pending_resize = Some(physical_size);
814 self.last_resize_at = Some(web_time::Instant::now());
815 if !self.resize_timer_scheduled {
816 self.schedule_resize_settle_check(Self::RESIZE_DEBOUNCE_MS);
817 }
818 }
819 #[cfg(not(target_arch = "wasm32"))]
820 {
821 let insets = self.safe_area_insets;
822 let width = physical_size.width - insets.left - insets.right;
823 let height = physical_size.height - insets.top - insets.bottom;
824 self.with_viewport(|v| v.window_size = (width, height));
825 self.request_redraw();
826 }
827 }
828 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
829 self.with_viewport(|v| v.set_hidpi_scale(scale_factor as f32));
830 self.request_redraw();
831 }
832 WindowEvent::ThemeChanged(theme) => {
833 let color_scheme = theme_to_color_scheme(self.theme_override.unwrap_or(theme));
834 let mut inner = self.doc.inner_mut();
835 inner.viewport_mut().color_scheme = color_scheme;
836 }
837 WindowEvent::Ime(ime_event) => {
838 self.doc.handle_ui_event(UiEvent::Ime(winit_ime_to_blitz(ime_event)));
839 self.request_redraw();
840 },
841 WindowEvent::ModifiersChanged(new_state) => {
842 self.keyboard_modifiers = new_state;
844 }
845 WindowEvent::KeyboardInput { event, .. } => {
846 if let PhysicalKey::Code(key_code) = event.physical_key && event.state.is_pressed() {
847 let ctrl = self.keyboard_modifiers.state().control_key();
848 let meta = self.keyboard_modifiers.state().meta_key();
849 let alt = self.keyboard_modifiers.state().alt_key();
850
851 if ctrl | meta {
853 match key_code {
854 KeyCode::Equal => {
855 self.doc.inner_mut().viewport_mut().zoom_by(0.1);
856 },
857 KeyCode::Minus => {
858 self.doc.inner_mut().viewport_mut().zoom_by(-0.1);
859 },
860 KeyCode::Digit0 => {
861 self.doc.inner_mut().viewport_mut().set_zoom(1.0);
862 }
863 _ => {}
864 };
865 }
866
867 if alt {
869 match key_code {
870 KeyCode::KeyD => {
871 let mut inner = self.doc.inner_mut();
872 inner.devtools_mut().toggle_show_layout();
873 drop(inner);
874 self.request_redraw();
875 }
876 KeyCode::KeyH => {
877 let mut inner = self.doc.inner_mut();
878 inner.devtools_mut().toggle_highlight_hover();
879 drop(inner);
880 self.request_redraw();
881 }
882 KeyCode::KeyT => self.doc.inner().print_taffy_tree(),
883 _ => {}
884 };
885 }
886
887 }
888
889 let key_event_data = winit_key_event_to_blitz(&event, self.keyboard_modifiers.state());
891 let event = if event.state.is_pressed() {
892 UiEvent::KeyDown(key_event_data)
893 } else {
894 UiEvent::KeyUp(key_event_data)
895 };
896
897 self.doc.handle_ui_event(event);
898 }
899 WindowEvent::PointerEntered { .. } => {}
900 WindowEvent::PointerLeft { position, primary, kind, .. } => {
901 let id = pointer_kind_to_blitz(&kind);
902
903 if id != BlitzPointerId::Mouse && self.remove_active_pointer(id) {
912 let position = position.unwrap_or(self.pointer_pos);
913 self.pointer_pos = position;
914
915 self.buttons ^= MouseEventButton::Main.into();
917
918 let event = BlitzPointerEvent {
919 id,
920 is_primary: primary,
921 coords: self.pointer_coords(position),
922 button: MouseEventButton::Main,
923 buttons: self.buttons,
924 mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
925 details: PointerDetails::default(),
926 element: Default::default(),
927 active_pointers: Arc::clone(&self.active_events),
928 };
929
930 self.doc.handle_ui_event(UiEvent::PointerCancel(event));
931 self.request_redraw();
932 }
933 }
934 WindowEvent::PointerMoved { position, source, primary, .. } => {
935 self.pointer_pos = position;
936 let id = pointer_source_to_blitz(&source);
937 let event = BlitzPointerEvent {
938 id,
939 is_primary: primary,
940 coords: self.pointer_coords(position),
941 button: Default::default(),
942 buttons: self.buttons,
943 mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
944 details: pointer_source_to_blitz_details(&source),
945 element: Default::default(),
946 active_pointers: Arc::clone(&self.active_events),
947 };
948 if id != BlitzPointerId::Mouse {
950 self.update_active_pointer(&event);
951 }
952 self.doc.handle_ui_event(UiEvent::PointerMove(event));
953 self.request_redraw();
959 }
960 WindowEvent::PointerButton { button, state, primary, position, .. } => {
961 let id = button_source_to_blitz(&button);
962 let coords = self.pointer_coords(position);
963 self.pointer_pos = position;
964 let button = match &button {
965 ButtonSource::Mouse(mouse_button) => match mouse_button {
966 MouseButton::Left => MouseEventButton::Main,
967 MouseButton::Right => MouseEventButton::Secondary,
968 MouseButton::Middle => MouseEventButton::Auxiliary,
969 _ => MouseEventButton::Auxiliary,
971 }
972 _ => MouseEventButton::Main,
973 };
974
975 match state {
976 ElementState::Pressed => self.buttons |= button.into(),
977 ElementState::Released => self.buttons ^= button.into(),
978 }
979
980 let pointer_event = BlitzPointerEvent {
981 id,
982 is_primary: primary,
983 coords,
984 button,
985 buttons: self.buttons,
986 mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
987
988 details: PointerDetails::default(),
990 element: Default::default(),
991 active_pointers: Arc::clone(&self.active_events),
992 };
993
994 if id != BlitzPointerId::Mouse && state == ElementState::Pressed {
1000 self.set_active_pointer(&pointer_event);
1001 }
1002
1003 if id != BlitzPointerId::Mouse {
1007 let event = BlitzPointerEvent {
1008 id,
1009 is_primary: primary,
1010 coords,
1011 button: Default::default(),
1012 buttons: self.buttons,
1013 mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
1014 details: PointerDetails::default(),
1015 element: Default::default(),
1016 active_pointers: Arc::clone(&self.active_events),
1017 };
1018 self.doc.handle_ui_event(UiEvent::PointerMove(event));
1019 }
1020
1021 if id != BlitzPointerId::Mouse && state == ElementState::Released {
1022 self.remove_active_pointer(id);
1023 }
1024
1025 let event = pointer_event;
1026
1027 let event = match state {
1028 ElementState::Pressed => UiEvent::PointerDown(event),
1029 ElementState::Released => UiEvent::PointerUp(event),
1030 };
1031
1032 self.doc.handle_ui_event(event);
1033 self.request_redraw();
1034 }
1035 WindowEvent::MouseWheel { delta, .. } => {
1036 let blitz_delta = match delta {
1037 winit::event::MouseScrollDelta::LineDelta(x, y) => BlitzWheelDelta::Lines(x as f64, y as f64),
1038 winit::event::MouseScrollDelta::PixelDelta(pos) => BlitzWheelDelta::Pixels(pos.x, pos.y),
1039 };
1040
1041 let event = BlitzWheelEvent {
1042 delta: blitz_delta,
1043 coords: self.pointer_coords(self.pointer_pos),
1044 buttons: self.buttons,
1045 mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
1046 element: Default::default()
1047 };
1048
1049 self.doc.handle_ui_event(UiEvent::Wheel(event));
1050 self.request_redraw();
1068 }
1069 WindowEvent::Focused(_) => {}
1070 WindowEvent::TouchpadPressure { .. } => {}
1071 WindowEvent::PinchGesture { .. } => {},
1072 WindowEvent::PanGesture { .. } => {},
1073 WindowEvent::DoubleTapGesture { .. } => {},
1074 WindowEvent::RotationGesture { .. } => {},
1075 WindowEvent::DragEntered { .. } => {},
1076 WindowEvent::DragMoved { .. } => {},
1077 WindowEvent::DragDropped { .. } => {},
1078 WindowEvent::DragLeft { .. } => {},
1079 }
1080 paint_committed
1081 }
1082}
1083
1084struct FrameStats {
1085 enabled: bool,
1086 output_path: Option<PathBuf>,
1087 refresh_millihertz: Option<u32>,
1088 last_frame_started: Option<Instant>,
1089 sample_started: Instant,
1090 frames: u32,
1091 active_intervals: u32,
1092 missed_refreshes: u32,
1093 interval_total: Duration,
1094 interval_max: Duration,
1095 resolve_total: Duration,
1096 paint_total: Duration,
1097 renderer_total: Duration,
1098 layers: blitz_paint::SceneLayerCounts,
1102}
1103
1104impl FrameStats {
1105 fn emit(output_path: Option<&PathBuf>, message: &str) {
1106 eprintln!("{message}");
1107 #[cfg(not(target_arch = "wasm32"))]
1108 if let Some(path) = output_path
1109 && let Ok(mut output) = std::fs::OpenOptions::new()
1110 .create(true)
1111 .append(true)
1112 .open(path)
1113 {
1114 let _ = std::io::Write::write_all(&mut output, message.as_bytes());
1115 let _ = std::io::Write::write_all(&mut output, b"\n");
1116 }
1117 }
1118
1119 fn new(window: &dyn Window) -> Self {
1120 #[cfg(not(target_arch = "wasm32"))]
1121 let enabled = std::env::var_os("BLITZ_FRAME_STATS").is_some();
1122 #[cfg(target_arch = "wasm32")]
1123 let enabled = false;
1124 #[cfg(not(target_arch = "wasm32"))]
1125 let output_path = std::env::var_os("BLITZ_FRAME_STATS_FILE").map(PathBuf::from);
1126 #[cfg(target_arch = "wasm32")]
1127 let output_path = None;
1128
1129 let refresh_millihertz = window
1130 .current_monitor()
1131 .and_then(|monitor| monitor.current_video_mode())
1132 .and_then(|mode| mode.refresh_rate_millihertz())
1133 .map(std::num::NonZeroU32::get);
1134
1135 crate::frame_stats::set_display_refresh_millihertz(refresh_millihertz);
1139
1140 if enabled {
1141 let message = match refresh_millihertz {
1142 Some(rate) => format!(
1143 "[blitz-frame] display_refresh_hz={:.3}",
1144 f64::from(rate) / 1000.0
1145 ),
1146 None => "[blitz-frame] display_refresh_hz=unknown".to_owned(),
1147 };
1148 Self::emit(output_path.as_ref(), &message);
1149 }
1150
1151 Self {
1152 enabled,
1153 output_path,
1154 refresh_millihertz,
1155 last_frame_started: None,
1156 sample_started: Instant::now(),
1157 frames: 0,
1158 active_intervals: 0,
1159 missed_refreshes: 0,
1160 interval_total: Duration::ZERO,
1161 interval_max: Duration::ZERO,
1162 resolve_total: Duration::ZERO,
1163 paint_total: Duration::ZERO,
1164 renderer_total: Duration::ZERO,
1165 layers: blitz_paint::SceneLayerCounts::default(),
1166 }
1167 }
1168
1169 fn record(
1170 &mut self,
1171 frame_started: Instant,
1172 resolve: Duration,
1173 paint: Duration,
1174 renderer: Duration,
1175 ) {
1176 crate::frame_stats::record_frame(frame_started, resolve, paint, renderer);
1182
1183 if !self.enabled {
1184 return;
1185 }
1186
1187 if let Some(previous) = self.last_frame_started.replace(frame_started) {
1188 let interval = frame_started.duration_since(previous);
1189 if interval <= Duration::from_millis(100) {
1192 self.active_intervals += 1;
1193 self.interval_total += interval;
1194 self.interval_max = self.interval_max.max(interval);
1195
1196 if let Some(rate) = self.refresh_millihertz {
1197 let target = Duration::from_secs_f64(1000.0 / f64::from(rate));
1198 if interval > target.mul_f64(1.5) {
1199 self.missed_refreshes += 1;
1200 }
1201 }
1202 }
1203 }
1204
1205 let layers = blitz_paint::latest_scene_layers();
1208 self.layers.wanted = self.layers.wanted.max(layers.wanted);
1209 self.layers.used = self.layers.used.max(layers.used);
1210 self.layers.max_depth = self.layers.max_depth.max(layers.max_depth);
1211 for (worst, seen) in self.layers.by_site.iter_mut().zip(layers.by_site) {
1212 *worst = (*worst).max(seen);
1213 }
1214
1215 self.frames += 1;
1216 self.resolve_total += resolve;
1217 self.paint_total += paint;
1218 self.renderer_total += renderer;
1219
1220 let sample_elapsed = self.sample_started.elapsed();
1221 if sample_elapsed < Duration::from_secs(1) || self.frames < 2 {
1222 return;
1223 }
1224
1225 let active_fps = if self.interval_total.is_zero() {
1226 0.0
1227 } else {
1228 f64::from(self.active_intervals) / self.interval_total.as_secs_f64()
1229 };
1230 let frames = f64::from(self.frames);
1231 let by_site = blitz_paint::LayerSite::ALL
1234 .iter()
1235 .zip(self.layers.by_site)
1236 .map(|(site, count)| format!("{}:{count}", site.name()))
1237 .collect::<Vec<_>>()
1238 .join(",");
1239 let message = format!(
1240 "[blitz-frame] active_fps={active_fps:.1} frames={} active_intervals={} missed_refreshes={} max_interval_ms={:.2} resolve_avg_ms={:.2} paint_avg_ms={:.2} renderer_avg_ms={:.2} layers_wanted_max={} layers_used_max={} layer_depth_max={} layers_by_site={by_site}",
1241 self.frames,
1242 self.active_intervals,
1243 self.missed_refreshes,
1244 self.interval_max.as_secs_f64() * 1000.0,
1245 self.resolve_total.as_secs_f64() * 1000.0 / frames,
1246 self.paint_total.as_secs_f64() * 1000.0 / frames,
1247 self.renderer_total.as_secs_f64() * 1000.0 / frames,
1248 self.layers.wanted,
1249 self.layers.used,
1250 self.layers.max_depth,
1251 );
1252 Self::emit(self.output_path.as_ref(), &message);
1253
1254 self.sample_started = frame_started;
1255 self.frames = 0;
1256 self.active_intervals = 0;
1257 self.missed_refreshes = 0;
1258 self.interval_total = Duration::ZERO;
1259 self.interval_max = Duration::ZERO;
1260 self.resolve_total = Duration::ZERO;
1261 self.paint_total = Duration::ZERO;
1262 self.renderer_total = Duration::ZERO;
1263 self.layers = blitz_paint::SceneLayerCounts::default();
1264 }
1265}
1266
1267#[cfg(test)]
1268mod animation_pacing_tests {
1269 use super::*;
1270
1271 #[test]
1272 fn css_animation_frames_are_limited_to_fifteen_fps() {
1273 assert_eq!(
1274 animation_frame_interval_for_refresh(
1275 blitz_dom::AnimationPacing::SlowCss,
1276 Some(120_000),
1277 ),
1278 Duration::from_secs_f64(1.0 / 15.0),
1279 );
1280 assert_eq!(
1281 animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::SlowCss, Some(60_000)),
1282 Duration::from_secs_f64(1.0 / 15.0),
1283 );
1284 assert_eq!(
1285 animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::SlowCss, None),
1286 Duration::from_millis(67),
1287 );
1288 }
1289
1290 #[test]
1291 fn interactive_animation_frames_remain_at_thirty_fps() {
1292 assert_eq!(
1293 animation_frame_interval_for_refresh(
1294 blitz_dom::AnimationPacing::Interactive,
1295 Some(120_000),
1296 ),
1297 Duration::from_secs_f64(1.0 / 30.0),
1298 );
1299 assert_eq!(
1300 animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::Interactive, None),
1301 Duration::from_millis(33),
1302 );
1303 }
1304
1305 #[test]
1306 fn caret_only_frames_run_at_blink_boundaries() {
1307 assert_eq!(
1308 animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::Caret, Some(120_000)),
1309 Duration::from_millis(500),
1310 );
1311 }
1312}