Skip to main content

presentar_terminal/widgets/
gpu_panel.rs

1//! `GpuPanel` widget for GPU monitoring.
2//!
3//! Displays GPU utilization, temperature, VRAM, power, and per-process memory.
4//! Supports NVIDIA (via nvidia-smi) and AMD (via sysfs).
5
6#![allow(dead_code)] // Some fields/constants reserved for future features
7
8use presentar_core::{
9    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
10    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
11};
12use std::any::Any;
13use std::time::Duration;
14
15/// Block characters for utilization bar (8 levels).
16const BAR_CHARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
17
18/// GPU vendor type for display.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum GpuVendor {
21    Nvidia,
22    Amd,
23    Intel,
24    #[default]
25    Unknown,
26}
27
28impl GpuVendor {
29    /// Get display name.
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            Self::Nvidia => "NVIDIA",
33            Self::Amd => "AMD",
34            Self::Intel => "Intel",
35            Self::Unknown => "GPU",
36        }
37    }
38}
39
40/// A GPU device entry.
41#[derive(Debug, Clone)]
42pub struct GpuDevice {
43    /// GPU index.
44    pub index: u32,
45    /// GPU name (e.g., "RTX 3080").
46    pub name: String,
47    /// GPU vendor.
48    pub vendor: GpuVendor,
49    /// GPU utilization (0-100).
50    pub utilization: f32,
51    /// Temperature in Celsius.
52    pub temperature: Option<f32>,
53    /// Total VRAM in bytes.
54    pub vram_total: u64,
55    /// Used VRAM in bytes.
56    pub vram_used: u64,
57    /// Power draw in watts.
58    pub power_draw: Option<f32>,
59    /// Power limit in watts.
60    pub power_limit: Option<f32>,
61    /// Fan speed percentage.
62    pub fan_speed: Option<u32>,
63}
64
65impl Default for GpuDevice {
66    fn default() -> Self {
67        Self {
68            index: 0,
69            name: "Unknown GPU".to_string(),
70            vendor: GpuVendor::Unknown,
71            utilization: 0.0,
72            temperature: None,
73            vram_total: 0,
74            vram_used: 0,
75            power_draw: None,
76            power_limit: None,
77            fan_speed: None,
78        }
79    }
80}
81
82impl GpuDevice {
83    /// Create a new GPU device.
84    #[must_use]
85    pub fn new(name: impl Into<String>) -> Self {
86        Self {
87            name: name.into(),
88            ..Default::default()
89        }
90    }
91
92    /// Set GPU vendor.
93    #[must_use]
94    pub fn with_vendor(mut self, vendor: GpuVendor) -> Self {
95        self.vendor = vendor;
96        self
97    }
98
99    /// Set utilization percentage.
100    #[must_use]
101    pub fn with_utilization(mut self, util: f32) -> Self {
102        self.utilization = util;
103        self
104    }
105
106    /// Set temperature.
107    #[must_use]
108    pub fn with_temperature(mut self, temp: f32) -> Self {
109        self.temperature = Some(temp);
110        self
111    }
112
113    /// Set VRAM usage.
114    #[must_use]
115    pub fn with_vram(mut self, used: u64, total: u64) -> Self {
116        self.vram_used = used;
117        self.vram_total = total;
118        self
119    }
120
121    /// Set power info.
122    #[must_use]
123    pub fn with_power(mut self, draw: f32, limit: Option<f32>) -> Self {
124        self.power_draw = Some(draw);
125        self.power_limit = limit;
126        self
127    }
128
129    /// Set fan speed.
130    #[must_use]
131    pub fn with_fan(mut self, speed: u32) -> Self {
132        self.fan_speed = Some(speed);
133        self
134    }
135
136    /// Get VRAM usage percentage.
137    pub fn vram_percent(&self) -> f32 {
138        if self.vram_total > 0 {
139            (self.vram_used as f64 / self.vram_total as f64 * 100.0) as f32
140        } else {
141            0.0
142        }
143    }
144
145    /// Format VRAM for display.
146    pub fn vram_display(&self) -> String {
147        let used_gb = self.vram_used as f64 / 1_073_741_824.0;
148        let total_gb = self.vram_total as f64 / 1_073_741_824.0;
149        format!("{used_gb:.1}G / {total_gb:.1}G")
150    }
151}
152
153/// A process using GPU memory.
154#[derive(Debug, Clone)]
155pub struct GpuProcess {
156    /// Process name.
157    pub name: String,
158    /// Process ID.
159    pub pid: u32,
160    /// GPU memory used in bytes.
161    pub vram_used: u64,
162}
163
164impl GpuProcess {
165    /// Create a new GPU process entry.
166    #[must_use]
167    pub fn new(name: impl Into<String>, pid: u32, vram: u64) -> Self {
168        Self {
169            name: name.into(),
170            pid,
171            vram_used: vram,
172        }
173    }
174
175    /// Format VRAM for display.
176    pub fn vram_display(&self) -> String {
177        let mb = self.vram_used / (1024 * 1024);
178        if mb >= 1024 {
179            format!("{:.1}G", mb as f64 / 1024.0)
180        } else {
181            format!("{mb}M")
182        }
183    }
184}
185
186/// GPU panel displaying GPU stats and per-process memory.
187#[derive(Debug, Clone)]
188pub struct GpuPanel {
189    /// GPU device info.
190    device: GpuDevice,
191    /// Processes using GPU.
192    processes: Vec<GpuProcess>,
193    /// Utilization bar color.
194    bar_color: Color,
195    /// Temperature color (changes based on value).
196    temp_color: Color,
197    /// Show process table.
198    show_processes: bool,
199    /// Max processes to show.
200    max_processes: usize,
201    /// Cached bounds.
202    bounds: Rect,
203}
204
205impl Default for GpuPanel {
206    fn default() -> Self {
207        Self::new()
208    }
209}
210
211impl GpuPanel {
212    /// Create a new GPU panel.
213    #[must_use]
214    pub fn new() -> Self {
215        Self {
216            device: GpuDevice::default(),
217            processes: Vec::new(),
218            bar_color: Color::new(0.4, 0.8, 0.4, 1.0), // Green
219            temp_color: Color::new(1.0, 0.8, 0.2, 1.0), // Yellow
220            show_processes: true,
221            max_processes: 3,
222            bounds: Rect::default(),
223        }
224    }
225
226    /// Set the GPU device.
227    #[must_use]
228    pub fn with_device(mut self, device: GpuDevice) -> Self {
229        self.device = device;
230        self
231    }
232
233    /// Set GPU processes.
234    #[must_use]
235    pub fn with_processes(mut self, processes: Vec<GpuProcess>) -> Self {
236        self.processes = processes;
237        self
238    }
239
240    /// Add a process.
241    pub fn add_process(&mut self, process: GpuProcess) {
242        self.processes.push(process);
243    }
244
245    /// Set bar color.
246    #[must_use]
247    pub fn with_bar_color(mut self, color: Color) -> Self {
248        self.bar_color = color;
249        self
250    }
251
252    /// Toggle process display.
253    #[must_use]
254    pub fn show_processes(mut self, show: bool) -> Self {
255        self.show_processes = show;
256        self
257    }
258
259    /// Set max processes to show.
260    #[must_use]
261    pub fn max_processes(mut self, max: usize) -> Self {
262        self.max_processes = max;
263        self
264    }
265
266    /// Draw the utilization bar.
267    fn draw_util_bar(&self, canvas: &mut dyn Canvas, y: f32, width: f32) {
268        use std::fmt::Write;
269        let util = self.device.utilization;
270        let bar_width = (width - 6.0) as usize; // Leave room for percentage
271        let filled = ((util / 100.0) * bar_width as f32) as usize;
272
273        let mut bar = String::new();
274        for i in 0..bar_width {
275            if i < filled {
276                bar.push('█');
277            } else {
278                bar.push('░');
279            }
280        }
281        write!(bar, " {util:3.0}%").ok();
282
283        canvas.draw_text(
284            &bar,
285            Point::new(self.bounds.x, y),
286            &TextStyle {
287                color: self.bar_color,
288                ..Default::default()
289            },
290        );
291    }
292
293    /// Draw GPU info lines.
294    fn draw_info(&self, canvas: &mut dyn Canvas, start_y: f32) -> f32 {
295        use std::fmt::Write as _;
296        let mut y = start_y;
297        let x = self.bounds.x;
298
299        // Temperature and Power on one line
300        let mut info_line = String::new();
301        if let Some(temp) = self.device.temperature {
302            write!(info_line, "Temp: {temp:3.0}°C").ok();
303        }
304        if let Some(power) = self.device.power_draw {
305            if !info_line.is_empty() {
306                info_line.push_str("  ");
307            }
308            write!(info_line, "Power: {power:3.0}W").ok();
309        }
310        if !info_line.is_empty() {
311            canvas.draw_text(
312                &info_line,
313                Point::new(x, y),
314                &TextStyle {
315                    color: Color::WHITE,
316                    ..Default::default()
317                },
318            );
319            y += 1.0;
320        }
321
322        // VRAM line
323        if self.device.vram_total > 0 {
324            let vram_line = format!(
325                "VRAM: {} ({:.0}%)",
326                self.device.vram_display(),
327                self.device.vram_percent()
328            );
329            canvas.draw_text(
330                &vram_line,
331                Point::new(x, y),
332                &TextStyle {
333                    color: Color::WHITE,
334                    ..Default::default()
335                },
336            );
337            y += 1.0;
338        }
339
340        // Fan speed
341        if let Some(fan) = self.device.fan_speed {
342            canvas.draw_text(
343                &format!("Fan: {fan}%"),
344                Point::new(x, y),
345                &TextStyle {
346                    color: Color::WHITE,
347                    ..Default::default()
348                },
349            );
350            y += 1.0;
351        }
352
353        y
354    }
355
356    /// Draw top processes.
357    fn draw_processes(&self, canvas: &mut dyn Canvas, start_y: f32) {
358        if !self.show_processes || self.processes.is_empty() {
359            return;
360        }
361
362        let x = self.bounds.x;
363        let mut y = start_y;
364
365        // Sort by VRAM usage
366        let mut sorted: Vec<_> = self.processes.iter().collect();
367        sorted.sort_by(|a, b| b.vram_used.cmp(&a.vram_used));
368
369        for proc in sorted.iter().take(self.max_processes) {
370            // Truncate name to fit
371            let max_name_len = 12;
372            let name: String = if proc.name.len() > max_name_len {
373                format!("{}...", &proc.name[..max_name_len - 3])
374            } else {
375                proc.name.clone()
376            };
377
378            let line = format!("{:<12} {:>6}", name, proc.vram_display());
379            canvas.draw_text(
380                &line,
381                Point::new(x, y),
382                &TextStyle {
383                    color: Color::new(0.7, 0.7, 0.7, 1.0),
384                    ..Default::default()
385                },
386            );
387            y += 1.0;
388        }
389    }
390}
391
392impl Brick for GpuPanel {
393    fn brick_name(&self) -> &'static str {
394        "gpu_panel"
395    }
396
397    fn assertions(&self) -> &[BrickAssertion] {
398        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(8)];
399        ASSERTIONS
400    }
401
402    fn budget(&self) -> BrickBudget {
403        BrickBudget::uniform(8)
404    }
405
406    fn verify(&self) -> BrickVerification {
407        BrickVerification {
408            passed: vec![BrickAssertion::max_latency_ms(8)],
409            failed: vec![],
410            verification_time: Duration::from_micros(25),
411        }
412    }
413
414    fn to_html(&self) -> String {
415        String::new()
416    }
417
418    fn to_css(&self) -> String {
419        String::new()
420    }
421}
422
423impl Widget for GpuPanel {
424    fn type_id(&self) -> TypeId {
425        TypeId::of::<Self>()
426    }
427
428    fn measure(&self, constraints: Constraints) -> Size {
429        // Header (1) + util bar (1) + info (3) + processes (3) = 8 lines typical
430        let height = 8.0_f32.min(constraints.max_height);
431        Size::new(constraints.max_width, height)
432    }
433
434    fn layout(&mut self, bounds: Rect) -> LayoutResult {
435        self.bounds = bounds;
436        LayoutResult {
437            size: Size::new(bounds.width, bounds.height),
438        }
439    }
440
441    fn paint(&self, canvas: &mut dyn Canvas) {
442        if self.bounds.width < 10.0 || self.bounds.height < 3.0 {
443            return;
444        }
445
446        let mut y = self.bounds.y;
447
448        // Utilization bar
449        self.draw_util_bar(canvas, y, self.bounds.width);
450        y += 1.0;
451
452        // Info lines
453        y = self.draw_info(canvas, y);
454
455        // Top processes
456        self.draw_processes(canvas, y);
457    }
458
459    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
460        None
461    }
462
463    fn children(&self) -> &[Box<dyn Widget>] {
464        &[]
465    }
466
467    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
468        &mut []
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn test_gpu_device_vram_percent() {
478        let device = GpuDevice::default().with_vram(4 * 1024 * 1024 * 1024, 8 * 1024 * 1024 * 1024);
479        assert!((device.vram_percent() - 50.0).abs() < 0.1);
480    }
481
482    #[test]
483    fn test_gpu_device_vram_display() {
484        let device = GpuDevice::default().with_vram(4 * 1024 * 1024 * 1024, 8 * 1024 * 1024 * 1024);
485        assert_eq!(device.vram_display(), "4.0G / 8.0G");
486    }
487
488    #[test]
489    fn test_gpu_process_vram_display() {
490        let proc_mb = GpuProcess::new("test", 1234, 512 * 1024 * 1024);
491        assert_eq!(proc_mb.vram_display(), "512M");
492
493        let proc_gb = GpuProcess::new("test", 1234, 2 * 1024 * 1024 * 1024);
494        assert_eq!(proc_gb.vram_display(), "2.0G");
495    }
496
497    #[test]
498    fn test_panel_default() {
499        let panel = GpuPanel::new();
500        assert!(panel.show_processes);
501        assert_eq!(panel.max_processes, 3);
502    }
503
504    #[test]
505    fn test_panel_builder() {
506        let device = GpuDevice::new("RTX 3080")
507            .with_vendor(GpuVendor::Nvidia)
508            .with_utilization(80.0)
509            .with_temperature(72.0)
510            .with_vram(8 * 1024 * 1024 * 1024, 10 * 1024 * 1024 * 1024)
511            .with_power(220.0, Some(320.0))
512            .with_fan(65);
513
514        let panel = GpuPanel::new().with_device(device).max_processes(5);
515
516        assert_eq!(panel.max_processes, 5);
517        assert_eq!(panel.device.name, "RTX 3080");
518    }
519
520    #[test]
521    fn test_gpu_vendor_as_str() {
522        assert_eq!(GpuVendor::Nvidia.as_str(), "NVIDIA");
523        assert_eq!(GpuVendor::Amd.as_str(), "AMD");
524        assert_eq!(GpuVendor::Intel.as_str(), "Intel");
525        assert_eq!(GpuVendor::Unknown.as_str(), "GPU");
526    }
527
528    #[test]
529    fn test_gpu_device_default() {
530        let device = GpuDevice::default();
531        assert_eq!(device.index, 0);
532        assert_eq!(device.vendor, GpuVendor::Unknown);
533        assert_eq!(device.utilization, 0.0);
534        assert!(device.temperature.is_none());
535    }
536
537    #[test]
538    fn test_gpu_device_zero_vram() {
539        let device = GpuDevice::default();
540        assert_eq!(device.vram_percent(), 0.0);
541    }
542
543    #[test]
544    fn test_gpu_panel_with_bar_color() {
545        let panel = GpuPanel::new().with_bar_color(Color::RED);
546        assert_eq!(
547            format!("{:?}", panel.bar_color),
548            format!("{:?}", Color::RED)
549        );
550    }
551
552    #[test]
553    fn test_gpu_panel_show_processes() {
554        let panel = GpuPanel::new().show_processes(false);
555        assert!(!panel.show_processes);
556    }
557
558    #[test]
559    fn test_gpu_panel_with_processes() {
560        let processes = vec![
561            GpuProcess::new("python", 1234, 1024 * 1024 * 1024),
562            GpuProcess::new("chrome", 5678, 512 * 1024 * 1024),
563        ];
564        let panel = GpuPanel::new().with_processes(processes);
565        assert_eq!(panel.processes.len(), 2);
566    }
567
568    #[test]
569    fn test_gpu_panel_add_process() {
570        let mut panel = GpuPanel::new();
571        panel.add_process(GpuProcess::new("test", 100, 256 * 1024 * 1024));
572        assert_eq!(panel.processes.len(), 1);
573    }
574
575    #[test]
576    fn test_gpu_panel_brick_traits() {
577        let panel = GpuPanel::new();
578        assert_eq!(panel.brick_name(), "gpu_panel");
579        assert!(!panel.assertions().is_empty());
580        assert!(panel.budget().paint_ms > 0);
581        assert!(panel.verify().is_valid());
582        assert!(panel.to_html().is_empty());
583        assert!(panel.to_css().is_empty());
584    }
585
586    #[test]
587    fn test_gpu_panel_widget_traits() {
588        let mut panel = GpuPanel::new();
589
590        // Measure
591        let size = panel.measure(Constraints {
592            min_width: 0.0,
593            min_height: 0.0,
594            max_width: 80.0,
595            max_height: 20.0,
596        });
597        assert!(size.width > 0.0);
598        assert!(size.height > 0.0);
599
600        // Layout
601        let result = panel.layout(Rect::new(0.0, 0.0, 80.0, 10.0));
602        assert_eq!(result.size.width, 80.0);
603
604        // Type ID
605        assert_eq!(Widget::type_id(&panel), TypeId::of::<GpuPanel>());
606
607        // Event
608        assert!(panel
609            .event(&Event::key_down(presentar_core::Key::Enter))
610            .is_none());
611
612        // Children
613        assert!(panel.children().is_empty());
614        assert!(panel.children_mut().is_empty());
615    }
616
617    #[test]
618    #[allow(clippy::identity_op)]
619    fn test_gpu_panel_paint_full() {
620        use crate::direct::{CellBuffer, DirectTerminalCanvas};
621
622        let device = GpuDevice::new("RTX 3080")
623            .with_vendor(GpuVendor::Nvidia)
624            .with_utilization(75.0)
625            .with_temperature(68.0)
626            .with_vram(6 * 1024 * 1024 * 1024, 10 * 1024 * 1024 * 1024)
627            .with_power(180.0, Some(320.0))
628            .with_fan(55);
629
630        let processes = vec![
631            GpuProcess::new("python3", 1234, 2 * 1024 * 1024 * 1024),
632            GpuProcess::new("verylongprocessname", 5678, 1 * 1024 * 1024 * 1024),
633            GpuProcess::new("chrome", 9012, 512 * 1024 * 1024),
634        ];
635
636        let mut panel = GpuPanel::new()
637            .with_device(device)
638            .with_processes(processes);
639
640        panel.layout(Rect::new(0.0, 0.0, 60.0, 12.0));
641
642        let mut buffer = CellBuffer::new(60, 12);
643        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
644        panel.paint(&mut canvas);
645    }
646
647    #[test]
648    fn test_gpu_panel_paint_without_processes() {
649        use crate::direct::{CellBuffer, DirectTerminalCanvas};
650
651        let device = GpuDevice::new("RTX 3080")
652            .with_utilization(50.0)
653            .with_temperature(60.0)
654            .with_vram(4 * 1024 * 1024 * 1024, 8 * 1024 * 1024 * 1024);
655
656        let mut panel = GpuPanel::new().with_device(device).show_processes(false);
657
658        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
659
660        let mut buffer = CellBuffer::new(60, 10);
661        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
662        panel.paint(&mut canvas);
663    }
664
665    #[test]
666    fn test_gpu_panel_paint_minimal_info() {
667        use crate::direct::{CellBuffer, DirectTerminalCanvas};
668
669        // Device with no temperature, power, fan - just utilization
670        let device = GpuDevice::new("Generic GPU").with_utilization(30.0);
671
672        let mut panel = GpuPanel::new().with_device(device);
673        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
674
675        let mut buffer = CellBuffer::new(60, 10);
676        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
677        panel.paint(&mut canvas);
678    }
679
680    #[test]
681    fn test_gpu_panel_paint_small_bounds() {
682        use crate::direct::{CellBuffer, DirectTerminalCanvas};
683
684        let mut panel = GpuPanel::new();
685        panel.layout(Rect::new(0.0, 0.0, 5.0, 2.0)); // Too small
686
687        let mut buffer = CellBuffer::new(5, 2);
688        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
689        panel.paint(&mut canvas); // Should early return
690    }
691
692    #[test]
693    fn test_gpu_panel_paint_only_power() {
694        use crate::direct::{CellBuffer, DirectTerminalCanvas};
695
696        // Device with power but no temperature
697        let device = GpuDevice::new("GPU")
698            .with_utilization(40.0)
699            .with_power(150.0, None);
700
701        let mut panel = GpuPanel::new().with_device(device);
702        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
703
704        let mut buffer = CellBuffer::new(60, 10);
705        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
706        panel.paint(&mut canvas);
707    }
708
709    #[test]
710    fn test_gpu_panel_default_trait() {
711        let panel = GpuPanel::default();
712        assert!(panel.show_processes);
713        assert_eq!(panel.max_processes, 3);
714    }
715
716    #[test]
717    fn test_gpu_process_new() {
718        let proc = GpuProcess::new("test_process", 12345, 100 * 1024 * 1024);
719        assert_eq!(proc.name, "test_process");
720        assert_eq!(proc.pid, 12345);
721        assert_eq!(proc.vram_used, 100 * 1024 * 1024);
722    }
723
724    #[test]
725    fn test_gpu_device_all_builders() {
726        let device = GpuDevice::new("Test GPU")
727            .with_vendor(GpuVendor::Amd)
728            .with_utilization(95.0)
729            .with_temperature(85.0)
730            .with_vram(8 * 1024 * 1024 * 1024, 16 * 1024 * 1024 * 1024)
731            .with_power(250.0, Some(350.0))
732            .with_fan(80);
733
734        assert_eq!(device.vendor, GpuVendor::Amd);
735        assert_eq!(device.utilization, 95.0);
736        assert_eq!(device.temperature, Some(85.0));
737        assert_eq!(device.vram_used, 8 * 1024 * 1024 * 1024);
738        assert_eq!(device.vram_total, 16 * 1024 * 1024 * 1024);
739        assert_eq!(device.power_draw, Some(250.0));
740        assert_eq!(device.power_limit, Some(350.0));
741        assert_eq!(device.fan_speed, Some(80));
742    }
743}