gpui_fps/monitor.rs
1use std::time::Duration;
2
3use web_time::Instant;
4
5use gpui::{
6 App, Bounds, Context, DisplayId, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton,
7 ParentElement, PathBuilder, Pixels, Point, Render, StatefulInteractiveElement as _, Styled,
8 Window, canvas, div, point, prelude::FluentBuilder as _, px, relative,
9};
10
11use gpui::Task;
12
13use crate::{
14 FrameTraceGuard,
15 refresh::display_refresh_rate,
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(28.);
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/// A monospace family that ships with the platform, so the value column stays
78/// aligned without the application having to configure a font. The generic
79/// `monospace` alias is not resolvable by every platform's font backend, hence
80/// the concrete names.
81#[cfg(target_os = "macos")]
82const DEFAULT_FONT: &str = "Menlo";
83#[cfg(target_os = "windows")]
84const DEFAULT_FONT: &str = "Consolas";
85#[cfg(not(any(target_os = "macos", target_os = "windows")))]
86const DEFAULT_FONT: &str = "monospace";
87
88/// A realtime performance HUD: frames per second, a rolling frame time chart,
89/// and this process' GPU, CPU and memory usage.
90///
91/// This is a view rather than a stateless component on purpose: driving
92/// redraws goes through [`Window::request_animation_frame`], which notifies the
93/// *current* view, and from inside a stateless component that would be whoever
94/// rendered the HUD — dirtying the host's own state to move a frame counter.
95///
96/// The HUD never asks for a frame of its own. A dirty view schedules a *window*
97/// draw and GPUI re-renders every view outside an [`Entity::cached`] boundary,
98/// so a HUD that drove the frame loop to keep its counter moving would be
99/// paying a full layout and paint per frame — and reporting that cost in the
100/// resource row as if it were the application's. The headline is derived from
101/// what a frame costs instead, which answers the same question for free and
102/// leaves the readings measuring the application alone.
103///
104/// ```no_run
105/// # use gpui::*;
106/// # use gpui_fps::FpsMonitor;
107/// # fn example(window: &mut Window, cx: &mut App) {
108/// let monitor = cx.new(|cx| FpsMonitor::new(window, cx).capacity(240));
109/// # }
110/// ```
111/// The numbers as last published to the screen.
112#[derive(Clone, Copy, Default)]
113struct Readout {
114 /// The rate a full redraw of this window could sustain: the reciprocal of
115 /// `frame_millis`.
116 ///
117 /// Derived rather than counted, because counting it would mean causing it.
118 /// A frame rate measured from presents is only the rate the application
119 /// happens to be drawing at, and the only way to make that number mean
120 /// "as fast as this UI can go" is to keep the window drawing back to back
121 /// — which costs a full layout and paint per frame and lands in the
122 /// resource row right underneath. The frame cost answers the same question
123 /// without being paid for.
124 ///
125 /// A ceiling the frame cost can prove, not one the display can show: a
126 /// window whose frames cost 3ms could redraw 333 times a second, on a
127 /// panel that would scan out sixty of them.
128 max_fps: f32,
129 /// Frames presented per second: the rate the window is actually drawing
130 /// at, which an idle application drives to zero. The reciprocal of
131 /// `interval_millis`.
132 fps: f32,
133 /// Mean time between presents, in milliseconds: the platform overlay's
134 /// "frame interval".
135 interval_millis: f32,
136 /// Mean `Window::draw` cost of the retained frames, in milliseconds.
137 frame_millis: f32,
138 /// The slow tail of the same frames `frame_millis` is the mean of.
139 percentile_millis: f32,
140 dropped_percent: f32,
141 /// Mean invalidations coalesced into one frame; one means none were wasted.
142 invalidations: f32,
143}
144
145/// The rate a full redraw could sustain: what a frame's cost implies, held to
146/// what the panel can scan out.
147///
148/// The cap is the half the derivation loses. Counting presents could never
149/// exceed the refresh rate — frames go to the compositor on vsync, so the
150/// bound came for free — while a frame drawn in 3ms reads as 333, a rate
151/// nobody could ever see. `display` is `None` where the platform would not say
152/// what the panel runs at, and an uncapped reading is better than one held to
153/// a guess: see [`crate::refresh`] for why guessing was tried and abandoned.
154fn sustainable_rate(mean_draw: Duration, display: Option<Duration>) -> f32 {
155 let mean_draw = mean_draw.as_secs_f32();
156 if mean_draw <= 0. {
157 return 0.;
158 }
159 let rate = 1. / mean_draw;
160 match display.map(|period| period.as_secs_f32()) {
161 Some(period) if period > 0. => rate.min(1. / period),
162 _ => rate,
163 }
164}
165
166/// Which question the headline answers.
167///
168/// Both readings come out of the same samples, so switching is free — which is
169/// the whole point. The rate a UI can hold and the rate it is holding are
170/// different questions, and the only expensive way to answer the first is to
171/// stop the second from being answerable.
172#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173enum Headline {
174 /// The rate a full redraw could sustain, from what one costs.
175 Max,
176 /// The rate the window is drawing at.
177 Observed,
178}
179
180pub struct FpsMonitor {
181 sampler: FrameSampler,
182 readout: Readout,
183 readout_at: Option<Instant>,
184 style: FpsStyle,
185 frame_budget: Duration,
186 headline: Headline,
187 /// The panel's refresh period, and which display it was asked about, so
188 /// that moving the window to another monitor re-asks and staying on one
189 /// does not ask again every frame.
190 display: Option<(DisplayId, Option<Duration>)>,
191 show_resources: bool,
192 resource_interval: Duration,
193 resources: Option<ResourceSample>,
194 compact: bool,
195 /// Upper bound of the chart's y axis, in seconds.
196 axis_max: f32,
197 clock: Option<Task<()>>,
198 _frame_trace: FrameTraceGuard,
199}
200
201impl FpsMonitor {
202 pub fn new(window: &Window, _cx: &mut Context<Self>) -> Self {
203 let frame_budget = DEFAULT_FRAME_BUDGET;
204 Self {
205 sampler: FrameSampler::new(window.window_handle().window_id(), DEFAULT_CAPACITY),
206 readout: Readout::default(),
207 readout_at: None,
208 style: FpsStyle::default(),
209 frame_budget,
210 headline: Headline::Max,
211 display: None,
212 show_resources: true,
213 resource_interval: DEFAULT_RESOURCE_INTERVAL,
214 resources: None,
215 compact: false,
216 axis_max: frame_budget.as_secs_f32() * 2.,
217 clock: None,
218 _frame_trace: FrameTraceGuard::acquire(),
219 }
220 }
221
222 /// How many frames the chart keeps. Defaults to 120.
223 pub fn capacity(mut self, capacity: usize) -> Self {
224 self.sampler.set_capacity(capacity);
225 self
226 }
227
228 /// The per-frame budget used for the chart's baseline and bar colors.
229 /// Defaults to one 60Hz frame; set it to `1/144s` on a high refresh rate
230 /// display.
231 pub fn frame_budget(mut self, budget: Duration) -> Self {
232 self.frame_budget = budget;
233 self.axis_max = budget.as_secs_f32() * 2.;
234 self
235 }
236
237 pub(crate) fn set_frame_budget(&mut self, budget: Duration) {
238 self.frame_budget = budget;
239 self.axis_max = budget.as_secs_f32() * 2.;
240 }
241
242 /// Whether to sample and show CPU, memory and GPU usage. Defaults to
243 /// `true`, and is always off on the web.
244 ///
245 /// The GPU reading is left out on its own where the platform publishes no
246 /// counter for it, so turning this on does not guarantee three readings.
247 pub fn show_resources(mut self, show_resources: bool) -> Self {
248 self.show_resources = show_resources;
249 self
250 }
251
252 /// How often CPU, memory and GPU are resampled. Defaults to 500ms, and is
253 /// clamped up to the shortest interval that yields a meaningful CPU delta.
254 pub fn resource_interval(mut self, interval: Duration) -> Self {
255 self.resource_interval = interval;
256 self
257 }
258
259 /// The clock that republishes the readings, started on the first render so
260 /// that the builder methods have already been applied by the time its
261 /// interval is read.
262 ///
263 /// Nothing else wakes the HUD. It does not drive the frame loop, and a
264 /// window that has stopped drawing produces no renders to refresh it from,
265 /// so without this the figures would freeze at whatever the application
266 /// last drew — exactly when a frozen `137` is most likely to be read as
267 /// the truth.
268 #[cfg(not(target_family = "wasm"))]
269 fn start_clock(&mut self, cx: &mut Context<Self>) {
270 use crate::sampler::ResourceProbe;
271
272 if self.clock.is_some() {
273 return;
274 }
275
276 let show_resources = self.show_resources;
277 let interval = if show_resources {
278 self.resource_interval.max(minimum_resource_interval())
279 } else {
280 READOUT_INTERVAL
281 };
282 self.clock = Some(cx.spawn(async move |this, cx| {
283 let executor = cx.background_executor().clone();
284 // Probing walks the process table, so it never runs on the render
285 // thread. The probe moves in and out of each background task rather
286 // than living behind a lock. A platform that cannot provide one
287 // still gets the clock; it just has no resource row to fill.
288 let mut probe = if show_resources {
289 executor
290 .spawn(async { ResourceProbe::new(RESOURCE_WINDOW) })
291 .await
292 } else {
293 None
294 };
295
296 loop {
297 executor.timer(interval).await;
298
299 let sample = match probe.take() {
300 Some(mut owned) => {
301 let (returned, sample) = executor
302 .spawn(async move {
303 let sample = owned.sample();
304 (owned, sample)
305 })
306 .await;
307 probe = Some(returned);
308 sample
309 }
310 None => None,
311 };
312
313 let alive = this.update(cx, |this, cx| {
314 if sample.is_some() {
315 this.resources = sample;
316 }
317 cx.notify();
318 });
319 if alive.is_err() {
320 break;
321 }
322 }
323 }));
324 }
325
326 #[cfg(target_family = "wasm")]
327 fn start_clock(&mut self, cx: &mut Context<Self>) {
328 let _ = minimum_resource_interval();
329
330 if self.clock.is_some() {
331 return;
332 }
333 self.clock = Some(cx.spawn(async move |this, cx| {
334 let executor = cx.background_executor().clone();
335 loop {
336 executor.timer(READOUT_INTERVAL).await;
337 if this.update(cx, |_, cx| cx.notify()).is_err() {
338 break;
339 }
340 }
341 }));
342 }
343
344 /// Re-asks the platform for the refresh rate when the window has moved to
345 /// another display, and not otherwise: the answer is a property of the
346 /// panel, and on some platforms asking is a round trip.
347 fn update_display(&mut self, window: &Window, cx: &App) {
348 let Some(display) = window.display(cx) else {
349 return;
350 };
351 let id = display.id();
352 if self.display.map(|(asked, _)| asked) != Some(id) {
353 self.display = Some((id, display_refresh_rate(display.as_ref())));
354 }
355 }
356
357 /// Republishes the readings if [`READOUT_INTERVAL`] has passed.
358 fn update_readout(&mut self) {
359 let now = Instant::now();
360 let due = self
361 .readout_at
362 .is_none_or(|at| now.duration_since(at) >= READOUT_INTERVAL);
363 if !due {
364 return;
365 }
366
367 self.readout = Readout {
368 max_fps: sustainable_rate(
369 self.sampler.mean_draw(),
370 self.display.and_then(|(_, refresh_rate)| refresh_rate),
371 ),
372 fps: self.sampler.fps(),
373 interval_millis: self.sampler.present_interval().as_secs_f32() * 1000.,
374 // The mean over the interval rather than the latest frame, which
375 // at this cadence would be an arbitrary sample.
376 frame_millis: self.sampler.mean_draw().as_secs_f32() * 1000.,
377 percentile_millis: self.sampler.percentile_draw(FRAME_PERCENTILE).as_secs_f32() * 1000.,
378 dropped_percent: self.sampler.over_budget_ratio(self.frame_budget) * 100.,
379 invalidations: self.sampler.mean_invalidations(),
380 };
381 self.readout_at = Some(now);
382 }
383
384 /// Grows immediately to fit the slowest retained frame and decays back
385 /// slowly, so a single spike doesn't make the whole chart jump.
386 fn update_axis(&mut self) {
387 let floor = self.frame_budget.as_secs_f32() * 2.;
388 let target = self.sampler.peak_draw().as_secs_f32().max(floor);
389 self.axis_max = if target > self.axis_max {
390 target
391 } else {
392 self.axis_max + (target - self.axis_max) * AXIS_DECAY
393 };
394 }
395
396 /// The frame time trace, drawn behind the readings so it fills the HUD
397 /// instead of taking a band of its own. It is dimmed to stay legible under
398 /// the text.
399 fn render_chart(&self) -> impl IntoElement {
400 let style = self.style;
401 let budget = self.frame_budget.as_secs_f32();
402 let axis_max = self.axis_max.max(f32::EPSILON);
403 let capacity = self.sampler.capacity();
404 let samples: Vec<(f32, Hsla)> = self
405 .sampler
406 .samples()
407 .map(|sample| {
408 let seconds = sample.draw.as_secs_f32();
409 (
410 (seconds / axis_max).clamp(0., 1.),
411 style.level_color(seconds, budget).opacity(TRACE_OPACITY),
412 )
413 })
414 .collect();
415
416 canvas(
417 |_, _, _| (),
418 move |bounds: Bounds<Pixels>, _, window, _| {
419 let slot = bounds.size.width / capacity as f32;
420 // Fewer samples than the capacity means the chart is still
421 // filling up; keep the newest frame pinned to the right edge so
422 // the history scrolls instead of stretching.
423 let leading = capacity.saturating_sub(samples.len());
424 let points: Vec<(Point<Pixels>, Hsla)> = samples
425 .iter()
426 .enumerate()
427 .map(|(index, (ratio, color))| {
428 (
429 point(
430 bounds.origin.x + slot * (leading + index) as f32 + slot / 2.,
431 bounds.origin.y + bounds.size.height * (1. - *ratio),
432 ),
433 *color,
434 )
435 })
436 .collect();
437
438 // The line is drawn as runs of equal color rather than one
439 // segment per frame: a single path can only carry one color,
440 // and in the common case where nothing is dropped the whole
441 // chart collapses into one path.
442 let mut start = 0;
443 while start + 1 < points.len() {
444 // A segment is as slow as the frame it ends on, so the
445 // color of the later point decides the run.
446 let color = points[start + 1].1;
447 let mut path = PathBuilder::stroke(px(1.));
448 path.move_to(points[start].0);
449
450 let mut end = start + 1;
451 while end < points.len() && points[end].1 == color {
452 path.line_to(points[end].0);
453 end += 1;
454 }
455
456 if let Ok(path) = path.build() {
457 window.paint_path(path, color);
458 }
459 // Share the boundary point with the next run so the line
460 // stays connected across a color change.
461 start = end - 1;
462 }
463 },
464 )
465 .absolute()
466 .inset_0()
467 }
468
469 /// The headline reading, with the frame time trace painted behind it.
470 ///
471 /// The trace lives in this row rather than spanning the whole HUD because
472 /// this is its emptiest part — the figure is centered and short, leaving
473 /// both flanks open — so the trace stays readable instead of being cut up
474 /// by the denser rows below.
475 ///
476 /// The figure is centered in a fixed box so neither the unit nor the group
477 /// shifts as the count gains or loses a digit; the two share a bottom edge.
478 fn render_headline(&self, rate: f32, color: Hsla) -> Div {
479 let style = self.style;
480
481 div()
482 .relative()
483 .overflow_hidden()
484 .w_full()
485 .h(HEADLINE_HEIGHT)
486 .child(self.render_chart())
487 .child(
488 div()
489 .flex()
490 .size_full()
491 .items_end()
492 .justify_center()
493 .gap_1()
494 // The box that balances the unit on the right. Without it
495 // the unit's own width pushes the figure off center by half
496 // of it, which reads as misalignment — so the mode marker
497 // goes here, where it costs no layout and lands where it
498 // is read: immediately before the figure it qualifies.
499 .child(
500 div()
501 .w(UNIT_WIDTH)
502 .text_right()
503 .text_color(style.muted)
504 .when(self.headline == Headline::Max, |this| this.child("MAX")),
505 )
506 .child(
507 div()
508 .w(FIGURE_WIDTH)
509 .text_center()
510 .text_size(FIGURE_SIZE)
511 .line_height(relative(1.))
512 .text_color(color)
513 .child(format!("{rate:.0}")),
514 )
515 .child(div().w(UNIT_WIDTH).text_color(style.muted).child("FPS")),
516 )
517 }
518}
519
520impl Render for FpsMonitor {
521 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
522 self.sampler.tick();
523 self.update_display(window, cx);
524 self.update_readout();
525 self.update_axis();
526 self.start_clock(cx);
527
528 let style = self.style;
529 let budget = self.frame_budget;
530 let Readout {
531 max_fps,
532 fps,
533 interval_millis,
534 frame_millis,
535 percentile_millis,
536 dropped_percent: dropped,
537 invalidations,
538 } = self.readout;
539 // Printed plain, never graded. It is the reciprocal of `FRAME`, which
540 // is graded already, and grading the same measurement twice in two
541 // units would just say the same thing louder.
542 let fps_color = style.foreground;
543 let resources = self.resources.filter(|_| self.show_resources);
544 let compact = self.compact;
545 let headline = self.headline;
546 let rate = match headline {
547 Headline::Max => max_fps,
548 Headline::Observed => fps,
549 };
550
551 div()
552 .id("gpui-fps-hud")
553 .flex()
554 .bg(style.background)
555 .font_family(DEFAULT_FONT)
556 .text_size(TEXT_SIZE)
557 .text_color(style.muted)
558 .on_click(cx.listener(|this, _, _, cx| {
559 this.compact = !this.compact;
560 cx.notify();
561 }))
562 // The `MAX` marker is what says which of the two the figure is.
563 .on_mouse_down(
564 MouseButton::Right,
565 cx.listener(|this, _, _, cx| {
566 this.headline = match this.headline {
567 Headline::Max => Headline::Observed,
568 Headline::Observed => Headline::Max,
569 };
570 cx.stop_propagation();
571 cx.notify();
572 }),
573 )
574 .map(|this| {
575 if compact {
576 // Collapsed, the HUD is one small tag: the figure drops to
577 // the same size as its unit, the box shrinks to the text,
578 // and everything else is dropped, so it sits over the
579 // interface without competing with it.
580 this.items_center()
581 .gap_1()
582 .px_1p5()
583 .py_0p5()
584 .rounded(px(3.))
585 .when(headline == Headline::Max, |this| this.child("MAX"))
586 .child(
587 div()
588 .w(COMPACT_FIGURE_WIDTH)
589 .text_right()
590 .text_color(fps_color)
591 .child(format!("{rate:.0}")),
592 )
593 .child("FPS")
594 } else {
595 this.flex_col()
596 .w(HUD_WIDTH)
597 .px_2()
598 .py_1p5()
599 .rounded(px(4.))
600 .child(self.render_headline(rate, fps_color))
601 .child(reading(
602 // The same figure the platform overlay calls its
603 // frame interval: time between presents. Where the
604 // headline says how fast this UI could go, this
605 // says how often it actually went — a wide gap
606 // between them is an idle window, not a slow one.
607 "INTERVAL",
608 format!("{interval_millis:.1} ms"),
609 style.foreground,
610 style,
611 ))
612 .child(reading(
613 "FRAME",
614 format!("{frame_millis:.1} ms"),
615 // Graded against the budget, and the first reading
616 // in the HUD that is: the rate above says how often
617 // frames happened, this says whether they were
618 // affordable. It is the one to read when something
619 // feels slow.
620 style.level_color(frame_millis / 1000., budget.as_secs_f32()),
621 style,
622 ))
623 .child(reading(
624 // Graded the same way, so the two millisecond rows
625 // read as one measurement seen twice: what a frame
626 // usually costs, and what its slow tail costs.
627 "P95",
628 format!("{percentile_millis:.1} ms"),
629 style.level_color(percentile_millis / 1000., budget.as_secs_f32()),
630 style,
631 ))
632 .child(
633 // Dropped frames and wasted invalidations share a
634 // row: both count redundant work rather than
635 // measuring a duration, so neither belongs in the
636 // millisecond column above.
637 row()
638 .child(pair(
639 "DROP",
640 format!("{dropped:.1}%"),
641 style.level_color(if dropped > 0. { 1. } else { 0. }, 0.5),
642 style,
643 ))
644 .child(pair(
645 "INV",
646 format!("{invalidations:.1}"),
647 // Ungraded, unlike every other reading in
648 // the HUD. One per frame is the ideal, but
649 // it is not the floor here: in continuous
650 // mode the monitor requests an animation
651 // frame of its own on every render, so an
652 // application invalidating once a frame
653 // measures two and a healthy HUD would sit
654 // permanently in the red. The baseline
655 // depends on that switch and on how the
656 // application drives its own redraws, which
657 // is not something the HUD can grade — so
658 // the number is reported and the reading is
659 // left to whoever knows what to expect.
660 style.foreground,
661 style,
662 )),
663 )
664 .when_some(
665 resources.and_then(|resources| resources.gpu_percent),
666 |this, gpu| {
667 this.child(reading(
668 "GPU",
669 format!("{gpu:.1}%"),
670 style.foreground,
671 style,
672 ))
673 },
674 )
675 .when_some(resources, |this, resources| {
676 this.child(
677 // CPU and memory share a row: both are coarse
678 // background samples, unlike the per-frame
679 // numbers.
680 row()
681 .child(pair(
682 "CPU",
683 format_cpu(resources.cpu_percent),
684 style.foreground,
685 style,
686 ))
687 .child(pair(
688 "MEM",
689 format_bytes(resources.memory_bytes),
690 style.foreground,
691 style,
692 )),
693 )
694 })
695 }
696 })
697 }
698}
699
700/// A row carrying two [`pair`]s, pushed to either inner edge.
701fn row() -> Div {
702 div().flex().w_full().justify_between().gap_2().py(px(1.))
703}
704
705/// A `LABEL value` pair kept together, for rows that carry more than one
706/// reading. The label stays muted so it reads as a caption, not as data.
707fn pair(label: &'static str, value: String, value_color: Hsla, style: FpsStyle) -> Div {
708 div()
709 .flex()
710 .gap_1()
711 .child(div().text_color(style.muted).child(label))
712 .child(div().text_color(value_color).child(value))
713}
714
715/// One `LABEL … value` row. The value is right aligned against the HUD's inner
716/// edge, so in a monospace font every row's digits line up in a column and
717/// nothing shifts as the readings change width.
718fn reading(label: &'static str, value: String, value_color: Hsla, style: FpsStyle) -> Div {
719 div()
720 .flex()
721 .w_full()
722 .justify_between()
723 .gap_2()
724 .py(px(1.))
725 .child(div().text_color(style.muted).child(label))
726 .child(div().text_color(value_color).child(value))
727}
728
729/// A CPU reading on the single core scale, which passes 100 as soon as the
730/// process spreads over more than one core and reaches the core count times a
731/// hundred when it saturates the machine.
732///
733/// A tenth is worth showing while the reading is small, where it is the
734/// difference between idle and a busy timer; past ten the extra digit only
735/// churns, and dropping it also keeps the reading inside the row's share of the
736/// HUD on a machine with enough cores to reach four figures.
737fn format_cpu(percent: f32) -> String {
738 if percent < 10. {
739 format!("{percent:.1}%")
740 } else {
741 format!("{percent:.0}%")
742 }
743}
744
745fn format_bytes(bytes: u64) -> String {
746 const MIB: f64 = 1024. * 1024.;
747 const GIB: f64 = MIB * 1024.;
748
749 let bytes = bytes as f64;
750 if bytes >= GIB {
751 format!("{:.2} GB", bytes / GIB)
752 } else {
753 format!("{:.0} MB", bytes / MIB)
754 }
755}
756
757#[cfg(test)]
758mod tests {
759 use gpui::{AppContext as _, TestAppContext};
760
761 use super::*;
762
763 #[test]
764 fn the_headline_rate_is_what_a_frame_costs_and_the_panel_allows() {
765 let sixty = Duration::from_micros(16_667);
766 // A cheap frame on a 60Hz panel is not 333 frames anyone could see.
767 assert!((sustainable_rate(Duration::from_millis(3), Some(sixty)) - 60.).abs() < 0.01);
768 // A frame that costs more than a refresh sets the rate itself.
769 assert_eq!(
770 sustainable_rate(Duration::from_millis(20), Some(sixty)),
771 50.
772 );
773 // Where the platform will not say, an uncapped reading beats a guess.
774 assert!((sustainable_rate(Duration::from_millis(3), None) - 333.33).abs() < 0.1);
775 // No frames drawn yet is no rate, not an infinite one.
776 assert_eq!(sustainable_rate(Duration::ZERO, Some(sixty)), 0.);
777 }
778
779 #[gpui::test]
780 fn test_fps_monitor_builder(cx: &mut TestAppContext) {
781 let cx = cx.add_empty_window();
782 cx.update(|window, cx| {
783 let budget = Duration::from_micros(6_944);
784 let monitor = cx.new(|cx| {
785 FpsMonitor::new(window, cx)
786 .capacity(240)
787 .frame_budget(budget)
788 .show_resources(false)
789 .resource_interval(Duration::from_secs(2))
790 });
791
792 let monitor = monitor.read(cx);
793 assert_eq!(monitor.sampler.capacity(), 240);
794 assert_eq!(monitor.frame_budget, budget);
795 assert!(!monitor.show_resources);
796 assert_eq!(monitor.resource_interval, Duration::from_secs(2));
797 // The axis floor tracks the budget so a 144Hz budget doesn't leave
798 // the chart scaled for 60Hz frames.
799 assert_eq!(monitor.axis_max, budget.as_secs_f32() * 2.);
800 });
801 }
802
803 #[test]
804 fn formats_memory_by_magnitude() {
805 assert_eq!(format_bytes(184 * 1024 * 1024), "184 MB");
806 assert_eq!(format_bytes(3 * 1024 * 1024 * 1024), "3.00 GB");
807 }
808
809 /// The reading is on the single core scale, so it passes 100 and keeps
810 /// going — the row must show that rather than round it away or clip it.
811 #[test]
812 fn formats_cpu_on_the_single_core_scale() {
813 // A process spread over a core and a half, which under a scale where
814 // 100 is the whole machine would have read 5.8% on a 24 core desktop.
815 assert_eq!(format_cpu(140.), "140%");
816 // Saturating every core of a big machine still has somewhere to go.
817 assert_eq!(format_cpu(2400.), "2400%");
818 // Small readings keep the tenth that distinguishes them.
819 assert_eq!(format_cpu(0.4), "0.4%");
820 assert_eq!(format_cpu(9.9), "9.9%");
821 assert_eq!(format_cpu(12.4), "12%");
822 }
823}