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 inner.set_window_focused(winit_window.has_focus());
296
297 let title = inner.find_title_node().map(|node| node.text_content());
301 if let Some(title) = title {
302 winit_window.set_title(&title);
303 }
304
305 drop(inner);
306
307 Self {
308 renderer: config.renderer,
309 waker: None,
310 poll_requested: Arc::new(AtomicBool::new(false)),
311 animation_timer: None,
312 keyboard_modifiers: Default::default(),
313 proxy: proxy.clone(),
314 window: winit_window.clone(),
315 doc,
316 theme_override: None,
317 buttons: MouseEventButtons::None,
318 active_events: Arc::new(AtomicRefCell::new(Vec::new())),
319 safe_area_insets,
320 #[cfg(target_arch = "wasm32")]
321 pending_resize: None,
322 #[cfg(target_arch = "wasm32")]
323 last_resize_at: None,
324 #[cfg(target_arch = "wasm32")]
325 resize_timer_scheduled: false,
326 pointer_pos: Default::default(),
327 is_visible: winit_window.is_visible().unwrap_or(true),
328 redraw_pending: std::cell::Cell::new(false),
329 frame_stats: FrameStats::new(&*winit_window),
330 #[cfg(feature = "accessibility")]
331 accessibility,
332
333 #[cfg(target_os = "ios")]
334 ios_request_redraw: std::cell::Cell::new(false),
335
336 animation_frame_due: std::cell::Cell::new(None),
337 }
338 }
339
340 pub fn replace_document(&mut self, new_doc: Box<dyn Document>, retain_scroll_position: bool) {
341 let inner = self.doc.inner();
342 let scroll = inner.viewport_scroll();
343 let viewport = inner.viewport().clone();
344 let shell_provider = inner.shell_provider.clone();
345 let focused = inner.window_focused();
346 drop(inner);
347
348 self.doc = new_doc;
349
350 let mut inner = self.doc.inner_mut();
351 inner.set_viewport(viewport);
352 inner.set_shell_provider(shell_provider);
353 inner.set_window_focused(focused);
354 drop(inner);
355
356 self.poll();
357 self.request_redraw();
358
359 if retain_scroll_position {
360 self.doc.inner_mut().set_viewport_scroll(scroll);
361 }
362 }
363
364 pub fn theme_override(&self) -> Option<Theme> {
365 self.theme_override
366 }
367
368 pub fn current_theme(&self) -> Theme {
369 color_scheme_to_theme(self.doc.inner().viewport().color_scheme)
370 }
371
372 pub fn set_theme_override(&mut self, theme: Option<Theme>) {
373 self.theme_override = theme;
374 let theme = theme.or(self.window.theme()).unwrap_or(Theme::Light);
375 self.with_viewport(|v| v.color_scheme = theme_to_color_scheme(theme));
376 }
377
378 pub fn downcast_doc_mut<T: 'static>(&mut self) -> &mut T {
379 (&mut *self.doc as &mut dyn Any)
380 .downcast_mut::<T>()
381 .unwrap()
382 }
383
384 pub fn try_downcast_doc_mut<T: 'static>(&mut self) -> Option<&mut T> {
385 (&mut *self.doc as &mut dyn Any).downcast_mut::<T>()
386 }
387
388 pub fn current_animation_time(&mut self) -> f64 {
389 match &self.animation_timer {
390 Some(start) => Instant::now().duration_since(*start).as_secs_f64(),
391 None => {
392 self.animation_timer = Some(Instant::now());
393 0.0
394 }
395 }
396 }
397}
398
399impl<Rend: WindowRenderer> View<Rend> {
400 pub fn resume(&mut self) {
405 let window_id = self.window_id();
406 let animation_time = self.current_animation_time();
407
408 let (width, height) = {
409 let mut inner = self.doc.inner_mut();
410 inner.resolve(animation_time);
411 inner.viewport().window_size
412 };
413
414 let proxy = self.proxy.clone();
415 self.renderer
416 .resume(Arc::new(self.window.clone()), width, height, move || {
417 proxy.send_event(BlitzShellEvent::ResumeReady { window_id });
418 });
419 }
420
421 pub fn complete_resume(&mut self) -> bool {
425 if !self.renderer.complete_resume() {
426 return false;
427 }
428
429 let animation_time = self.current_animation_time();
434 let mut inner = self.doc.inner_mut();
435 inner.resolve(animation_time);
436 let (width, height) = inner.viewport().window_size;
437 let scale = inner.viewport().scale_f64();
438 let insets = self.safe_area_insets;
444
445 #[cfg(feature = "custom-widget")]
446 inner.can_create_surfaces(&mut self.renderer as _);
447
448 self.renderer.set_size(width, height);
449
450 self.renderer.render(|scene| {
451 paint_scene(
452 scene,
453 &mut inner,
454 scale,
455 width,
456 height,
457 insets.left,
458 insets.top,
459 )
460 });
461 drop(inner);
462 self.redraw_pending.set(false);
463
464 self.waker = Some(create_waker(&self.proxy, Arc::clone(&self.poll_requested)));
465 self.poll();
469 true
470 }
471
472 pub fn suspend(&mut self) {
473 self.waker = None;
474 self.redraw_pending.set(false);
475 self.renderer.suspend();
476
477 #[cfg(feature = "custom-widget")]
478 self.doc.inner_mut().destroy_surfaces();
479 }
480
481 pub fn request_poll(&self) {
486 self.poll_requested.store(true, Ordering::Release);
487 }
488
489 pub fn poll_if_requested(&mut self) -> bool {
491 if self.poll_requested.swap(false, Ordering::AcqRel) {
492 self.poll()
493 } else {
494 false
495 }
496 }
497
498 pub fn poll(&mut self) -> bool {
499 if let Some(waker) = &self.waker {
500 let cx = std::task::Context::from_waker(waker);
501 if self.doc.poll(Some(cx)) {
502 #[cfg(feature = "accessibility")]
503 {
504 let inner = self.doc.inner();
505 self.accessibility.update_tree(&inner);
510 }
511
512 self.request_redraw();
513 return true;
514 }
515 }
516
517 false
518 }
519
520 pub fn request_redraw(&self) {
521 if self.renderer.is_active() && !self.redraw_pending.replace(true) {
522 self.window.request_redraw();
523 #[cfg(target_os = "ios")]
524 self.ios_request_redraw.set(true);
525 }
526 }
527
528 pub fn redraw(&mut self) -> bool {
530 let profiling = blitz_traits::profiling::deep_profiling_permitted();
547 let frame_started = Instant::now();
548 self.redraw_pending.set(false);
549 #[cfg(target_os = "ios")]
550 self.ios_request_redraw.set(false);
551 let animation_time = self.current_animation_time();
552 let is_visible = self.is_visible;
553
554 let resolve_started = profiling.then(Instant::now);
555 let mut inner = self.doc.inner_mut();
556 inner.resolve(animation_time);
557 let resolve_time = resolve_started.map_or(Duration::ZERO, |started| started.elapsed());
558
559 #[cfg(feature = "custom-widget")]
561 for id in inner.take_pending_resource_deallocations() {
562 self.renderer.unregister_resource(id);
563 }
564
565 let (width, height) = inner.viewport().window_size;
566 let scale = inner.viewport().scale_f64();
567 let animation_pacing = inner.animation_pacing();
568 let is_animating = animation_pacing != blitz_dom::AnimationPacing::Idle;
569 let is_blocked = inner.has_pending_critical_resources();
570 let insets = self.safe_area_insets;
576
577 let mut paint_time = Duration::ZERO;
578 let render_started = profiling.then(Instant::now);
579 let committed = !is_blocked && is_visible;
580 if committed {
581 self.renderer.render(|scene| {
582 let paint_started = profiling.then(Instant::now);
583 blitz_paint::paint_scene_at_time(
584 scene,
585 &mut inner,
586 scale,
587 width,
588 height,
589 insets.left,
590 insets.top,
591 animation_time,
592 );
593 paint_time = paint_started.map_or(Duration::ZERO, |started| started.elapsed());
594 });
595 }
596 let renderer_time = render_started
597 .map_or(Duration::ZERO, |started| started.elapsed())
598 .saturating_sub(paint_time);
599
600 drop(inner);
601
602 if profiling {
603 self.frame_stats
604 .record(frame_started, resolve_time, paint_time, renderer_time);
605 }
606
607 if !is_blocked && is_visible && is_animating {
608 self.animation_frame_due.set(Some(
618 frame_started + animation_frame_interval(animation_pacing),
619 ));
620 } else {
621 self.animation_frame_due.set(None);
622 }
623 committed
624 }
625
626 pub fn poll_animation_frame(&self, now: Instant) -> Option<Instant> {
632 let due = self.animation_frame_due.get()?;
633 if now >= due {
634 self.animation_frame_due.set(None);
635 self.request_redraw();
636 None
637 } else {
638 Some(due)
639 }
640 }
641
642 pub fn pointer_coords(&self, position: PhysicalPosition<f64>) -> PointerCoords {
643 let inner = self.doc.inner();
644 let scale = inner.viewport().scale_f64();
645 let LogicalPosition::<f32> {
646 x: screen_x,
647 y: screen_y,
648 } = position.to_logical(scale);
649 let viewport_scroll_offset = inner.viewport_scroll();
650 let client_x = screen_x - (self.safe_area_insets.left as f64 / scale) as f32;
651 let client_y = screen_y - (self.safe_area_insets.top as f64 / scale) as f32;
652 let page_x = client_x + viewport_scroll_offset.x as f32;
653 let page_y = client_y + viewport_scroll_offset.y as f32;
654
655 PointerCoords {
656 screen_x,
657 screen_y,
658 client_x,
659 client_y,
660 page_x,
661 page_y,
662 }
663 }
664
665 pub fn window_id(&self) -> WindowId {
666 self.window.id()
667 }
668
669 fn set_active_pointer(&self, event: &BlitzPointerEvent) {
673 let mut stored = event.clone();
674 stored.active_pointers = Default::default();
675
676 let mut active = self.active_events.borrow_mut();
677 if let Some(existing) = active.iter_mut().find(|e| e.id == stored.id) {
678 *existing = stored;
679 } else {
680 active.push(stored);
681 }
682 }
683
684 fn update_active_pointer(&self, event: &BlitzPointerEvent) {
687 let mut active = self.active_events.borrow_mut();
688 if let Some(existing) = active.iter_mut().find(|e| e.id == event.id) {
689 let mut stored = event.clone();
690 stored.active_pointers = Default::default();
691 *existing = stored;
692 }
693 }
694
695 fn remove_active_pointer(&self, id: BlitzPointerId) -> bool {
697 let mut active = self.active_events.borrow_mut();
698 let len_before = active.len();
699 active.retain(|e| e.id != id);
700 active.len() != len_before
701 }
702
703 #[inline]
704 pub fn with_viewport(&mut self, cb: impl FnOnce(&mut Viewport)) {
705 let mut inner = self.doc.inner_mut();
706 let mut viewport = inner.viewport_mut();
707 cb(&mut viewport);
708 let (width, height) = viewport.window_size;
709 drop(viewport);
710 drop(inner);
711 if width > 0 && height > 0 {
712 let insets = self.safe_area_insets;
713 self.renderer.set_size(
714 width + insets.left + insets.right,
715 height + insets.top + insets.bottom,
716 );
717 self.request_redraw();
718 }
719 }
720
721 #[cfg(feature = "accessibility")]
722 pub fn build_accessibility_tree(&mut self) {
723 let inner = self.doc.inner();
724 self.accessibility.update_tree(&inner);
725 }
726
727 #[cfg(target_arch = "wasm32")]
728 const RESIZE_DEBOUNCE_MS: u32 = 100;
729
730 #[cfg(target_arch = "wasm32")]
731 fn schedule_resize_settle_check(&mut self, delay_ms: u32) {
732 use wasm_bindgen::JsCast;
733 use wasm_bindgen::closure::Closure;
734
735 let proxy = self.proxy.clone();
736 let window_id = self.window_id();
737 let cb = Closure::once_into_js(move || {
738 proxy.send_event(BlitzShellEvent::ResizeSettleCheck { window_id });
739 });
740 if let Some(win) = web_sys::window() {
741 let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(
742 cb.unchecked_ref(),
743 delay_ms as i32,
744 );
745 self.resize_timer_scheduled = true;
746 }
747 }
748
749 #[cfg(target_arch = "wasm32")]
753 pub fn apply_pending_resize_if_settled(&mut self) {
754 self.resize_timer_scheduled = false;
755 let Some(last) = self.last_resize_at else {
756 return;
757 };
758 let debounce = std::time::Duration::from_millis(Self::RESIZE_DEBOUNCE_MS as u64);
759 let elapsed = web_time::Instant::now().saturating_duration_since(last);
760 if elapsed < debounce {
761 let remaining_ms = (debounce - elapsed).as_millis() as u32;
763 self.schedule_resize_settle_check(remaining_ms);
764 return;
765 }
766 let Some(size) = self.pending_resize.take() else {
767 return;
768 };
769 self.last_resize_at = None;
770
771 let insets = self.safe_area_insets;
772 let width = size.width.saturating_sub(insets.left + insets.right);
773 let height = size.height.saturating_sub(insets.top + insets.bottom);
774 self.with_viewport(|v| v.window_size = (width, height));
775 self.request_redraw();
776 }
777
778 #[cfg(target_os = "macos")]
779 pub fn handle_apple_standard_keybinding(&mut self, command: &str) {
780 use blitz_traits::SmolStr;
781 let event = UiEvent::AppleStandardKeybinding(SmolStr::new(command));
782 self.doc.handle_ui_event(event);
783 }
784
785 pub fn handle_winit_event(&mut self, event: WindowEvent) -> bool {
787 #[cfg(feature = "accessibility")]
789 self.accessibility
790 .process_window_event(&*self.window, &event);
791
792 let mut paint_committed = false;
793 match event {
794 WindowEvent::Destroyed => {}
795 WindowEvent::ActivationTokenDone { .. } => {},
796 WindowEvent::CloseRequested => {
797 }
799 WindowEvent::RedrawRequested => {
800 paint_committed = self.redraw();
801 }
802 WindowEvent::Moved(_) => {}
803 WindowEvent::Occluded(is_occluded) => {
804 self.is_visible = !is_occluded;
805 if self.is_visible {
806 self.request_redraw();
807 }
808 },
809 WindowEvent::SurfaceResized(physical_size) => {
810 self.safe_area_insets = get_safe_area_insets(&*self.window);
811 #[cfg(target_arch = "wasm32")]
815 {
816 self.pending_resize = Some(physical_size);
817 self.last_resize_at = Some(web_time::Instant::now());
818 if !self.resize_timer_scheduled {
819 self.schedule_resize_settle_check(Self::RESIZE_DEBOUNCE_MS);
820 }
821 }
822 #[cfg(not(target_arch = "wasm32"))]
823 {
824 let insets = self.safe_area_insets;
825 let width = physical_size.width - insets.left - insets.right;
826 let height = physical_size.height - insets.top - insets.bottom;
827 self.with_viewport(|v| v.window_size = (width, height));
828 self.request_redraw();
829 }
830 }
831 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
832 self.with_viewport(|v| v.set_hidpi_scale(scale_factor as f32));
833 self.request_redraw();
834 }
835 WindowEvent::ThemeChanged(theme) => {
836 let color_scheme = theme_to_color_scheme(self.theme_override.unwrap_or(theme));
837 let mut inner = self.doc.inner_mut();
838 inner.viewport_mut().color_scheme = color_scheme;
839 }
840 WindowEvent::Ime(ime_event) => {
841 if let Some(ime_event) = winit_ime_to_blitz(ime_event) {
842 self.doc.handle_ui_event(UiEvent::Ime(ime_event));
843 }
844 self.request_redraw();
845 },
846 WindowEvent::ModifiersChanged(new_state) => {
847 self.keyboard_modifiers = new_state;
849 }
850 WindowEvent::KeyboardInput { event, .. } => {
851 if let PhysicalKey::Code(key_code) = event.physical_key && event.state.is_pressed() {
852 let ctrl = self.keyboard_modifiers.state().control_key();
853 let meta = self.keyboard_modifiers.state().meta_key();
854 let alt = self.keyboard_modifiers.state().alt_key();
855
856 if ctrl | meta {
858 match key_code {
859 KeyCode::Equal => {
860 self.doc.inner_mut().viewport_mut().zoom_by(0.1);
861 },
862 KeyCode::Minus => {
863 self.doc.inner_mut().viewport_mut().zoom_by(-0.1);
864 },
865 KeyCode::Digit0 => {
866 self.doc.inner_mut().viewport_mut().set_zoom(1.0);
867 }
868 _ => {}
869 };
870 }
871
872 if alt {
874 match key_code {
875 KeyCode::KeyD => {
876 let mut inner = self.doc.inner_mut();
877 inner.devtools_mut().toggle_show_layout();
878 drop(inner);
879 self.request_redraw();
880 }
881 KeyCode::KeyH => {
882 let mut inner = self.doc.inner_mut();
883 inner.devtools_mut().toggle_highlight_hover();
884 drop(inner);
885 self.request_redraw();
886 }
887 KeyCode::KeyT => self.doc.inner().print_taffy_tree(),
888 _ => {}
889 };
890 }
891
892 }
893
894 let key_event_data = winit_key_event_to_blitz(&event, self.keyboard_modifiers.state());
896 let event = if event.state.is_pressed() {
897 UiEvent::KeyDown(key_event_data)
898 } else {
899 UiEvent::KeyUp(key_event_data)
900 };
901
902 self.doc.handle_ui_event(event);
903 }
904 WindowEvent::PointerEntered { .. } => {}
905 WindowEvent::PointerLeft { position, primary, kind, .. } => {
906 let id = pointer_kind_to_blitz(&kind);
907
908 if id != BlitzPointerId::Mouse && self.remove_active_pointer(id) {
917 let position = position.unwrap_or(self.pointer_pos);
918 self.pointer_pos = position;
919
920 self.buttons ^= MouseEventButton::Main.into();
922
923 let event = BlitzPointerEvent {
924 id,
925 is_primary: primary,
926 coords: self.pointer_coords(position),
927 button: MouseEventButton::Main,
928 buttons: self.buttons,
929 mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
930 details: PointerDetails::default(),
931 element: Default::default(),
932 active_pointers: Arc::clone(&self.active_events),
933 };
934
935 self.doc.handle_ui_event(UiEvent::PointerCancel(event));
936 self.request_redraw();
937 }
938 }
939 WindowEvent::PointerMoved { position, source, primary, .. } => {
940 self.pointer_pos = position;
941 let id = pointer_source_to_blitz(&source);
942 let event = BlitzPointerEvent {
943 id,
944 is_primary: primary,
945 coords: self.pointer_coords(position),
946 button: Default::default(),
947 buttons: self.buttons,
948 mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
949 details: pointer_source_to_blitz_details(&source),
950 element: Default::default(),
951 active_pointers: Arc::clone(&self.active_events),
952 };
953 if id != BlitzPointerId::Mouse {
955 self.update_active_pointer(&event);
956 }
957 self.doc.handle_ui_event(UiEvent::PointerMove(event));
958 self.request_redraw();
964 }
965 WindowEvent::PointerButton { button, state, primary, position, .. } => {
966 let id = button_source_to_blitz(&button);
967 let coords = self.pointer_coords(position);
968 self.pointer_pos = position;
969 let button = match &button {
970 ButtonSource::Mouse(mouse_button) => match mouse_button {
971 MouseButton::Left => MouseEventButton::Main,
972 MouseButton::Right => MouseEventButton::Secondary,
973 MouseButton::Middle => MouseEventButton::Auxiliary,
974 _ => MouseEventButton::Auxiliary,
976 }
977 _ => MouseEventButton::Main,
978 };
979
980 match state {
981 ElementState::Pressed => self.buttons |= button.into(),
982 ElementState::Released => self.buttons ^= button.into(),
983 }
984
985 let pointer_event = BlitzPointerEvent {
986 id,
987 is_primary: primary,
988 coords,
989 button,
990 buttons: self.buttons,
991 mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
992
993 details: PointerDetails::default(),
995 element: Default::default(),
996 active_pointers: Arc::clone(&self.active_events),
997 };
998
999 if id != BlitzPointerId::Mouse && state == ElementState::Pressed {
1005 self.set_active_pointer(&pointer_event);
1006 }
1007
1008 if id != BlitzPointerId::Mouse {
1012 let event = BlitzPointerEvent {
1013 id,
1014 is_primary: primary,
1015 coords,
1016 button: Default::default(),
1017 buttons: self.buttons,
1018 mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
1019 details: PointerDetails::default(),
1020 element: Default::default(),
1021 active_pointers: Arc::clone(&self.active_events),
1022 };
1023 self.doc.handle_ui_event(UiEvent::PointerMove(event));
1024 }
1025
1026 if id != BlitzPointerId::Mouse && state == ElementState::Released {
1027 self.remove_active_pointer(id);
1028 }
1029
1030 let event = pointer_event;
1031
1032 let event = match state {
1033 ElementState::Pressed => UiEvent::PointerDown(event),
1034 ElementState::Released => UiEvent::PointerUp(event),
1035 };
1036
1037 self.doc.handle_ui_event(event);
1038 self.request_redraw();
1039 }
1040 WindowEvent::MouseWheel { delta, .. } => {
1041 let blitz_delta = match delta {
1042 winit::event::MouseScrollDelta::LineDelta(x, y) => BlitzWheelDelta::Lines(x as f64, y as f64),
1043 winit::event::MouseScrollDelta::PixelDelta(pos) => BlitzWheelDelta::Pixels(pos.x, pos.y),
1044 _ => return paint_committed,
1045 };
1046
1047 let event = BlitzWheelEvent {
1048 delta: blitz_delta,
1049 coords: self.pointer_coords(self.pointer_pos),
1050 buttons: self.buttons,
1051 mods: winit_modifiers_to_kbt_modifiers(self.keyboard_modifiers.state()),
1052 element: Default::default()
1053 };
1054
1055 self.doc.handle_ui_event(UiEvent::Wheel(event));
1056 self.request_redraw();
1074 }
1075 WindowEvent::Focused(focused) => {
1076 self.doc.inner_mut().set_window_focused(focused);
1077 self.request_redraw();
1078 }
1079 WindowEvent::TouchpadPressure { .. } => {}
1080 WindowEvent::PinchGesture { .. } => {},
1081 WindowEvent::PanGesture { .. } => {},
1082 WindowEvent::DoubleTapGesture { .. } => {},
1083 WindowEvent::RotationGesture { .. } => {},
1084 WindowEvent::DragEntered { .. } => {},
1085 WindowEvent::DragDropped { .. } => {},
1086 WindowEvent::DragLeft { .. } => {},
1087 _ => {},
1088 }
1089 paint_committed
1090 }
1091}
1092
1093struct FrameStats {
1094 enabled: bool,
1095 output_path: Option<PathBuf>,
1096 refresh_millihertz: Option<u32>,
1097 last_frame_started: Option<Instant>,
1098 sample_started: Instant,
1099 frames: u32,
1100 active_intervals: u32,
1101 missed_refreshes: u32,
1102 interval_total: Duration,
1103 interval_max: Duration,
1104 resolve_total: Duration,
1105 paint_total: Duration,
1106 renderer_total: Duration,
1107 layers: blitz_paint::SceneLayerCounts,
1111}
1112
1113impl FrameStats {
1114 fn emit(output_path: Option<&PathBuf>, message: &str) {
1115 eprintln!("{message}");
1116 #[cfg(not(target_arch = "wasm32"))]
1117 if let Some(path) = output_path
1118 && let Ok(mut output) = std::fs::OpenOptions::new()
1119 .create(true)
1120 .append(true)
1121 .open(path)
1122 {
1123 let _ = std::io::Write::write_all(&mut output, message.as_bytes());
1124 let _ = std::io::Write::write_all(&mut output, b"\n");
1125 }
1126 }
1127
1128 fn new(window: &dyn Window) -> Self {
1129 #[cfg(not(target_arch = "wasm32"))]
1130 let enabled = std::env::var_os("BLITZ_FRAME_STATS").is_some();
1131 #[cfg(target_arch = "wasm32")]
1132 let enabled = false;
1133 #[cfg(not(target_arch = "wasm32"))]
1134 let output_path = std::env::var_os("BLITZ_FRAME_STATS_FILE").map(PathBuf::from);
1135 #[cfg(target_arch = "wasm32")]
1136 let output_path = None;
1137
1138 let refresh_millihertz = window
1139 .current_monitor()
1140 .and_then(|monitor| monitor.current_video_mode())
1141 .and_then(|mode| mode.refresh_rate_millihertz())
1142 .map(std::num::NonZeroU32::get);
1143
1144 crate::frame_stats::set_display_refresh_millihertz(refresh_millihertz);
1148
1149 if enabled {
1150 let message = match refresh_millihertz {
1151 Some(rate) => format!(
1152 "[blitz-frame] display_refresh_hz={:.3}",
1153 f64::from(rate) / 1000.0
1154 ),
1155 None => "[blitz-frame] display_refresh_hz=unknown".to_owned(),
1156 };
1157 Self::emit(output_path.as_ref(), &message);
1158 }
1159
1160 Self {
1161 enabled,
1162 output_path,
1163 refresh_millihertz,
1164 last_frame_started: None,
1165 sample_started: Instant::now(),
1166 frames: 0,
1167 active_intervals: 0,
1168 missed_refreshes: 0,
1169 interval_total: Duration::ZERO,
1170 interval_max: Duration::ZERO,
1171 resolve_total: Duration::ZERO,
1172 paint_total: Duration::ZERO,
1173 renderer_total: Duration::ZERO,
1174 layers: blitz_paint::SceneLayerCounts::default(),
1175 }
1176 }
1177
1178 fn record(
1179 &mut self,
1180 frame_started: Instant,
1181 resolve: Duration,
1182 paint: Duration,
1183 renderer: Duration,
1184 ) {
1185 crate::frame_stats::record_frame(frame_started, resolve, paint, renderer);
1191
1192 if !self.enabled {
1193 return;
1194 }
1195
1196 if let Some(previous) = self.last_frame_started.replace(frame_started) {
1197 let interval = frame_started.duration_since(previous);
1198 if interval <= Duration::from_millis(100) {
1201 self.active_intervals += 1;
1202 self.interval_total += interval;
1203 self.interval_max = self.interval_max.max(interval);
1204
1205 if let Some(rate) = self.refresh_millihertz {
1206 let target = Duration::from_secs_f64(1000.0 / f64::from(rate));
1207 if interval > target.mul_f64(1.5) {
1208 self.missed_refreshes += 1;
1209 }
1210 }
1211 }
1212 }
1213
1214 let layers = blitz_paint::latest_scene_layers();
1217 self.layers.wanted = self.layers.wanted.max(layers.wanted);
1218 self.layers.used = self.layers.used.max(layers.used);
1219 self.layers.max_depth = self.layers.max_depth.max(layers.max_depth);
1220 for (worst, seen) in self.layers.by_site.iter_mut().zip(layers.by_site) {
1221 *worst = (*worst).max(seen);
1222 }
1223
1224 self.frames += 1;
1225 self.resolve_total += resolve;
1226 self.paint_total += paint;
1227 self.renderer_total += renderer;
1228
1229 let sample_elapsed = self.sample_started.elapsed();
1230 if sample_elapsed < Duration::from_secs(1) || self.frames < 2 {
1231 return;
1232 }
1233
1234 let active_fps = if self.interval_total.is_zero() {
1235 0.0
1236 } else {
1237 f64::from(self.active_intervals) / self.interval_total.as_secs_f64()
1238 };
1239 let frames = f64::from(self.frames);
1240 let by_site = blitz_paint::LayerSite::ALL
1243 .iter()
1244 .zip(self.layers.by_site)
1245 .map(|(site, count)| format!("{}:{count}", site.name()))
1246 .collect::<Vec<_>>()
1247 .join(",");
1248 let message = format!(
1249 "[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}",
1250 self.frames,
1251 self.active_intervals,
1252 self.missed_refreshes,
1253 self.interval_max.as_secs_f64() * 1000.0,
1254 self.resolve_total.as_secs_f64() * 1000.0 / frames,
1255 self.paint_total.as_secs_f64() * 1000.0 / frames,
1256 self.renderer_total.as_secs_f64() * 1000.0 / frames,
1257 self.layers.wanted,
1258 self.layers.used,
1259 self.layers.max_depth,
1260 );
1261 Self::emit(self.output_path.as_ref(), &message);
1262
1263 self.sample_started = frame_started;
1264 self.frames = 0;
1265 self.active_intervals = 0;
1266 self.missed_refreshes = 0;
1267 self.interval_total = Duration::ZERO;
1268 self.interval_max = Duration::ZERO;
1269 self.resolve_total = Duration::ZERO;
1270 self.paint_total = Duration::ZERO;
1271 self.renderer_total = Duration::ZERO;
1272 self.layers = blitz_paint::SceneLayerCounts::default();
1273 }
1274}
1275
1276#[cfg(test)]
1277mod animation_pacing_tests {
1278 use super::*;
1279
1280 #[test]
1281 fn css_animation_frames_are_limited_to_fifteen_fps() {
1282 assert_eq!(
1283 animation_frame_interval_for_refresh(
1284 blitz_dom::AnimationPacing::SlowCss,
1285 Some(120_000),
1286 ),
1287 Duration::from_secs_f64(1.0 / 15.0),
1288 );
1289 assert_eq!(
1290 animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::SlowCss, Some(60_000)),
1291 Duration::from_secs_f64(1.0 / 15.0),
1292 );
1293 assert_eq!(
1294 animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::SlowCss, None),
1295 Duration::from_millis(67),
1296 );
1297 }
1298
1299 #[test]
1300 fn interactive_animation_frames_remain_at_thirty_fps() {
1301 assert_eq!(
1302 animation_frame_interval_for_refresh(
1303 blitz_dom::AnimationPacing::Interactive,
1304 Some(120_000),
1305 ),
1306 Duration::from_secs_f64(1.0 / 30.0),
1307 );
1308 assert_eq!(
1309 animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::Interactive, None),
1310 Duration::from_millis(33),
1311 );
1312 }
1313
1314 #[test]
1315 fn caret_only_frames_run_at_blink_boundaries() {
1316 assert_eq!(
1317 animation_frame_interval_for_refresh(blitz_dom::AnimationPacing::Caret, Some(120_000)),
1318 Duration::from_millis(500),
1319 );
1320 }
1321}