gpui-fps 0.6.0

Realtime FPS, frame time, CPU, GPU and memory HUD for GPUI applications.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
use std::time::Duration;

use web_time::Instant;

use gpui::{
    Bounds, Context, Div, Hsla, InteractiveElement as _, IntoElement, ParentElement, PathBuilder,
    Pixels, Point, Render, StatefulInteractiveElement as _, Styled, Window, canvas, div, point,
    prelude::FluentBuilder as _, px, relative,
};

#[cfg(not(target_family = "wasm"))]
use gpui::Task;

use crate::{
    FrameTraceGuard,
    sampler::{FrameSampler, ResourceSample, minimum_resource_interval},
    style::FpsStyle,
};

/// One frame at 60Hz, the default budget a frame is judged against.
const DEFAULT_FRAME_BUDGET: Duration = Duration::from_nanos(16_666_667);
const DEFAULT_CAPACITY: usize = 120;
const DEFAULT_RESOURCE_INTERVAL: Duration = Duration::from_millis(500);

/// How far back CPU, memory and GPU are averaged over. At the default interval
/// that is six readings: long enough to settle the churn between one sample and
/// the next, short enough that a real change reaches the HUD while the reader
/// is still looking at what caused it.
#[cfg(not(target_family = "wasm"))]
const RESOURCE_WINDOW: Duration = Duration::from_secs(3);

/// Which frame the `P95` row reports. The 95th rather than the 99th: the chart
/// keeps 120 frames by default, so the 99th is the second slowest of them — one
/// frame, which moves the row on its own and reads as noise.
const FRAME_PERCENTILE: f32 = 0.95;

/// How fast the chart's y axis relaxes back down after a spike. Growth is
/// immediate so a slow frame is never clipped, while the decay is gradual so
/// the bars don't visibly rescale every frame.
const AXIS_DECAY: f32 = 0.04;

/// A fixed width keeps every row flush with the chart and stops the HUD from
/// resizing as the readings gain or lose digits. Collapsed, the HUD hugs its
/// text instead and only the figure gets a fixed box.
const HUD_WIDTH: Pixels = px(172.);
const COMPACT_FIGURE_WIDTH: Pixels = px(25.);

/// Size of every label and reading. Collapsed, the figure uses it too.
const TEXT_SIZE: Pixels = px(10.);

/// The trace sits behind the headline, so it is dimmed enough to stay out of
/// the figure's way while still showing its shape and color.
const TRACE_OPACITY: f32 = 0.35;

/// Tall enough to give the trace room to show its shape around the figure.
const HEADLINE_HEIGHT: Pixels = px(35.);

/// The headline figure. Its box has to fit four digits at [`FIGURE_SIZE`] —
/// a monospace digit runs about 0.6em, and an uncapped frame rate on a small
/// window reaches four figures — or the reading is clipped instead of merely
/// looking cramped.
const FIGURE_SIZE: Pixels = px(28.);
const FIGURE_WIDTH: Pixels = px(70.);

/// Width of the `FPS` unit, and of the empty box mirroring it on the other side
/// of the figure so the figure lands on the HUD's true center.
const UNIT_WIDTH: Pixels = px(22.);

/// How often the numbers are recomputed.
///
/// The trace keeps up with every frame, but the readings do not: recomputed
/// per frame they flicker through digits too fast to read, and the eye tracks
/// the churn rather than the value. Twice a second is slow enough to read and
/// fast enough to feel live.
const READOUT_INTERVAL: Duration = Duration::from_millis(500);

/// Fraction of the target frame rate that still counts as meeting it. Vsync and
/// the sampling window each cost a frame or so a second, so a 60Hz display that
/// is keeping up perfectly reports 58 to 60, never a flat 60.
const FPS_TOLERANCE: f32 = 0.95;

/// A monospace family that ships with the platform, so the value column stays
/// aligned without the application having to configure a font. The generic
/// `monospace` alias is not resolvable by every platform's font backend, hence
/// the concrete names.
#[cfg(target_os = "macos")]
const DEFAULT_FONT: &str = "Menlo";
#[cfg(target_os = "windows")]
const DEFAULT_FONT: &str = "Consolas";
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
const DEFAULT_FONT: &str = "monospace";

