@@ -13,6 +13,7 @@ use gartk_core::{InputEvent, Key, Point, Rect}; |
| 13 | use gartk_render::{Renderer, TextStyle}; | 13 | use gartk_render::{Renderer, TextStyle}; |
| 14 | use gartk_x11::{Connection, EventLoop, EventLoopConfig, Window, WindowConfig}; | 14 | use gartk_x11::{Connection, EventLoop, EventLoopConfig, Window, WindowConfig}; |
| 15 | use gartop_ipc::{Command, CpuStats, DiskStats, MemoryStats, NetworkStats, ProcessInfo, Response, SortField, StatusInfo, TempStats}; | 15 | use gartop_ipc::{Command, CpuStats, DiskStats, MemoryStats, NetworkStats, ProcessInfo, Response, SortField, StatusInfo, TempStats}; |
| | 16 | +use std::collections::HashMap; |
| 16 | use std::io::{BufRead, BufReader, Write}; | 17 | use std::io::{BufRead, BufReader, Write}; |
| 17 | use std::os::unix::net::UnixStream; | 18 | use std::os::unix::net::UnixStream; |
| 18 | use std::time::Instant; | 19 | use std::time::Instant; |
@@ -30,6 +31,33 @@ const CONTENT_PADDING: u32 = 16; |
| 30 | /// Vertical gap between sections. | 31 | /// Vertical gap between sections. |
| 31 | const SECTION_GAP: u32 = 12; | 32 | const SECTION_GAP: u32 = 12; |
| 32 | | 33 | |
| | 34 | +/// Max history points per process |
| | 35 | +const PROCESS_HISTORY_LEN: usize = 60; |
| | 36 | + |
| | 37 | +/// Per-process CPU/memory history for sparkline graphs. |
| | 38 | +struct ProcessHistory { |
| | 39 | + cpu: Vec<f64>, |
| | 40 | + memory: Vec<f64>, |
| | 41 | +} |
| | 42 | + |
| | 43 | +impl ProcessHistory { |
| | 44 | + fn new() -> Self { |
| | 45 | + Self { |
| | 46 | + cpu: Vec::with_capacity(PROCESS_HISTORY_LEN), |
| | 47 | + memory: Vec::with_capacity(PROCESS_HISTORY_LEN), |
| | 48 | + } |
| | 49 | + } |
| | 50 | + |
| | 51 | + fn push(&mut self, cpu: f64, memory: f64) { |
| | 52 | + if self.cpu.len() >= PROCESS_HISTORY_LEN { |
| | 53 | + self.cpu.remove(0); |
| | 54 | + self.memory.remove(0); |
| | 55 | + } |
| | 56 | + self.cpu.push(cpu); |
| | 57 | + self.memory.push(memory); |
| | 58 | + } |
| | 59 | +} |
| | 60 | + |
| 33 | /// GUI application. | 61 | /// GUI application. |
| 34 | pub struct App { | 62 | pub struct App { |
| 35 | window: Window, | 63 | window: Window, |
@@ -75,6 +103,8 @@ pub struct App { |
| 75 | network_history: Vec<Vec<NetworkStats>>, | 103 | network_history: Vec<Vec<NetworkStats>>, |
| 76 | disk_history: Vec<Vec<DiskStats>>, | 104 | disk_history: Vec<Vec<DiskStats>>, |
| 77 | processes: Vec<ProcessInfo>, | 105 | processes: Vec<ProcessInfo>, |
| | 106 | + /// Per-process CPU/memory history for sparkline graphs |
| | 107 | + process_history: HashMap<i32, ProcessHistory>, |
| 78 | } | 108 | } |
| 79 | | 109 | |
| 80 | impl App { | 110 | impl App { |
@@ -170,6 +200,7 @@ impl App { |
| 170 | network_history: Vec::new(), | 200 | network_history: Vec::new(), |
| 171 | disk_history: Vec::new(), | 201 | disk_history: Vec::new(), |
| 172 | processes: Vec::new(), | 202 | processes: Vec::new(), |
| | 203 | + process_history: HashMap::new(), |
| 173 | }) | 204 | }) |
| 174 | } | 205 | } |
| 175 | | 206 | |
@@ -307,6 +338,18 @@ impl App { |
| 307 | // Sync selection - tracks process by PID across list reorders | 338 | // Sync selection - tracks process by PID across list reorders |
| 308 | self.process_list.sync_selection(&self.processes); | 339 | self.process_list.sync_selection(&self.processes); |
| 309 | self.process_list.set_sort(sort_field); | 340 | self.process_list.set_sort(sort_field); |
| | 341 | + |
| | 342 | + // Record per-process history for sparklines |
| | 343 | + for proc in &self.processes { |
| | 344 | + self.process_history |
| | 345 | + .entry(proc.pid) |
| | 346 | + .or_insert_with(ProcessHistory::new) |
| | 347 | + .push(proc.cpu_percent, proc.memory_percent); |
| | 348 | + } |
| | 349 | + // Clean up history for dead processes |
| | 350 | + let active_pids: std::collections::HashSet<i32> = |
| | 351 | + self.processes.iter().map(|p| p.pid).collect(); |
| | 352 | + self.process_history.retain(|pid, _| active_pids.contains(pid)); |
| 310 | } | 353 | } |
| 311 | } | 354 | } |
| 312 | } | 355 | } |
@@ -737,6 +780,85 @@ impl App { |
| 737 | }; | 780 | }; |
| 738 | self.renderer.text("[K] Kill (SIGTERM) [X] Kill (SIGKILL)", x, footer_y, &action_style)?; | 781 | self.renderer.text("[K] Kill (SIGTERM) [X] Kill (SIGKILL)", x, footer_y, &action_style)?; |
| 739 | | 782 | |
| | 783 | + // History sparklines (if we have history for this process) |
| | 784 | + if let Some(history) = self.process_history.get(&process.pid) { |
| | 785 | + if history.cpu.len() >= 2 { |
| | 786 | + let spark_y = footer_y - 90.0; |
| | 787 | + let spark_height = 50.0; |
| | 788 | + let spark_width = ((self.width - margin * 2) as f64 - 40.0) / 2.0 - 10.0; |
| | 789 | + |
| | 790 | + // CPU sparkline |
| | 791 | + self.renderer.text("CPU History:", x, spark_y - 14.0, &label_style)?; |
| | 792 | + self.render_sparkline( |
| | 793 | + x, spark_y, spark_width, spark_height, |
| | 794 | + &history.cpu, self.theme.cpu_color, |
| | 795 | + )?; |
| | 796 | + |
| | 797 | + // Memory sparkline |
| | 798 | + let mem_x = x + spark_width + 20.0; |
| | 799 | + self.renderer.text("Memory History:", mem_x, spark_y - 14.0, &label_style)?; |
| | 800 | + self.render_sparkline( |
| | 801 | + mem_x, spark_y, spark_width, spark_height, |
| | 802 | + &history.memory, self.theme.memory_color, |
| | 803 | + )?; |
| | 804 | + } |
| | 805 | + } |
| | 806 | + |
| | 807 | + Ok(()) |
| | 808 | + } |
| | 809 | + |
| | 810 | + /// Render a sparkline graph (small inline chart). |
| | 811 | + fn render_sparkline(&self, x: f64, y: f64, w: f64, h: f64, values: &[f64], color: gartk_core::Color) -> Result<()> { |
| | 812 | + if values.is_empty() || w <= 0.0 || h <= 0.0 { |
| | 813 | + return Ok(()); |
| | 814 | + } |
| | 815 | + |
| | 816 | + let ctx = self.renderer.context()?; |
| | 817 | + |
| | 818 | + // Background |
| | 819 | + ctx.set_source_rgba( |
| | 820 | + self.theme.graph_bg.r, |
| | 821 | + self.theme.graph_bg.g, |
| | 822 | + self.theme.graph_bg.b, |
| | 823 | + self.theme.graph_bg.a, |
| | 824 | + ); |
| | 825 | + ctx.rectangle(x, y, w, h); |
| | 826 | + let _ = ctx.fill(); |
| | 827 | + |
| | 828 | + // Find max for scaling (at least 1% to avoid division by zero) |
| | 829 | + let max_val = values.iter().cloned().fold(1.0_f64, f64::max); |
| | 830 | + let scale = h / max_val.max(1.0); |
| | 831 | + |
| | 832 | + let step = w / (values.len().saturating_sub(1).max(1)) as f64; |
| | 833 | + |
| | 834 | + // Draw filled area |
| | 835 | + ctx.set_source_rgba(color.r, color.g, color.b, 0.3); |
| | 836 | + ctx.move_to(x, y + h); |
| | 837 | + for (i, &val) in values.iter().enumerate() { |
| | 838 | + let px = x + i as f64 * step; |
| | 839 | + let py = y + h - (val * scale); |
| | 840 | + ctx.line_to(px, py); |
| | 841 | + } |
| | 842 | + ctx.line_to(x + (values.len() - 1) as f64 * step, y + h); |
| | 843 | + ctx.close_path(); |
| | 844 | + let _ = ctx.fill(); |
| | 845 | + |
| | 846 | + // Draw line |
| | 847 | + ctx.set_source_rgba(color.r, color.g, color.b, 1.0); |
| | 848 | + ctx.set_line_width(1.5); |
| | 849 | + let mut first = true; |
| | 850 | + for (i, &val) in values.iter().enumerate() { |
| | 851 | + let px = x + i as f64 * step; |
| | 852 | + let py = y + h - (val * scale); |
| | 853 | + if first { |
| | 854 | + ctx.move_to(px, py); |
| | 855 | + first = false; |
| | 856 | + } else { |
| | 857 | + ctx.line_to(px, py); |
| | 858 | + } |
| | 859 | + } |
| | 860 | + let _ = ctx.stroke(); |
| | 861 | + |
| 740 | Ok(()) | 862 | Ok(()) |
| 741 | } | 863 | } |
| 742 | | 864 | |