Skip to main content

cvkg_cli/
devtools.rs

1//! DevTools Dashboard Module
2//!
3//! Provides an in-process developer tools dashboard for inspecting and
4//! debugging CVKG applications at runtime. Includes panels for graph
5//! visualization, node inspection, performance metrics, log viewing, and
6//! theme editing.
7
8use std::time::{SystemTime, UNIX_EPOCH};
9
10/// The content type rendered inside a dashboard panel.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum PanelContent {
13    /// Interactive graph visualization of the scene tree.
14    GraphView,
15    /// Inspector panel showing properties of the selected node.
16    NodeInspector,
17    /// Real-time performance metrics overlay.
18    PerformanceMetrics,
19    /// Scrollable log viewer.
20    LogView,
21    /// Live theme editor with color pickers.
22    ThemeEditor,
23}
24
25/// A single panel in the DevTools dashboard.
26#[derive(Debug, Clone)]
27pub struct Panel {
28    /// The display title of the panel tab.
29    pub title: String,
30    /// The type of content this panel renders.
31    pub content: PanelContent,
32    /// The panel width as a fraction of the dashboard (0.0–1.0).
33    pub width: f32,
34    /// The panel height as a fraction of the dashboard (0.0–1.0).
35    pub height: f32,
36}
37
38impl Panel {
39    /// Create a new [`Panel`] with the given title and content type.
40    ///
41    /// # Arguments
42    ///
43    /// * `title` — The tab title displayed in the dashboard.
44    /// * `content` — The [`PanelContent`] variant to render.
45    pub fn new(title: &str, content: PanelContent) -> Self {
46        Self {
47            title: title.to_string(),
48            content,
49            width: 1.0,
50            height: 1.0,
51        }
52    }
53
54    /// Set the panel dimensions as fractions of the dashboard size.
55    pub fn with_size(mut self, width: f32, height: f32) -> Self {
56        self.width = width.clamp(0.0, 1.0);
57        self.height = height.clamp(0.0, 1.0);
58        self
59    }
60}
61
62/// Performance metrics captured from the running application.
63#[derive(Debug, Clone)]
64pub struct PerfMetrics {
65    /// Time in milliseconds to render the last frame.
66    pub frame_time_ms: f32,
67    /// Frames per second.
68    pub fps: f32,
69    /// Number of nodes in the current scene graph.
70    pub node_count: usize,
71    /// Number of edges (relationships) in the current scene graph.
72    pub edge_count: usize,
73    /// Estimated GPU memory usage in megabytes.
74    pub gpu_memory_mb: f32,
75}
76
77impl Default for PerfMetrics {
78    fn default() -> Self {
79        Self {
80            frame_time_ms: 0.0,
81            fps: 0.0,
82            node_count: 0,
83            edge_count: 0,
84            gpu_memory_mb: 0.0,
85        }
86    }
87}
88
89/// Severity level for a log entry.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
91pub enum LogLevel {
92    /// Debug-level message.
93    Debug,
94    /// Informational message.
95    Info,
96    /// Warning message.
97    Warn,
98    /// Error message.
99    Error,
100}
101
102/// A single log entry in the DevTools log view.
103#[derive(Debug, Clone)]
104pub struct LogEntry {
105    /// ISO-8601 formatted timestamp string.
106    pub timestamp: String,
107    /// The severity level of this entry.
108    pub level: LogLevel,
109    /// The log message body.
110    pub message: String,
111}
112
113/// Widget primitives rendered by the DevTools dashboard.
114///
115/// Each variant represents a distinct UI element that can be drawn
116/// by the rendering backend.
117#[derive(Debug, Clone)]
118pub enum DevToolWidget {
119    /// A text label with RGBA color.
120    Text {
121        /// The text content to display.
122        content: String,
123        /// RGBA color values each in the range 0.0–1.0.
124        color: [f32; 4],
125    },
126    /// A line graph plotting the given data points.
127    Graph {
128        /// Y-axis data values.
129        data: Vec<f32>,
130        /// Label displayed below the graph.
131        label: String,
132    },
133    /// A key-value property inspector.
134    Inspector {
135        /// Pairs of (property_name, value).
136        properties: Vec<(String, String)>,
137    },
138    /// A clickable button.
139    Button {
140        /// The button label.
141        label: String,
142        /// Whether the button was clicked this frame.
143        clicked: bool,
144    },
145}
146
147/// The DevTools dashboard managing panels and rendering widgets.
148#[derive(Debug, Clone)]
149pub struct DevToolsDashboard {
150    /// The panels currently open in the dashboard.
151    pub panels: Vec<Panel>,
152    /// Index of the currently active (selected) panel.
153    pub active_panel: usize,
154    /// Whether the dashboard overlay is visible.
155    pub visible: bool,
156}
157
158impl Default for DevToolsDashboard {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164impl DevToolsDashboard {
165    /// Create a new [`DevToolsDashboard`] with default panels.
166    ///
167    /// The dashboard starts hidden with a Performance Metrics panel
168    /// and a Log View panel pre-configured.
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use cvkg_cli::devtools::DevToolsDashboard;
174    /// let dashboard = DevToolsDashboard::new();
175    /// assert!(!dashboard.visible);
176    /// assert_eq!(dashboard.panels.len(), 2);
177    /// ```
178    pub fn new() -> Self {
179        Self {
180            panels: vec![
181                Panel::new("Performance", PanelContent::PerformanceMetrics),
182                Panel::new("Logs", PanelContent::LogView),
183            ],
184            active_panel: 0,
185            visible: false,
186        }
187    }
188
189    /// Toggle the dashboard visibility on or off.
190    pub fn toggle(&mut self) {
191        self.visible = !self.visible;
192    }
193
194    /// Add a new panel to the dashboard.
195    ///
196    /// # Arguments
197    ///
198    /// * `panel` — The [`Panel`] to add.
199    pub fn add_panel(&mut self, panel: Panel) {
200        self.panels.push(panel);
201    }
202
203    /// Remove the panel at the given index.
204    ///
205    /// If the index is out of bounds, this is a no-op. After removal,
206    /// the active panel index is clamped to the new length.
207    ///
208    /// # Arguments
209    ///
210    /// * `index` — The zero-based index of the panel to remove.
211    pub fn remove_panel(&mut self, index: usize) {
212        if index < self.panels.len() {
213            self.panels.remove(index);
214            if self.active_panel >= self.panels.len() && !self.panels.is_empty() {
215                self.active_panel = self.panels.len() - 1;
216            }
217        }
218    }
219
220    /// Set the active (selected) panel by index.
221    ///
222    /// If the index is out of bounds, this is a no-op.
223    ///
224    /// # Arguments
225    ///
226    /// * `index` — The zero-based index of the panel to activate.
227    pub fn set_active(&mut self, index: usize) {
228        if index < self.panels.len() {
229            self.active_panel = index;
230        }
231    }
232
233    /// Render the dashboard into a list of [`DevToolWidget`] primitives.
234    ///
235    /// Only the active panel is rendered. If the dashboard is not visible
236    /// or has no panels, an empty Vec is returned.
237    pub fn render(&self) -> Vec<DevToolWidget> {
238        if !self.visible || self.panels.is_empty() {
239            return Vec::new();
240        }
241
242        let mut widgets = Vec::new();
243
244        // Render tab bar
245        for (i, panel) in self.panels.iter().enumerate() {
246            let color = if i == self.active_panel {
247                [0.0, 1.0, 1.0, 1.0]
248            } else {
249                [0.5, 0.5, 0.5, 1.0]
250            };
251            widgets.push(DevToolWidget::Button {
252                label: panel.title.clone(),
253                clicked: false,
254            });
255            widgets.push(DevToolWidget::Text {
256                content: panel.title.clone(),
257                color,
258            });
259        }
260
261        // Render active panel content
262        if let Some(panel) = self.panels.get(self.active_panel) {
263            match &panel.content {
264                PanelContent::PerformanceMetrics => {
265                    let metrics = capture_metrics();
266                    widgets.push(DevToolWidget::Text {
267                        content: format!("FPS: {:.1}", metrics.fps),
268                        color: [0.0, 1.0, 0.0, 1.0],
269                    });
270                    widgets.push(DevToolWidget::Text {
271                        content: format!("Frame Time: {:.2} ms", metrics.frame_time_ms),
272                        color: [1.0, 1.0, 0.0, 1.0],
273                    });
274                    widgets.push(DevToolWidget::Text {
275                        content: format!("Nodes: {}", metrics.node_count),
276                        color: [1.0, 1.0, 1.0, 1.0],
277                    });
278                    widgets.push(DevToolWidget::Text {
279                        content: format!("Edges: {}", metrics.edge_count),
280                        color: [1.0, 1.0, 1.0, 1.0],
281                    });
282                    widgets.push(DevToolWidget::Text {
283                        content: format!("GPU Memory: {:.1} MB", metrics.gpu_memory_mb),
284                        color: [1.0, 0.5, 0.0, 1.0],
285                    });
286                    widgets.push(DevToolWidget::Graph {
287                        data: vec![metrics.frame_time_ms],
288                        label: "Frame Time (ms)".to_string(),
289                    });
290                }
291                PanelContent::NodeInspector => {
292                    widgets.push(DevToolWidget::Inspector {
293                        properties: vec![
294                            ("type".to_string(), "Node".to_string()),
295                            ("id".to_string(), "0".to_string()),
296                        ],
297                    });
298                }
299                PanelContent::GraphView => {
300                    widgets.push(DevToolWidget::Text {
301                        content: "Graph View".to_string(),
302                        color: [0.0, 1.0, 1.0, 1.0],
303                    });
304                }
305                PanelContent::LogView => {
306                    widgets.push(DevToolWidget::Text {
307                        content: "Log View — No entries".to_string(),
308                        color: [0.7, 0.7, 0.7, 1.0],
309                    });
310                }
311                PanelContent::ThemeEditor => {
312                    widgets.push(DevToolWidget::Text {
313                        content: "Theme Editor".to_string(),
314                        color: [1.0, 0.0, 1.0, 1.0],
315                    });
316                }
317            }
318        }
319
320        widgets
321    }
322}
323
324use std::sync::RwLock;
325
326/// Global metrics store updated by the dev server.
327static METRICS: RwLock<PerfMetrics> = RwLock::new(PerfMetrics {
328    frame_time_ms: 0.0,
329    fps: 0.0,
330    node_count: 0,
331    edge_count: 0,
332    gpu_memory_mb: 0.0,
333});
334
335/// Capture current performance metrics.
336///
337/// Returns live metrics populated by the dev server, or default zeros
338/// if the server is not running or metrics have not been set.
339///
340/// # Examples
341///
342/// ```
343/// use cvkg_cli::devtools::capture_metrics;
344/// let metrics = capture_metrics();
345/// // Default metrics should have zero values
346/// assert_eq!(metrics.fps, 0.0);
347/// assert_eq!(metrics.node_count, 0);
348/// ```
349pub fn capture_metrics() -> PerfMetrics {
350    METRICS.read().map(|m| m.clone()).unwrap_or_default()
351}
352
353/// Update the global metrics store with new values.
354///
355/// Called by the dev server or build pipeline to publish live metrics.
356pub fn update_metrics(metrics: PerfMetrics) {
357    if let Ok(mut m) = METRICS.write() {
358        *m = metrics;
359    }
360}
361
362/// Format a [`LogEntry`] into a human-readable string.
363///
364/// The output format is: `[TIMESTAMP] LEVEL: message`
365///
366/// # Arguments
367///
368/// * `entry` — The [`LogEntry`] to format.
369///
370/// # Examples
371///
372/// ```
373/// use cvkg_cli::devtools::{LogEntry, LogLevel, format_log_entry};
374/// let entry = LogEntry {
375///     timestamp: "2025-01-01T00:00:00Z".to_string(),
376///     level: LogLevel::Info,
377///     message: "Application started".to_string(),
378/// };
379/// let formatted = format_log_entry(&entry);
380/// assert_eq!(formatted, "[2025-01-01T00:00:00Z] INFO: Application started");
381/// ```
382pub fn format_log_entry(entry: &LogEntry) -> String {
383    let level_str = match entry.level {
384        LogLevel::Debug => "DEBUG",
385        LogLevel::Info => "INFO",
386        LogLevel::Warn => "WARN",
387        LogLevel::Error => "ERROR",
388    };
389    format!("[{}] {}: {}", entry.timestamp, level_str, entry.message)
390}
391
392/// Create a timestamp string suitable for [`LogEntry::timestamp`].
393///
394/// Returns the current system time formatted as a Unix timestamp string.
395pub fn current_timestamp() -> String {
396    match SystemTime::now().duration_since(UNIX_EPOCH) {
397        Ok(d) => format!("{}", d.as_secs()),
398        Err(_) => "0".to_string(),
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_dashboard_new() {
408        let dashboard = DevToolsDashboard::new();
409        assert!(!dashboard.visible);
410        assert_eq!(dashboard.panels.len(), 2);
411        assert_eq!(dashboard.active_panel, 0);
412        assert_eq!(dashboard.panels[0].title, "Performance");
413        assert_eq!(dashboard.panels[1].title, "Logs");
414    }
415
416    #[test]
417    fn test_dashboard_toggle() {
418        let mut dashboard = DevToolsDashboard::new();
419        assert!(!dashboard.visible);
420        dashboard.toggle();
421        assert!(dashboard.visible);
422        dashboard.toggle();
423        assert!(!dashboard.visible);
424    }
425
426    #[test]
427    fn test_dashboard_add_panel() {
428        let mut dashboard = DevToolsDashboard::new();
429        dashboard.add_panel(Panel::new("Inspector", PanelContent::NodeInspector));
430        assert_eq!(dashboard.panels.len(), 3);
431        assert_eq!(dashboard.panels[2].title, "Inspector");
432        assert_eq!(dashboard.panels[2].content, PanelContent::NodeInspector);
433    }
434
435    #[test]
436    fn test_dashboard_remove_panel() {
437        let mut dashboard = DevToolsDashboard::new();
438        dashboard.add_panel(Panel::new("Extra", PanelContent::GraphView));
439        assert_eq!(dashboard.panels.len(), 3);
440        dashboard.remove_panel(2);
441        assert_eq!(dashboard.panels.len(), 2);
442    }
443
444    #[test]
445    fn test_dashboard_remove_panel_out_of_bounds() {
446        let mut dashboard = DevToolsDashboard::new();
447        dashboard.remove_panel(99);
448        assert_eq!(dashboard.panels.len(), 2);
449    }
450
451    #[test]
452    fn test_dashboard_remove_active_panel_clamps() {
453        let mut dashboard = DevToolsDashboard::new();
454        dashboard.set_active(1);
455        assert_eq!(dashboard.active_panel, 1);
456        dashboard.remove_panel(1);
457        // Active should clamp to last valid index
458        assert_eq!(dashboard.active_panel, 0);
459    }
460
461    #[test]
462    fn test_dashboard_set_active() {
463        let mut dashboard = DevToolsDashboard::new();
464        dashboard.add_panel(Panel::new("Third", PanelContent::ThemeEditor));
465        dashboard.set_active(2);
466        assert_eq!(dashboard.active_panel, 2);
467    }
468
469    #[test]
470    fn test_dashboard_set_active_out_of_bounds() {
471        let mut dashboard = DevToolsDashboard::new();
472        dashboard.set_active(99);
473        assert_eq!(dashboard.active_panel, 0);
474    }
475
476    #[test]
477    fn test_dashboard_render_hidden() {
478        let dashboard = DevToolsDashboard::new();
479        let widgets = dashboard.render();
480        assert!(widgets.is_empty());
481    }
482
483    #[test]
484    fn test_dashboard_render_visible() {
485        let mut dashboard = DevToolsDashboard::new();
486        dashboard.toggle();
487        let widgets = dashboard.render();
488        // Should have tab buttons/text + panel content widgets
489        assert!(!widgets.is_empty());
490    }
491
492    #[test]
493    fn test_panel_new() {
494        let panel = Panel::new("Test", PanelContent::GraphView);
495        assert_eq!(panel.title, "Test");
496        assert_eq!(panel.content, PanelContent::GraphView);
497        assert_eq!(panel.width, 1.0);
498        assert_eq!(panel.height, 1.0);
499    }
500
501    #[test]
502    fn test_panel_with_size() {
503        let panel = Panel::new("Sized", PanelContent::LogView).with_size(0.5, 0.75);
504        assert_eq!(panel.width, 0.5);
505        assert_eq!(panel.height, 0.75);
506    }
507
508    #[test]
509    fn test_panel_with_size_clamped() {
510        let panel = Panel::new("Clamped", PanelContent::LogView).with_size(1.5, -0.5);
511        assert_eq!(panel.width, 1.0);
512        assert_eq!(panel.height, 0.0);
513    }
514
515    #[test]
516    fn test_capture_metrics() {
517        let metrics = capture_metrics();
518        assert_eq!(metrics.frame_time_ms, 0.0);
519        assert_eq!(metrics.fps, 0.0);
520        assert_eq!(metrics.node_count, 0);
521        assert_eq!(metrics.edge_count, 0);
522        assert_eq!(metrics.gpu_memory_mb, 0.0);
523    }
524
525    #[test]
526    fn test_format_log_entry_info() {
527        let entry = LogEntry {
528            timestamp: "2025-01-01T00:00:00Z".to_string(),
529            level: LogLevel::Info,
530            message: "Application started".to_string(),
531        };
532        assert_eq!(
533            format_log_entry(&entry),
534            "[2025-01-01T00:00:00Z] INFO: Application started"
535        );
536    }
537
538    #[test]
539    fn test_format_log_entry_debug() {
540        let entry = LogEntry {
541            timestamp: "T1".to_string(),
542            level: LogLevel::Debug,
543            message: "debug msg".to_string(),
544        };
545        assert_eq!(format_log_entry(&entry), "[T1] DEBUG: debug msg");
546    }
547
548    #[test]
549    fn test_format_log_entry_warn() {
550        let entry = LogEntry {
551            timestamp: "T2".to_string(),
552            level: LogLevel::Warn,
553            message: "watch out".to_string(),
554        };
555        assert_eq!(format_log_entry(&entry), "[T2] WARN: watch out");
556    }
557
558    #[test]
559    fn test_format_log_entry_error() {
560        let entry = LogEntry {
561            timestamp: "T3".to_string(),
562            level: LogLevel::Error,
563            message: "something broke".to_string(),
564        };
565        assert_eq!(format_log_entry(&entry), "[T3] ERROR: something broke");
566    }
567
568    #[test]
569    fn test_log_level_ordering() {
570        assert!(LogLevel::Debug < LogLevel::Info);
571        assert!(LogLevel::Info < LogLevel::Warn);
572        assert!(LogLevel::Warn < LogLevel::Error);
573    }
574
575    #[test]
576    fn test_perf_metrics_default() {
577        let m = PerfMetrics::default();
578        assert_eq!(m.frame_time_ms, 0.0);
579        assert_eq!(m.fps, 0.0);
580        assert_eq!(m.node_count, 0);
581        assert_eq!(m.edge_count, 0);
582        assert_eq!(m.gpu_memory_mb, 0.0);
583    }
584
585    #[test]
586    fn test_dev_tool_widget_variants() {
587        let text = DevToolWidget::Text {
588            content: "hello".to_string(),
589            color: [1.0, 1.0, 1.0, 1.0],
590        };
591        let graph = DevToolWidget::Graph {
592            data: vec![1.0, 2.0],
593            label: "test".to_string(),
594        };
595        let inspector = DevToolWidget::Inspector {
596            properties: vec![("k".to_string(), "v".to_string())],
597        };
598        let button = DevToolWidget::Button {
599            label: "click".to_string(),
600            clicked: false,
601        };
602        // Just verify they construct without panic
603        drop(text);
604        drop(graph);
605        drop(inspector);
606        drop(button);
607    }
608
609    #[test]
610    fn test_current_timestamp_nonzero() {
611        let ts = current_timestamp();
612        assert!(!ts.is_empty());
613        assert!(ts.parse::<u64>().is_ok());
614    }
615}