/// A realtime performance HUD: frames per second, a rolling frame time chart,
/// and this process' GPU, CPU and memory usage.
///
/// This is a view rather than a stateless component on purpose. Driving
/// continuous redraws goes through [`Window::request_animation_frame`], which
/// notifies the *current* view — from inside a stateless component that would
/// be the parent, forcing the whole parent tree to redraw every frame. As its
/// own view, only the HUD subtree repaints.
///
/// ```no_run
/// # use gpui::*;
/// # use gpui_fps::FpsMonitor;
/// # fn example(window: &mut Window, cx: &mut App) {
/// let monitor = cx.new(|cx| FpsMonitor::new(window, cx).capacity(240));
/// # }
/// ```
/// The numbers as last published to the screen.
#[derive(Clone, Copy, Default)]
struct Readout {
    /// Frames presented per second.
    fps: f32,
    /// Mean time between presents, in milliseconds: the platform overlay's
    /// "frame interval", and `1000 / fps`.
    interval_millis: f32,
    /// Mean `Window::draw` cost of the retained frames, in milliseconds.
    frame_millis: f32,
    /// The slow tail of the same frames `frame_millis` is the mean of.
    percentile_millis: f32,
    dropped_percent: f32,
    /// Mean invalidations coalesced into one frame; one means none were wasted.
    invalidations: f32,
}

pub struct FpsMonitor {
    sampler: FrameSampler,
    readout: Readout,
    readout_at: Option<Instant>,
    style: FpsStyle,
    frame_budget: Duration,
    continuous: bool,
    show_resources: bool,
    resource_interval: Duration,
    resources: Option<ResourceSample>,
    compact: bool,
    /// Upper bound of the chart's y axis, in seconds.
    axis_max: f32,
    #[cfg(not(target_family = "wasm"))]
    resource_task: Option<Task<()>>,
    _frame_trace: FrameTraceGuard,
}

impl FpsMonitor {
    pub fn new(window: &Window, _cx: &mut Context<Self>) -> Self {
        let frame_budget = DEFAULT_FRAME_BUDGET;
        Self {
            sampler: FrameSampler::new(window.window_handle().window_id(), DEFAULT_CAPACITY),
            readout: Readout::default(),
            readout_at: None,
            style: FpsStyle::default(),
            frame_budget,
            continuous: true,
            show_resources: true,
            resource_interval: DEFAULT_RESOURCE_INTERVAL,
            resources: None,
            compact: false,
            axis_max: frame_budget.as_secs_f32() * 2.,
            #[cfg(not(target_family = "wasm"))]
            resource_task: None,
            _frame_trace: FrameTraceGuard::acquire(),
        }
    }

    /// How many frames the chart keeps. Defaults to 120.
    pub fn capacity(mut self, capacity: usize) -> Self {
        self.sampler.set_capacity(capacity);
        self
    }

    /// The per-frame budget used for the chart's baseline and bar colors.
    /// Defaults to one 60Hz frame; set it to `1/144s` on a high refresh rate
    /// display.
    pub fn frame_budget(mut self, budget: Duration) -> Self {
        self.frame_budget = budget;
        self.axis_max = budget.as_secs_f32() * 2.;
        self
    }

    /// Whether to request a frame on every render, keeping the window drawing
    /// back to back. Defaults to `true`.
    ///
    /// This is what makes the readout behave like an in-game FPS counter, and
    /// it has the same caveat: the window never idles, so the number is the
    /// frame rate the application *can* sustain, not the rate it happens to be
    /// drawing at. Turn it off to measure the real workload — the HUD then only
    /// updates when the window redraws for its own reasons, and reads zero
    /// while the window is idle.
    pub fn continuous(mut self, continuous: bool) -> Self {
        self.continuous = continuous;
        self
    }

    pub(crate) fn set_frame_budget(&mut self, budget: Duration) {
        self.frame_budget = budget;
        self.axis_max = budget.as_secs_f32() * 2.;
    }

    pub(crate) fn set_continuous(&mut self, continuous: bool) {
        self.continuous = continuous;
    }

    /// Whether to sample and show CPU, memory and GPU usage. Defaults to
    /// `true`, and is always off on the web.
    ///
    /// The GPU reading is left out on its own where the platform publishes no
    /// counter for it, so turning this on does not guarantee three readings.
    pub fn show_resources(mut self, show_resources: bool) -> Self {
        self.show_resources = show_resources;
        self
    }

