Skip to main content

gpui_fps/
monitor.rs

1use std::time::Duration;
2
3use web_time::Instant;
4
5use gpui::{
6    Bounds, Context, Div, Hsla, InteractiveElement as _, IntoElement, ParentElement, PathBuilder,
7    Pixels, Point, Render, StatefulInteractiveElement as _, Styled, Window, canvas, div, point,
8    prelude::FluentBuilder as _, px, relative,
9};
10
11#[cfg(not(target_family = "wasm"))]
12use gpui::Task;
13
14use crate::{
15    FrameTraceGuard,
16    sampler::{FrameSampler, ResourceSample, minimum_resource_interval},
17    style::FpsStyle,
18};
19
20/// One frame at 60Hz, the default budget a frame is judged against.
21const DEFAULT_FRAME_BUDGET: Duration = Duration::from_nanos(16_666_667);
22const DEFAULT_CAPACITY: usize = 120;
23const DEFAULT_RESOURCE_INTERVAL: Duration = Duration::from_millis(500);
24
25/// How far back CPU, memory and GPU are averaged over. At the default interval
26/// that is six readings: long enough to settle the churn between one sample and
27/// the next, short enough that a real change reaches the HUD while the reader
28/// is still looking at what caused it.
29#[cfg(not(target_family = "wasm"))]
30const RESOURCE_WINDOW: Duration = Duration::from_secs(3);
31
32/// Which frame the `P95` row reports. The 95th rather than the 99th: the chart
33/// keeps 120 frames by default, so the 99th is the second slowest of them — one
34/// frame, which moves the row on its own and reads as noise.
35const FRAME_PERCENTILE: f32 = 0.95;
36
37/// How fast the chart's y axis relaxes back down after a spike. Growth is
38/// immediate so a slow frame is never clipped, while the decay is gradual so
39/// the bars don't visibly rescale every frame.
40const AXIS_DECAY: f32 = 0.04;
41
42/// A fixed width keeps every row flush with the chart and stops the HUD from
43/// resizing as the readings gain or lose digits. Collapsed, the HUD hugs its
44/// text instead and only the figure gets a fixed box.
45const HUD_WIDTH: Pixels = px(172.);
46const COMPACT_FIGURE_WIDTH: Pixels = px(25.);
47
48/// Size of every label and reading. Collapsed, the figure uses it too.
49const TEXT_SIZE: Pixels = px(10.);
50
51/// The trace sits behind the headline, so it is dimmed enough to stay out of
52/// the figure's way while still showing its shape and color.
53const TRACE_OPACITY: f32 = 0.35;
54
55/// Tall enough to give the trace room to show its shape around the figure.
56const HEADLINE_HEIGHT: Pixels = px(35.);
57
58/// The headline figure. Its box has to fit four digits at [`FIGURE_SIZE`] —
59/// a monospace digit runs about 0.6em, and an uncapped frame rate on a small
60/// window reaches four figures — or the reading is clipped instead of merely
61/// looking cramped.
62const FIGURE_SIZE: Pixels = px(28.);
63const FIGURE_WIDTH: Pixels = px(70.);
64
65/// Width of the `FPS` unit, and of the empty box mirroring it on the other side
66/// of the figure so the figure lands on the HUD's true center.
67const UNIT_WIDTH: Pixels = px(22.);
68
69/// How often the numbers are recomputed.
70///
71/// The trace keeps up with every frame, but the readings do not: recomputed
72/// per frame they flicker through digits too fast to read, and the eye tracks
73/// the churn rather than the value. Twice a second is slow enough to read and
74/// fast enough to feel live.
75const READOUT_INTERVAL: Duration = Duration::from_millis(500);
76
77/// Fraction of the target frame rate that still counts as meeting it. Vsync and
78/// the sampling window each cost a frame or so a second, so a 60Hz display that
79/// is keeping up perfectly reports 58 to 60, never a flat 60.
80const FPS_TOLERANCE: f32 = 0.95;
81
82/// A monospace family that ships with the platform, so the value column stays
83/// aligned without the application having to configure a font. The generic
84/// `monospace` alias is not resolvable by every platform's font backend, hence
85/// the concrete names.
86#[cfg(target_os = "macos")]
87const DEFAULT_FONT: &str = "Menlo";
88#[cfg(target_os = "windows")]
89const DEFAULT_FONT: &str = "Consolas";
90#[cfg(not(any(target_os = "macos", target_os = "windows")))]
91const DEFAULT_FONT: &str = "monospace";
92
93/// A realtime performance HUD: frames per second, a rolling frame time chart,
94/// and this process' GPU, CPU and memory usage.
95///
96/// This is a view rather than a stateless component on purpose. Driving
97/// continuous redraws goes through [`Window::request_animation_frame`], which
98/// notifies the *current* view — from inside a stateless component that would
99/// be the parent, forcing the whole parent tree to redraw every frame. As its
100/// own view, only the HUD subtree repaints.
101///
102/// ```no_run
103/// # use gpui::*;
104/// # use gpui_fps::FpsMonitor;
105/// # fn example(window: &mut Window, cx: &mut App) {
106/// let monitor = cx.new(|cx| FpsMonitor::new(window, cx).capacity(240));
107/// # }
108/// ```
109/// The numbers as last published to the screen.
110#[derive(Clone, Copy, Default)]
111struct Readout {
112    /// Frames presented per second.
113    fps: f32,
114    /// Mean time between presents, in milliseconds: the platform overlay's
115    /// "frame interval", and `1000 / fps`.
116    interval_millis: f32,
117    /// Mean `Window::draw` cost of the retained frames, in milliseconds.
118    frame_millis: f32,
119    /// The slow tail of the same frames `frame_millis` is the mean of.
120    percentile_millis: f32,
121    dropped_percent: f32,
122    /// Mean invalidations coalesced into one frame; one means none were wasted.
123    invalidations: f32,
124}
125
126pub struct FpsMonitor {
127    sampler: FrameSampler,
128    readout: Readout,
129    readout_at: Option<Instant>,
130    style: FpsStyle,
131    frame_budget: Duration,
132    continuous: bool,
133    show_resources: bool,
134    resource_interval: Duration,
135    resources: Option<ResourceSample>,
136    compact: bool,
137    /// Upper bound of the chart's y axis, in seconds.
138    axis_max: f32,
139    #[cfg(not(target_family = "wasm"))]
140    resource_task: Option<Task<()>>,
141    _frame_trace: FrameTraceGuard,
142}
143
144impl FpsMonitor {
145    pub fn new(window: &Window, _cx: &mut Context<Self>) -> Self {
146        let frame_budget = DEFAULT_FRAME_BUDGET;
147        Self {
148            sampler: FrameSampler::new(window.window_handle().window_id(), DEFAULT_CAPACITY),
149            readout: Readout::default(),
150            readout_at: None,
151            style: FpsStyle::default(),
152            frame_budget,
153            continuous: true,
154            show_resources: true,
155            resource_interval: DEFAULT_RESOURCE_INTERVAL,
156            resources: None,
157            compact: false,
158            axis_max: frame_budget.as_secs_f32() * 2.,
159            #[cfg(not(target_family = "wasm"))]
160            resource_task: None,
161            _frame_trace: FrameTraceGuard::acquire(),
162        }
163    }
164
165    /// How many frames the chart keeps. Defaults to 120.
166    pub fn capacity(mut self, capacity: usize) -> Self {
167        self.sampler.set_capacity(capacity);
168        self
169    }
170
171    /// The per-frame budget used for the chart's baseline and bar colors.
172    /// Defaults to one 60Hz frame; set it to `1/144s` on a high refresh rate
173    /// display.
174    pub fn frame_budget(mut self, budget: Duration) -> Self {
175        self.frame_budget = budget;
176        self.axis_max = budget.as_secs_f32() * 2.;
177        self
178    }
179
180    /// Whether to request a frame on every render, keeping the window drawing
181    /// back to back. Defaults to `true`.
182    ///
183    /// This is what makes the readout behave like an in-game FPS counter, and
184    /// it has the same caveat: the window never idles, so the number is the
185    /// frame rate the application *can* sustain, not the rate it happens to be
186    /// drawing at. Turn it off to measure the real workload — the HUD then only
187    /// updates when the window redraws for its own reasons, and reads zero
188    /// while the window is idle.
189    pub fn continuous(mut self, continuous: bool) -> Self {
190        self.continuous = continuous;
191        self
192    }
193
194    pub(crate) fn set_frame_budget(&mut self, budget: Duration) {
195        self.frame_budget = budget;
196        self.axis_max = budget.as_secs_f32() * 2.;
197    }
198
199    pub(crate) fn set_continuous(&mut self, continuous: bool) {
200        self.continuous = continuous;
201    }
202
203    /// Whether to sample and show CPU, memory and GPU usage. Defaults to
204    /// `true`, and is always off on the web.
205    ///
206    /// The GPU reading is left out on its own where the platform publishes no
207    /// counter for it, so turning this on does not guarantee three readings.
208    pub fn show_resources(mut self, show_resources: bool) -> Self {
209        self.show_resources = show_resources;
210        self
211    }
212
213    /// How often CPU, memory and GPU are resampled. Defaults to 500ms, and is
214    /// clamped up to the shortest interval that yields a meaningful CPU delta.
215    pub fn resource_interval(mut self, interval: Duration) -> Self {
216        self.resource_interval = interval;
217        self
218    }
219
220    /// Sampling starts on the first render rather than in `new` so that the
221    /// builder methods have already been applied by the time the interval is
222    /// read.
223    #[cfg(not(target_family = "wasm"))]
224    fn start_resource_sampling(&mut self, cx: &mut Context<Self>) {
225        use crate::sampler::ResourceProbe;
226
227        if !self.show_resources || self.resource_task.is_some() {
228            return;
229        }
230
231        let interval = self.resource_interval.max(minimum_resource_interval());
232        self.resource_task = Some(cx.spawn(async move |this, cx| {
233            let executor = cx.background_executor().clone();
234            // Probing walks the process table, so it never runs on the render
235            // thread. The probe moves in and out of each background task rather
236            // than living behind a lock.
237            let Some(mut probe) = executor
238                .spawn(async { ResourceProbe::new(RESOURCE_WINDOW) })
239                .await
240            else {
241                return;
242            };
243
244            loop {
245                executor.timer(interval).await;
246
247                let (returned, sample) = executor
248                    .spawn(async move {
249                        let sample = probe.sample();
250                        (probe, sample)
251                    })
252                    .await;
253                probe = returned;
254
255                let Some(sample) = sample else { continue };
256                let updated = this.update(cx, |this, cx| {
257                    this.resources = Some(sample);
258                    cx.notify();
259                });
260                if updated.is_err() {
261                    break;
262                }
263            }
264        }));
265    }
266
267    #[cfg(target_family = "wasm")]
268    fn start_resource_sampling(&mut self, _cx: &mut Context<Self>) {
269        let _ = minimum_resource_interval();
270    }
271
272    /// Republishes the readings if [`READOUT_INTERVAL`] has passed.
273    fn update_readout(&mut self) {
274        let now = Instant::now();
275        let due = self
276            .readout_at
277            .is_none_or(|at| now.duration_since(at) >= READOUT_INTERVAL);
278        if !due {
279            return;
280        }
281
282        self.readout = Readout {
283            fps: self.sampler.fps(),
284            interval_millis: self.sampler.present_interval().as_secs_f32() * 1000.,
285            // The mean over the interval rather than the latest frame, which
286            // at this cadence would be an arbitrary sample.
287            frame_millis: self.sampler.mean_draw().as_secs_f32() * 1000.,
288            percentile_millis: self.sampler.percentile_draw(FRAME_PERCENTILE).as_secs_f32() * 1000.,
289            dropped_percent: self.sampler.over_budget_ratio(self.frame_budget) * 100.,
290            invalidations: self.sampler.mean_invalidations(),
291        };
292        self.readout_at = Some(now);
293    }
294
295    /// Grows immediately to fit the slowest retained frame and decays back
296    /// slowly, so a single spike doesn't make the whole chart jump.
297    fn update_axis(&mut self) {
298        let floor = self.frame_budget.as_secs_f32() * 2.;
299        let target = self.sampler.peak_draw().as_secs_f32().max(floor);
300        self.axis_max = if target > self.axis_max {
301            target
302        } else {
303            self.axis_max + (target - self.axis_max) * AXIS_DECAY
304        };
305    }
306
307    /// The frame time trace, drawn behind the readings so it fills the HUD
308    /// instead of taking a band of its own. It is dimmed to stay legible under
309    /// the text.
310    fn render_chart(&self) -> impl IntoElement {
311        let style = self.style;
312        let budget = self.frame_budget.as_secs_f32();
313        let axis_max = self.axis_max.max(f32::EPSILON);
314        let capacity = self.sampler.capacity();
315        let samples: Vec<(f32, Hsla)> = self
316            .sampler
317            .samples()
318            .map(|sample| {
319                let seconds = sample.draw.as_secs_f32();
320                (
321                    (seconds / axis_max).clamp(0., 1.),
322                    style.level_color(seconds, budget).opacity(TRACE_OPACITY),
323                )
324            })
325            .collect();
326
327        canvas(
328            |_, _, _| (),
329            move |bounds: Bounds<Pixels>, _, window, _| {
330                let slot = bounds.size.width / capacity as f32;
331                // Fewer samples than the capacity means the chart is still
332                // filling up; keep the newest frame pinned to the right edge so
333                // the history scrolls instead of stretching.
334                let leading = capacity.saturating_sub(samples.len());
335                let points: Vec<(Point<Pixels>, Hsla)> = samples
336                    .iter()
337                    .enumerate()
338                    .map(|(index, (ratio, color))| {
339                        (
340                            point(
341                                bounds.origin.x + slot * (leading + index) as f32 + slot / 2.,
342                                bounds.origin.y + bounds.size.height * (1. - *ratio),
343                            ),
344                            *color,
345                        )
346                    })
347                    .collect();
348
349                // The line is drawn as runs of equal color rather than one
350                // segment per frame: a single path can only carry one color,
351                // and in the common case where nothing is dropped the whole
352                // chart collapses into one path.
353                let mut start = 0;
354                while start + 1 < points.len() {
355                    // A segment is as slow as the frame it ends on, so the
356                    // color of the later point decides the run.
357                    let color = points[start + 1].1;
358                    let mut path = PathBuilder::stroke(px(1.));
359                    path.move_to(points[start].0);
360
361                    let mut end = start + 1;
362                    while end < points.len() && points[end].1 == color {
363                        path.line_to(points[end].0);
364                        end += 1;
365                    }
366
367                    if let Ok(path) = path.build() {
368                        window.paint_path(path, color);
369                    }
370                    // Share the boundary point with the next run so the line
371                    // stays connected across a color change.
372                    start = end - 1;
373                }
374            },
375        )
376        .absolute()
377        .inset_0()
378    }
379
380    /// The headline reading, with the frame time trace painted behind it.
381    ///
382    /// The trace lives in this row rather than spanning the whole HUD because
383    /// this is its emptiest part — the figure is centered and short, leaving
384    /// both flanks open — so the trace stays readable instead of being cut up
385    /// by the denser rows below.
386    ///
387    /// The figure is centered in a fixed box so neither the unit nor the group
388    /// shifts as the count gains or loses a digit; the two share a bottom edge.
389    fn render_headline(&self, fps: f32, color: Hsla) -> Div {
390        let style = self.style;
391
392        div()
393            .relative()
394            .overflow_hidden()
395            .w_full()
396            .h(HEADLINE_HEIGHT)
397            .child(self.render_chart())
398            .child(
399                div()
400                    .flex()
401                    .size_full()
402                    .items_end()
403                    .justify_center()
404                    .gap_1()
405                    // An empty box matching the unit on the right. Without it
406                    // the unit's own width pushes the figure off center by half
407                    // of it, which reads as misalignment.
408                    .child(div().w(UNIT_WIDTH))
409                    .child(
410                        div()
411                            .w(FIGURE_WIDTH)
412                            .text_center()
413                            .text_size(FIGURE_SIZE)
414                            .line_height(relative(1.))
415                            .text_color(color)
416                            .child(format!("{fps:.0}")),
417                    )
418                    .child(div().w(UNIT_WIDTH).text_color(style.muted).child("FPS")),
419            )
420    }
421}
422
423impl Render for FpsMonitor {
424    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
425        self.sampler.tick();
426        self.update_readout();
427        self.update_axis();
428        self.start_resource_sampling(cx);
429        if self.continuous {
430            window.request_animation_frame();
431        }
432
433        let style = self.style;
434        let budget = self.frame_budget;
435        let Readout {
436            fps,
437            interval_millis,
438            frame_millis,
439            percentile_millis,
440            dropped_percent: dropped,
441            invalidations,
442        } = self.readout;
443        // Continuous, the rate is the rate the window can sustain, and
444        // falling short of the target is the finding. Drawing on demand, the
445        // rate is how often something changed, and the platform's own overlay
446        // prints it plain -- so does this one.
447        let fps_color = if self.continuous {
448            fps_color(fps, budget, style)
449        } else {
450            style.foreground
451        };
452        let resources = self.resources.filter(|_| self.show_resources);
453        let compact = self.compact;
454
455        div()
456            .id("gpui-fps-hud")
457            .flex()
458            .bg(style.background)
459            .font_family(DEFAULT_FONT)
460            .text_size(TEXT_SIZE)
461            .text_color(style.muted)
462            .on_click(cx.listener(|this, _, _, cx| {
463                this.compact = !this.compact;
464                cx.notify();
465            }))
466            .map(|this| {
467                if compact {
468                    // Collapsed, the HUD is one small tag: the figure drops to
469                    // the same size as its unit, the box shrinks to the text,
470                    // and everything else is dropped, so it sits over the
471                    // interface without competing with it.
472                    this.items_center()
473                        .gap_1()
474                        .px_1p5()
475                        .py_0p5()
476                        .rounded(px(3.))
477                        .child(
478                            div()
479                                .w(COMPACT_FIGURE_WIDTH)
480                                .text_right()
481                                .text_color(fps_color)
482                                .child(format!("{fps:.0}")),
483                        )
484                        .child("FPS")
485                } else {
486                    this.flex_col()
487                        .w(HUD_WIDTH)
488                        .px_2()
489                        .py_1p5()
490                        .rounded(px(4.))
491                        .child(self.render_headline(fps, fps_color))
492                        .child(reading(
493                            // The same figure the platform overlay calls its
494                            // frame interval: time between presents, which is
495                            // the headline's reciprocal. Ungraded, like there.
496                            "INTERVAL",
497                            format!("{interval_millis:.1} ms"),
498                            style.foreground,
499                            style,
500                        ))
501                        .child(reading(
502                            "FRAME",
503                            format!("{frame_millis:.1} ms"),
504                            // Graded against the budget, not against the frame
505                            // rate. An idle window draws a handful of frames a
506                            // second, so the headline goes red while every one
507                            // of those frames was in fact drawn well inside the
508                            // budget; this row is what says so.
509                            style.level_color(frame_millis / 1000., budget.as_secs_f32()),
510                            style,
511                        ))
512                        .child(reading(
513                            // Graded the same way, so the two millisecond rows
514                            // read as one measurement seen twice: what a frame
515                            // usually costs, and what its slow tail costs.
516                            "P95",
517                            format!("{percentile_millis:.1} ms"),
518                            style.level_color(percentile_millis / 1000., budget.as_secs_f32()),
519                            style,
520                        ))
521                        .child(
522                            // Dropped frames and wasted invalidations share a
523                            // row: both count redundant work rather than
524                            // measuring a duration, so neither belongs in the
525                            // millisecond column above.
526                            row()
527                                .child(pair(
528                                    "DROP",
529                                    format!("{dropped:.1}%"),
530                                    style.level_color(if dropped > 0. { 1. } else { 0. }, 0.5),
531                                    style,
532                                ))
533                                .child(pair(
534                                    "INV",
535                                    format!("{invalidations:.1}"),
536                                    // Ungraded, unlike every other reading in
537                                    // the HUD. One per frame is the ideal, but
538                                    // it is not the floor here: in continuous
539                                    // mode the monitor requests an animation
540                                    // frame of its own on every render, so an
541                                    // application invalidating once a frame
542                                    // measures two and a healthy HUD would sit
543                                    // permanently in the red. The baseline
544                                    // depends on that switch and on how the
545                                    // application drives its own redraws, which
546                                    // is not something the HUD can grade — so
547                                    // the number is reported and the reading is
548                                    // left to whoever knows what to expect.
549                                    style.foreground,
550                                    style,
551                                )),
552                        )
553                        .when_some(
554                            resources.and_then(|resources| resources.gpu_percent),
555                            |this, gpu| {
556                                this.child(reading(
557                                    "GPU",
558                                    format!("{gpu:.1}%"),
559                                    style.foreground,
560                                    style,
561                                ))
562                            },
563                        )
564                        .when_some(resources, |this, resources| {
565                            this.child(
566                                // CPU and memory share a row: both are coarse
567                                // background samples, unlike the per-frame
568                                // numbers.
569                                row()
570                                    .child(pair(
571                                        "CPU",
572                                        format_cpu(resources.cpu_percent),
573                                        style.foreground,
574                                        style,
575                                    ))
576                                    .child(pair(
577                                        "MEM",
578                                        format_bytes(resources.memory_bytes),
579                                        style.foreground,
580                                        style,
581                                    )),
582                            )
583                        })
584                }
585            })
586    }
587}
588
589/// Grades the frame rate against the rate the budget implies.
590///
591/// This deliberately does not compare `1/fps` against the budget the way the
592/// per-frame trace does. Under vsync the measured rate lands just under the
593/// refresh rate essentially always — a 60Hz display reads 58 to 60, never
594/// exactly 60.00 — so an exact comparison would paint a perfectly healthy
595/// application as over budget. Anything within [`FPS_TOLERANCE`] of the target
596/// counts as meeting it.
597fn fps_color(fps: f32, budget: Duration, style: FpsStyle) -> Hsla {
598    if fps <= 0. {
599        return style.muted;
600    }
601
602    let target = 1. / budget.as_secs_f32();
603    if fps >= target * FPS_TOLERANCE {
604        style.good
605    } else if fps >= target * 0.5 {
606        style.warn
607    } else {
608        style.bad
609    }
610}
611
612/// A row carrying two [`pair`]s, pushed to either inner edge.
613fn row() -> Div {
614    div().flex().w_full().justify_between().gap_2().py(px(1.))
615}
616
617/// A `LABEL value` pair kept together, for rows that carry more than one
618/// reading. The label stays muted so it reads as a caption, not as data.
619fn pair(label: &'static str, value: String, value_color: Hsla, style: FpsStyle) -> Div {
620    div()
621        .flex()
622        .gap_1()
623        .child(div().text_color(style.muted).child(label))
624        .child(div().text_color(value_color).child(value))
625}
626
627/// One `LABEL … value` row. The value is right aligned against the HUD's inner
628/// edge, so in a monospace font every row's digits line up in a column and
629/// nothing shifts as the readings change width.
630fn reading(label: &'static str, value: String, value_color: Hsla, style: FpsStyle) -> Div {
631    div()
632        .flex()
633        .w_full()
634        .justify_between()
635        .gap_2()
636        .py(px(1.))
637        .child(div().text_color(style.muted).child(label))
638        .child(div().text_color(value_color).child(value))
639}
640
641/// A CPU reading on the single core scale, which passes 100 as soon as the
642/// process spreads over more than one core and reaches the core count times a
643/// hundred when it saturates the machine.
644///
645/// A tenth is worth showing while the reading is small, where it is the
646/// difference between idle and a busy timer; past ten the extra digit only
647/// churns, and dropping it also keeps the reading inside the row's share of the
648/// HUD on a machine with enough cores to reach four figures.
649fn format_cpu(percent: f32) -> String {
650    if percent < 10. {
651        format!("{percent:.1}%")
652    } else {
653        format!("{percent:.0}%")
654    }
655}
656
657fn format_bytes(bytes: u64) -> String {
658    const MIB: f64 = 1024. * 1024.;
659    const GIB: f64 = MIB * 1024.;
660
661    let bytes = bytes as f64;
662    if bytes >= GIB {
663        format!("{:.2} GB", bytes / GIB)
664    } else {
665        format!("{:.0} MB", bytes / MIB)
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use gpui::{AppContext as _, TestAppContext};
672
673    use super::*;
674
675    #[gpui::test]
676    fn test_fps_monitor_builder(cx: &mut TestAppContext) {
677        let cx = cx.add_empty_window();
678        cx.update(|window, cx| {
679            let budget = Duration::from_micros(6_944);
680            let monitor = cx.new(|cx| {
681                FpsMonitor::new(window, cx)
682                    .capacity(240)
683                    .frame_budget(budget)
684                    .continuous(false)
685                    .show_resources(false)
686                    .resource_interval(Duration::from_secs(2))
687            });
688
689            let monitor = monitor.read(cx);
690            assert_eq!(monitor.sampler.capacity(), 240);
691            assert_eq!(monitor.frame_budget, budget);
692            assert!(!monitor.continuous);
693            assert!(!monitor.show_resources);
694            assert_eq!(monitor.resource_interval, Duration::from_secs(2));
695            // The axis floor tracks the budget so a 144Hz budget doesn't leave
696            // the chart scaled for 60Hz frames.
697            assert_eq!(monitor.axis_max, budget.as_secs_f32() * 2.);
698        });
699    }
700
701    #[test]
702    fn a_display_keeping_up_is_never_graded_as_falling_behind() {
703        let style = FpsStyle::dark();
704        let budget = DEFAULT_FRAME_BUDGET;
705
706        // What a healthy 60Hz display actually reports.
707        for rate in [58., 59., 59.7, 60., 61.] {
708            assert_eq!(
709                fps_color(rate, budget, style),
710                style.good,
711                "{rate} fps should read as healthy on a 60Hz display"
712            );
713        }
714
715        assert_eq!(fps_color(45., budget, style), style.warn);
716        assert_eq!(fps_color(20., budget, style), style.bad);
717        assert_eq!(fps_color(0., budget, style), style.muted);
718    }
719
720    #[test]
721    fn formats_memory_by_magnitude() {
722        assert_eq!(format_bytes(184 * 1024 * 1024), "184 MB");
723        assert_eq!(format_bytes(3 * 1024 * 1024 * 1024), "3.00 GB");
724    }
725
726    /// The reading is on the single core scale, so it passes 100 and keeps
727    /// going — the row must show that rather than round it away or clip it.
728    #[test]
729    fn formats_cpu_on_the_single_core_scale() {
730        // A process spread over a core and a half, which under a scale where
731        // 100 is the whole machine would have read 5.8% on a 24 core desktop.
732        assert_eq!(format_cpu(140.), "140%");
733        // Saturating every core of a big machine still has somewhere to go.
734        assert_eq!(format_cpu(2400.), "2400%");
735        // Small readings keep the tenth that distinguishes them.
736        assert_eq!(format_cpu(0.4), "0.4%");
737        assert_eq!(format_cpu(9.9), "9.9%");
738        assert_eq!(format_cpu(12.4), "12%");
739    }
740}