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