    /// How often CPU, memory and GPU are resampled. Defaults to 500ms, and is
    /// clamped up to the shortest interval that yields a meaningful CPU delta.
    pub fn resource_interval(mut self, interval: Duration) -> Self {
        self.resource_interval = interval;
        self
    }

    /// Sampling starts on the first render rather than in `new` so that the
    /// builder methods have already been applied by the time the interval is
    /// read.
    #[cfg(not(target_family = "wasm"))]
    fn start_resource_sampling(&mut self, cx: &mut Context<Self>) {
        use crate::sampler::ResourceProbe;

        if !self.show_resources || self.resource_task.is_some() {
            return;
        }

        let interval = self.resource_interval.max(minimum_resource_interval());
        self.resource_task = Some(cx.spawn(async move |this, cx| {
            let executor = cx.background_executor().clone();
            // Probing walks the process table, so it never runs on the render
            // thread. The probe moves in and out of each background task rather
            // than living behind a lock.
            let Some(mut probe) = executor
                .spawn(async { ResourceProbe::new(RESOURCE_WINDOW) })
                .await
            else {
                return;
            };

            loop {
                executor.timer(interval).await;

                let (returned, sample) = executor
                    .spawn(async move {
                        let sample = probe.sample();
                        (probe, sample)
                    })
                    .await;
                probe = returned;

                let Some(sample) = sample else { continue };
                let updated = this.update(cx, |this, cx| {
                    this.resources = Some(sample);
                    cx.notify();
                });
                if updated.is_err() {
                    break;
                }
            }
        }));
    }

    #[cfg(target_family = "wasm")]
    fn start_resource_sampling(&mut self, _cx: &mut Context<Self>) {
        let _ = minimum_resource_interval();
    }

    /// Republishes the readings if [`READOUT_INTERVAL`] has passed.
    fn update_readout(&mut self) {
        let now = Instant::now();
        let due = self
            .readout_at
            .is_none_or(|at| now.duration_since(at) >= READOUT_INTERVAL);
        if !due {
            return;
        }

        self.readout = Readout {
            fps: self.sampler.fps(),
            interval_millis: self.sampler.present_interval().as_secs_f32() * 1000.,
            // The mean over the interval rather than the latest frame, which
            // at this cadence would be an arbitrary sample.
            frame_millis: self.sampler.mean_draw().as_secs_f32() * 1000.,
            percentile_millis: self.sampler.percentile_draw(FRAME_PERCENTILE).as_secs_f32() * 1000.,
            dropped_percent: self.sampler.over_budget_ratio(self.frame_budget) * 100.,
            invalidations: self.sampler.mean_invalidations(),
        };
        self.readout_at = Some(now);
    }

    /// Grows immediately to fit the slowest retained frame and decays back
    /// slowly, so a single spike doesn't make the whole chart jump.
    fn update_axis(&mut self) {
        let floor = self.frame_budget.as_secs_f32() * 2.;
        let target = self.sampler.peak_draw().as_secs_f32().max(floor);
        self.axis_max = if target > self.axis_max {
            target
        } else {
            self.axis_max + (target - self.axis_max) * AXIS_DECAY
        };
    }

    /// The frame time trace, drawn behind the readings so it fills the HUD
    /// instead of taking a band of its own. It is dimmed to stay legible under
    /// the text.
    fn render_chart(&self) -> impl IntoElement {
        let style = self.style;
        let budget = self.frame_budget.as_secs_f32();
        let axis_max = self.axis_max.max(f32::EPSILON);
        let capacity = self.sampler.capacity();
        let samples: Vec<(f32, Hsla)> = self
            .sampler
            .samples()
            .map(|sample| {
                let seconds = sample.draw.as_secs_f32();
                (
                    (seconds / axis_max).clamp(0., 1.),
                    style.level_color(seconds, budget).opacity(TRACE_OPACITY),
                )
            })
            .collect();

        canvas(
            |_, _, _| (),
            move |bounds: Bounds<Pixels>, _, window, _| {
                let slot = bounds.size.width / capacity as f32;
                // Fewer samples than the capacity means the chart is still
                // filling up; keep the newest frame pinned to the right edge so
                // the history scrolls instead of stretching.
                let leading = capacity.saturating_sub(samples.len());
                let points: Vec<(Point<Pixels>, Hsla)> = samples
                    .iter()
                    .enumerate()
                    .map(|(index, (ratio, color))| {
                        (
                            point(
                                bounds.origin.x + slot * (leading + index) as f32 + slot / 2.,
                                bounds.origin.y + bounds.size.height * (1. - *ratio),
                            ),
                            *color,
                        )
                    })
                    .collect();

                // The line is drawn as runs of equal color rather than one
                // segment per frame: a single path can only carry one color,
                // and in the common case where nothing is dropped the whole
                // chart collapses into one path.
                let mut start = 0;
                while start + 1 < points.len() {
                    // A segment is as slow as the frame it ends on, so the
                    // color of the later point decides the run.
                    let color = points[start + 1].1;
                    let mut path = PathBuilder::stroke(px(1.));
                    path.move_to(points[start].0);

                    let mut end = start + 1;
                    while end < points.len() && points[end].1 == color {
                        path.line_to(points[end].0);
                        end += 1;
                    }

                    if let Ok(path) = path.build() {
                        window.paint_path(path, color);
                    }
                    // Share the boundary point with the next run so the line
                    // stays connected across a color change.
                    start = end - 1;
                }
            },
        )
        .absolute()
        .inset_0()
    }

