Skip to main content

fret_core/
window.rs

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/// User contrast preference for accessibility.
16///
17/// This is based on the `prefers-contrast` media query vocabulary used on the web. Runners may
18/// supply best-effort values and leave it `None` when unavailable.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ContrastPreference {
21    NoPreference,
22    More,
23    Less,
24    Custom,
25}
26
27/// Forced colors mode (high contrast) preference.
28///
29/// This is based on the `forced-colors` media query vocabulary used on the web. Runners may
30/// supply best-effort values and leave it `None` when unavailable.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ForcedColorsMode {
33    None,
34    Active,
35}
36
37/// Window position in screen space, expressed in **logical pixels** (see ADR 0017).
38///
39/// This is intended for best-effort window placement persistence and multi-window orchestration.
40#[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    /// Returns the process-wide fixed frame delta override (if any).
275    ///
276    /// This is cached (OnceLock) so it can be called from hot paths.
277    ///
278    /// Env var precedence:
279    /// - `FRET_DIAG_FIXED_FRAME_DELTA_MS` (preferred)
280    /// - `FRET_FRAME_CLOCK_FIXED_DELTA_MS` (generic)
281    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    /// Returns the effective fixed frame delta for `window`.
306    ///
307    /// This prefers an explicit per-window override set via `set_fixed_delta`, and falls back to
308    /// the process-wide env override (if any).
309    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    /// Record a best-effort frame clock snapshot for `window` (ADR 0240).
335    ///
336    /// Within a single frame (as identified by `frame_id`), repeated calls are ignored to keep
337    /// `delta` stable.
338    ///
339    /// When a fixed delta override is enabled (via `set_fixed_delta`, or the env vars
340    /// `FRET_DIAG_FIXED_FRAME_DELTA_MS` / `FRET_FRAME_CLOCK_FIXED_DELTA_MS`), the snapshot becomes
341    /// deterministic and advances `now_monotonic` by `delta` each time `frame_id` changes.
342    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        // Same frame id: stable snapshot.
590        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}