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