    /// The headline reading, with the frame time trace painted behind it.
    ///
    /// The trace lives in this row rather than spanning the whole HUD because
    /// this is its emptiest part — the figure is centered and short, leaving
    /// both flanks open — so the trace stays readable instead of being cut up
    /// by the denser rows below.
    ///
    /// The figure is centered in a fixed box so neither the unit nor the group
    /// shifts as the count gains or loses a digit; the two share a bottom edge.
    fn render_headline(&self, fps: f32, color: Hsla) -> Div {
        let style = self.style;

        div()
            .relative()
            .overflow_hidden()
            .w_full()
            .h(HEADLINE_HEIGHT)
            .child(self.render_chart())
            .child(
                div()
                    .flex()
                    .size_full()
                    .items_end()
                    .justify_center()
                    .gap_1()
                    // An empty box matching the unit on the right. Without it
                    // the unit's own width pushes the figure off center by half
                    // of it, which reads as misalignment.
                    .child(div().w(UNIT_WIDTH))
                    .child(
                        div()
                            .w(FIGURE_WIDTH)
                            .text_center()
                            .text_size(FIGURE_SIZE)
                            .line_height(relative(1.))
                            .text_color(color)
                            .child(format!("{fps:.0}")),
                    )
                    .child(div().w(UNIT_WIDTH).text_color(style.muted).child("FPS")),
            )
    }
}

impl Render for FpsMonitor {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        self.sampler.tick();
        self.update_readout();
        self.update_axis();
        self.start_resource_sampling(cx);
        if self.continuous {
            window.request_animation_frame();
        }

        let style = self.style;
        let budget = self.frame_budget;
        let Readout {
            fps,
            interval_millis,
            frame_millis,
            percentile_millis,
            dropped_percent: dropped,
            invalidations,
        } = self.readout;
        // Continuous, the rate is the rate the window can sustain, and
        // falling short of the target is the finding. Drawing on demand, the
        // rate is how often something changed, and the platform's own overlay
        // prints it plain -- so does this one.
        let fps_color = if self.continuous {
            fps_color(fps, budget, style)
        } else {
            style.foreground
        };
        let resources = self.resources.filter(|_| self.show_resources);
        let compact = self.compact;

