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//! When the `render` feature is enabled, `DiagnosticHud::render_hud`
11//! encodes the overlay into a `martensite_render::PaintList` using simple
12//! fill, stroke, and text commands.
13//!
14//! # Example
15//!
16//! ```
17//! use martensite_devtools::hud::{DiagnosticHud, FrameTiming, Rect};
18//!
19//! let mut hud = DiagnosticHud::new();
20//! hud.toggle();
21//! assert!(hud.is_enabled());
22//!
23//! hud.record_frame(FrameTiming {
24//!     layout_time_ns: 500_000,
25//!     paint_time_ns: 300_000,
26//!     gpu_wait_time_ns: 100_000,
27//!     total_time_ns: 900_000,
28//! });
29//!
30//! hud.add_dirty_rect(Rect::new(0, 0, 100, 100));
31//! ```
32
33#[cfg(feature = "render")]
34use kurbo::{Point, Rect as KurboRect};
35
36#[cfg(feature = "render")]
37use martensite_render::PaintList;
38
39/// Number of frames retained in the rolling histogram.
40const HISTOGRAM_SIZE: usize = 120;
41
42/// Frame timing data for a single frame.
43///
44/// All times are in nanoseconds for consistent units.
45///
46/// # Example
47///
48/// ```
49/// use martensite_devtools::hud::FrameTiming;
50///
51/// let timing = FrameTiming {
52///     layout_time_ns: 500_000,
53///     paint_time_ns: 300_000,
54///     gpu_wait_time_ns: 100_000,
55///     total_time_ns: 900_000,
56/// };
57/// assert_eq!(timing.total_time_ns, 900_000);
58/// ```
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
60pub struct FrameTiming {
61    /// Time spent in layout passes (nanoseconds).
62    pub layout_time_ns: u64,
63    /// Time spent encoding the paint list (nanoseconds).
64    pub paint_time_ns: u64,
65    /// Time spent waiting for the GPU (nanoseconds).
66    pub gpu_wait_time_ns: u64,
67    /// Total frame time (nanoseconds).
68    pub total_time_ns: u64,
69}
70
71impl FrameTiming {
72    /// Creates a `FrameTiming` from millisecond values.
73    ///
74    /// # Example
75    ///
76    /// ```
77    /// use martensite_devtools::hud::FrameTiming;
78    ///
79    /// let t = FrameTiming::from_ms(0.5, 0.3, 0.1, 0.9);
80    /// assert_eq!(t.layout_time_ns, 500_000);
81    /// ```
82    pub fn from_ms(layout: f64, paint: f64, gpu_wait: f64, total: f64) -> Self {
83        Self {
84            layout_time_ns: (layout * 1_000_000.0) as u64,
85            paint_time_ns: (paint * 1_000_000.0) as u64,
86            gpu_wait_time_ns: (gpu_wait * 1_000_000.0) as u64,
87            total_time_ns: (total * 1_000_000.0) as u64,
88        }
89    }
90
91    /// Adds two `FrameTiming` values component-wise.
92    #[inline]
93    pub fn add(&self, other: &Self) -> Self {
94        Self {
95            layout_time_ns: self.layout_time_ns.saturating_add(other.layout_time_ns),
96            paint_time_ns: self.paint_time_ns.saturating_add(other.paint_time_ns),
97            gpu_wait_time_ns: self.gpu_wait_time_ns.saturating_add(other.gpu_wait_time_ns),
98            total_time_ns: self.total_time_ns.saturating_add(other.total_time_ns),
99        }
100    }
101
102    /// Divides all components by a scalar.
103    #[inline]
104    pub fn div(&self, n: u64) -> Self {
105        if n == 0 {
106            return Self::default();
107        }
108        Self {
109            layout_time_ns: self.layout_time_ns / n,
110            paint_time_ns: self.paint_time_ns / n,
111            gpu_wait_time_ns: self.gpu_wait_time_ns / n,
112            total_time_ns: self.total_time_ns / n,
113        }
114    }
115}
116
117/// Rolling 120-frame timing histogram.
118///
119/// Stores the last `120` frame timings in a fixed-size ring buffer
120/// with zero heap allocation.
121///
122/// # Example
123///
124/// ```
125/// use martensite_devtools::hud::{FrameHistogram, FrameTiming};
126///
127/// let mut hist = FrameHistogram::new();
128/// hist.record(FrameTiming {
129///     layout_time_ns: 500_000,
130///     paint_time_ns: 300_000,
131///     gpu_wait_time_ns: 100_000,
132///     total_time_ns: 900_000,
133/// });
134/// assert_eq!(hist.len(), 1);
135/// let avg = hist.average();
136/// assert_eq!(avg.total_time_ns, 900_000);
137/// ```
138#[derive(Debug, Clone)]
139pub struct FrameHistogram {
140    frames: [FrameTiming; HISTOGRAM_SIZE],
141    index: usize,
142    count: usize,
143}
144
145impl Default for FrameHistogram {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151impl FrameHistogram {
152    /// Creates a new empty histogram.
153    ///
154    /// # Example
155    ///
156    /// ```
157    /// use martensite_devtools::hud::FrameHistogram;
158    ///
159    /// let hist = FrameHistogram::new();
160    /// assert!(hist.is_empty());
161    /// ```
162    pub fn new() -> Self {
163        Self {
164            frames: [FrameTiming::default(); HISTOGRAM_SIZE],
165            index: 0,
166            count: 0,
167        }
168    }
169
170    /// Records a frame timing into the ring buffer.
171    ///
172    /// # Example
173    ///
174    /// ```
175    /// use martensite_devtools::hud::{FrameHistogram, FrameTiming};
176    ///
177    /// let mut hist = FrameHistogram::new();
178    /// hist.record(FrameTiming { total_time_ns: 16_000_000, ..Default::default() });
179    /// assert_eq!(hist.len(), 1);
180    /// ```
181    pub fn record(&mut self, timing: FrameTiming) {
182        self.frames[self.index] = timing;
183        self.index = (self.index + 1) % HISTOGRAM_SIZE;
184        if self.count < HISTOGRAM_SIZE {
185            self.count += 1;
186        }
187    }
188
189    /// Returns the average frame timing across all recorded frames.
190    ///
191    /// # Example
192    ///
193    /// ```
194    /// use martensite_devtools::hud::{FrameHistogram, FrameTiming};
195    ///
196    /// let mut hist = FrameHistogram::new();
197    /// hist.record(FrameTiming { total_time_ns: 10_000_000, ..Default::default() });
198    /// hist.record(FrameTiming { total_time_ns: 20_000_000, ..Default::default() });
199    /// assert_eq!(hist.average().total_time_ns, 15_000_000);
200    /// ```
201    pub fn average(&self) -> FrameTiming {
202        if self.count == 0 {
203            return FrameTiming::default();
204        }
205        let mut sum = FrameTiming::default();
206        for i in 0..self.count {
207            sum = sum.add(&self.frames[i]);
208        }
209        sum.div(self.count as u64)
210    }
211
212    /// Returns the timing at the given percentile (0.0–100.0).
213    ///
214    /// `p=50` gives the median, `p=99` gives the 99th percentile.
215    ///
216    /// # Example
217    ///
218    /// ```
219    /// use martensite_devtools::hud::{FrameHistogram, FrameTiming};
220    ///
221    /// let mut hist = FrameHistogram::new();
222    /// for i in 1..=100 {
223    ///     hist.record(FrameTiming { total_time_ns: i * 1_000_000, ..Default::default() });
224    /// }
225    /// let p50 = hist.percentile(50.0);
226    /// assert!(p50.total_time_ns >= 49_000_000 && p50.total_time_ns <= 51_000_000);
227    /// ```
228    pub fn percentile(&self, p: f32) -> FrameTiming {
229        if self.count == 0 {
230            return FrameTiming::default();
231        }
232        let mut totals: Vec<u64> = self.frames[..self.count]
233            .iter()
234            .map(|f| f.total_time_ns)
235            .collect();
236        totals.sort_unstable();
237        let idx = ((p.clamp(0.0, 100.0) / 100.0) * (self.count as f32 - 1.0)) as usize;
238        let target = totals[idx];
239        self.frames[..self.count]
240            .iter()
241            .find(|f| f.total_time_ns == target)
242            .copied()
243            .unwrap_or_default()
244    }
245
246    /// Returns the maximum recorded frame timing.
247    ///
248    /// # Example
249    ///
250    /// ```
251    /// use martensite_devtools::hud::{FrameHistogram, FrameTiming};
252    ///
253    /// let mut hist = FrameHistogram::new();
254    /// hist.record(FrameTiming { total_time_ns: 10_000_000, ..Default::default() });
255    /// hist.record(FrameTiming { total_time_ns: 30_000_000, ..Default::default() });
256    /// hist.record(FrameTiming { total_time_ns: 20_000_000, ..Default::default() });
257    /// assert_eq!(hist.max().total_time_ns, 30_000_000);
258    /// ```
259    pub fn max(&self) -> FrameTiming {
260        self.frames[..self.count]
261            .iter()
262            .copied()
263            .max_by_key(|f| f.total_time_ns)
264            .unwrap_or_default()
265    }
266
267    /// Returns a slice of all recorded frame timings.
268    ///
269    /// **Note**: After the ring buffer wraps (more than 120 frames recorded),
270    /// the slice is in physical storage order, not chronological order. The
271    /// newest frame may appear at any position. This is suitable for
272    /// aggregation (average, max, percentile) but not for chronological
273    /// display.
274    ///
275    /// # Example
276    ///
277    /// ```
278    /// use martensite_devtools::hud::{FrameHistogram, FrameTiming};
279    ///
280    /// let mut hist = FrameHistogram::new();
281    /// hist.record(FrameTiming::default());
282    /// assert_eq!(hist.frames().len(), 1);
283    /// ```
284    pub fn frames(&self) -> &[FrameTiming] {
285        &self.frames[..self.count]
286    }
287
288    /// Returns the number of recorded frames.
289    #[inline]
290    pub fn len(&self) -> usize {
291        self.count
292    }
293
294    /// Returns `true` if no frames have been recorded.
295    #[inline]
296    pub fn is_empty(&self) -> bool {
297        self.count == 0
298    }
299
300    /// Clears all recorded frames.
301    pub fn clear(&mut self) {
302        self.index = 0;
303        self.count = 0;
304    }
305}
306
307/// A rectangle for dirty rect tracking.
308///
309/// # Example
310///
311/// ```
312/// use martensite_devtools::hud::Rect;
313///
314/// let r = Rect::new(10, 20, 100, 200);
315/// assert_eq!(r.area(), 20_000);
316/// ```
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
318pub struct Rect {
319    /// X coordinate (pixels from left).
320    pub x: i32,
321    /// Y coordinate (pixels from top).
322    pub y: i32,
323    /// Width in pixels.
324    pub width: u32,
325    /// Height in pixels.
326    pub height: u32,
327}
328
329impl Rect {
330    /// Creates a new rectangle.
331    ///
332    /// # Example
333    ///
334    /// ```
335    /// use martensite_devtools::hud::Rect;
336    ///
337    /// let r = Rect::new(0, 0, 100, 100);
338    /// assert_eq!(r.area(), 10_000);
339    /// ```
340    pub fn new(x: i32, y: i32, width: u32, height: u32) -> Self {
341        Self {
342            x,
343            y,
344            width,
345            height,
346        }
347    }
348
349    /// Returns the area of the rectangle in pixels.
350    ///
351    /// # Example
352    ///
353    /// ```
354    /// use martensite_devtools::hud::Rect;
355    ///
356    /// assert_eq!(Rect::new(0, 0, 100, 50).area(), 5_000);
357    /// ```
358    pub fn area(&self) -> u64 {
359        self.width as u64 * self.height as u64
360    }
361
362    /// Returns `true` if this rectangle intersects another.
363    ///
364    /// # Example
365    ///
366    /// ```
367    /// use martensite_devtools::hud::Rect;
368    ///
369    /// let a = Rect::new(0, 0, 100, 100);
370    /// let b = Rect::new(50, 50, 100, 100);
371    /// let c = Rect::new(200, 200, 50, 50);
372    /// assert!(a.intersects(&b));
373    /// assert!(!a.intersects(&c));
374    /// ```
375    pub fn intersects(&self, other: &Rect) -> bool {
376        let self_right = self.x + self.width as i32;
377        let self_bottom = self.y + self.height as i32;
378        let other_right = other.x + other.width as i32;
379        let other_bottom = other.y + other.height as i32;
380        self.x < other_right
381            && self_right > other.x
382            && self.y < other_bottom
383            && self_bottom > other.y
384    }
385
386    /// Returns the bounding union of two rectangles.
387    ///
388    /// # Example
389    ///
390    /// ```
391    /// use martensite_devtools::hud::Rect;
392    ///
393    /// let a = Rect::new(0, 0, 100, 100);
394    /// let b = Rect::new(50, 50, 100, 100);
395    /// let u = a.union(&b);
396    /// assert_eq!(u.x, 0);
397    /// assert_eq!(u.y, 0);
398    /// assert_eq!(u.width, 150);
399    /// assert_eq!(u.height, 150);
400    /// ```
401    pub fn union(&self, other: &Rect) -> Rect {
402        let x = self.x.min(other.x);
403        let y = self.y.min(other.y);
404        let right = (self.x + self.width as i32).max(other.x + other.width as i32);
405        let bottom = (self.y + self.height as i32).max(other.y + other.height as i32);
406        Rect::new(x, y, (right - x) as u32, (bottom - y) as u32)
407    }
408}
409
410/// Dirty rectangle tracking for visualization.
411///
412/// Tracks repainted screen regions for the diagnostic HUD overlay.
413///
414/// # Example
415///
416/// ```
417/// use martensite_devtools::hud::{DirtyRectTracker, Rect};
418///
419/// let mut tracker = DirtyRectTracker::new(64);
420/// tracker.add(Rect::new(0, 0, 100, 100));
421/// tracker.add(Rect::new(50, 50, 100, 100));
422/// assert_eq!(tracker.rects().len(), 2);
423/// assert!(tracker.total_area() > 0);
424/// ```
425#[derive(Debug, Clone)]
426pub struct DirtyRectTracker {
427    rects: Vec<Rect>,
428    max_rects: usize,
429}
430
431impl DirtyRectTracker {
432    /// Creates a new tracker with the given maximum number of rectangles.
433    ///
434    /// # Example
435    ///
436    /// ```
437    /// use martensite_devtools::hud::DirtyRectTracker;
438    ///
439    /// let tracker = DirtyRectTracker::new(32);
440    /// assert!(tracker.is_empty());
441    /// ```
442    pub fn new(max_rects: usize) -> Self {
443        Self {
444            rects: Vec::with_capacity(max_rects),
445            max_rects,
446        }
447    }
448
449    /// Adds a dirty rectangle. If the tracker is full, the oldest
450    /// rectangle is dropped.
451    ///
452    /// # Example
453    ///
454    /// ```
455    /// use martensite_devtools::hud::{DirtyRectTracker, Rect};
456    ///
457    /// let mut tracker = DirtyRectTracker::new(2);
458    /// tracker.add(Rect::new(0, 0, 10, 10));
459    /// tracker.add(Rect::new(10, 10, 10, 10));
460    /// tracker.add(Rect::new(20, 20, 10, 10)); // drops oldest
461    /// assert_eq!(tracker.rects().len(), 2);
462    /// ```
463    pub fn add(&mut self, rect: Rect) {
464        if self.rects.len() >= self.max_rects {
465            self.rects.remove(0);
466        }
467        self.rects.push(rect);
468    }
469
470    /// Returns the tracked rectangles.
471    #[inline]
472    pub fn rects(&self) -> &[Rect] {
473        &self.rects
474    }
475
476    /// Clears all tracked rectangles.
477    pub fn clear(&mut self) {
478        self.rects.clear();
479    }
480
481    /// Returns the total area of all tracked rectangles.
482    ///
483    /// # Example
484    ///
485    /// ```
486    /// use martensite_devtools::hud::{DirtyRectTracker, Rect};
487    ///
488    /// let mut tracker = DirtyRectTracker::new(10);
489    /// tracker.add(Rect::new(0, 0, 100, 100));
490    /// assert_eq!(tracker.total_area(), 10_000);
491    /// ```
492    pub fn total_area(&self) -> u64 {
493        self.rects.iter().map(|r| r.area()).sum()
494    }
495
496    /// Returns `true` if no rectangles are tracked.
497    #[inline]
498    pub fn is_empty(&self) -> bool {
499        self.rects.is_empty()
500    }
501
502    /// Returns the number of tracked rectangles.
503    #[inline]
504    pub fn len(&self) -> usize {
505        self.rects.len()
506    }
507}
508
509/// Arena memory telemetry data.
510///
511/// Tracks `WidgetArena` slot utilization and compaction statistics.
512///
513/// # Example
514///
515/// ```
516/// use martensite_devtools::hud::ArenaTelemetry;
517///
518/// let telemetry = ArenaTelemetry {
519///     total_slots: 1000,
520///     used_slots: 750,
521///     free_slots: 250,
522///     utilization_pct: 75.0,
523///     compaction_count: 3,
524/// };
525/// assert_eq!(telemetry.free_slots, 250);
526/// ```
527#[derive(Debug, Clone, Copy, Default, PartialEq)]
528pub struct ArenaTelemetry {
529    /// Total number of slots in the arena.
530    pub total_slots: usize,
531    /// Number of slots currently in use.
532    pub used_slots: usize,
533    /// Number of free (available) slots.
534    pub free_slots: usize,
535    /// Slot utilization as a percentage (0.0–100.0).
536    pub utilization_pct: f32,
537    /// Number of compaction passes performed.
538    pub compaction_count: u64,
539}
540
541impl ArenaTelemetry {
542    /// Creates telemetry from slot counts, computing derived fields.
543    ///
544    /// # Example
545    ///
546    /// ```
547    /// use martensite_devtools::hud::ArenaTelemetry;
548    ///
549    /// let t = ArenaTelemetry::from_slots(1000, 750, 2);
550    /// assert_eq!(t.free_slots, 250);
551    /// assert_eq!(t.utilization_pct, 75.0);
552    /// ```
553    pub fn from_slots(total: usize, used: usize, compactions: u64) -> Self {
554        let free = total.saturating_sub(used);
555        let utilization_pct = if total > 0 {
556            (used as f32 / total as f32) * 100.0
557        } else {
558            0.0
559        };
560        Self {
561            total_slots: total,
562            used_slots: used,
563            free_slots: free,
564            utilization_pct,
565            compaction_count: compactions,
566        }
567    }
568}
569
570/// The diagnostic HUD state.
571///
572/// Aggregates frame timing, dirty rect, and arena telemetry data
573/// for the F12 diagnostic overlay.
574///
575/// # Example
576///
577/// ```
578/// use martensite_devtools::hud::{DiagnosticHud, FrameTiming, Rect, ArenaTelemetry};
579///
580/// let mut hud = DiagnosticHud::new();
581/// hud.toggle();
582/// assert!(hud.is_enabled());
583///
584/// hud.record_frame(FrameTiming {
585///     layout_time_ns: 500_000,
586///     paint_time_ns: 300_000,
587///     gpu_wait_time_ns: 100_000,
588///     total_time_ns: 900_000,
589/// });
590/// hud.add_dirty_rect(Rect::new(0, 0, 100, 100));
591/// hud.update_arena_telemetry(ArenaTelemetry::from_slots(1000, 500, 0));
592///
593/// assert_eq!(hud.histogram().len(), 1);
594/// assert_eq!(hud.dirty_rects().len(), 1);
595/// ```
596#[derive(Debug, Clone)]
597pub struct DiagnosticHud {
598    enabled: bool,
599    histogram: FrameHistogram,
600    dirty_rects: DirtyRectTracker,
601    arena_telemetry: ArenaTelemetry,
602}
603
604impl Default for DiagnosticHud {
605    fn default() -> Self {
606        Self::new()
607    }
608}
609
610impl DiagnosticHud {
611    /// Creates a new disabled HUD.
612    ///
613    /// # Example
614    ///
615    /// ```
616    /// use martensite_devtools::hud::DiagnosticHud;
617    ///
618    /// let hud = DiagnosticHud::new();
619    /// assert!(!hud.is_enabled());
620    /// ```
621    pub fn new() -> Self {
622        Self {
623            enabled: false,
624            histogram: FrameHistogram::new(),
625            dirty_rects: DirtyRectTracker::new(256),
626            arena_telemetry: ArenaTelemetry::default(),
627        }
628    }
629
630    /// Toggles the HUD on/off (bound to F12 in the application).
631    ///
632    /// # Example
633    ///
634    /// ```
635    /// use martensite_devtools::hud::DiagnosticHud;
636    ///
637    /// let mut hud = DiagnosticHud::new();
638    /// hud.toggle();
639    /// assert!(hud.is_enabled());
640    /// hud.toggle();
641    /// assert!(!hud.is_enabled());
642    /// ```
643    pub fn toggle(&mut self) {
644        self.enabled = !self.enabled;
645    }
646
647    /// Returns whether the HUD is currently visible.
648    #[inline]
649    pub fn is_enabled(&self) -> bool {
650        self.enabled
651    }
652
653    /// Records a frame's timing data.
654    ///
655    /// # Example
656    ///
657    /// ```
658    /// use martensite_devtools::hud::{DiagnosticHud, FrameTiming};
659    ///
660    /// let mut hud = DiagnosticHud::new();
661    /// hud.record_frame(FrameTiming { total_time_ns: 16_000_000, ..Default::default() });
662    /// assert_eq!(hud.histogram().len(), 1);
663    /// ```
664    pub fn record_frame(&mut self, timing: FrameTiming) {
665        self.histogram.record(timing);
666    }
667
668    /// Adds a dirty rectangle for visualization.
669    pub fn add_dirty_rect(&mut self, rect: Rect) {
670        self.dirty_rects.add(rect);
671    }
672
673    /// Updates the arena telemetry data.
674    pub fn update_arena_telemetry(&mut self, telemetry: ArenaTelemetry) {
675        self.arena_telemetry = telemetry;
676    }
677
678    /// Returns the frame timing histogram.
679    #[inline]
680    pub fn histogram(&self) -> &FrameHistogram {
681        &self.histogram
682    }
683
684    /// Returns the dirty rect tracker.
685    #[inline]
686    pub fn dirty_rects(&self) -> &DirtyRectTracker {
687        &self.dirty_rects
688    }
689
690    /// Returns the arena telemetry.
691    #[inline]
692    pub fn arena_telemetry(&self) -> &ArenaTelemetry {
693        &self.arena_telemetry
694    }
695
696    /// Clears all dirty rects (call after each frame).
697    pub fn clear_dirty_rects(&mut self) {
698        self.dirty_rects.clear();
699    }
700
701    /// Renders the HUD overlay into the given [`PaintList`].
702    ///
703    /// This is only available when the `render` feature is enabled. The HUD
704    /// is positioned in the top-left corner and uses a semi-transparent
705    /// background for readability. It draws:
706    ///
707    /// - A frame timing histogram (one bar per recorded frame).
708    /// - Dirty rect visualization (outlined rectangles).
709    /// - Arena telemetry (text).
710    /// - Memory usage (text).
711    ///
712    /// # Example
713    ///
714    /// ```no_run
715    /// # #[cfg(feature = "render")]
716    /// # {
717    /// use martensite_devtools::hud::{DiagnosticHud, FrameTiming, Rect};
718    /// use martensite_render::PaintList;
719    ///
720    /// let mut hud = DiagnosticHud::new();
721    /// hud.toggle();
722    /// hud.record_frame(FrameTiming { total_time_ns: 16_000_000, ..Default::default() });
723    /// hud.add_dirty_rect(Rect::new(10, 10, 50, 50));
724    ///
725    /// let mut paint = PaintList::new();
726    /// hud.render_hud(&mut paint);
727    /// assert!(!paint.is_empty());
728    /// # }
729    /// ```
730    #[cfg(feature = "render")]
731    pub fn render_hud(&self, paint: &mut PaintList) {
732        // --- Semi-transparent background panel (top-left corner). ---
733        let bg = KurboRect::new(HUD_X, HUD_Y, HUD_X + HUD_WIDTH, HUD_Y + HUD_HEIGHT);
734        paint.push_fill_rect(bg, HUD_BG_COLOR);
735
736        // --- Frame timing histogram (bars). ---
737        let hist_origin_y = HUD_Y + HUD_PADDING + HUD_TITLE_SIZE as f64 + 4.0;
738        let hist_bottom = hist_origin_y + HUD_HISTOGRAM_HEIGHT;
739        let bar_area_width = HUD_WIDTH - 2.0 * HUD_PADDING;
740        let frames = self.histogram.frames();
741        let max_total = frames
742            .iter()
743            .map(|f| f.total_time_ns)
744            .max()
745            .unwrap_or(1)
746            .max(1);
747        let bar_width = if frames.is_empty() {
748            bar_area_width
749        } else {
750            (bar_area_width / frames.len() as f64).max(1.0)
751        };
752        for (i, frame) in frames.iter().enumerate() {
753            let bar_x = HUD_X + HUD_PADDING + i as f64 * bar_width;
754            let bar_h = (frame.total_time_ns as f64 / max_total as f64) * HUD_HISTOGRAM_HEIGHT;
755            let bar = KurboRect::new(bar_x, hist_bottom - bar_h, bar_x + bar_width, hist_bottom);
756            let color = if frame.total_time_ns > 16_666_666 {
757                HUD_BAR_COLOR_SLOW
758            } else {
759                HUD_BAR_COLOR_OK
760            };
761            paint.push_fill_rect(bar, color);
762        }
763
764        // Outline the histogram region.
765        let hist_outline = KurboRect::new(
766            HUD_X + HUD_PADDING,
767            hist_origin_y,
768            HUD_X + HUD_PADDING + bar_area_width,
769            hist_bottom,
770        );
771        paint.push_stroke_rect(hist_outline, 1.0, HUD_OUTLINE_COLOR);
772
773        // --- Text: title and averages. ---
774        let text_x = HUD_X + HUD_PADDING;
775        let mut text_y = hist_bottom + 16.0;
776        paint.push_text(
777            Point::new(text_x, HUD_Y + HUD_PADDING + HUD_TITLE_SIZE as f64),
778            "Martensite HUD".to_string(),
779            HUD_TITLE_SIZE,
780            HUD_TEXT_COLOR,
781        );
782
783        let avg = self.histogram.average();
784        let avg_ms = avg.total_time_ns as f64 / 1_000_000.0;
785        paint.push_text(
786            Point::new(text_x, text_y),
787            format!("avg frame: {avg_ms:.2} ms"),
788            HUD_TEXT_SIZE,
789            HUD_TEXT_COLOR,
790        );
791        text_y += HUD_LINE_HEIGHT;
792
793        let max = self.histogram.max();
794        let max_ms = max.total_time_ns as f64 / 1_000_000.0;
795        paint.push_text(
796            Point::new(text_x, text_y),
797            format!("max frame: {max_ms:.2} ms"),
798            HUD_TEXT_SIZE,
799            HUD_TEXT_COLOR,
800        );
801        text_y += HUD_LINE_HEIGHT;
802
803        // --- Arena telemetry (text). ---
804        let t = &self.arena_telemetry;
805        paint.push_text(
806            Point::new(text_x, text_y),
807            format!(
808                "arena: {}/{} slots ({:.1}%)",
809                t.used_slots, t.total_slots, t.utilization_pct
810            ),
811            HUD_TEXT_SIZE,
812            HUD_TEXT_COLOR,
813        );
814        text_y += HUD_LINE_HEIGHT;
815        paint.push_text(
816            Point::new(text_x, text_y),
817            format!("compactions: {}", t.compaction_count),
818            HUD_TEXT_SIZE,
819            HUD_TEXT_COLOR,
820        );
821        text_y += HUD_LINE_HEIGHT;
822
823        // --- Memory usage (text). ---
824        let mem_bytes = t.used_slots * 64; // approximate bytes per slot
825        paint.push_text(
826            Point::new(text_x, text_y),
827            format!("mem (est): {} KB", mem_bytes / 1024),
828            HUD_TEXT_SIZE,
829            HUD_TEXT_COLOR,
830        );
831
832        // --- Dirty rect visualization (outlined rectangles). ---
833        for rect in self.dirty_rects.rects() {
834            let dirty = KurboRect::new(
835                rect.x as f64,
836                rect.y as f64,
837                (rect.x + rect.width as i32) as f64,
838                (rect.y + rect.height as i32) as f64,
839            );
840            paint.push_stroke_rect(dirty, 2.0, HUD_DIRTY_COLOR);
841        }
842    }
843
844    /// Paints the HUD overlay into the given [`PaintList`].
845    ///
846    /// This is a convenience alias for [`DiagnosticHud::render_hud`].
847    ///
848    /// # Example
849    ///
850    /// ```no_run
851    /// # #[cfg(feature = "render")]
852    /// # {
853    /// use martensite_devtools::hud::{DiagnosticHud, FrameTiming};
854    /// use martensite_render::PaintList;
855    ///
856    /// let mut hud = DiagnosticHud::new();
857    /// hud.toggle();
858    /// hud.record_frame(FrameTiming { total_time_ns: 16_000_000, ..Default::default() });
859    ///
860    /// let mut paint = PaintList::new();
861    /// hud.paint(&mut paint);
862    /// assert!(!paint.is_empty());
863    /// # }
864    /// ```
865    #[cfg(feature = "render")]
866    pub fn paint(&self, paint: &mut PaintList) {
867        self.render_hud(paint);
868    }
869}
870
871/// HUD layout and color constants (only compiled with the `render` feature).
872#[cfg(feature = "render")]
873mod hud_layout {
874    /// X offset of the HUD panel (pixels from left).
875    pub(super) const HUD_X: f64 = 8.0;
876    /// Y offset of the HUD panel (pixels from top).
877    pub(super) const HUD_Y: f64 = 8.0;
878    /// Width of the HUD panel.
879    pub(super) const HUD_WIDTH: f64 = 320.0;
880    /// Height of the HUD panel.
881    pub(super) const HUD_HEIGHT: f64 = 220.0;
882    /// Inner padding of the HUD panel.
883    pub(super) const HUD_PADDING: f64 = 8.0;
884    /// Font size of the HUD title.
885    pub(super) const HUD_TITLE_SIZE: f32 = 14.0;
886    /// Font size of HUD body text.
887    pub(super) const HUD_TEXT_SIZE: f32 = 12.0;
888    /// Line height for stacked text lines.
889    pub(super) const HUD_LINE_HEIGHT: f64 = 16.0;
890    /// Height of the histogram bar region.
891    pub(super) const HUD_HISTOGRAM_HEIGHT: f64 = 60.0;
892    /// Semi-transparent dark background color.
893    pub(super) const HUD_BG_COLOR: [u8; 4] = [20, 20, 30, 200];
894    /// Outline color for the histogram region.
895    pub(super) const HUD_OUTLINE_COLOR: [u8; 4] = [120, 120, 140, 255];
896    /// Bar color for frames within the 60 fps budget (green).
897    pub(super) const HUD_BAR_COLOR_OK: [u8; 4] = [80, 200, 120, 255];
898    /// Bar color for frames exceeding the 60 fps budget (red).
899    pub(super) const HUD_BAR_COLOR_SLOW: [u8; 4] = [220, 80, 80, 255];
900    /// Text color (light gray).
901    pub(super) const HUD_TEXT_COLOR: [u8; 4] = [230, 230, 230, 255];
902    /// Dirty rect outline color (cyan).
903    pub(super) const HUD_DIRTY_COLOR: [u8; 4] = [80, 200, 220, 255];
904}
905
906#[cfg(feature = "render")]
907use hud_layout::*;
908
909#[cfg(test)]
910mod tests {
911    use super::*;
912
913    #[test]
914    fn frame_timing_from_ms() {
915        let t = FrameTiming::from_ms(0.5, 0.3, 0.1, 0.9);
916        assert_eq!(t.layout_time_ns, 500_000);
917        assert_eq!(t.paint_time_ns, 300_000);
918        assert_eq!(t.gpu_wait_time_ns, 100_000);
919        assert_eq!(t.total_time_ns, 900_000);
920    }
921
922    #[test]
923    fn frame_timing_add_and_div() {
924        let a = FrameTiming {
925            total_time_ns: 10,
926            ..Default::default()
927        };
928        let b = FrameTiming {
929            total_time_ns: 20,
930            ..Default::default()
931        };
932        let sum = a.add(&b);
933        assert_eq!(sum.total_time_ns, 30);
934        let avg = sum.div(2);
935        assert_eq!(avg.total_time_ns, 15);
936        let div_zero = sum.div(0);
937        assert_eq!(div_zero.total_time_ns, 0);
938    }
939
940    #[test]
941    fn histogram_new_is_empty() {
942        let hist = FrameHistogram::new();
943        assert!(hist.is_empty());
944        assert_eq!(hist.len(), 0);
945    }
946
947    #[test]
948    fn histogram_record_and_average() {
949        let mut hist = FrameHistogram::new();
950        hist.record(FrameTiming {
951            total_time_ns: 10_000_000,
952            ..Default::default()
953        });
954        hist.record(FrameTiming {
955            total_time_ns: 20_000_000,
956            ..Default::default()
957        });
958        assert_eq!(hist.len(), 2);
959        let avg = hist.average();
960        assert_eq!(avg.total_time_ns, 15_000_000);
961    }
962
963    #[test]
964    fn histogram_max() {
965        let mut hist = FrameHistogram::new();
966        hist.record(FrameTiming {
967            total_time_ns: 10,
968            ..Default::default()
969        });
970        hist.record(FrameTiming {
971            total_time_ns: 30,
972            ..Default::default()
973        });
974        hist.record(FrameTiming {
975            total_time_ns: 20,
976            ..Default::default()
977        });
978        assert_eq!(hist.max().total_time_ns, 30);
979    }
980
981    #[test]
982    fn histogram_percentile() {
983        let mut hist = FrameHistogram::new();
984        for i in 1..=100 {
985            hist.record(FrameTiming {
986                total_time_ns: i * 1_000_000,
987                ..Default::default()
988            });
989        }
990        let p50 = hist.percentile(50.0);
991        assert!(p50.total_time_ns >= 49_000_000 && p50.total_time_ns <= 51_000_000);
992        let p99 = hist.percentile(99.0);
993        assert!(p99.total_time_ns >= 98_000_000);
994    }
995
996    #[test]
997    fn histogram_ring_buffer_wrap() {
998        let mut hist = FrameHistogram::new();
999        for i in 0..150 {
1000            hist.record(FrameTiming {
1001                total_time_ns: i,
1002                ..Default::default()
1003            });
1004        }
1005        assert_eq!(hist.len(), 120);
1006    }
1007
1008    #[test]
1009    fn histogram_clear() {
1010        let mut hist = FrameHistogram::new();
1011        hist.record(FrameTiming::default());
1012        hist.clear();
1013        assert!(hist.is_empty());
1014    }
1015
1016    #[test]
1017    fn rect_area() {
1018        assert_eq!(Rect::new(0, 0, 100, 50).area(), 5_000);
1019        assert_eq!(Rect::new(10, 20, 0, 100).area(), 0);
1020    }
1021
1022    #[test]
1023    fn rect_intersects() {
1024        let a = Rect::new(0, 0, 100, 100);
1025        let b = Rect::new(50, 50, 100, 100);
1026        let c = Rect::new(200, 200, 50, 50);
1027        assert!(a.intersects(&b));
1028        assert!(!a.intersects(&c));
1029    }
1030
1031    #[test]
1032    fn rect_union() {
1033        let a = Rect::new(0, 0, 100, 100);
1034        let b = Rect::new(50, 50, 100, 100);
1035        let u = a.union(&b);
1036        assert_eq!(u.x, 0);
1037        assert_eq!(u.y, 0);
1038        assert_eq!(u.width, 150);
1039        assert_eq!(u.height, 150);
1040    }
1041
1042    #[test]
1043    fn dirty_rect_tracker_basic() {
1044        let mut tracker = DirtyRectTracker::new(64);
1045        assert!(tracker.is_empty());
1046        tracker.add(Rect::new(0, 0, 100, 100));
1047        tracker.add(Rect::new(50, 50, 100, 100));
1048        assert_eq!(tracker.len(), 2);
1049        assert!(!tracker.is_empty());
1050        assert!(tracker.total_area() > 0);
1051        tracker.clear();
1052        assert!(tracker.is_empty());
1053    }
1054
1055    #[test]
1056    fn dirty_rect_tracker_eviction() {
1057        let mut tracker = DirtyRectTracker::new(2);
1058        tracker.add(Rect::new(0, 0, 10, 10));
1059        tracker.add(Rect::new(10, 10, 10, 10));
1060        tracker.add(Rect::new(20, 20, 10, 10));
1061        assert_eq!(tracker.len(), 2);
1062        assert_eq!(tracker.rects()[0], Rect::new(10, 10, 10, 10));
1063    }
1064
1065    #[test]
1066    fn arena_telemetry_from_slots() {
1067        let t = ArenaTelemetry::from_slots(1000, 750, 3);
1068        assert_eq!(t.total_slots, 1000);
1069        assert_eq!(t.used_slots, 750);
1070        assert_eq!(t.free_slots, 250);
1071        assert_eq!(t.utilization_pct, 75.0);
1072        assert_eq!(t.compaction_count, 3);
1073    }
1074
1075    #[test]
1076    fn arena_telemetry_zero_total() {
1077        let t = ArenaTelemetry::from_slots(0, 0, 0);
1078        assert_eq!(t.utilization_pct, 0.0);
1079    }
1080
1081    #[test]
1082    fn hud_toggle() {
1083        let mut hud = DiagnosticHud::new();
1084        assert!(!hud.is_enabled());
1085        hud.toggle();
1086        assert!(hud.is_enabled());
1087        hud.toggle();
1088        assert!(!hud.is_enabled());
1089    }
1090
1091    #[test]
1092    fn hud_record_frame_and_dirty_rect() {
1093        let mut hud = DiagnosticHud::new();
1094        hud.record_frame(FrameTiming {
1095            total_time_ns: 16_000_000,
1096            ..Default::default()
1097        });
1098        hud.add_dirty_rect(Rect::new(0, 0, 100, 100));
1099        assert_eq!(hud.histogram().len(), 1);
1100        assert_eq!(hud.dirty_rects().len(), 1);
1101        hud.clear_dirty_rects();
1102        assert_eq!(hud.dirty_rects().len(), 0);
1103    }
1104
1105    #[test]
1106    fn hud_arena_telemetry_update() {
1107        let mut hud = DiagnosticHud::new();
1108        hud.update_arena_telemetry(ArenaTelemetry::from_slots(500, 250, 1));
1109        assert_eq!(hud.arena_telemetry().used_slots, 250);
1110    }
1111
1112    #[cfg(feature = "render")]
1113    #[test]
1114    fn render_hud_emits_background_fill() {
1115        let mut hud = DiagnosticHud::new();
1116        hud.toggle();
1117        let mut paint = PaintList::new();
1118        hud.render_hud(&mut paint);
1119        // The first command must be the semi-transparent background fill.
1120        assert!(!paint.is_empty());
1121        assert!(matches!(
1122            paint.commands[0],
1123            martensite_render::PaintCommand::FillRect(..)
1124        ));
1125    }
1126
1127    #[cfg(feature = "render")]
1128    #[test]
1129    fn render_hud_emits_histogram_bars_for_recorded_frames() {
1130        let mut hud = DiagnosticHud::new();
1131        hud.toggle();
1132        hud.record_frame(FrameTiming {
1133            total_time_ns: 16_000_000,
1134            ..Default::default()
1135        });
1136        hud.record_frame(FrameTiming {
1137            total_time_ns: 8_000_000,
1138            ..Default::default()
1139        });
1140        let mut paint = PaintList::new();
1141        hud.render_hud(&mut paint);
1142        // Background + 2 bars + 1 outline = at least 4 fill/stroke rects.
1143        let fills = paint
1144            .commands
1145            .iter()
1146            .filter(|c| matches!(c, martensite_render::PaintCommand::FillRect(..)))
1147            .count();
1148        assert!(
1149            fills >= 3,
1150            "expected at least 3 FillRect commands, got {fills}"
1151        );
1152    }
1153
1154    #[cfg(feature = "render")]
1155    #[test]
1156    fn render_hud_emits_dirty_rect_strokes() {
1157        let mut hud = DiagnosticHud::new();
1158        hud.toggle();
1159        hud.add_dirty_rect(Rect::new(10, 20, 30, 40));
1160        let mut paint = PaintList::new();
1161        hud.render_hud(&mut paint);
1162        // At least one StrokeRect for the dirty rect visualization.
1163        let strokes = paint
1164            .commands
1165            .iter()
1166            .filter(|c| matches!(c, martensite_render::PaintCommand::StrokeRect(..)))
1167            .count();
1168        assert!(
1169            strokes >= 2,
1170            "expected at least 2 StrokeRect commands (outline + dirty rect), got {strokes}"
1171        );
1172    }
1173
1174    #[cfg(feature = "render")]
1175    #[test]
1176    fn render_hud_emits_text_commands() {
1177        let mut hud = DiagnosticHud::new();
1178        hud.toggle();
1179        hud.update_arena_telemetry(ArenaTelemetry::from_slots(1000, 750, 2));
1180        let mut paint = PaintList::new();
1181        hud.render_hud(&mut paint);
1182        let texts = paint
1183            .commands
1184            .iter()
1185            .filter(|c| matches!(c, martensite_render::PaintCommand::DrawText(..)))
1186            .count();
1187        // Title + avg + max + arena + compactions + mem = 6 text lines.
1188        assert!(
1189            texts >= 6,
1190            "expected at least 6 DrawText commands, got {texts}"
1191        );
1192        // Verify the arena telemetry text contains the slot count.
1193        let has_arena_text = paint.commands.iter().any(|c| {
1194            if let martensite_render::PaintCommand::DrawText(_, text, _, _) = c {
1195                text.contains("arena")
1196            } else {
1197                false
1198            }
1199        });
1200        assert!(has_arena_text, "expected an arena telemetry text line");
1201    }
1202
1203    #[cfg(feature = "render")]
1204    #[test]
1205    fn render_hud_disabled_produces_commands_anyway() {
1206        // render_hud always emits commands regardless of the enabled flag;
1207        // the caller is responsible for gating on is_enabled().
1208        let hud = DiagnosticHud::new();
1209        assert!(!hud.is_enabled());
1210        let mut paint = PaintList::new();
1211        hud.render_hud(&mut paint);
1212        assert!(!paint.is_empty());
1213    }
1214
1215    #[cfg(feature = "render")]
1216    #[test]
1217    fn render_hud_empty_histogram_does_not_panic() {
1218        let mut hud = DiagnosticHud::new();
1219        hud.toggle();
1220        let mut paint = PaintList::new();
1221        // No frames recorded — must not divide by zero or panic.
1222        hud.render_hud(&mut paint);
1223        assert!(!paint.is_empty());
1224    }
1225}