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
324/// Capture current performance metrics from the CVKG core.
325///
326/// Reads frame timing, scene graph statistics, and GPU memory usage
327/// from the running application. When not connected to a live runtime,
328/// returns default (zero) values.
329///
330/// # Examples
331///
332/// ```
333/// use cvkg_cli::devtools::capture_metrics;
334/// let metrics = capture_metrics();
335/// // Default metrics should have zero values
336/// assert_eq!(metrics.fps, 0.0);
337/// assert_eq!(metrics.node_count, 0);
338/// ```
339pub fn capture_metrics() -> PerfMetrics {
340    // In a real implementation this would query cvkg-core for live data.
341    // For now we return default/placeholder values.
342    PerfMetrics::default()
343}
344
345/// Format a [`LogEntry`] into a human-readable string.
346///
347/// The output format is: `[TIMESTAMP] LEVEL: message`
348///
349/// # Arguments
350///
351/// * `entry` — The [`LogEntry`] to format.
352///
353/// # Examples
354///
355/// ```
356/// use cvkg_cli::devtools::{LogEntry, LogLevel, format_log_entry};
357/// let entry = LogEntry {
358///     timestamp: "2025-01-01T00:00:00Z".to_string(),
359///     level: LogLevel::Info,
360///     message: "Application started".to_string(),
361/// };
362/// let formatted = format_log_entry(&entry);
363/// assert_eq!(formatted, "[2025-01-01T00:00:00Z] INFO: Application started");
364/// ```
365pub fn format_log_entry(entry: &LogEntry) -> String {
366    let level_str = match entry.level {
367        LogLevel::Debug => "DEBUG",
368        LogLevel::Info => "INFO",
369        LogLevel::Warn => "WARN",
370        LogLevel::Error => "ERROR",
371    };
372    format!("[{}] {}: {}", entry.timestamp, level_str, entry.message)
373}
374
375/// Create a timestamp string suitable for [`LogEntry::timestamp`].
376///
377/// Returns the current system time formatted as a Unix timestamp string.
378pub fn current_timestamp() -> String {
379    match SystemTime::now().duration_since(UNIX_EPOCH) {
380        Ok(d) => format!("{}", d.as_secs()),
381        Err(_) => "0".to_string(),
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_dashboard_new() {
391        let dashboard = DevToolsDashboard::new();
392        assert!(!dashboard.visible);
393        assert_eq!(dashboard.panels.len(), 2);
394        assert_eq!(dashboard.active_panel, 0);
395        assert_eq!(dashboard.panels[0].title, "Performance");
396        assert_eq!(dashboard.panels[1].title, "Logs");
397    }
398
399    #[test]
400    fn test_dashboard_toggle() {
401        let mut dashboard = DevToolsDashboard::new();
402        assert!(!dashboard.visible);
403        dashboard.toggle();
404        assert!(dashboard.visible);
405        dashboard.toggle();
406        assert!(!dashboard.visible);
407    }
408
409    #[test]
410    fn test_dashboard_add_panel() {
411        let mut dashboard = DevToolsDashboard::new();
412        dashboard.add_panel(Panel::new("Inspector", PanelContent::NodeInspector));
413        assert_eq!(dashboard.panels.len(), 3);
414        assert_eq!(dashboard.panels[2].title, "Inspector");
415        assert_eq!(dashboard.panels[2].content, PanelContent::NodeInspector);
416    }
417
418    #[test]
419    fn test_dashboard_remove_panel() {
420        let mut dashboard = DevToolsDashboard::new();
421        dashboard.add_panel(Panel::new("Extra", PanelContent::GraphView));
422        assert_eq!(dashboard.panels.len(), 3);
423        dashboard.remove_panel(2);
424        assert_eq!(dashboard.panels.len(), 2);
425    }
426
427    #[test]
428    fn test_dashboard_remove_panel_out_of_bounds() {
429        let mut dashboard = DevToolsDashboard::new();
430        dashboard.remove_panel(99);
431        assert_eq!(dashboard.panels.len(), 2);
432    }
433
434    #[test]
435    fn test_dashboard_remove_active_panel_clamps() {
436        let mut dashboard = DevToolsDashboard::new();
437        dashboard.set_active(1);
438        assert_eq!(dashboard.active_panel, 1);
439        dashboard.remove_panel(1);
440        // Active should clamp to last valid index
441        assert_eq!(dashboard.active_panel, 0);
442    }
443
444    #[test]
445    fn test_dashboard_set_active() {
446        let mut dashboard = DevToolsDashboard::new();
447        dashboard.add_panel(Panel::new("Third", PanelContent::ThemeEditor));
448        dashboard.set_active(2);
449        assert_eq!(dashboard.active_panel, 2);
450    }
451
452    #[test]
453    fn test_dashboard_set_active_out_of_bounds() {
454        let mut dashboard = DevToolsDashboard::new();
455        dashboard.set_active(99);
456        assert_eq!(dashboard.active_panel, 0);
457    }
458
459    #[test]
460    fn test_dashboard_render_hidden() {
461        let dashboard = DevToolsDashboard::new();
462        let widgets = dashboard.render();
463        assert!(widgets.is_empty());
464    }
465
466    #[test]
467    fn test_dashboard_render_visible() {
468        let mut dashboard = DevToolsDashboard::new();
469        dashboard.toggle();
470        let widgets = dashboard.render();
471        // Should have tab buttons/text + panel content widgets
472        assert!(!widgets.is_empty());
473    }
474
475    #[test]
476    fn test_panel_new() {
477        let panel = Panel::new("Test", PanelContent::GraphView);
478        assert_eq!(panel.title, "Test");
479        assert_eq!(panel.content, PanelContent::GraphView);
480        assert_eq!(panel.width, 1.0);
481        assert_eq!(panel.height, 1.0);
482    }
483
484    #[test]
485    fn test_panel_with_size() {
486        let panel = Panel::new("Sized", PanelContent::LogView).with_size(0.5, 0.75);
487        assert_eq!(panel.width, 0.5);
488        assert_eq!(panel.height, 0.75);
489    }
490
491    #[test]
492    fn test_panel_with_size_clamped() {
493        let panel = Panel::new("Clamped", PanelContent::LogView).with_size(1.5, -0.5);
494        assert_eq!(panel.width, 1.0);
495        assert_eq!(panel.height, 0.0);
496    }
497
498    #[test]
499    fn test_capture_metrics() {
500        let metrics = capture_metrics();
501        assert_eq!(metrics.frame_time_ms, 0.0);
502        assert_eq!(metrics.fps, 0.0);
503        assert_eq!(metrics.node_count, 0);
504        assert_eq!(metrics.edge_count, 0);
505        assert_eq!(metrics.gpu_memory_mb, 0.0);
506    }
507
508    #[test]
509    fn test_format_log_entry_info() {
510        let entry = LogEntry {
511            timestamp: "2025-01-01T00:00:00Z".to_string(),
512            level: LogLevel::Info,
513            message: "Application started".to_string(),
514        };
515        assert_eq!(
516            format_log_entry(&entry),
517            "[2025-01-01T00:00:00Z] INFO: Application started"
518        );
519    }
520
521    #[test]
522    fn test_format_log_entry_debug() {
523        let entry = LogEntry {
524            timestamp: "T1".to_string(),
525            level: LogLevel::Debug,
526            message: "debug msg".to_string(),
527        };
528        assert_eq!(format_log_entry(&entry), "[T1] DEBUG: debug msg");
529    }
530
531    #[test]
532    fn test_format_log_entry_warn() {
533        let entry = LogEntry {
534            timestamp: "T2".to_string(),
535            level: LogLevel::Warn,
536            message: "watch out".to_string(),
537        };
538        assert_eq!(format_log_entry(&entry), "[T2] WARN: watch out");
539    }
540
541    #[test]
542    fn test_format_log_entry_error() {
543        let entry = LogEntry {
544            timestamp: "T3".to_string(),
545            level: LogLevel::Error,
546            message: "something broke".to_string(),
547        };
548        assert_eq!(format_log_entry(&entry), "[T3] ERROR: something broke");
549    }
550
551    #[test]
552    fn test_log_level_ordering() {
553        assert!(LogLevel::Debug < LogLevel::Info);
554        assert!(LogLevel::Info < LogLevel::Warn);
555        assert!(LogLevel::Warn < LogLevel::Error);
556    }
557
558    #[test]
559    fn test_perf_metrics_default() {
560        let m = PerfMetrics::default();
561        assert_eq!(m.frame_time_ms, 0.0);
562        assert_eq!(m.fps, 0.0);
563        assert_eq!(m.node_count, 0);
564        assert_eq!(m.edge_count, 0);
565        assert_eq!(m.gpu_memory_mb, 0.0);
566    }
567
568    #[test]
569    fn test_dev_tool_widget_variants() {
570        let text = DevToolWidget::Text {
571            content: "hello".to_string(),
572            color: [1.0, 1.0, 1.0, 1.0],
573        };
574        let graph = DevToolWidget::Graph {
575            data: vec![1.0, 2.0],
576            label: "test".to_string(),
577        };
578        let inspector = DevToolWidget::Inspector {
579            properties: vec![("k".to_string(), "v".to_string())],
580        };
581        let button = DevToolWidget::Button {
582            label: "click".to_string(),
583            clicked: false,
584        };
585        // Just verify they construct without panic
586        drop(text);
587        drop(graph);
588        drop(inspector);
589        drop(button);
590    }
591
592    #[test]
593    fn test_current_timestamp_nonzero() {
594        let ts = current_timestamp();
595        assert!(!ts.is_empty());
596        assert!(ts.parse::<u64>().is_ok());
597    }
598}