        div()
            .id("gpui-fps-hud")
            .flex()
            .bg(style.background)
            .font_family(DEFAULT_FONT)
            .text_size(TEXT_SIZE)
            .text_color(style.muted)
            .on_click(cx.listener(|this, _, _, cx| {
                this.compact = !this.compact;
                cx.notify();
            }))
            .map(|this| {
                if compact {
                    // Collapsed, the HUD is one small tag: the figure drops to
                    // the same size as its unit, the box shrinks to the text,
                    // and everything else is dropped, so it sits over the
                    // interface without competing with it.
                    this.items_center()
                        .gap_1()
                        .px_1p5()
                        .py_0p5()
                        .rounded(px(3.))
                        .child(
                            div()
                                .w(COMPACT_FIGURE_WIDTH)
                                .text_right()
                                .text_color(fps_color)
                                .child(format!("{fps:.0}")),
                        )
                        .child("FPS")
                } else {
                    this.flex_col()
                        .w(HUD_WIDTH)
                        .px_2()
                        .py_1p5()
                        .rounded(px(4.))
                        .child(self.render_headline(fps, fps_color))
                        .child(reading(
                            // The same figure the platform overlay calls its
                            // frame interval: time between presents, which is
                            // the headline's reciprocal. Ungraded, like there.
                            "INTERVAL",
                            format!("{interval_millis:.1} ms"),
                            style.foreground,
                            style,
                        ))
                        .child(reading(
                            "FRAME",
                            format!("{frame_millis:.1} ms"),
                            // Graded against the budget, not against the frame
                            // rate. An idle window draws a handful of frames a
                            // second, so the headline goes red while every one
                            // of those frames was in fact drawn well inside the
                            // budget; this row is what says so.
                            style.level_color(frame_millis / 1000., budget.as_secs_f32()),
                            style,
                        ))
                        .child(reading(
                            // Graded the same way, so the two millisecond rows
                            // read as one measurement seen twice: what a frame
                            // usually costs, and what its slow tail costs.
                            "P95",
                            format!("{percentile_millis:.1} ms"),
                            style.level_color(percentile_millis / 1000., budget.as_secs_f32()),
                            style,
                        ))
                        .child(
                            // Dropped frames and wasted invalidations share a
                            // row: both count redundant work rather than
                            // measuring a duration, so neither belongs in the
                            // millisecond column above.
                            row()
                                .child(pair(
                                    "DROP",
                                    format!("{dropped:.1}%"),
                                    style.level_color(if dropped > 0. { 1. } else { 0. }, 0.5),
                                    style,
                                ))
                                .child(pair(
                                    "INV",
                                    format!("{invalidations:.1}"),
                                    // Ungraded, unlike every other reading in
                                    // the HUD. One per frame is the ideal, but
                                    // it is not the floor here: in continuous
                                    // mode the monitor requests an animation
                                    // frame of its own on every render, so an
                                    // application invalidating once a frame
                                    // measures two and a healthy HUD would sit
                                    // permanently in the red. The baseline
                                    // depends on that switch and on how the
                                    // application drives its own redraws, which
                                    // is not something the HUD can grade — so
                                    // the number is reported and the reading is
                                    // left to whoever knows what to expect.
                                    style.foreground,
                                    style,
                                )),
                        )
                        .when_some(
                            resources.and_then(|resources| resources.gpu_percent),
                            |this, gpu| {
                                this.child(reading(
                                    "GPU",
                                    format!("{gpu:.1}%"),
                                    style.foreground,
                                    style,
                                ))
                            },
                        )
                        .when_some(resources, |this, resources| {
                            this.child(
                                // CPU and memory share a row: both are coarse
                                // background samples, unlike the per-frame
                                // numbers.
                                row()
                                    .child(pair(
                                        "CPU",
                                        format_cpu(resources.cpu_percent),
                                        style.foreground,
                                        style,
                                    ))
                                    .child(pair(
                                        "MEM",
                                        format_bytes(resources.memory_bytes),
                                        style.foreground,
                                        style,
                                    )),
                            )
                        })
                }
            })
    }
}

/// Grades the frame rate against the rate the budget implies.
///
/// This deliberately does not compare `1/fps` against the budget the way the
/// per-frame trace does. Under vsync the measured rate lands just under the
/// refresh rate essentially always — a 60Hz display reads 58 to 60, never
/// exactly 60.00 — so an exact comparison would paint a perfectly healthy
/// application as over budget. Anything within [`FPS_TOLERANCE`] of the target
/// counts as meeting it.
fn fps_color(fps: f32, budget: Duration, style: FpsStyle) -> Hsla {
    if fps <= 0. {
        return style.muted;
    }

    let target = 1. / budget.as_secs_f32();
    if fps >= target * FPS_TOLERANCE {
        style.good
    } else if fps >= target * 0.5 {
        style.warn
    } else {
        style.bad
    }
}

/// A row carrying two [`pair`]s, pushed to either inner edge.
fn row() -> Div {
    div().flex().w_full().justify_between().gap_2().py(px(1.))
}

/// A `LABEL value` pair kept together, for rows that carry more than one
/// reading. The label stays muted so it reads as a caption, not as data.
fn pair(label: &'static str, value: String, value_color: Hsla, style: FpsStyle) -> Div {
    div()
        .flex()
        .gap_1()
        .child(div().text_color(style.muted).child(label))
        .child(div().text_color(value_color).child(value))
}

