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