1use 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
23const REFRESH: f64 = 0.5;
26const TEST_SECONDS: f64 = 5.0;
28const PANEL_WIDTH: f32 = 520.0;
30const TIMELINE_HEIGHT: f32 = 96.0;
32const TIMELINE_SPAN: f32 = 0.0333;
34const FRAME_BUDGET: f32 = 1.0 / 120.0;
36const VIEWS_SHOWN: usize = 8;
38
39type ReportSource = Rc<dyn Fn() -> DiagnosticReport>;
40type OpenUrl = Rc<dyn Fn(&str) -> Result<(), String>>;
41
42#[derive(Clone)]
44struct BugReporter {
45 new_issue: String,
46 open: OpenUrl,
47}
48
49#[derive(Clone, Copy, Debug, Default)]
51enum TestPhase {
52 #[default]
53 Idle,
54 Starting,
56 Running {
57 started: f64,
58 },
59 Done(PerformanceResult),
60}
61
62#[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 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 pub fn is_open(&self) -> bool {
107 self.open.get()
108 }
109
110 pub fn set_open(&self, open: bool) {
112 self.open.set(open);
113 }
114
115 pub fn toggle(&self) {
117 self.open.update(|open| *open = !*open);
118 }
119
120 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 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 pub fn record(&self, sample: FrameSample) {
146 self.frames.borrow_mut().record(sample);
147 }
148
149 pub fn set_view_costs(&self, costs: Vec<ViewCost>) {
151 *self.view_costs.borrow_mut() = costs;
152 }
153
154 pub fn trace_json(&self) -> String {
156 self.frames.borrow().trace_json()
157 }
158
159 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 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 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
188pub 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 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
237struct 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
320fn 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
359fn 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 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 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}