/// One `LABEL … value` row. The value is right aligned against the HUD's inner
/// edge, so in a monospace font every row's digits line up in a column and
/// nothing shifts as the readings change width.
fn reading(label: &'static str, value: String, value_color: Hsla, style: FpsStyle) -> Div {
    div()
        .flex()
        .w_full()
        .justify_between()
        .gap_2()
        .py(px(1.))
        .child(div().text_color(style.muted).child(label))
        .child(div().text_color(value_color).child(value))
}

/// A CPU reading on the single core scale, which passes 100 as soon as the
/// process spreads over more than one core and reaches the core count times a
/// hundred when it saturates the machine.
///
/// A tenth is worth showing while the reading is small, where it is the
/// difference between idle and a busy timer; past ten the extra digit only
/// churns, and dropping it also keeps the reading inside the row's share of the
/// HUD on a machine with enough cores to reach four figures.
fn format_cpu(percent: f32) -> String {
    if percent < 10. {
        format!("{percent:.1}%")
    } else {
        format!("{percent:.0}%")
    }
}

fn format_bytes(bytes: u64) -> String {
    const MIB: f64 = 1024. * 1024.;
    const GIB: f64 = MIB * 1024.;

    let bytes = bytes as f64;
    if bytes >= GIB {
        format!("{:.2} GB", bytes / GIB)
    } else {
        format!("{:.0} MB", bytes / MIB)
    }
}

#[cfg(test)]
mod tests {
    use gpui::{AppContext as _, TestAppContext};

    use super::*;

    #[gpui::test]
    fn test_fps_monitor_builder(cx: &mut TestAppContext) {
        let cx = cx.add_empty_window();
        cx.update(|window, cx| {
            let budget = Duration::from_micros(6_944);
            let monitor = cx.new(|cx| {
                FpsMonitor::new(window, cx)
                    .capacity(240)
                    .frame_budget(budget)
                    .continuous(false)
                    .show_resources(false)
                    .resource_interval(Duration::from_secs(2))
            });

            let monitor = monitor.read(cx);
            assert_eq!(monitor.sampler.capacity(), 240);
            assert_eq!(monitor.frame_budget, budget);
            assert!(!monitor.continuous);
            assert!(!monitor.show_resources);
            assert_eq!(monitor.resource_interval, Duration::from_secs(2));
            // The axis floor tracks the budget so a 144Hz budget doesn't leave
            // the chart scaled for 60Hz frames.
            assert_eq!(monitor.axis_max, budget.as_secs_f32() * 2.);
        });
    }

    #[test]
    fn a_display_keeping_up_is_never_graded_as_falling_behind() {
        let style = FpsStyle::dark();
        let budget = DEFAULT_FRAME_BUDGET;

        // What a healthy 60Hz display actually reports.
        for rate in [58., 59., 59.7, 60., 61.] {
            assert_eq!(
                fps_color(rate, budget, style),
                style.good,
                "{rate} fps should read as healthy on a 60Hz display"
            );
        }

        assert_eq!(fps_color(45., budget, style), style.warn);
        assert_eq!(fps_color(20., budget, style), style.bad);
        assert_eq!(fps_color(0., budget, style), style.muted);
    }

    #[test]
    fn formats_memory_by_magnitude() {
        assert_eq!(format_bytes(184 * 1024 * 1024), "184 MB");
        assert_eq!(format_bytes(3 * 1024 * 1024 * 1024), "3.00 GB");
    }

    /// The reading is on the single core scale, so it passes 100 and keeps
    /// going — the row must show that rather than round it away or clip it.
    #[test]
    fn formats_cpu_on_the_single_core_scale() {
        // A process spread over a core and a half, which under a scale where
        // 100 is the whole machine would have read 5.8% on a 24 core desktop.
        assert_eq!(format_cpu(140.), "140%");
        // Saturating every core of a big machine still has somewhere to go.
        assert_eq!(format_cpu(2400.), "2400%");
        // Small readings keep the tenth that distinguishes them.
        assert_eq!(format_cpu(0.4), "0.4%");
        assert_eq!(format_cpu(9.9), "9.9%");
        assert_eq!(format_cpu(12.4), "12%");
    }
}