Skip to main content

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}