Skip to main content

ravenrs_widgets/
diagnostics.rs

1//! A panel any desktop app can open to see its GPU and what its frames cost.
2
3use std::cell::{Cell, RefCell};
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{
8    Bottleneck, BuildCause, DiagnosticFinding, DiagnosticReport, FrameLog, FrameSample, Percentiles,
9    PerformanceResult, PerformanceVerdict, Quad, Rect, ViewCost, new_issue_url, redact_user_paths,
10};
11use ravenrs_reactive::State;
12use ravenrs_style::{Semantic, Theme};
13
14use crate::context::{BuildCx, NodeId, Placement};
15use crate::floating::{SheetLayer, Side};
16use crate::labels::Labels;
17use crate::view::View;
18use crate::{
19    Alert, Button, ButtonStyle, Canvas, CodeBlock, Grid, IconName, ProgressBar, ScrollView, Stat, Text, Tone,
20    VStack, ViewExt as _,
21};
22
23/// How often the open panel shows new numbers, in seconds. Its own rebuilds are
24/// frames too, so this stays slow enough not to weigh on what it measures.
25const REFRESH: f64 = 0.5;
26/// How long a performance test redraws the window, in seconds.
27const TEST_SECONDS: f64 = 5.0;
28/// The panel's width in logical pixels.
29const PANEL_WIDTH: f32 = 520.0;
30/// The height of the picture of the latest frames.
31const TIMELINE_HEIGHT: f32 = 96.0;
32/// What the picture's height stands for, in seconds: two frames at 60 Hz.
33const TIMELINE_SPAN: f32 = 0.0333;
34/// The line across the picture: a frame at 120 Hz.
35const FRAME_BUDGET: f32 = 1.0 / 120.0;
36/// How many views the panel lists.
37const VIEWS_SHOWN: usize = 8;
38
39type ReportSource = Rc<dyn Fn() -> DiagnosticReport>;
40type OpenUrl = Rc<dyn Fn(&str) -> Result<(), String>>;
41
42/// Where a bug report goes and how the platform gets it there.
43#[derive(Clone)]
44struct BugReporter {
45    new_issue: String,
46    open: OpenUrl,
47}
48
49/// Where a performance test stands.
50#[derive(Clone, Copy, Debug, Default)]
51enum TestPhase {
52    #[default]
53    Idle,
54    /// Asked for; the clock starts at the next build.
55    Starting,
56    Running {
57        started: f64,
58    },
59    Done(PerformanceResult),
60}
61
62/// What a platform shares with the panel of a [`DiagnosticsHost`]: it records
63/// frames here, says how to describe the GPU, and opens the panel.
64///
65/// Frames are kept out of reactive state on purpose: recording one must never
66/// ask for another.
67#[derive(Clone)]
68#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
69pub struct Diagnostics {
70    open: State<bool>,
71    frames: Rc<RefCell<FrameLog>>,
72    source: Rc<RefCell<Option<ReportSource>>>,
73    report: Rc<RefCell<Option<DiagnosticReport>>>,
74    bug_reporter: Rc<RefCell<Option<BugReporter>>>,
75    /// Changes without reactive state: builds move it along, and must not ask for
76    /// another build by doing so. `test_runs` is what tells the UI about a new one.
77    test: Rc<Cell<TestPhase>>,
78    test_runs: State<u32>,
79    view_costs: Rc<RefCell<Vec<ViewCost>>>,
80}
81
82impl fmt::Debug for Diagnostics {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        f.debug_struct("Diagnostics").field("open", &self.open.get()).finish_non_exhaustive()
85    }
86}
87
88impl Default for Diagnostics {
89    fn default() -> Self {
90        Self {
91            open: State::new(false),
92            frames: Rc::default(),
93            source: Rc::default(),
94            report: Rc::default(),
95            bug_reporter: Rc::default(),
96            test: Rc::default(),
97            test_runs: State::new(0),
98            view_costs: Rc::default(),
99        }
100    }
101}
102
103#[cfg_attr(feature = "live", ravenrs_live::expose)]
104impl Diagnostics {
105    /// Whether the panel is open.
106    pub fn is_open(&self) -> bool {
107        self.open.get()
108    }
109
110    /// Opens or closes the panel.
111    pub fn set_open(&self, open: bool) {
112        self.open.set(open);
113    }
114
115    /// Opens the panel if it is closed and closes it if it is open.
116    pub fn toggle(&self) {
117        self.open.update(|open| *open = !*open);
118    }
119
120    /// Opens the panel and redraws the window nonstop for a few seconds, to tell
121    /// the person in plain words how fast this machine draws this app.
122    pub fn start_test(&self) {
123        self.test.set(TestPhase::Starting);
124        self.test_runs.update(|runs| *runs += 1);
125        self.open.set(true);
126    }
127
128    /// Moves the test along to the time `now` and says where it stands.
129    fn advance_test(&self, now: f64) -> TestPhase {
130        let phase = match self.test.get() {
131            TestPhase::Starting => {
132                self.frames.borrow_mut().clear();
133                TestPhase::Running { started: now }
134            }
135            TestPhase::Running { started } if now - started >= TEST_SECONDS => {
136                TestPhase::Done(self.frames.borrow().measure(now - started))
137            }
138            phase => phase,
139        };
140        self.test.set(phase);
141        phase
142    }
143
144    /// Adds a frame to the log.
145    pub fn record(&self, sample: FrameSample) {
146        self.frames.borrow_mut().record(sample);
147    }
148
149    /// Says what each type of view cost in the latest build; see `Ui::view_costs`.
150    pub fn set_view_costs(&self, costs: Vec<ViewCost>) {
151        *self.view_costs.borrow_mut() = costs;
152    }
153
154    /// The latest frames in the Trace Event format, which Perfetto opens.
155    pub fn trace_json(&self) -> String {
156        self.frames.borrow().trace_json()
157    }
158
159    /// Sets how to describe the GPU. Describing it can take a moment, so `source`
160    /// only runs when the panel first needs it, and again after the next call here.
161    pub fn set_report_source(&self, source: impl Fn() -> DiagnosticReport + 'static) {
162        *self.source.borrow_mut() = Some(Rc::new(source));
163        *self.report.borrow_mut() = None;
164    }
165
166    /// Adds a button to the panel that starts a bug report on the `new_issue` page.
167    /// `open` shows a URL to the person, in a browser on the desktop, and says why
168    /// when it cannot. Nothing is sent: the person reviews and submits on that page.
169    pub fn set_bug_reporter(
170        &self,
171        new_issue: impl Into<String>,
172        open: impl Fn(&str) -> Result<(), String> + 'static,
173    ) {
174        *self.bug_reporter.borrow_mut() =
175            Some(BugReporter { new_issue: new_issue.into(), open: Rc::new(open) });
176    }
177
178    /// The report, gathering it if needed; `None` before a source is set.
179    pub fn report(&self) -> Option<DiagnosticReport> {
180        if self.report.borrow().is_none() {
181            let source = self.source.borrow().clone()?;
182            *self.report.borrow_mut() = Some(source());
183        }
184        self.report.borrow().clone()
185    }
186}
187
188/// Shows `root` and, while [`Diagnostics`] says so, the diagnostics panel over
189/// it. Closed, it adds nothing to the UI: no element and no wake-up.
190pub struct DiagnosticsHost<V> {
191    root: V,
192    diagnostics: Diagnostics,
193}
194
195impl<V> fmt::Debug for DiagnosticsHost<V> {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        f.debug_struct("DiagnosticsHost").field("diagnostics", &self.diagnostics).finish_non_exhaustive()
198    }
199}
200
201#[cfg_attr(feature = "live", ravenrs_live::expose)]
202impl<V: View> DiagnosticsHost<V> {
203    /// Hosts `root` with the panel `diagnostics` controls.
204    pub fn new(root: V, diagnostics: Diagnostics) -> Self {
205        Self { root, diagnostics }
206    }
207}
208
209impl<V: View> View for DiagnosticsHost<V> {
210    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
211        let root = self.root.build(cx);
212        if self.diagnostics.is_open() {
213            self.diagnostics.test_runs.get();
214            let open = self.diagnostics.open.binding();
215            let close = open.clone();
216            let labels = cx.labels();
217            let panel = Panel { diagnostics: &self.diagnostics };
218            let layer = SheetLayer {
219                open: &open,
220                title: &labels.diagnostics,
221                description: None,
222                content: Some(&panel),
223                side: Side::Right,
224                width: PANEL_WIDTH,
225                bare: false,
226            };
227            cx.overlay(root, Placement::Fill, &layer, Some(Rc::new(move || close.set(false))));
228        }
229        root
230    }
231}
232
233struct Panel<'a> {
234    diagnostics: &'a Diagnostics,
235}
236
237/// What a bug report would share, and the button that starts one.
238struct ReportBug {
239    reporter: BugReporter,
240    text: String,
241}
242
243impl View for ReportBug {
244    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
245        let labels = cx.labels();
246        let spacing = cx.theme().spacing.s2;
247        let failed = cx.use_state(|| false);
248        let (reporter, text, failure) = (self.reporter.clone(), self.text.clone(), failed.clone());
249        let on_click = {
250            let labels = labels.clone();
251            move || {
252                let url = new_issue_url(
253                    &reporter.new_issue,
254                    &labels.diagnostics_report_questions,
255                    &text,
256                    &labels.diagnostics_report_truncated,
257                );
258                let opened = (reporter.open)(&url);
259                if let Err(e) = &opened {
260                    tracing::warn!(error = %e, "could not open the bug report");
261                }
262                failure.set(opened.is_err());
263            }
264        };
265        let failure = failed.get().then(|| {
266            Alert::new(
267                labels.diagnostics_report_failed.clone(),
268                labels.diagnostics_report_failed_detail.clone(),
269            )
270            .tone(Tone::Danger)
271        });
272        cx.child(
273            &VStack::new((
274                Alert::new(
275                    labels.diagnostics_report_shares.clone(),
276                    labels.diagnostics_report_shares_detail.clone(),
277                ),
278                failure,
279                Button::new(labels.diagnostics_report_bug.clone())
280                    .icon(IconName::ExternalLink)
281                    .style(ButtonStyle::Secondary)
282                    .on_click(on_click),
283            ))
284            .spacing(spacing),
285        )
286    }
287}
288
289fn finding_alert(labels: &Labels, finding: &DiagnosticFinding) -> Alert {
290    let (title, message, tone) = match finding {
291        DiagnosticFinding::SoftwareRendering => {
292            (&labels.diagnostics_software, labels.diagnostics_software_detail.clone(), Tone::Danger)
293        }
294        DiagnosticFinding::UnoptimizedBuild => {
295            (&labels.diagnostics_unoptimized, labels.diagnostics_unoptimized_detail.clone(), Tone::Warning)
296        }
297        DiagnosticFinding::HiddenAdapters(names) => {
298            (&labels.diagnostics_hidden, names.join(", "), Tone::Warning)
299        }
300        DiagnosticFinding::NoGpu(reason) => (&labels.diagnostics_no_gpu, reason.clone(), Tone::Danger),
301    };
302    Alert::new(title.clone(), redact_user_paths(&message)).tone(tone)
303}
304
305fn result_alert(labels: &Labels, result: &PerformanceResult) -> Alert {
306    let (title, tone) = match result.verdict {
307        PerformanceVerdict::Good => (&labels.diagnostics_test_good, Tone::Success),
308        PerformanceVerdict::Fair => (&labels.diagnostics_test_fair, Tone::Warning),
309        PerformanceVerdict::Poor => (&labels.diagnostics_test_poor, Tone::Danger),
310    };
311    let cause = match result.bottleneck {
312        None => &labels.diagnostics_test_cause_none,
313        Some(Bottleneck::Gpu) => &labels.diagnostics_test_cause_gpu,
314        Some(Bottleneck::Interface) => &labels.diagnostics_test_cause_interface,
315    };
316    Alert::new(title.clone(), format!("{:.0} {}. {cause}", result.fps, labels.diagnostics_test_fps))
317        .tone(tone)
318}
319
320/// A bar per recent frame, split by where its time went.
321fn timeline(frames: Vec<FrameSample>, theme: &Theme) -> Canvas {
322    let colors = [
323        theme.color(Semantic::Accent),
324        theme.color(Semantic::Success),
325        theme.color(Semantic::Warning),
326        theme.color(Semantic::Danger),
327        theme.color(Semantic::InkMuted),
328    ];
329    let budget = theme.color(Semantic::Ink);
330    let surface = theme.color(Semantic::Surface);
331    Canvas::new(move |area: Rect, scene| {
332        scene.push_quad(Quad::new(area, surface));
333        let width = area.width() / frames.len().max(1) as f32;
334        for (i, frame) in frames.iter().enumerate() {
335            let phases = frame.work.phases;
336            let other = frame.update.saturating_sub(phases.total());
337            let parts = [
338                phases.build,
339                phases.layout + phases.place,
340                phases.paint + other,
341                frame.render + frame.render_work.gpu.unwrap_or_default(),
342                frame.acquire + frame.present,
343            ];
344            let mut bottom = area.max_y();
345            for (part, color) in parts.iter().zip(colors) {
346                let height = part.as_secs_f32() / TIMELINE_SPAN * area.height();
347                let top = (bottom - height).max(area.y());
348                let x = area.x() + i as f32 * width;
349                scene.push_quad(Quad::new(Rect::new(x, top, (width - 0.5).max(0.5), bottom - top), color));
350                bottom = top;
351            }
352        }
353        let y = area.max_y() - FRAME_BUDGET / TIMELINE_SPAN * area.height();
354        scene.push_quad(Quad::new(Rect::new(area.x(), y, area.width(), 1.0), budget));
355    })
356    .size(PANEL_WIDTH - 48.0, TIMELINE_HEIGHT)
357}
358
359/// Why the recent frames rebuilt the screen, the most common first.
360fn causes_text(labels: &Labels, frames: &[FrameSample]) -> String {
361    let mut lines: Vec<(u32, &str)> = BuildCause::ALL
362        .iter()
363        .zip(&labels.diagnostics_causes)
364        .map(|(cause, name)| {
365            let count = frames.iter().filter(|f| f.work.cause == Some(*cause)).count() as u32;
366            (count, name.as_str())
367        })
368        .filter(|(count, _)| *count > 0)
369        .collect();
370    if lines.is_empty() {
371        return labels.diagnostics_why_none.clone();
372    }
373    lines.sort_by_key(|line| std::cmp::Reverse(line.0));
374    lines.iter().map(|(count, name)| format!("{name}: {count}")).collect::<Vec<_>>().join("\n")
375}
376
377fn views_text(costs: &[ViewCost]) -> String {
378    costs
379        .iter()
380        .take(VIEWS_SHOWN)
381        .map(|cost| {
382            format!(
383                "{:<24} {:>5}x {:>8.1?} {:>5} elements {:>5} shapes",
384                cost.short_name(),
385                cost.count,
386                cost.time,
387                cost.elements,
388                cost.shapes
389            )
390        })
391        .collect::<Vec<_>>()
392        .join("\n")
393}
394
395fn stage(name: &str, stage: Percentiles) -> Stat {
396    Stat::new(format!("{name} · p95 {:.1?}", stage.p95), format!("{:.1?}", stage.median))
397}
398
399impl View for Panel<'_> {
400    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
401        let now = cx.now();
402        let phase = self.diagnostics.advance_test(now);
403        // While testing, every frame asks for the next: that is the measurement.
404        cx.wake_at(if matches!(phase, TestPhase::Running { .. }) { now } else { now + REFRESH });
405        let labels = cx.labels();
406        let spacing = cx.theme().spacing.s3;
407        let report = self.diagnostics.report().unwrap_or_default();
408        let summary = self.diagnostics.frames.borrow().summary();
409        let frames: Vec<FrameSample> = self.diagnostics.frames.borrow().samples().copied().collect();
410        let work = summary.latest.render_work;
411        let theme = cx.theme();
412        let clipboard = cx.clipboard();
413        let capture = self.diagnostics.clone();
414        // The exact text a bug report would carry, so what is read here is what is shared.
415        let measured = match phase {
416            TestPhase::Done(result) => result.to_string(),
417            _ => String::new(),
418        };
419        let text = redact_user_paths(&format!("{report}{measured}{summary}"));
420        let reporter = self.diagnostics.bug_reporter.borrow().clone();
421        let report_bug = reporter.map(|reporter| ReportBug { reporter, text: text.clone() });
422        let alerts: Vec<_> = report.findings().iter().map(|f| finding_alert(&labels, f)).collect();
423        let test: crate::AnyView = match phase {
424            TestPhase::Running { started } => Box::new(
425                VStack::new((
426                    Alert::new(labels.diagnostics_testing.clone(), labels.diagnostics_testing_detail.clone()),
427                    ProgressBar::new(((now - started) / TEST_SECONDS) as f32),
428                ))
429                .spacing(spacing),
430            ),
431            TestPhase::Starting | TestPhase::Idle | TestPhase::Done(_) => {
432                let diagnostics = self.diagnostics.clone();
433                let result = match phase {
434                    TestPhase::Done(result) => Some(result_alert(&labels, &result)),
435                    _ => None,
436                };
437                Box::new(
438                    VStack::new((
439                        result,
440                        Button::new(labels.diagnostics_test.clone())
441                            .icon(IconName::Activity)
442                            .on_click(move || diagnostics.start_test()),
443                    ))
444                    .spacing(spacing),
445                )
446            }
447        };
448        cx.child(
449            &ScrollView::new((
450                test,
451                VStack::new(alerts).spacing(spacing),
452                Grid::new(
453                    2,
454                    (
455                        stage(&labels.diagnostics_frame, summary.total),
456                        stage(&labels.diagnostics_update, summary.update),
457                        stage(&labels.diagnostics_acquire, summary.acquire),
458                        stage(&labels.diagnostics_render, summary.render),
459                    ),
460                )
461                .spacing(spacing),
462                report_bug,
463                VStack::new((
464                    Text::new(labels.diagnostics_timeline.clone()),
465                    timeline(frames.clone(), theme),
466                    Text::new(labels.diagnostics_timeline_detail.clone()).foreground(Semantic::InkMuted),
467                ))
468                .spacing(spacing),
469                VStack::new((
470                    Text::new(labels.diagnostics_why.clone()),
471                    CodeBlock::new(causes_text(&labels, &frames)),
472                    Text::new(labels.diagnostics_views.clone()),
473                    CodeBlock::new(views_text(&self.diagnostics.view_costs.borrow())),
474                    Text::new(labels.diagnostics_batches.clone()),
475                    CodeBlock::new(format!(
476                        "kind {}, image {}, clip {}",
477                        work.breaks_kind, work.breaks_image, work.breaks_clip
478                    )),
479                    Text::new(labels.diagnostics_memory.clone()),
480                    CodeBlock::new(format!(
481                        "glyph atlas {} px, {:.0}% full, {} resets\n{} images, {} KB sent per frame, GPU {}",
482                        work.atlas_size,
483                        work.atlas_used as f32 / 10.0,
484                        work.atlas_resets,
485                        work.cached_images,
486                        work.uploaded_bytes / 1024,
487                        work.gpu.map_or_else(|| "not timed".to_owned(), |gpu| format!("{gpu:.1?}")),
488                    )),
489                ))
490                .spacing(spacing),
491                Button::new(labels.diagnostics_copy_trace.clone())
492                    .icon(IconName::Copy)
493                    .style(ButtonStyle::Secondary)
494                    .on_click(move || clipboard.write(&capture.trace_json())),
495                CodeBlock::new(text),
496            ))
497            .grow(1.0),
498        )
499    }
500}