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#[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 submitted_paint: u64,
119 pub safe_area_insets: PhysicalInsets<u32>,
120
121 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 #[cfg(target_arch = "wasm32")]
136 resize_timer_scheduled: bool,
137
138 #[cfg(feature = "accessibility")]
139 pub accessibility: AccessibilityState,
141
142 #[cfg(target_os = "ios")]
147 pub ios_request_redraw: std::cell::Cell<bool>,
148
149 pub animation_frame_due: std::cell::Cell<Option<Instant>>,
158}
159
160const CSS_ANIMATION_TARGET_FPS: u32 = 15;
173
174const INTERACTIVE_ANIMATION_TARGET_FPS: u32 = 30;
177const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
178
179const CSS_ANIMATION_FALLBACK_INTERVAL: Duration = Duration::from_millis(67);
181const INTERACTIVE_ANIMATION_FALLBACK_INTERVAL: Duration = Duration::from_millis(33);
182
183fn 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 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 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 let is_visible = config.attributes.visible;
243 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 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 #[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 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 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 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 pub fn complete_resume(&mut self) -> bool {
427 if !self.renderer.complete_resume() {
428 return false;
429 }
430
431 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 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 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 pub fn request_poll(&self) {
488 self.poll_requested.store(true, Ordering::Release);
489 }
490
491 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 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 pub fn redraw(&mut self) -> bool {
540 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 #[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 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 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 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 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 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 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 #[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 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 pub fn handle_winit_event(&mut self, event: WindowEvent) -> bool {
808 #[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 }
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 #[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 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 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 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 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 { .. } => {}
926 WindowEvent::PointerLeft { position, primary, kind, .. } => {
927 let id = pointer_kind_to_blitz(&kind);
928
929 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 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 if id != BlitzPointerId::Mouse {
976 self.update_active_pointer(&event);
977 }
978 self.doc.handle_ui_event(UiEvent::PointerMove(event));
979 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 _ => 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 details: PointerDetails::default(),
1016 element: Default::default(),
1017 active_pointers: Arc::clone(&self.active_events),
1018 };
1019
1020 if id != BlitzPointerId::Mouse && state == ElementState::Pressed {
1026 self.set_active_pointer(&pointer_event);
1027 }
1028
1029 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 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 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 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 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 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 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 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}