Skip to main content

repose_devtools/
lib.rs

1use std::num::NonZeroUsize;
2use std::sync::Arc;
3
4use web_time::Instant;
5
6use repose_core::{Brush, Color, Px, Rect, Scene, SceneNode, TextPaintStyle};
7
8const FPS_HISTORY_LEN: usize = 60;
9
10pub struct Hud {
11    pub inspector_enabled: bool,
12    pub hovered: Option<Rect>,
13    pub hovered_semantics: Option<HoveredInfo>,
14    frame_count: u64,
15    last_frame: Option<Instant>,
16    fps_smooth: f32,
17    fps_history: web_workers::ring::RingBuffer<f32>,
18    pub metrics: Option<Metrics>,
19    selected_widget: Option<SelectedWidget>,
20}
21
22#[derive(Clone, Debug)]
23pub struct HoveredInfo {
24    pub id: u64,
25    pub role: String,
26    pub label: Option<String>,
27}
28
29#[derive(Clone, Debug)]
30pub struct SelectedWidget {
31    pub id: u64,
32    pub role: String,
33    pub label: Option<String>,
34    pub bounds: Rect,
35}
36
37impl Default for Hud {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl Hud {
44    pub fn new() -> Self {
45        Self {
46            inspector_enabled: false,
47            hovered: None,
48            hovered_semantics: None,
49            frame_count: 0,
50            last_frame: None,
51            fps_smooth: 0.0,
52            fps_history: web_workers::ring::RingBuffer::new(
53                NonZeroUsize::new(FPS_HISTORY_LEN).unwrap(),
54            ),
55            metrics: None,
56            selected_widget: None,
57        }
58    }
59    pub fn toggle_inspector(&mut self) {
60        self.inspector_enabled = !self.inspector_enabled;
61    }
62    pub fn set_hovered(&mut self, r: Option<Rect>, info: Option<HoveredInfo>) {
63        self.hovered = r;
64        self.hovered_semantics = info;
65    }
66    pub fn select_widget(&mut self, info: SelectedWidget) {
67        self.selected_widget = Some(info);
68    }
69    pub fn clear_selection(&mut self) {
70        self.selected_widget = None;
71    }
72
73    fn update_fps(&mut self, now: Instant) {
74        if let Some(prev) = self.last_frame.replace(now) {
75            let dt = (now - prev).as_secs_f32();
76            if dt > 0.0 && dt < 1.0 {
77                let fps = 1.0 / dt;
78                let a = 0.3;
79                self.fps_smooth = if self.fps_smooth == 0.0 {
80                    fps
81                } else {
82                    (1.0 - a) * self.fps_smooth + a * fps
83                };
84                self.fps_history.push(fps);
85            }
86        }
87    }
88
89    pub fn overlay(&mut self, scene: &mut Scene) {
90        self.frame_count += 1;
91        self.update_fps(Instant::now());
92
93        let bar_x = 8.0;
94        let bar_y = 8.0;
95        let bar_w = 120.0;
96        let bar_h = 24.0;
97
98        if let Some(m) = &self.metrics {
99            scene.nodes.push(SceneNode::Rect {
100                rect: Rect {
101                    x: bar_x,
102                    y: bar_y,
103                    w: bar_w,
104                    h: bar_h,
105                },
106                brush: Brush::Solid(Color::from_hex("#1A1A1ACC")),
107                radius: [Px(4.0); 4],
108            });
109
110            Self::draw_fps_sparkline(
111                scene,
112                bar_x + 2.0,
113                bar_y + bar_h + 4.0,
114                bar_w - 4.0,
115                16.0,
116                &self.fps_history,
117            );
118
119            let fps_norm = (self.fps_smooth / 60.0).min(1.0);
120            let bar_fill = bar_w * fps_norm;
121            scene.nodes.push(SceneNode::Rect {
122                rect: Rect {
123                    x: bar_x + 2.0,
124                    y: bar_y + 2.0,
125                    w: bar_fill,
126                    h: bar_h - 4.0,
127                },
128                brush: Brush::Solid(if self.fps_smooth >= 50.0 {
129                    Color::from_hex("#44FF44")
130                } else if self.fps_smooth >= 30.0 {
131                    Color::from_hex("#FFAA00")
132                } else {
133                    Color::from_hex("#FF4444")
134                }),
135                radius: [Px(2.0); 4],
136            });
137
138            let mut text_y = bar_y + bar_h + 24.0;
139            Self::push_text(
140                scene,
141                bar_x,
142                text_y,
143                100.0,
144                &format!("{:.0} fps", self.fps_smooth),
145                "#AAAAAA",
146                12.0,
147            );
148            text_y += 16.0;
149
150            Self::push_text(
151                scene,
152                bar_x,
153                text_y,
154                100.0,
155                &format!("frame: {}", self.frame_count),
156                "#888888",
157                11.0,
158            );
159            text_y += 14.0;
160            Self::push_text(
161                scene,
162                bar_x,
163                text_y,
164                120.0,
165                &format!("build: {:.1}ms", m.build_ms),
166                "#888888",
167                11.0,
168            );
169            text_y += 14.0;
170            Self::push_text(
171                scene,
172                bar_x,
173                text_y,
174                120.0,
175                &format!("layout: {:.1}ms", m.layout_ms),
176                "#888888",
177                11.0,
178            );
179            text_y += 14.0;
180            Self::push_text(
181                scene,
182                bar_x,
183                text_y,
184                120.0,
185                &format!("paint: {:.1}ms", m.paint_ms),
186                "#888888",
187                11.0,
188            );
189            text_y += 14.0;
190            Self::push_text(
191                scene,
192                bar_x,
193                text_y,
194                120.0,
195                &format!("widgets: {}", m.widget_count),
196                "#888888",
197                11.0,
198            );
199            text_y += 14.0;
200            Self::push_text(
201                scene,
202                bar_x,
203                text_y,
204                120.0,
205                &format!("signals: {}", m.signal_count),
206                "#888888",
207                11.0,
208            );
209            text_y += 14.0;
210            Self::push_text(
211                scene,
212                bar_x,
213                text_y,
214                140.0,
215                &format!("scene: {}", m.scene_nodes),
216                "#888888",
217                11.0,
218            );
219            text_y += 14.0;
220            Self::push_text(
221                scene,
222                bar_x,
223                text_y,
224                200.0,
225                &format!("taffy: {:+}/{:+}", m.taffy_created, m.taffy_reused),
226                "#888888",
227                11.0,
228            );
229            text_y += 14.0;
230            Self::push_text(
231                scene,
232                bar_x,
233                text_y,
234                200.0,
235                &format!("layout: {}h/{}m", m.layout_hits, m.layout_misses),
236                "#888888",
237                11.0,
238            );
239            text_y += 14.0;
240            Self::push_text(
241                scene,
242                bar_x,
243                text_y,
244                200.0,
245                &format!(
246                    "paint cache: {}h/{}m ({} culled)",
247                    m.paint_cache_hits, m.paint_cache_misses, m.paint_culled
248                ),
249                "#888888",
250                11.0,
251            );
252            text_y += 14.0;
253
254            if let Some(hover) = &self.hovered_semantics {
255                text_y += 6.0;
256                Self::push_text(
257                    scene,
258                    bar_x,
259                    text_y,
260                    200.0,
261                    &format!("↳ {}: {:?}", hover.id, hover.role),
262                    "#44AAFF",
263                    11.0,
264                );
265                if let Some(lbl) = &hover.label {
266                    text_y += 14.0;
267                    Self::push_text(
268                        scene,
269                        bar_x,
270                        text_y,
271                        200.0,
272                        &format!("  \"{}\"", lbl),
273                        "#66CCFF",
274                        10.0,
275                    );
276                }
277            }
278        }
279
280        if let Some(r) = self.hovered {
281            scene.nodes.push(SceneNode::Border {
282                rect: r,
283                brush: Brush::Solid(Color::from_hex("#44AAFF")),
284                width: Px(2.0),
285                radius: [Px(2.0); 4],
286            });
287        }
288
289        if let Some(sel) = &self.selected_widget {
290            scene.nodes.push(SceneNode::Border {
291                rect: sel.bounds,
292                brush: Brush::Solid(Color::from_hex("#FFAA00")),
293                width: Px(2.0),
294                radius: [Px(2.0); 4],
295            });
296        }
297    }
298
299    /// Push a single HUD text line (px screen-space overlay).
300    fn push_text(scene: &mut Scene, x: f32, y: f32, w: f32, txt: &str, color: &str, size: f32) {
301        scene.nodes.push(SceneNode::Text {
302            rect: Rect { x, y, w, h: 14.0 },
303            text: Arc::<str>::from(txt.to_string()),
304            color: Color::from_hex(color),
305            size: Px(size),
306            style: TextPaintStyle::default(),
307        });
308    }
309
310    /// Draw a horizontal bar-chart sparkline from a rolling FPS history.
311    ///
312    /// The newest sample is drawn at the right edge. Older samples scroll left.
313    fn draw_fps_sparkline(
314        scene: &mut Scene,
315        x: f32,
316        y: f32,
317        w: f32,
318        h: f32,
319        history: &web_workers::ring::RingBuffer<f32>,
320    ) {
321        let n = history.len();
322        if n == 0 {
323            return;
324        }
325        scene.nodes.push(SceneNode::Rect {
326            rect: Rect { x, y, w, h },
327            brush: Brush::Solid(Color::from_hex("#1A1A1ACC")),
328            radius: [Px(2.0); 4],
329        });
330        let bin_w = w / n as f32;
331        let max_fps = 60.0f32.max(history.iter().copied().fold(0.0f32, f32::max));
332        for (i, sample) in history.iter().copied().enumerate() {
333            let frac = (sample / max_fps).min(1.0);
334            let bh = (h - 2.0) * frac;
335            let color = if frac >= 0.83 {
336                "#44FF44"
337            } else if frac >= 0.5 {
338                "#FFAA00"
339            } else {
340                "#FF4444"
341            };
342            scene.nodes.push(SceneNode::Rect {
343                rect: Rect {
344                    x: x + i as f32 * bin_w,
345                    y: y + (h - 2.0) - bh,
346                    w: (bin_w - 1.0).max(0.5),
347                    h: bh.max(1.0),
348                },
349                brush: Brush::Solid(Color::from_hex(color)),
350                radius: [Px::ZERO; 4],
351            });
352        }
353    }
354}
355
356#[derive(Clone, Debug, Default)]
357pub struct Metrics {
358    pub build_ms: f32,
359    pub layout_ms: f32,
360    pub paint_ms: f32,
361    pub scene_nodes: usize,
362    pub widget_count: usize,
363    pub signal_count: usize,
364    // Layout engine counters.
365    pub taffy_created: usize,
366    pub taffy_reused: usize,
367    pub layout_hits: usize,
368    pub layout_misses: usize,
369    pub paint_cache_hits: usize,
370    pub paint_cache_misses: usize,
371    pub paint_culled: usize,
372}
373
374pub struct Inspector {
375    pub hud: Hud,
376}
377impl Default for Inspector {
378    fn default() -> Self {
379        Self::new()
380    }
381}
382
383impl Inspector {
384    pub fn new() -> Self {
385        Self { hud: Hud::new() }
386    }
387    pub fn frame(&mut self, scene: &mut Scene) {
388        if self.hud.inspector_enabled {
389            self.hud.overlay(scene);
390        }
391    }
392}