1use std::collections::HashMap;
2use std::sync::OnceLock;
3
4use serde::{Deserialize, Serialize};
5
6use crate::time::{Duration, Instant};
7use crate::{AppWindowId, Color, Edges, Event, FrameId, Point, Rect, Size};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum ColorScheme {
11 Light,
12 Dark,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ContrastPreference {
21 NoPreference,
22 More,
23 Less,
24 Custom,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ForcedColorsMode {
33 None,
34 Active,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub struct WindowLogicalPosition {
42 pub x: i32,
43 pub y: i32,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct WindowAnchor {
48 pub window: AppWindowId,
49 pub position: Point,
50}
51
52#[derive(Debug, Default, Clone)]
53pub struct WindowMetricsService {
54 inner_sizes: HashMap<AppWindowId, Size>,
55 logical_positions: HashMap<AppWindowId, WindowLogicalPosition>,
56 scale_factors: HashMap<AppWindowId, f32>,
57 focused: HashMap<AppWindowId, bool>,
58 prefers_reduced_motion: HashMap<AppWindowId, Option<bool>>,
59 text_scale_factor: HashMap<AppWindowId, Option<f32>>,
60 prefers_reduced_transparency: HashMap<AppWindowId, Option<bool>>,
61 accent_color: HashMap<AppWindowId, Option<Color>>,
62 color_scheme: HashMap<AppWindowId, Option<ColorScheme>>,
63 contrast_preference: HashMap<AppWindowId, Option<ContrastPreference>>,
64 forced_colors_mode: HashMap<AppWindowId, Option<ForcedColorsMode>>,
65 safe_area_insets: HashMap<AppWindowId, Option<Edges>>,
66 occlusion_insets: HashMap<AppWindowId, Option<Edges>>,
67}
68
69impl WindowMetricsService {
70 pub fn set_inner_size(&mut self, window: AppWindowId, size: Size) {
71 self.inner_sizes.insert(window, size);
72 }
73
74 pub fn inner_size(&self, window: AppWindowId) -> Option<Size> {
75 self.inner_sizes.get(&window).copied()
76 }
77
78 pub fn set_logical_position(&mut self, window: AppWindowId, position: WindowLogicalPosition) {
79 self.logical_positions.insert(window, position);
80 }
81
82 pub fn logical_position(&self, window: AppWindowId) -> Option<WindowLogicalPosition> {
83 self.logical_positions.get(&window).copied()
84 }
85
86 pub fn set_scale_factor(&mut self, window: AppWindowId, scale_factor: f32) {
87 self.scale_factors.insert(window, scale_factor);
88 }
89
90 pub fn scale_factor(&self, window: AppWindowId) -> Option<f32> {
91 self.scale_factors.get(&window).copied()
92 }
93
94 pub fn set_focused(&mut self, window: AppWindowId, focused: bool) {
95 self.focused.insert(window, focused);
96 }
97
98 pub fn focused(&self, window: AppWindowId) -> Option<bool> {
99 self.focused.get(&window).copied()
100 }
101
102 pub fn set_prefers_reduced_motion(&mut self, window: AppWindowId, prefers: Option<bool>) {
103 self.prefers_reduced_motion.insert(window, prefers);
104 }
105
106 pub fn prefers_reduced_motion(&self, window: AppWindowId) -> Option<bool> {
107 self.prefers_reduced_motion.get(&window).copied().flatten()
108 }
109
110 pub fn prefers_reduced_motion_is_known(&self, window: AppWindowId) -> bool {
111 self.prefers_reduced_motion.contains_key(&window)
112 }
113
114 pub fn set_text_scale_factor(&mut self, window: AppWindowId, factor: Option<f32>) {
115 self.text_scale_factor.insert(window, factor);
116 }
117
118 pub fn text_scale_factor(&self, window: AppWindowId) -> Option<f32> {
119 self.text_scale_factor.get(&window).copied().flatten()
120 }
121
122 pub fn text_scale_factor_is_known(&self, window: AppWindowId) -> bool {
123 self.text_scale_factor.contains_key(&window)
124 }
125
126 pub fn set_prefers_reduced_transparency(&mut self, window: AppWindowId, prefers: Option<bool>) {
127 self.prefers_reduced_transparency.insert(window, prefers);
128 }
129
130 pub fn prefers_reduced_transparency(&self, window: AppWindowId) -> Option<bool> {
131 self.prefers_reduced_transparency
132 .get(&window)
133 .copied()
134 .flatten()
135 }
136
137 pub fn prefers_reduced_transparency_is_known(&self, window: AppWindowId) -> bool {
138 self.prefers_reduced_transparency.contains_key(&window)
139 }
140
141 pub fn set_accent_color(&mut self, window: AppWindowId, color: Option<Color>) {
142 self.accent_color.insert(window, color);
143 }
144
145 pub fn accent_color(&self, window: AppWindowId) -> Option<Color> {
146 self.accent_color.get(&window).copied().flatten()
147 }
148
149 pub fn accent_color_is_known(&self, window: AppWindowId) -> bool {
150 self.accent_color.contains_key(&window)
151 }
152
153 pub fn set_color_scheme(&mut self, window: AppWindowId, scheme: Option<ColorScheme>) {
154 self.color_scheme.insert(window, scheme);
155 }
156
157 pub fn color_scheme(&self, window: AppWindowId) -> Option<ColorScheme> {
158 self.color_scheme.get(&window).copied().flatten()
159 }
160
161 pub fn color_scheme_is_known(&self, window: AppWindowId) -> bool {
162 self.color_scheme.contains_key(&window)
163 }
164
165 pub fn set_contrast_preference(
166 &mut self,
167 window: AppWindowId,
168 value: Option<ContrastPreference>,
169 ) {
170 self.contrast_preference.insert(window, value);
171 }
172
173 pub fn contrast_preference(&self, window: AppWindowId) -> Option<ContrastPreference> {
174 self.contrast_preference.get(&window).copied().flatten()
175 }
176
177 pub fn contrast_preference_is_known(&self, window: AppWindowId) -> bool {
178 self.contrast_preference.contains_key(&window)
179 }
180
181 pub fn set_forced_colors_mode(&mut self, window: AppWindowId, value: Option<ForcedColorsMode>) {
182 self.forced_colors_mode.insert(window, value);
183 }
184
185 pub fn forced_colors_mode(&self, window: AppWindowId) -> Option<ForcedColorsMode> {
186 self.forced_colors_mode.get(&window).copied().flatten()
187 }
188
189 pub fn forced_colors_mode_is_known(&self, window: AppWindowId) -> bool {
190 self.forced_colors_mode.contains_key(&window)
191 }
192
193 pub fn set_safe_area_insets(&mut self, window: AppWindowId, insets: Option<Edges>) {
194 self.safe_area_insets.insert(window, insets);
195 }
196
197 pub fn safe_area_insets(&self, window: AppWindowId) -> Option<Edges> {
198 self.safe_area_insets.get(&window).copied().flatten()
199 }
200
201 pub fn safe_area_insets_is_known(&self, window: AppWindowId) -> bool {
202 self.safe_area_insets.contains_key(&window)
203 }
204
205 pub fn set_occlusion_insets(&mut self, window: AppWindowId, insets: Option<Edges>) {
206 self.occlusion_insets.insert(window, insets);
207 }
208
209 pub fn occlusion_insets(&self, window: AppWindowId) -> Option<Edges> {
210 self.occlusion_insets.get(&window).copied().flatten()
211 }
212
213 pub fn occlusion_insets_is_known(&self, window: AppWindowId) -> bool {
214 self.occlusion_insets.contains_key(&window)
215 }
216
217 pub fn inner_bounds(&self, window: AppWindowId) -> Option<Rect> {
218 let size = self.inner_size(window)?;
219 Some(Rect::new(Point::new(crate::Px(0.0), crate::Px(0.0)), size))
220 }
221
222 pub fn apply_event(&mut self, window: AppWindowId, event: &Event) {
223 match event {
224 Event::WindowResized { width, height } => {
225 self.set_inner_size(window, Size::new(*width, *height));
226 }
227 Event::WindowMoved(position) => {
228 self.set_logical_position(window, *position);
229 }
230 Event::WindowFocusChanged(focused) => {
231 self.set_focused(window, *focused);
232 }
233 Event::WindowScaleFactorChanged(scale_factor) => {
234 self.set_scale_factor(window, *scale_factor);
235 }
236 _ => {}
237 }
238 }
239
240 pub fn remove(&mut self, window: AppWindowId) {
241 self.inner_sizes.remove(&window);
242 self.logical_positions.remove(&window);
243 self.scale_factors.remove(&window);
244 self.focused.remove(&window);
245 self.prefers_reduced_motion.remove(&window);
246 self.text_scale_factor.remove(&window);
247 self.prefers_reduced_transparency.remove(&window);
248 self.accent_color.remove(&window);
249 self.color_scheme.remove(&window);
250 self.contrast_preference.remove(&window);
251 self.forced_colors_mode.remove(&window);
252 self.safe_area_insets.remove(&window);
253 self.occlusion_insets.remove(&window);
254 }
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub struct WindowFrameClockSnapshot {
259 pub now_monotonic: Duration,
260 pub delta: Duration,
261}
262
263#[derive(Debug, Default, Clone)]
264pub struct WindowFrameClockService {
265 origin: Option<Instant>,
266 last_frame_id: HashMap<AppWindowId, FrameId>,
267 last_instant: HashMap<AppWindowId, Instant>,
268 snapshots: HashMap<AppWindowId, WindowFrameClockSnapshot>,
269 fixed_deltas: HashMap<AppWindowId, Duration>,
270 fixed_now_monotonic: HashMap<AppWindowId, Duration>,
271}
272
273impl WindowFrameClockService {
274 pub fn fixed_delta_from_env() -> Option<Duration> {
282 static FIXED: OnceLock<Option<Duration>> = OnceLock::new();
283 *FIXED.get_or_init(|| {
284 let value = std::env::var("FRET_DIAG_FIXED_FRAME_DELTA_MS")
285 .ok()
286 .filter(|v| !v.trim().is_empty())
287 .or_else(|| {
288 std::env::var("FRET_FRAME_CLOCK_FIXED_DELTA_MS")
289 .ok()
290 .filter(|v| !v.trim().is_empty())
291 })?;
292 let ms: u64 = value.trim().parse().ok()?;
293 (ms > 0).then(|| Duration::from_millis(ms))
294 })
295 }
296
297 pub fn snapshot(&self, window: AppWindowId) -> Option<WindowFrameClockSnapshot> {
298 self.snapshots.get(&window).copied()
299 }
300
301 pub fn fixed_delta(&self, window: AppWindowId) -> Option<Duration> {
302 self.fixed_deltas.get(&window).copied()
303 }
304
305 pub fn effective_fixed_delta(&self, window: AppWindowId) -> Option<Duration> {
310 self.fixed_delta(window).or_else(Self::fixed_delta_from_env)
311 }
312
313 pub fn set_snapshot(&mut self, window: AppWindowId, snapshot: WindowFrameClockSnapshot) {
314 self.snapshots.insert(window, snapshot);
315 }
316
317 pub fn set_fixed_delta(&mut self, window: AppWindowId, delta: Option<Duration>) {
318 match delta {
319 Some(delta) if delta > Duration::default() => {
320 self.fixed_deltas.insert(window, delta);
321 if let Some(snapshot) = self.snapshots.get(&window).copied() {
322 self.fixed_now_monotonic
323 .entry(window)
324 .or_insert(snapshot.now_monotonic);
325 }
326 }
327 _ => {
328 self.fixed_deltas.remove(&window);
329 self.fixed_now_monotonic.remove(&window);
330 }
331 }
332 }
333
334 pub fn record_frame(&mut self, window: AppWindowId, frame_id: FrameId) {
343 if self.last_frame_id.get(&window).copied() == Some(frame_id) {
344 return;
345 }
346
347 let fixed_delta = self.effective_fixed_delta(window);
348 if let Some(fixed_delta) = fixed_delta {
349 let had_prev = self.last_frame_id.contains_key(&window);
350 let prev_now = self
351 .fixed_now_monotonic
352 .get(&window)
353 .copied()
354 .unwrap_or_default();
355 let now_monotonic = if had_prev {
356 prev_now.saturating_add(fixed_delta)
357 } else {
358 prev_now
359 };
360 self.fixed_now_monotonic.insert(window, now_monotonic);
361
362 let delta = if had_prev {
363 fixed_delta
364 } else {
365 Duration::default()
366 };
367 self.last_frame_id.insert(window, frame_id);
368 self.snapshots.insert(
369 window,
370 WindowFrameClockSnapshot {
371 now_monotonic,
372 delta,
373 },
374 );
375 return;
376 }
377
378 let now_instant = Instant::now();
379 let origin = *self.origin.get_or_insert(now_instant);
380 let now_monotonic = now_instant.duration_since(origin);
381 let delta = self
382 .last_instant
383 .insert(window, now_instant)
384 .map(|prev| now_instant.duration_since(prev))
385 .unwrap_or_default();
386
387 self.last_frame_id.insert(window, frame_id);
388 self.snapshots.insert(
389 window,
390 WindowFrameClockSnapshot {
391 now_monotonic,
392 delta,
393 },
394 );
395 }
396
397 pub fn remove(&mut self, window: AppWindowId) {
398 self.last_frame_id.remove(&window);
399 self.last_instant.remove(&window);
400 self.snapshots.remove(&window);
401 self.fixed_deltas.remove(&window);
402 self.fixed_now_monotonic.remove(&window);
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::Px;
410
411 #[test]
412 fn window_metrics_apply_event_tracks_resize_move_scale() {
413 let mut svc = WindowMetricsService::default();
414 let window = AppWindowId::from(slotmap::KeyData::from_ffi(1));
415
416 svc.apply_event(
417 window,
418 &Event::WindowResized {
419 width: Px(100.0),
420 height: Px(200.0),
421 },
422 );
423 assert_eq!(
424 svc.inner_size(window),
425 Some(Size::new(Px(100.0), Px(200.0)))
426 );
427
428 svc.apply_event(
429 window,
430 &Event::WindowMoved(WindowLogicalPosition { x: 10, y: 20 }),
431 );
432 assert_eq!(
433 svc.logical_position(window),
434 Some(WindowLogicalPosition { x: 10, y: 20 })
435 );
436
437 svc.apply_event(window, &Event::WindowScaleFactorChanged(2.0));
438 assert_eq!(svc.scale_factor(window), Some(2.0));
439
440 svc.apply_event(window, &Event::WindowFocusChanged(true));
441 assert_eq!(svc.focused(window), Some(true));
442 }
443
444 #[test]
445 fn window_metrics_remove_clears_all_fields() {
446 let mut svc = WindowMetricsService::default();
447 let window = AppWindowId::from(slotmap::KeyData::from_ffi(2));
448
449 svc.set_inner_size(window, Size::new(Px(1.0), Px(2.0)));
450 svc.set_logical_position(window, WindowLogicalPosition { x: 1, y: 2 });
451 svc.set_scale_factor(window, 1.5);
452 svc.set_focused(window, true);
453 svc.set_prefers_reduced_motion(window, Some(true));
454 svc.set_text_scale_factor(window, Some(1.25));
455 svc.set_prefers_reduced_transparency(window, Some(true));
456 svc.set_accent_color(
457 window,
458 Some(crate::Color {
459 r: 1.0,
460 g: 0.5,
461 b: 0.25,
462 a: 1.0,
463 }),
464 );
465 svc.set_color_scheme(window, Some(ColorScheme::Dark));
466 svc.set_contrast_preference(window, Some(ContrastPreference::More));
467 svc.set_forced_colors_mode(window, Some(ForcedColorsMode::Active));
468 svc.set_safe_area_insets(window, Some(Edges::all(Px(1.0))));
469 svc.set_occlusion_insets(window, Some(Edges::all(Px(2.0))));
470 svc.remove(window);
471
472 assert_eq!(svc.inner_size(window), None);
473 assert_eq!(svc.logical_position(window), None);
474 assert_eq!(svc.scale_factor(window), None);
475 assert_eq!(svc.focused(window), None);
476 assert_eq!(svc.prefers_reduced_motion(window), None);
477 assert_eq!(svc.text_scale_factor(window), None);
478 assert_eq!(svc.prefers_reduced_transparency(window), None);
479 assert_eq!(svc.accent_color(window), None);
480 assert_eq!(svc.color_scheme(window), None);
481 assert_eq!(svc.contrast_preference(window), None);
482 assert_eq!(svc.forced_colors_mode(window), None);
483 assert_eq!(svc.safe_area_insets(window), None);
484 assert_eq!(svc.occlusion_insets(window), None);
485 }
486
487 #[test]
488 fn window_metrics_insets_can_be_explicitly_set_to_none() {
489 let mut svc = WindowMetricsService::default();
490 let window = AppWindowId::from(slotmap::KeyData::from_ffi(3));
491
492 svc.set_safe_area_insets(window, None);
493 svc.set_occlusion_insets(window, None);
494
495 assert_eq!(svc.safe_area_insets(window), None);
496 assert_eq!(svc.occlusion_insets(window), None);
497 assert!(svc.safe_area_insets_is_known(window));
498 assert!(svc.occlusion_insets_is_known(window));
499 }
500
501 #[test]
502 fn window_metrics_prefers_reduced_motion_can_be_explicitly_set_to_none() {
503 let mut svc = WindowMetricsService::default();
504 let window = AppWindowId::from(slotmap::KeyData::from_ffi(4));
505
506 svc.set_prefers_reduced_motion(window, None);
507
508 assert_eq!(svc.prefers_reduced_motion(window), None);
509 assert!(svc.prefers_reduced_motion_is_known(window));
510 }
511
512 #[test]
513 fn window_metrics_text_scale_factor_can_be_explicitly_set_to_none() {
514 let mut svc = WindowMetricsService::default();
515 let window = AppWindowId::from(slotmap::KeyData::from_ffi(41));
516
517 svc.set_text_scale_factor(window, None);
518
519 assert_eq!(svc.text_scale_factor(window), None);
520 assert!(svc.text_scale_factor_is_known(window));
521 }
522
523 #[test]
524 fn window_metrics_prefers_reduced_transparency_can_be_explicitly_set_to_none() {
525 let mut svc = WindowMetricsService::default();
526 let window = AppWindowId::from(slotmap::KeyData::from_ffi(42));
527
528 svc.set_prefers_reduced_transparency(window, None);
529
530 assert_eq!(svc.prefers_reduced_transparency(window), None);
531 assert!(svc.prefers_reduced_transparency_is_known(window));
532 }
533
534 #[test]
535 fn window_metrics_accent_color_can_be_explicitly_set_to_none() {
536 let mut svc = WindowMetricsService::default();
537 let window = AppWindowId::from(slotmap::KeyData::from_ffi(43));
538
539 svc.set_accent_color(window, None);
540
541 assert_eq!(svc.accent_color(window), None);
542 assert!(svc.accent_color_is_known(window));
543 }
544
545 #[test]
546 fn window_metrics_color_scheme_can_be_explicitly_set_to_none() {
547 let mut svc = WindowMetricsService::default();
548 let window = AppWindowId::from(slotmap::KeyData::from_ffi(5));
549
550 svc.set_color_scheme(window, None);
551
552 assert_eq!(svc.color_scheme(window), None);
553 assert!(svc.color_scheme_is_known(window));
554 }
555
556 #[test]
557 fn window_metrics_contrast_preference_can_be_explicitly_set_to_none() {
558 let mut svc = WindowMetricsService::default();
559 let window = AppWindowId::from(slotmap::KeyData::from_ffi(6));
560
561 svc.set_contrast_preference(window, None);
562
563 assert_eq!(svc.contrast_preference(window), None);
564 assert!(svc.contrast_preference_is_known(window));
565 }
566
567 #[test]
568 fn window_metrics_forced_colors_mode_can_be_explicitly_set_to_none() {
569 let mut svc = WindowMetricsService::default();
570 let window = AppWindowId::from(slotmap::KeyData::from_ffi(7));
571
572 svc.set_forced_colors_mode(window, None);
573
574 assert_eq!(svc.forced_colors_mode(window), None);
575 assert!(svc.forced_colors_mode_is_known(window));
576 }
577
578 #[test]
579 fn window_frame_clock_fixed_delta_is_deterministic() {
580 let mut svc = WindowFrameClockService::default();
581 let window = AppWindowId::from(slotmap::KeyData::from_ffi(8));
582 svc.set_fixed_delta(window, Some(Duration::from_millis(16)));
583
584 svc.record_frame(window, FrameId(1));
585 let s1 = svc.snapshot(window).unwrap();
586 assert_eq!(s1.now_monotonic, Duration::default());
587 assert_eq!(s1.delta, Duration::default());
588
589 svc.record_frame(window, FrameId(1));
591 let s1b = svc.snapshot(window).unwrap();
592 assert_eq!(s1b, s1);
593
594 svc.record_frame(window, FrameId(2));
595 let s2 = svc.snapshot(window).unwrap();
596 assert_eq!(s2.now_monotonic, Duration::from_millis(16));
597 assert_eq!(s2.delta, Duration::from_millis(16));
598
599 svc.record_frame(window, FrameId(3));
600 let s3 = svc.snapshot(window).unwrap();
601 assert_eq!(s3.now_monotonic, Duration::from_millis(32));
602 assert_eq!(s3.delta, Duration::from_millis(16));
603 }
604}