Skip to main content

revue/devtools/profiler/
core.rs

1//! Profiler core implementation
2
3use super::super::helpers::draw_text_overlay;
4use super::super::DevToolsConfig;
5use super::types::{ComponentStats, Frame, ProfilerView, RenderEvent};
6use crate::layout::Rect;
7use crate::render::Buffer;
8use crate::style::Color;
9use std::collections::HashMap;
10use std::time::{Duration, Instant};
11
12/// Performance profiler for tracking render performance
13pub struct Profiler {
14    /// Is recording
15    recording: bool,
16    /// Recorded frames
17    frames: Vec<Frame>,
18    /// Current frame (while recording)
19    current_frame: Option<Frame>,
20    /// Component statistics
21    stats: HashMap<String, ComponentStats>,
22    /// Frame counter
23    frame_counter: u64,
24    /// Recording start time
25    recording_start: Option<Instant>,
26    /// Current view mode
27    view: ProfilerView,
28    /// Selected frame index (for timeline)
29    selected_frame: Option<usize>,
30    /// Scroll offset
31    scroll_offset: usize,
32}
33
34impl Profiler {
35    /// Create a new profiler
36    pub fn new() -> Self {
37        Self {
38            recording: false,
39            frames: Vec::new(),
40            current_frame: None,
41            stats: HashMap::new(),
42            frame_counter: 0,
43            recording_start: None,
44            view: ProfilerView::default(),
45            selected_frame: None,
46            scroll_offset: 0,
47        }
48    }
49
50    /// Start recording
51    pub fn start_recording(&mut self) {
52        self.recording = true;
53        self.recording_start = Some(Instant::now());
54        self.frames.clear();
55        self.stats.clear();
56        self.frame_counter = 0;
57        self.start_frame();
58    }
59
60    /// Stop recording
61    pub fn stop_recording(&mut self) {
62        self.end_frame();
63        self.recording = false;
64        self.recording_start = None;
65    }
66
67    /// Check if recording
68    pub fn is_recording(&self) -> bool {
69        self.recording
70    }
71
72    /// Toggle recording
73    pub fn toggle_recording(&mut self) {
74        if self.recording {
75            self.stop_recording();
76        } else {
77            self.start_recording();
78        }
79    }
80
81    /// Start a new frame
82    pub fn start_frame(&mut self) {
83        if self.recording {
84            self.frame_counter += 1;
85            self.current_frame = Some(Frame::new(self.frame_counter));
86        }
87    }
88
89    /// End the current frame
90    pub fn end_frame(&mut self) {
91        if let Some(mut frame) = self.current_frame.take() {
92            frame.end();
93            self.frames.push(frame);
94        }
95    }
96
97    /// Record a render event
98    pub fn record_render(&mut self, event: RenderEvent) {
99        // Update stats
100        let stats = self
101            .stats
102            .entry(event.component.clone())
103            .or_insert_with(|| ComponentStats::new(&event.component));
104        stats.record(event.duration, event.reason);
105
106        // Add to current frame
107        if let Some(frame) = &mut self.current_frame {
108            frame.add_event(event);
109        }
110    }
111
112    /// Get frame count
113    pub fn frame_count(&self) -> usize {
114        self.frames.len()
115    }
116
117    /// Get total recording duration
118    pub fn recording_duration(&self) -> Duration {
119        self.recording_start
120            .map(|start| start.elapsed())
121            .unwrap_or(Duration::ZERO)
122    }
123
124    /// Get average frame time
125    pub fn avg_frame_time(&self) -> Duration {
126        if self.frames.is_empty() {
127            return Duration::ZERO;
128        }
129        let total: Duration = self.frames.iter().map(|f| f.duration).sum();
130        total / self.frames.len() as u32
131    }
132
133    /// Get stats sorted by total time
134    pub fn stats_by_time(&self) -> Vec<&ComponentStats> {
135        let mut stats: Vec<_> = self.stats.values().collect();
136        stats.sort_by(|a, b| b.total_time.cmp(&a.total_time));
137        stats
138    }
139
140    /// Get stats sorted by render count
141    pub fn stats_by_count(&self) -> Vec<&ComponentStats> {
142        let mut stats: Vec<_> = self.stats.values().collect();
143        stats.sort_by(|a, b| b.render_count.cmp(&a.render_count));
144        stats
145    }
146
147    /// Get current view
148    pub fn view(&self) -> ProfilerView {
149        self.view
150    }
151
152    /// Set view
153    pub fn set_view(&mut self, view: ProfilerView) {
154        self.view = view;
155        self.scroll_offset = 0;
156    }
157
158    /// Next view
159    pub fn next_view(&mut self) {
160        self.view = self.view.next();
161        self.scroll_offset = 0;
162    }
163
164    /// Select a frame
165    pub fn select_frame(&mut self, index: Option<usize>) {
166        self.selected_frame = index;
167    }
168
169    /// Scroll up
170    pub fn scroll_up(&mut self) {
171        self.scroll_offset = self.scroll_offset.saturating_sub(1);
172    }
173
174    /// Scroll down
175    pub fn scroll_down(&mut self) {
176        self.scroll_offset += 1;
177    }
178
179    /// Clear all data
180    pub fn clear(&mut self) {
181        self.frames.clear();
182        self.stats.clear();
183        self.current_frame = None;
184        self.frame_counter = 0;
185        self.selected_frame = None;
186        self.scroll_offset = 0;
187    }
188
189    /// Render profiler content
190    pub fn render_content(&self, buffer: &mut Buffer, area: Rect, config: &DevToolsConfig) {
191        match self.view {
192            ProfilerView::Flamegraph => self.render_flamegraph(buffer, area, config),
193            ProfilerView::Timeline => self.render_timeline(buffer, area, config),
194            ProfilerView::Ranked => self.render_ranked(buffer, area, config),
195            ProfilerView::Counts => self.render_counts(buffer, area, config),
196        }
197    }
198
199    fn render_flamegraph(&self, buffer: &mut Buffer, area: Rect, config: &DevToolsConfig) {
200        if self.frames.is_empty() {
201            self.render_empty(buffer, area, config, "No data. Press R to start recording.");
202            return;
203        }
204
205        // Header
206        let header = format!(
207            "Flamegraph - {} frames, avg {:.2}ms/frame",
208            self.frames.len(),
209            self.avg_frame_time().as_secs_f64() * 1000.0
210        );
211        self.render_text(buffer, area.x, area.y, &header, config.fg_color);
212
213        // Get the selected frame or last frame
214        let frame = self
215            .selected_frame
216            .and_then(|i| self.frames.get(i))
217            .or_else(|| self.frames.last());
218
219        if let Some(frame) = frame {
220            let content_y = area.y + 2;
221            let content_height = area.height.saturating_sub(3);
222
223            // Group events by depth for flamegraph rows
224            let max_depth = frame.events.iter().map(|e| e.depth).max().unwrap_or(0);
225            let row_height = if max_depth > 0 {
226                (content_height as usize / (max_depth + 1)).max(1)
227            } else {
228                content_height as usize
229            };
230
231            for event in &frame.events {
232                let y = content_y + (event.depth * row_height) as u16;
233                if y >= area.y + area.height {
234                    continue;
235                }
236
237                // Calculate bar width based on duration
238                let total_time = frame.total_render_time().as_nanos() as f64;
239                let event_time = event.duration.as_nanos() as f64;
240                let width = if total_time > 0.0 {
241                    ((event_time / total_time) * area.width as f64) as u16
242                } else {
243                    1
244                };
245                let width = width.max(1).min(area.width);
246
247                // Draw bar
248                let color = event.reason.color();
249                for x in area.x..area.x + width {
250                    if let Some(cell) = buffer.get_mut(x, y) {
251                        cell.bg = Some(color);
252                    }
253                }
254
255                // Draw label
256                let label = format!(
257                    "{} ({:.2}ms)",
258                    event.component,
259                    event.duration.as_secs_f64() * 1000.0
260                );
261                self.render_text(buffer, area.x, y, &label, config.bg_color);
262            }
263        }
264    }
265
266    fn render_timeline(&self, buffer: &mut Buffer, area: Rect, config: &DevToolsConfig) {
267        if self.frames.is_empty() {
268            self.render_empty(buffer, area, config, "No data. Press R to start recording.");
269            return;
270        }
271
272        // Header
273        let header = format!("Timeline - {} frames", self.frames.len());
274        self.render_text(buffer, area.x, area.y, &header, config.fg_color);
275
276        let content_y = area.y + 2;
277        let content_height = area.height.saturating_sub(3) as usize;
278
279        // Find max frame time for scaling
280        let max_time = self
281            .frames
282            .iter()
283            .map(|f| f.duration)
284            .max()
285            .unwrap_or(Duration::from_millis(16));
286
287        // Draw timeline bars
288        let visible_frames = area.width as usize;
289        let start_frame = self
290            .scroll_offset
291            .min(self.frames.len().saturating_sub(visible_frames));
292
293        for (i, frame) in self
294            .frames
295            .iter()
296            .skip(start_frame)
297            .take(visible_frames)
298            .enumerate()
299        {
300            let x = area.x + i as u16;
301            let height = ((frame.duration.as_nanos() as f64 / max_time.as_nanos() as f64)
302                * content_height as f64) as u16;
303            let height = height.max(1);
304
305            let bar_y = content_y + (content_height as u16).saturating_sub(height);
306
307            // Color based on frame time (green = fast, red = slow)
308            let color = if frame.duration < Duration::from_millis(8) {
309                Color::rgb(100, 200, 100) // Green - very fast
310            } else if frame.duration < Duration::from_millis(16) {
311                Color::rgb(200, 200, 100) // Yellow - 60fps
312            } else if frame.duration < Duration::from_millis(33) {
313                Color::rgb(220, 150, 100) // Orange - 30fps
314            } else {
315                Color::rgb(220, 100, 100) // Red - slow
316            };
317
318            // Draw bar
319            for y in bar_y..content_y + content_height as u16 {
320                if let Some(cell) = buffer.get_mut(x, y) {
321                    cell.bg = Some(color);
322                    cell.symbol = ' ';
323                }
324            }
325
326            // Highlight selected frame
327            if self.selected_frame == Some(start_frame + i) {
328                if let Some(cell) = buffer.get_mut(x, bar_y) {
329                    cell.symbol = '▼';
330                    cell.fg = Some(config.accent_color);
331                }
332            }
333        }
334
335        // Show frame info at bottom
336        if let Some(idx) = self.selected_frame {
337            if let Some(frame) = self.frames.get(idx) {
338                let info = format!(
339                    "Frame {}: {:.2}ms, {} renders",
340                    frame.number,
341                    frame.duration.as_secs_f64() * 1000.0,
342                    frame.event_count()
343                );
344                self.render_text(
345                    buffer,
346                    area.x,
347                    area.y + area.height - 1,
348                    &info,
349                    config.accent_color,
350                );
351            }
352        }
353    }
354
355    fn render_ranked(&self, buffer: &mut Buffer, area: Rect, config: &DevToolsConfig) {
356        if self.stats.is_empty() {
357            self.render_empty(buffer, area, config, "No data. Press R to start recording.");
358            return;
359        }
360
361        // Header
362        let header = "Ranked by Total Time";
363        self.render_text(buffer, area.x, area.y, header, config.fg_color);
364
365        let content_y = area.y + 2;
366        let content_height = area.height.saturating_sub(3) as usize;
367
368        let stats = self.stats_by_time();
369
370        for (i, stat) in stats
371            .iter()
372            .skip(self.scroll_offset)
373            .take(content_height)
374            .enumerate()
375        {
376            let y = content_y + i as u16;
377
378            // Component name
379            let name = if stat.name.len() > 20 {
380                format!("{}...", &stat.name[..17])
381            } else {
382                stat.name.clone()
383            };
384
385            // Stats
386            let line = format!(
387                "{:<20} {:>6.2}ms total  {:>6.2}ms avg  {:>4} renders",
388                name,
389                stat.total_time.as_secs_f64() * 1000.0,
390                stat.avg_time.as_secs_f64() * 1000.0,
391                stat.render_count
392            );
393
394            self.render_text(buffer, area.x, y, &line, config.fg_color);
395
396            // Color indicator for render reason
397            if let Some(cell) = buffer.get_mut(area.x + area.width - 2, y) {
398                cell.bg = Some(stat.last_reason.color());
399            }
400        }
401    }
402
403    fn render_counts(&self, buffer: &mut Buffer, area: Rect, config: &DevToolsConfig) {
404        if self.stats.is_empty() {
405            self.render_empty(buffer, area, config, "No data. Press R to start recording.");
406            return;
407        }
408
409        // Header
410        let header = "Ranked by Render Count";
411        self.render_text(buffer, area.x, area.y, header, config.fg_color);
412
413        let content_y = area.y + 2;
414        let content_height = area.height.saturating_sub(3) as usize;
415
416        let stats = self.stats_by_count();
417        let max_count = stats.first().map(|s| s.render_count).unwrap_or(1);
418
419        for (i, stat) in stats
420            .iter()
421            .skip(self.scroll_offset)
422            .take(content_height)
423            .enumerate()
424        {
425            let y = content_y + i as u16;
426
427            // Component name
428            let name = if stat.name.len() > 20 {
429                format!("{}...", &stat.name[..17])
430            } else {
431                stat.name.clone()
432            };
433
434            // Bar width based on count
435            let bar_width =
436                ((stat.render_count as f64 / max_count as f64) * (area.width as f64 / 2.0)) as u16;
437            let bar_width = bar_width.max(1);
438
439            // Draw name and count
440            let count_str = format!("{:<20} {:>6}", name, stat.render_count);
441            self.render_text(buffer, area.x, y, &count_str, config.fg_color);
442
443            // Draw bar
444            let bar_start = area.x + 28;
445            for x in bar_start..bar_start + bar_width {
446                if x < area.x + area.width {
447                    if let Some(cell) = buffer.get_mut(x, y) {
448                        cell.bg = Some(config.accent_color);
449                        cell.symbol = ' ';
450                    }
451                }
452            }
453        }
454    }
455
456    fn render_empty(&self, buffer: &mut Buffer, area: Rect, config: &DevToolsConfig, msg: &str) {
457        let x = area.x + (area.width.saturating_sub(msg.len() as u16)) / 2;
458        let y = area.y + area.height / 2;
459        self.render_text(buffer, x, y, msg, config.fg_color);
460    }
461
462    fn render_text(&self, buffer: &mut Buffer, x: u16, y: u16, text: &str, color: Color) {
463        draw_text_overlay(buffer, x, y, text, color);
464    }
465}
466
467impl Default for Profiler {
468    fn default() -> Self {
469        Self::new()
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use crate::devtools::profiler::types::RenderReason;
477
478    #[test]
479    fn test_profiler_creation() {
480        let profiler = Profiler::new();
481        assert!(!profiler.is_recording());
482        assert_eq!(profiler.frame_count(), 0);
483    }
484
485    #[test]
486    fn test_profiler_recording() {
487        let mut profiler = Profiler::new();
488
489        profiler.start_recording();
490        assert!(profiler.is_recording());
491
492        profiler.record_render(RenderEvent::new("Button", Duration::from_micros(100)));
493        profiler.end_frame();
494        profiler.start_frame();
495        profiler.record_render(RenderEvent::new("Input", Duration::from_micros(200)));
496        profiler.end_frame();
497
498        profiler.stop_recording();
499        assert!(!profiler.is_recording());
500        assert_eq!(profiler.frame_count(), 2);
501    }
502
503    #[test]
504    fn test_profiler_toggle() {
505        let mut profiler = Profiler::new();
506
507        profiler.toggle_recording();
508        assert!(profiler.is_recording());
509
510        profiler.toggle_recording();
511        assert!(!profiler.is_recording());
512    }
513
514    #[test]
515    fn test_render_event() {
516        let event = RenderEvent::new("MyComponent", Duration::from_millis(5))
517            .parent("ParentComponent")
518            .reason(RenderReason::StateChange)
519            .depth(2);
520
521        assert_eq!(event.component, "MyComponent");
522        assert_eq!(event.parent, Some("ParentComponent".to_string()));
523        assert_eq!(event.reason, RenderReason::StateChange);
524        assert_eq!(event.depth, 2);
525    }
526
527    #[test]
528    fn test_frame() {
529        let mut frame = Frame::new(1);
530        frame.add_event(RenderEvent::new("A", Duration::from_micros(100)));
531        frame.add_event(RenderEvent::new("B", Duration::from_micros(200)));
532        frame.end();
533
534        assert_eq!(frame.number, 1);
535        assert_eq!(frame.event_count(), 2);
536        assert_eq!(frame.total_render_time(), Duration::from_micros(300));
537    }
538
539    #[test]
540    fn test_component_stats() {
541        let mut stats = ComponentStats::new("Button");
542
543        stats.record(Duration::from_micros(100), RenderReason::Initial);
544        stats.record(Duration::from_micros(200), RenderReason::StateChange);
545        stats.record(Duration::from_micros(150), RenderReason::PropsChange);
546
547        assert_eq!(stats.render_count, 3);
548        assert_eq!(stats.total_time, Duration::from_micros(450));
549        assert_eq!(stats.min_time, Duration::from_micros(100));
550        assert_eq!(stats.max_time, Duration::from_micros(200));
551        assert_eq!(stats.last_reason, RenderReason::PropsChange);
552    }
553
554    #[test]
555    fn test_stats_sorting() {
556        let mut profiler = Profiler::new();
557        profiler.start_recording();
558
559        // Record with different times
560        profiler.record_render(RenderEvent::new("Fast", Duration::from_micros(50)));
561        profiler.record_render(RenderEvent::new("Slow", Duration::from_micros(500)));
562        profiler.record_render(RenderEvent::new("Medium", Duration::from_micros(200)));
563
564        let by_time = profiler.stats_by_time();
565        assert_eq!(by_time[0].name, "Slow");
566        assert_eq!(by_time[1].name, "Medium");
567        assert_eq!(by_time[2].name, "Fast");
568    }
569
570    #[test]
571    fn test_profiler_view_cycle() {
572        let mut profiler = Profiler::new();
573        assert_eq!(profiler.view(), ProfilerView::Flamegraph);
574
575        profiler.next_view();
576        assert_eq!(profiler.view(), ProfilerView::Timeline);
577
578        profiler.next_view();
579        assert_eq!(profiler.view(), ProfilerView::Ranked);
580
581        profiler.next_view();
582        assert_eq!(profiler.view(), ProfilerView::Counts);
583
584        profiler.next_view();
585        assert_eq!(profiler.view(), ProfilerView::Flamegraph);
586    }
587
588    #[test]
589    fn test_profiler_clear() {
590        let mut profiler = Profiler::new();
591        profiler.start_recording();
592        profiler.record_render(RenderEvent::new("Test", Duration::from_micros(100)));
593        profiler.end_frame();
594        profiler.stop_recording();
595
596        assert!(!profiler.stats.is_empty());
597        assert!(!profiler.frames.is_empty());
598
599        profiler.clear();
600
601        assert!(profiler.stats.is_empty());
602        assert!(profiler.frames.is_empty());
603    }
604
605    #[test]
606    fn test_render_reason_colors() {
607        // Each reason should have a distinct color
608        let reasons = [
609            RenderReason::Initial,
610            RenderReason::StateChange,
611            RenderReason::PropsChange,
612            RenderReason::ContextChange,
613            RenderReason::ParentRender,
614            RenderReason::ForceUpdate,
615        ];
616
617        for reason in &reasons {
618            // Just ensure color() doesn't panic
619            let _ = reason.color();
620            let _ = reason.label();
621        }
622    }
623
624    #[test]
625    fn test_profiler_scroll() {
626        let mut profiler = Profiler::new();
627
628        profiler.scroll_down();
629        assert_eq!(profiler.scroll_offset, 1);
630
631        profiler.scroll_down();
632        assert_eq!(profiler.scroll_offset, 2);
633
634        profiler.scroll_up();
635        assert_eq!(profiler.scroll_offset, 1);
636
637        profiler.scroll_up();
638        profiler.scroll_up(); // Should not go below 0
639        assert_eq!(profiler.scroll_offset, 0);
640    }
641
642    #[test]
643    fn test_profiler_select_frame() {
644        let mut profiler = Profiler::new();
645
646        profiler.select_frame(Some(5));
647        assert_eq!(profiler.selected_frame, Some(5));
648
649        profiler.select_frame(None);
650        assert_eq!(profiler.selected_frame, None);
651    }
652
653    #[test]
654    fn test_stats_by_count() {
655        let mut profiler = Profiler::new();
656        profiler.start_recording();
657
658        // Record with different counts
659        profiler.record_render(RenderEvent::new("Once", Duration::from_micros(100)));
660        profiler.record_render(RenderEvent::new("Twice", Duration::from_micros(50)));
661        profiler.record_render(RenderEvent::new("Twice", Duration::from_micros(50)));
662
663        let by_count = profiler.stats_by_count();
664        assert_eq!(by_count[0].name, "Twice");
665        assert_eq!(by_count[1].name, "Once");
666    }
667
668    #[test]
669    fn test_set_view() {
670        let mut profiler = Profiler::new();
671        assert_eq!(profiler.view(), ProfilerView::Flamegraph);
672        assert_eq!(profiler.scroll_offset, 0);
673
674        profiler.set_view(ProfilerView::Timeline);
675        assert_eq!(profiler.view(), ProfilerView::Timeline);
676        // Scroll offset should reset when changing view
677        assert_eq!(profiler.scroll_offset, 0);
678
679        profiler.scroll_down();
680        assert_eq!(profiler.scroll_offset, 1);
681
682        profiler.set_view(ProfilerView::Ranked);
683        assert_eq!(profiler.view(), ProfilerView::Ranked);
684        assert_eq!(profiler.scroll_offset, 0);
685    }
686
687    #[test]
688    fn test_recording_duration() {
689        let mut profiler = Profiler::new();
690
691        // Not recording - duration should be zero
692        assert_eq!(profiler.recording_duration(), Duration::ZERO);
693
694        profiler.start_recording();
695        // Duration should be non-zero while recording
696        std::thread::sleep(std::time::Duration::from_millis(2));
697        let duration_while_recording = profiler.recording_duration();
698        assert!(duration_while_recording >= Duration::from_millis(2));
699
700        profiler.stop_recording();
701
702        // After stopping, duration returns to zero (no start time)
703        assert_eq!(profiler.recording_duration(), Duration::ZERO);
704    }
705
706    #[test]
707    fn test_avg_frame_time_empty() {
708        let profiler = Profiler::new();
709        assert_eq!(profiler.avg_frame_time(), Duration::ZERO);
710    }
711
712    #[test]
713    fn test_avg_frame_time() {
714        let mut profiler = Profiler::new();
715        profiler.start_recording();
716
717        profiler.record_render(RenderEvent::new("Test", Duration::from_micros(100)));
718        std::thread::sleep(std::time::Duration::from_millis(1));
719        profiler.end_frame();
720
721        profiler.record_render(RenderEvent::new("Test", Duration::from_micros(200)));
722        std::thread::sleep(std::time::Duration::from_millis(1));
723        profiler.end_frame();
724
725        profiler.stop_recording();
726
727        // Average should be the average of frame durations (wall-clock time)
728        // Each frame is at least 1ms due to sleep
729        let avg = profiler.avg_frame_time();
730        assert!(avg >= Duration::from_millis(1));
731    }
732}