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}