Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 72 additions & 10 deletions src/dashboard/dashboard.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::dashboard::config::{DashboardConfig, OverflowMode};
use crate::dashboard::data_cache::DashboardDataCache;
use crate::dashboard::diagnostics::{DashboardDiagnostics, DashboardDiagnosticsSnapshot};
use crate::dashboard::layout::{normalize_slots, NormalizedSlot};
use crate::dashboard::widgets::{Widget, WidgetAction, WidgetRegistry};
use crate::{actions::Action, common::json_watch::JsonWatcher};
Expand All @@ -12,6 +13,7 @@ use siphasher::sip::SipHasher24;
use std::collections::HashMap;
use std::hash::Hasher;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
#[cfg(test)]
use std::sync::Mutex;

Expand Down Expand Up @@ -46,6 +48,8 @@ pub struct DashboardContext<'a> {
pub dashboard_visible: bool,
pub dashboard_focused: bool,
pub reduce_dashboard_work_when_unfocused: bool,
pub diagnostics: Option<DashboardDiagnosticsSnapshot>,
pub show_diagnostics_widget: bool,
}

struct SlotRuntime {
Expand Down Expand Up @@ -107,6 +111,7 @@ pub struct Dashboard {
watcher: Option<JsonWatcher>,
pub warnings: Vec<String>,
event_cb: Option<std::sync::Arc<dyn Fn(DashboardEvent) + Send + Sync>>,
diagnostics: DashboardDiagnostics,
}

impl Dashboard {
Expand All @@ -126,6 +131,7 @@ impl Dashboard {
watcher: None,
warnings,
event_cb,
diagnostics: DashboardDiagnostics::new(),
};
dashboard.rebuild_runtime_slots(slots);
dashboard
Expand Down Expand Up @@ -217,6 +223,9 @@ impl Dashboard {

for slot in &mut self.runtime_slots {
let normalized = &slot.slot;
if !ctx.show_diagnostics_widget && normalized.widget == "diagnostics" {
continue;
}
let slot_rect = egui::Rect::from_min_size(
rect.min
+ egui::vec2(
Expand All @@ -232,7 +241,15 @@ impl Dashboard {
let response = child.allocate_ui_at_rect(slot_rect, |slot_ui| {
slot_ui.set_clip_rect(slot_clip);
slot_ui.set_min_size(slot_rect.size());
Self::render_slot(slot, slot_rect, slot_clip, slot_ui, ctx, activation)
Self::render_slot(
slot,
slot_rect,
slot_clip,
slot_ui,
ctx,
activation,
&mut self.diagnostics,
)
});
clicked = clicked.or(response.inner);
}
Expand All @@ -247,12 +264,9 @@ impl Dashboard {
ui: &mut egui::Ui,
ctx: &DashboardContext<'_>,
activation: WidgetActivation,
diagnostics: &mut DashboardDiagnostics,
) -> Option<WidgetAction> {
let heading = slot
.slot
.id
.clone()
.unwrap_or_else(|| slot.slot.widget.clone());
let (heading, diag_key) = slot_diagnostics_label(&slot.slot);

ui.set_clip_rect(slot_clip);
ui.set_min_size(slot_rect.size());
Expand Down Expand Up @@ -282,7 +296,16 @@ impl Dashboard {

let action = match overflow {
OverflowPolicy::Clip => {
Self::render_clipped_widget(slot, ui, ctx, activation, body_height)
Self::render_clipped_widget(
slot,
ui,
ctx,
activation,
body_height,
diagnostics,
&heading,
&diag_key,
)
}
OverflowPolicy::Scroll { visibility } => Self::render_scrollable_widget(
slot,
Expand All @@ -292,6 +315,9 @@ impl Dashboard {
body_height,
slot_clip,
visibility,
diagnostics,
&heading,
&diag_key,
),
};

Expand All @@ -308,10 +334,13 @@ impl Dashboard {
ctx: &DashboardContext<'_>,
activation: WidgetActivation,
body_height: f32,
diagnostics: &mut DashboardDiagnostics,
heading: &str,
diag_key: &str,
) -> Option<WidgetAction> {
ui.set_min_height(body_height);
ui.set_max_height(body_height);
Self::render_widget_content(slot, ui, ctx, activation)
Self::render_widget_content(slot, ui, ctx, activation, diagnostics, heading, diag_key)
}

fn render_scrollable_widget(
Expand All @@ -322,6 +351,9 @@ impl Dashboard {
body_height: f32,
slot_clip: egui::Rect,
visibility: ScrollBarVisibility,
diagnostics: &mut DashboardDiagnostics,
heading: &str,
diag_key: &str,
) -> Option<WidgetAction> {
let scroll_id = egui::Id::new((
"slot-scroll",
Expand All @@ -345,7 +377,7 @@ impl Dashboard {
let _ = viewport;
ui.set_clip_rect(ui.clip_rect().intersect(slot_clip));
ui.set_min_height(body_height);
Self::render_widget_content(slot, ui, ctx, activation)
Self::render_widget_content(slot, ui, ctx, activation, diagnostics, heading, diag_key)
})
.inner
}
Expand All @@ -355,13 +387,41 @@ impl Dashboard {
ui: &mut egui::Ui,
ctx: &DashboardContext<'_>,
activation: WidgetActivation,
diagnostics: &mut DashboardDiagnostics,
heading: &str,
diag_key: &str,
) -> Option<WidgetAction> {
slot.widget.render(ui, ctx, activation)
let start = Instant::now();
let response = slot.widget.render(ui, ctx, activation);
diagnostics.record_widget_refresh(
diag_key.to_string(),
heading.to_string(),
start.elapsed(),
);
response
}

pub fn registry(&self) -> &WidgetRegistry {
&self.registry
}

pub fn update_frame_timing(&mut self, frame_time: Duration) {
self.diagnostics.update_frame_timing(frame_time);
}

pub fn diagnostics_snapshot(&self) -> DashboardDiagnosticsSnapshot {
self.diagnostics.snapshot()
}
}

fn slot_diagnostics_label(slot: &NormalizedSlot) -> (String, String) {
if let Some(id) = &slot.id {
(id.clone(), id.clone())
} else {
let label = format!("{} ({}, {})", slot.widget, slot.row, slot.col);
let key = format!("{}@{}x{}", slot.widget, slot.row, slot.col);
(label, key)
}
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -546,6 +606,8 @@ mod tests {
dashboard_visible: true,
dashboard_focused: true,
reduce_dashboard_work_when_unfocused: false,
diagnostics: None,
show_diagnostics_widget: true,
}
}

Expand Down
169 changes: 169 additions & 0 deletions src/dashboard/diagnostics.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
use std::collections::HashMap;
use std::time::{Duration, Instant};

pub const DIAGNOSTICS_REFRESH_INTERVAL: Duration = Duration::from_millis(500);
pub const REFRESH_WARNING_THRESHOLD: Duration = Duration::from_millis(75);

#[derive(Clone, Debug)]
pub struct WidgetRefreshSnapshot {
pub label: String,
pub last_refresh_at: Instant,
pub last_duration: Duration,
}

#[derive(Clone, Debug, Default)]
pub struct DashboardDiagnosticsSnapshot {
pub fps: f32,
pub frame_time: Duration,
pub widget_refreshes: Vec<WidgetRefreshSnapshot>,
}

struct WidgetRefreshState {
label: String,
last_refresh_at: Instant,
last_duration: Duration,
last_sample: Instant,
}

pub struct DashboardDiagnostics {
widget_states: HashMap<String, WidgetRefreshState>,
fps: f32,
frame_time: Duration,
refresh_interval: Duration,
warning_threshold: Duration,
last_frame_sample: Instant,
}

impl DashboardDiagnostics {
pub fn new() -> Self {
Self::new_with_config(DIAGNOSTICS_REFRESH_INTERVAL, REFRESH_WARNING_THRESHOLD)
}

pub fn new_with_config(refresh_interval: Duration, warning_threshold: Duration) -> Self {
let now = Instant::now();
Self {
widget_states: HashMap::new(),
fps: 0.0,
frame_time: Duration::from_millis(0),
refresh_interval,
warning_threshold,
last_frame_sample: now - refresh_interval,
}
}

pub fn update_frame_timing(&mut self, frame_time: Duration) {
let now = Instant::now();
if now.duration_since(self.last_frame_sample) < self.refresh_interval {
return;
}
self.frame_time = frame_time;
let secs = frame_time.as_secs_f32();
self.fps = if secs > 0.0 { 1.0 / secs } else { 0.0 };
self.last_frame_sample = now;
}

pub fn record_widget_refresh(&mut self, key: String, label: String, duration: Duration) {
let now = Instant::now();
let update_due = match self.widget_states.get(&key) {
Some(state) => now.duration_since(state.last_sample) >= self.refresh_interval,
None => true,
};
if update_due || duration >= self.warning_threshold {
let entry = self.widget_states.entry(key).or_insert(WidgetRefreshState {
label: label.clone(),
last_refresh_at: now,
last_duration: duration,
last_sample: now,
});
entry.label = label;
entry.last_refresh_at = now;
entry.last_duration = duration;
entry.last_sample = now;
}
}

pub fn snapshot(&self) -> DashboardDiagnosticsSnapshot {
let mut widget_refreshes: Vec<WidgetRefreshSnapshot> = self
.widget_states
.values()
.map(|state| WidgetRefreshSnapshot {
label: state.label.clone(),
last_refresh_at: state.last_refresh_at,
last_duration: state.last_duration,
})
.collect();
widget_refreshes.sort_by(|a, b| a.label.cmp(&b.label));
DashboardDiagnosticsSnapshot {
fps: self.fps,
frame_time: self.frame_time,
widget_refreshes,
}
}

pub fn warning_threshold(&self) -> Duration {
self.warning_threshold
}
}

#[cfg(test)]
impl DashboardDiagnostics {
fn set_last_frame_sample_for_test(&mut self, instant: Instant) {
self.last_frame_sample = instant;
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn frame_metrics_throttle_until_interval() {
let mut diagnostics = DashboardDiagnostics::new_with_config(
Duration::from_secs(10),
REFRESH_WARNING_THRESHOLD,
);
diagnostics.update_frame_timing(Duration::from_millis(16));
let first = diagnostics.snapshot();

diagnostics.update_frame_timing(Duration::from_millis(33));
let second = diagnostics.snapshot();
assert_eq!(first.frame_time, second.frame_time);

diagnostics.set_last_frame_sample_for_test(Instant::now() - Duration::from_secs(11));
diagnostics.update_frame_timing(Duration::from_millis(33));
let third = diagnostics.snapshot();
assert_ne!(first.frame_time, third.frame_time);
}

#[test]
fn widget_refresh_updates_on_threshold() {
let mut diagnostics = DashboardDiagnostics::new_with_config(
Duration::from_secs(10),
Duration::from_millis(50),
);
diagnostics.record_widget_refresh(
"widget-a".to_string(),
"Widget A".to_string(),
Duration::from_millis(10),
);
let first = diagnostics.snapshot();
assert_eq!(first.widget_refreshes.len(), 1);
assert_eq!(first.widget_refreshes[0].last_duration, Duration::from_millis(10));

diagnostics.record_widget_refresh(
"widget-a".to_string(),
"Widget A".to_string(),
Duration::from_millis(5),
);
let second = diagnostics.snapshot();
assert_eq!(second.widget_refreshes[0].last_duration, Duration::from_millis(10));

diagnostics.record_widget_refresh(
"widget-a".to_string(),
"Widget A".to_string(),
Duration::from_millis(75),
);
let third = diagnostics.snapshot();
assert_eq!(third.widget_refreshes[0].last_duration, Duration::from_millis(75));
}
}
2 changes: 2 additions & 0 deletions src/dashboard/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
pub mod config;
pub mod dashboard;
pub mod data_cache;
pub mod diagnostics;
pub mod layout;
pub mod widgets;

pub use dashboard::{Dashboard, DashboardContext, DashboardEvent, WidgetActivation};
pub use data_cache::{DashboardDataCache, DashboardDataSnapshot};
pub use diagnostics::{DashboardDiagnosticsSnapshot, DIAGNOSTICS_REFRESH_INTERVAL};
pub use widgets::{WidgetAction, WidgetFactory, WidgetRegistry};
Loading