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}