Skip to main content

martensite_devtools/
hud.rs

1//! In-app diagnostic HUD overlay.
2//!
3//! Activating F12 toggles a zero-allocation diagnostic overlay that
4//! displays:
5//!
6//! - A rolling 120-frame timing histogram (layout, paint, GPU wait).
7//! - Real-time dirty rect visualization.
8//! - Live `WidgetArena` slot utilization and compaction telemetry.
9//!
10//! # Example
11//!
12//! ```
13//! use martensite_devtools::hud::{DiagnosticHud, FrameTiming, Rect};
14//!
15//! let mut hud = DiagnosticHud::new();
16//! hud.toggle();
17//! assert!(hud.is_enabled());
18//!
19//! hud.record_frame(FrameTiming {
20//!     layout_time_ns: 500_000,
21//!     paint_time_ns: 300_000,
22//!     gpu_wait_time_ns: 100_000,
23//!     total_time_ns: 900_000,
24//! });
25//!
26//! hud.add_dirty_rect(Rect::new(0, 0, 100, 100));
27//! ```
28
29/// Number of frames retained in the rolling histogram.
30const HISTOGRAM_SIZE: usize = 120;
31
32/// Frame timing data for a single frame.
33///
34/// All times are in nanoseconds for consistent units.
35///
36/// # Example
37///
38/// ```
39/// use martensite_devtools::hud::FrameTiming;
40///
41/// let timing = FrameTiming {
42///     layout_time_ns: 500_000,
43///     paint_time_ns: 300_000,
44///     gpu_wait_time_ns: 100_000,
45///     total_time_ns: 900_000,
46/// };
47/// assert_eq!(timing.total_time_ns, 900_000);
48/// ```
49#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50pub struct FrameTiming {
51    /// Time spent in layout passes (nanoseconds).
52    pub layout_time_ns: u64,
53    /// Time spent encoding the paint list (nanoseconds).
54    pub paint_time_ns: u64,
55    /// Time spent waiting for the GPU (nanoseconds).
56    pub gpu_wait_time_ns: u64,
57    /// Total frame time (nanoseconds).
58    pub total_time_ns: u64,
59}
60
61impl FrameTiming {
62    /// Creates a `FrameTiming` from millisecond values.
63    ///
64    /// # Example
65    ///
66    /// ```
67    /// use martensite_devtools::hud::FrameTiming;
68    ///
69    /// let t = FrameTiming::from_ms(0.5, 0.3, 0.1, 0.9);
70    /// assert_eq!(t.layout_time_ns, 500_000);
71    /// ```
72    pub fn from_ms(layout: f64, paint: f64, gpu_wait: f64, total: f64) -> Self {
73        Self {
74            layout_time_ns: (layout * 1_000_000.0) as u64,
75            paint_time_ns: (paint * 1_000_000.0) as u64,
76            gpu_wait_time_ns: (gpu_wait * 1_000_000.0) as u64,
77            total_time_ns: (total * 1_000_000.0) as u64,
78        }
79    }
80
81    /// Adds two `FrameTiming` values component-wise.
82    #[inline]
83    pub fn add(&self, other: &Self) -> Self {
84        Self {
85            layout_time_ns: self.layout_time_ns.saturating_add(other.layout_time_ns),
86            paint_time_ns: self.paint_time_ns.saturating_add(other.paint_time_ns),
87            gpu_wait_time_ns: self.gpu_wait_time_ns.saturating_add(other.gpu_wait_time_ns),
88            total_time_ns: self.total_time_ns.saturating_add(other.total_time_ns),
89        }
90    }
91
92    /// Divides all components by a scalar.
93    #[inline]
94    pub fn div(&self, n: u64) -> Self {
95        if n == 0 {
96            return Self::default();
97        }
98        Self {
99            layout_time_ns: self.layout_time_ns / n,
100            paint_time_ns: self.paint_time_ns / n,
101            gpu_wait_time_ns: self.gpu_wait_time_ns / n,
102            total_time_ns: self.total_time_ns / n,
103        }
104    }
105}
106
107/// Rolling 120-frame timing histogram.
108///
109/// Stores the last `120` frame timings in a fixed-size ring buffer
110/// with zero heap allocation.
111///
112/// # Example
113///
114/// ```
115/// use martensite_devtools::hud::{FrameHistogram, FrameTiming};
116///
117/// let mut hist = FrameHistogram::new();
118/// hist.record(FrameTiming {
119///     layout_time_ns: 500_000,
120///     paint_time_ns: 300_000,
121///     gpu_wait_time_ns: 100_000,
122///     total_time_ns: 900_000,
123/// });
124/// assert_eq!(hist.len(), 1);
125/// let avg = hist.average();
126/// assert_eq!(avg.total_time_ns, 900_000);
127/// ```
128#[derive(Debug, Clone)]
129pub struct FrameHistogram {
130    frames: [FrameTiming; HISTOGRAM_SIZE],
131    index: usize,
132    count: usize,
133}
134
135impl Default for FrameHistogram {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl FrameHistogram {
142    /// Creates a new empty histogram.
143    ///
144    /// # Example
145    ///
146    /// ```
147    /// use martensite_devtools::hud::FrameHistogram;
148    ///
149    /// let hist = FrameHistogram::new();
150    /// assert!(hist.is_empty());
151    /// ```
152    pub fn new() -> Self {
153        Self {
154            frames: [FrameTiming::default(); HISTOGRAM_SIZE],
155            index: 0,
156            count: 0,
157        }
158    }
159
160    /// Records a frame timing into the ring buffer.
161    ///
162    /// # Example
163    ///
164    /// ```
165    /// use martensite_devtools::hud::{FrameHistogram, FrameTiming};
166    ///
167    /// let mut hist = FrameHistogram::new();
168    /// hist.record(FrameTiming { total_time_ns: 16_000_000, ..Default::default() });
169    /// assert_eq!(hist.len(), 1);
170    /// ```
171    pub fn record(&mut self, timing: FrameTiming) {
172        self.frames[self.index] = timing;
173        self.index = (self.index + 1) % HISTOGRAM_SIZE;
174        if self.count < HISTOGRAM_SIZE {
175            self.count += 1;
176        }
177    }
178
179    /// Returns the average frame timing across all recorded frames.
180    ///
181    /// # Example
182    ///
183    /// ```
184    /// use martensite_devtools::hud::{FrameHistogram, FrameTiming};
185    ///
186    /// let mut hist = FrameHistogram::new();
187    /// hist.record(FrameTiming { total_time_ns: 10_000_000, ..Default::default() });
188    /// hist.record(FrameTiming { total_time_ns: 20_000_000, ..Default::default() });
189    /// assert_eq!(hist.average().total_time_ns, 15_000_000);
190    /// ```
191    pub fn average(&self) -> FrameTiming {
192        if self.count == 0 {
193            return FrameTiming::default();
194        }
195        let mut sum = FrameTiming::default();
196        for i in 0..self.count {
197            sum = sum.add(&self.frames[i]);
198        }
199        sum.div(self.count as u64)
200    }
201
202    /// Returns the timing at the given percentile (0.0–100.0).
203    ///
204    /// `p=50` gives the median, `p=99` gives the 99th percentile.
205    ///
206    /// # Example
207    ///
208    /// ```
209    /// use martensite_devtools::hud::{FrameHistogram, FrameTiming};
210    ///
211    /// let mut hist = FrameHistogram::new();
212    /// for i in 1..=100 {
213    ///     hist.record(FrameTiming { total_time_ns: i * 1_000_000, ..Default::default() });
214    /// }
215    /// let p50 = hist.percentile(50.0);
216    /// assert!(p50.total_time_ns >= 49_000_000 && p50.total_time_ns <= 51_000_000);
217    /// ```
218    pub fn percentile(&self, p: f32) -> FrameTiming {
219        if self.count == 0 {
220            return FrameTiming::default();
221        }
222        let mut totals: Vec<u64> = self.frames[..self.count]
223            .iter()
224            .map(|f| f.total_time_ns)
225            .collect();
226        totals.sort_unstable();
227        let idx = ((p.clamp(0.0, 100.0) / 100.0) * (self.count as f32 - 1.0)) as usize;
228        let target = totals[idx];
229        self.frames[..self.count]
230            .iter()
231            .find(|f| f.total_time_ns == target)
232            .copied()
233            .unwrap_or_default()
234    }
235
236    /// Returns the maximum recorded frame timing.
237    ///
238    /// # Example
239    ///
240    /// ```
241    /// use martensite_devtools::hud::{FrameHistogram, FrameTiming};
242    ///
243    /// let mut hist = FrameHistogram::new();
244    /// hist.record(FrameTiming { total_time_ns: 10_000_000, ..Default::default() });
245    /// hist.record(FrameTiming { total_time_ns: 30_000_000, ..Default::default() });
246    /// hist.record(FrameTiming { total_time_ns: 20_000_000, ..Default::default() });
247    /// assert_eq!(hist.max().total_time_ns, 30_000_000);
248    /// ```
249    pub fn max(&self) -> FrameTiming {
250        self.frames[..self.count]
251            .iter()
252            .copied()
253            .max_by_key(|f| f.total_time_ns)
254            .unwrap_or_default()
255    }
256
257    /// Returns a slice of all recorded frame timings.
258    ///
259    /// **Note**: After the ring buffer wraps (more than 120 frames recorded),
260    /// the slice is in physical storage order, not chronological order. The
261    /// newest frame may appear at any position. This is suitable for
262    /// aggregation (average, max, percentile) but not for chronological
263    /// display.
264    ///
265    /// # Example
266    ///
267    /// ```
268    /// use martensite_devtools::hud::{FrameHistogram, FrameTiming};
269    ///
270    /// let mut hist = FrameHistogram::new();
271    /// hist.record(FrameTiming::default());
272    /// assert_eq!(hist.frames().len(), 1);
273    /// ```
274    pub fn frames(&self) -> &[FrameTiming] {
275        &self.frames[..self.count]
276    }
277
278    /// Returns the number of recorded frames.
279    #[inline]
280    pub fn len(&self) -> usize {
281        self.count
282    }
283
284    /// Returns `true` if no frames have been recorded.
285    #[inline]
286    pub fn is_empty(&self) -> bool {
287        self.count == 0
288    }
289
290    /// Clears all recorded frames.
291    pub fn clear(&mut self) {
292        self.index = 0;
293        self.count = 0;
294    }
295}
296
297/// A rectangle for dirty rect tracking.
298///
299/// # Example
300///
301/// ```
302/// use martensite_devtools::hud::Rect;
303///
304/// let r = Rect::new(10, 20, 100, 200);
305/// assert_eq!(r.area(), 20_000);
306/// ```
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
308pub struct Rect {
309    /// X coordinate (pixels from left).
310    pub x: i32,
311    /// Y coordinate (pixels from top).
312    pub y: i32,
313    /// Width in pixels.
314    pub width: u32,
315    /// Height in pixels.
316    pub height: u32,
317}
318
319impl Rect {
320    /// Creates a new rectangle.
321    ///
322    /// # Example
323    ///
324    /// ```
325    /// use martensite_devtools::hud::Rect;
326    ///
327    /// let r = Rect::new(0, 0, 100, 100);
328    /// assert_eq!(r.area(), 10_000);
329    /// ```
330    pub fn new(x: i32, y: i32, width: u32, height: u32) -> Self {
331        Self {
332            x,
333            y,
334            width,
335            height,
336        }
337    }
338
339    /// Returns the area of the rectangle in pixels.
340    ///
341    /// # Example
342    ///
343    /// ```
344    /// use martensite_devtools::hud::Rect;
345    ///
346    /// assert_eq!(Rect::new(0, 0, 100, 50).area(), 5_000);
347    /// ```
348    pub fn area(&self) -> u64 {
349        self.width as u64 * self.height as u64
350    }
351
352    /// Returns `true` if this rectangle intersects another.
353    ///
354    /// # Example
355    ///
356    /// ```
357    /// use martensite_devtools::hud::Rect;
358    ///
359    /// let a = Rect::new(0, 0, 100, 100);
360    /// let b = Rect::new(50, 50, 100, 100);
361    /// let c = Rect::new(200, 200, 50, 50);
362    /// assert!(a.intersects(&b));
363    /// assert!(!a.intersects(&c));
364    /// ```
365    pub fn intersects(&self, other: &Rect) -> bool {
366        let self_right = self.x + self.width as i32;
367        let self_bottom = self.y + self.height as i32;
368        let other_right = other.x + other.width as i32;
369        let other_bottom = other.y + other.height as i32;
370        self.x < other_right
371            && self_right > other.x
372            && self.y < other_bottom
373            && self_bottom > other.y
374    }
375
376    /// Returns the bounding union of two rectangles.
377    ///
378    /// # Example
379    ///
380    /// ```
381    /// use martensite_devtools::hud::Rect;
382    ///
383    /// let a = Rect::new(0, 0, 100, 100);
384    /// let b = Rect::new(50, 50, 100, 100);
385    /// let u = a.union(&b);
386    /// assert_eq!(u.x, 0);
387    /// assert_eq!(u.y, 0);
388    /// assert_eq!(u.width, 150);
389    /// assert_eq!(u.height, 150);
390    /// ```
391    pub fn union(&self, other: &Rect) -> Rect {
392        let x = self.x.min(other.x);
393        let y = self.y.min(other.y);
394        let right = (self.x + self.width as i32).max(other.x + other.width as i32);
395        let bottom = (self.y + self.height as i32).max(other.y + other.height as i32);
396        Rect::new(x, y, (right - x) as u32, (bottom - y) as u32)
397    }
398}
399
400/// Dirty rectangle tracking for visualization.
401///
402/// Tracks repainted screen regions for the diagnostic HUD overlay.
403///
404/// # Example
405///
406/// ```
407/// use martensite_devtools::hud::{DirtyRectTracker, Rect};
408///
409/// let mut tracker = DirtyRectTracker::new(64);
410/// tracker.add(Rect::new(0, 0, 100, 100));
411/// tracker.add(Rect::new(50, 50, 100, 100));
412/// assert_eq!(tracker.rects().len(), 2);
413/// assert!(tracker.total_area() > 0);
414/// ```
415#[derive(Debug, Clone)]
416pub struct DirtyRectTracker {
417    rects: Vec<Rect>,
418    max_rects: usize,
419}
420
421impl DirtyRectTracker {
422    /// Creates a new tracker with the given maximum number of rectangles.
423    ///
424    /// # Example
425    ///
426    /// ```
427    /// use martensite_devtools::hud::DirtyRectTracker;
428    ///
429    /// let tracker = DirtyRectTracker::new(32);
430    /// assert!(tracker.is_empty());
431    /// ```
432    pub fn new(max_rects: usize) -> Self {
433        Self {
434            rects: Vec::with_capacity(max_rects),
435            max_rects,
436        }
437    }
438
439    /// Adds a dirty rectangle. If the tracker is full, the oldest
440    /// rectangle is dropped.
441    ///
442    /// # Example
443    ///
444    /// ```
445    /// use martensite_devtools::hud::{DirtyRectTracker, Rect};
446    ///
447    /// let mut tracker = DirtyRectTracker::new(2);
448    /// tracker.add(Rect::new(0, 0, 10, 10));
449    /// tracker.add(Rect::new(10, 10, 10, 10));
450    /// tracker.add(Rect::new(20, 20, 10, 10)); // drops oldest
451    /// assert_eq!(tracker.rects().len(), 2);
452    /// ```
453    pub fn add(&mut self, rect: Rect) {
454        if self.rects.len() >= self.max_rects {
455            self.rects.remove(0);
456        }
457        self.rects.push(rect);
458    }
459
460    /// Returns the tracked rectangles.
461    #[inline]
462    pub fn rects(&self) -> &[Rect] {
463        &self.rects
464    }
465
466    /// Clears all tracked rectangles.
467    pub fn clear(&mut self) {
468        self.rects.clear();
469    }
470
471    /// Returns the total area of all tracked rectangles.
472    ///
473    /// # Example
474    ///
475    /// ```
476    /// use martensite_devtools::hud::{DirtyRectTracker, Rect};
477    ///
478    /// let mut tracker = DirtyRectTracker::new(10);
479    /// tracker.add(Rect::new(0, 0, 100, 100));
480    /// assert_eq!(tracker.total_area(), 10_000);
481    /// ```
482    pub fn total_area(&self) -> u64 {
483        self.rects.iter().map(|r| r.area()).sum()
484    }
485
486    /// Returns `true` if no rectangles are tracked.
487    #[inline]
488    pub fn is_empty(&self) -> bool {
489        self.rects.is_empty()
490    }
491
492    /// Returns the number of tracked rectangles.
493    #[inline]
494    pub fn len(&self) -> usize {
495        self.rects.len()
496    }
497}
498
499/// Arena memory telemetry data.
500///
501/// Tracks `WidgetArena` slot utilization and compaction statistics.
502///
503/// # Example
504///
505/// ```
506/// use martensite_devtools::hud::ArenaTelemetry;
507///
508/// let telemetry = ArenaTelemetry {
509///     total_slots: 1000,
510///     used_slots: 750,
511///     free_slots: 250,
512///     utilization_pct: 75.0,
513///     compaction_count: 3,
514/// };
515/// assert_eq!(telemetry.free_slots, 250);
516/// ```
517#[derive(Debug, Clone, Copy, Default, PartialEq)]
518pub struct ArenaTelemetry {
519    /// Total number of slots in the arena.
520    pub total_slots: usize,
521    /// Number of slots currently in use.
522    pub used_slots: usize,
523    /// Number of free (available) slots.
524    pub free_slots: usize,
525    /// Slot utilization as a percentage (0.0–100.0).
526    pub utilization_pct: f32,
527    /// Number of compaction passes performed.
528    pub compaction_count: u64,
529}
530
531impl ArenaTelemetry {
532    /// Creates telemetry from slot counts, computing derived fields.
533    ///
534    /// # Example
535    ///
536    /// ```
537    /// use martensite_devtools::hud::ArenaTelemetry;
538    ///
539    /// let t = ArenaTelemetry::from_slots(1000, 750, 2);
540    /// assert_eq!(t.free_slots, 250);
541    /// assert_eq!(t.utilization_pct, 75.0);
542    /// ```
543    pub fn from_slots(total: usize, used: usize, compactions: u64) -> Self {
544        let free = total.saturating_sub(used);
545        let utilization_pct = if total > 0 {
546            (used as f32 / total as f32) * 100.0
547        } else {
548            0.0
549        };
550        Self {
551            total_slots: total,
552            used_slots: used,
553            free_slots: free,
554            utilization_pct,
555            compaction_count: compactions,
556        }
557    }
558}
559
560/// The diagnostic HUD state.
561///
562/// Aggregates frame timing, dirty rect, and arena telemetry data
563/// for the F12 diagnostic overlay.
564///
565/// # Example
566///
567/// ```
568/// use martensite_devtools::hud::{DiagnosticHud, FrameTiming, Rect, ArenaTelemetry};
569///
570/// let mut hud = DiagnosticHud::new();
571/// hud.toggle();
572/// assert!(hud.is_enabled());
573///
574/// hud.record_frame(FrameTiming {
575///     layout_time_ns: 500_000,
576///     paint_time_ns: 300_000,
577///     gpu_wait_time_ns: 100_000,
578///     total_time_ns: 900_000,
579/// });
580/// hud.add_dirty_rect(Rect::new(0, 0, 100, 100));
581/// hud.update_arena_telemetry(ArenaTelemetry::from_slots(1000, 500, 0));
582///
583/// assert_eq!(hud.histogram().len(), 1);
584/// assert_eq!(hud.dirty_rects().len(), 1);
585/// ```
586#[derive(Debug, Clone)]
587pub struct DiagnosticHud {
588    enabled: bool,
589    histogram: FrameHistogram,
590    dirty_rects: DirtyRectTracker,
591    arena_telemetry: ArenaTelemetry,
592}
593
594impl Default for DiagnosticHud {
595    fn default() -> Self {
596        Self::new()
597    }
598}
599
600impl DiagnosticHud {
601    /// Creates a new disabled HUD.
602    ///
603    /// # Example
604    ///
605    /// ```
606    /// use martensite_devtools::hud::DiagnosticHud;
607    ///
608    /// let hud = DiagnosticHud::new();
609    /// assert!(!hud.is_enabled());
610    /// ```
611    pub fn new() -> Self {
612        Self {
613            enabled: false,
614            histogram: FrameHistogram::new(),
615            dirty_rects: DirtyRectTracker::new(256),
616            arena_telemetry: ArenaTelemetry::default(),
617        }
618    }
619
620    /// Toggles the HUD on/off (bound to F12 in the application).
621    ///
622    /// # Example
623    ///
624    /// ```
625    /// use martensite_devtools::hud::DiagnosticHud;
626    ///
627    /// let mut hud = DiagnosticHud::new();
628    /// hud.toggle();
629    /// assert!(hud.is_enabled());
630    /// hud.toggle();
631    /// assert!(!hud.is_enabled());
632    /// ```
633    pub fn toggle(&mut self) {
634        self.enabled = !self.enabled;
635    }
636
637    /// Returns whether the HUD is currently visible.
638    #[inline]
639    pub fn is_enabled(&self) -> bool {
640        self.enabled
641    }
642
643    /// Records a frame's timing data.
644    ///
645    /// # Example
646    ///
647    /// ```
648    /// use martensite_devtools::hud::{DiagnosticHud, FrameTiming};
649    ///
650    /// let mut hud = DiagnosticHud::new();
651    /// hud.record_frame(FrameTiming { total_time_ns: 16_000_000, ..Default::default() });
652    /// assert_eq!(hud.histogram().len(), 1);
653    /// ```
654    pub fn record_frame(&mut self, timing: FrameTiming) {
655        self.histogram.record(timing);
656    }
657
658    /// Adds a dirty rectangle for visualization.
659    pub fn add_dirty_rect(&mut self, rect: Rect) {
660        self.dirty_rects.add(rect);
661    }
662
663    /// Updates the arena telemetry data.
664    pub fn update_arena_telemetry(&mut self, telemetry: ArenaTelemetry) {
665        self.arena_telemetry = telemetry;
666    }
667
668    /// Returns the frame timing histogram.
669    #[inline]
670    pub fn histogram(&self) -> &FrameHistogram {
671        &self.histogram
672    }
673
674    /// Returns the dirty rect tracker.
675    #[inline]
676    pub fn dirty_rects(&self) -> &DirtyRectTracker {
677        &self.dirty_rects
678    }
679
680    /// Returns the arena telemetry.
681    #[inline]
682    pub fn arena_telemetry(&self) -> &ArenaTelemetry {
683        &self.arena_telemetry
684    }
685
686    /// Clears all dirty rects (call after each frame).
687    pub fn clear_dirty_rects(&mut self) {
688        self.dirty_rects.clear();
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    #[test]
697    fn frame_timing_from_ms() {
698        let t = FrameTiming::from_ms(0.5, 0.3, 0.1, 0.9);
699        assert_eq!(t.layout_time_ns, 500_000);
700        assert_eq!(t.paint_time_ns, 300_000);
701        assert_eq!(t.gpu_wait_time_ns, 100_000);
702        assert_eq!(t.total_time_ns, 900_000);
703    }
704
705    #[test]
706    fn frame_timing_add_and_div() {
707        let a = FrameTiming {
708            total_time_ns: 10,
709            ..Default::default()
710        };
711        let b = FrameTiming {
712            total_time_ns: 20,
713            ..Default::default()
714        };
715        let sum = a.add(&b);
716        assert_eq!(sum.total_time_ns, 30);
717        let avg = sum.div(2);
718        assert_eq!(avg.total_time_ns, 15);
719        let div_zero = sum.div(0);
720        assert_eq!(div_zero.total_time_ns, 0);
721    }
722
723    #[test]
724    fn histogram_new_is_empty() {
725        let hist = FrameHistogram::new();
726        assert!(hist.is_empty());
727        assert_eq!(hist.len(), 0);
728    }
729
730    #[test]
731    fn histogram_record_and_average() {
732        let mut hist = FrameHistogram::new();
733        hist.record(FrameTiming {
734            total_time_ns: 10_000_000,
735            ..Default::default()
736        });
737        hist.record(FrameTiming {
738            total_time_ns: 20_000_000,
739            ..Default::default()
740        });
741        assert_eq!(hist.len(), 2);
742        let avg = hist.average();
743        assert_eq!(avg.total_time_ns, 15_000_000);
744    }
745
746    #[test]
747    fn histogram_max() {
748        let mut hist = FrameHistogram::new();
749        hist.record(FrameTiming {
750            total_time_ns: 10,
751            ..Default::default()
752        });
753        hist.record(FrameTiming {
754            total_time_ns: 30,
755            ..Default::default()
756        });
757        hist.record(FrameTiming {
758            total_time_ns: 20,
759            ..Default::default()
760        });
761        assert_eq!(hist.max().total_time_ns, 30);
762    }
763
764    #[test]
765    fn histogram_percentile() {
766        let mut hist = FrameHistogram::new();
767        for i in 1..=100 {
768            hist.record(FrameTiming {
769                total_time_ns: i * 1_000_000,
770                ..Default::default()
771            });
772        }
773        let p50 = hist.percentile(50.0);
774        assert!(p50.total_time_ns >= 49_000_000 && p50.total_time_ns <= 51_000_000);
775        let p99 = hist.percentile(99.0);
776        assert!(p99.total_time_ns >= 98_000_000);
777    }
778
779    #[test]
780    fn histogram_ring_buffer_wrap() {
781        let mut hist = FrameHistogram::new();
782        for i in 0..150 {
783            hist.record(FrameTiming {
784                total_time_ns: i,
785                ..Default::default()
786            });
787        }
788        assert_eq!(hist.len(), 120);
789    }
790
791    #[test]
792    fn histogram_clear() {
793        let mut hist = FrameHistogram::new();
794        hist.record(FrameTiming::default());
795        hist.clear();
796        assert!(hist.is_empty());
797    }
798
799    #[test]
800    fn rect_area() {
801        assert_eq!(Rect::new(0, 0, 100, 50).area(), 5_000);
802        assert_eq!(Rect::new(10, 20, 0, 100).area(), 0);
803    }
804
805    #[test]
806    fn rect_intersects() {
807        let a = Rect::new(0, 0, 100, 100);
808        let b = Rect::new(50, 50, 100, 100);
809        let c = Rect::new(200, 200, 50, 50);
810        assert!(a.intersects(&b));
811        assert!(!a.intersects(&c));
812    }
813
814    #[test]
815    fn rect_union() {
816        let a = Rect::new(0, 0, 100, 100);
817        let b = Rect::new(50, 50, 100, 100);
818        let u = a.union(&b);
819        assert_eq!(u.x, 0);
820        assert_eq!(u.y, 0);
821        assert_eq!(u.width, 150);
822        assert_eq!(u.height, 150);
823    }
824
825    #[test]
826    fn dirty_rect_tracker_basic() {
827        let mut tracker = DirtyRectTracker::new(64);
828        assert!(tracker.is_empty());
829        tracker.add(Rect::new(0, 0, 100, 100));
830        tracker.add(Rect::new(50, 50, 100, 100));
831        assert_eq!(tracker.len(), 2);
832        assert!(!tracker.is_empty());
833        assert!(tracker.total_area() > 0);
834        tracker.clear();
835        assert!(tracker.is_empty());
836    }
837
838    #[test]
839    fn dirty_rect_tracker_eviction() {
840        let mut tracker = DirtyRectTracker::new(2);
841        tracker.add(Rect::new(0, 0, 10, 10));
842        tracker.add(Rect::new(10, 10, 10, 10));
843        tracker.add(Rect::new(20, 20, 10, 10));
844        assert_eq!(tracker.len(), 2);
845        assert_eq!(tracker.rects()[0], Rect::new(10, 10, 10, 10));
846    }
847
848    #[test]
849    fn arena_telemetry_from_slots() {
850        let t = ArenaTelemetry::from_slots(1000, 750, 3);
851        assert_eq!(t.total_slots, 1000);
852        assert_eq!(t.used_slots, 750);
853        assert_eq!(t.free_slots, 250);
854        assert_eq!(t.utilization_pct, 75.0);
855        assert_eq!(t.compaction_count, 3);
856    }
857
858    #[test]
859    fn arena_telemetry_zero_total() {
860        let t = ArenaTelemetry::from_slots(0, 0, 0);
861        assert_eq!(t.utilization_pct, 0.0);
862    }
863
864    #[test]
865    fn hud_toggle() {
866        let mut hud = DiagnosticHud::new();
867        assert!(!hud.is_enabled());
868        hud.toggle();
869        assert!(hud.is_enabled());
870        hud.toggle();
871        assert!(!hud.is_enabled());
872    }
873
874    #[test]
875    fn hud_record_frame_and_dirty_rect() {
876        let mut hud = DiagnosticHud::new();
877        hud.record_frame(FrameTiming {
878            total_time_ns: 16_000_000,
879            ..Default::default()
880        });
881        hud.add_dirty_rect(Rect::new(0, 0, 100, 100));
882        assert_eq!(hud.histogram().len(), 1);
883        assert_eq!(hud.dirty_rects().len(), 1);
884        hud.clear_dirty_rects();
885        assert_eq!(hud.dirty_rects().len(), 0);
886    }
887
888    #[test]
889    fn hud_arena_telemetry_update() {
890        let mut hud = DiagnosticHud::new();
891        hud.update_arena_telemetry(ArenaTelemetry::from_slots(500, 250, 1));
892        assert_eq!(hud.arena_telemetry().used_slots, 250);
893    }
894}