gardesk/gartop / 9f6cd44

Browse files

Add tabbed GUI with CPU/Memory views and process list

- Add TabBar component for switching between CPU and Memory tabs
- Add ProcessList component showing top processes by resource usage
- Fix graph colors (gartk Color uses 0.0-1.0 range, not 0-255)
- Increase header height to 40px to prevent text cutoff
- Support keyboard shortcuts (1/2/Tab) for tab switching
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
9f6cd44fb2a115ce6c6bcc74e2579cf8e256aedc
Parents
d5c0d70
Tree
0bb3986

5 changed files

StatusFile+-
M gartop/src/gui/app.rs 262 139
M gartop/src/gui/graph.rs 9 9
M gartop/src/gui/mod.rs 4 0
A gartop/src/gui/process_list.rs 261 0
A gartop/src/gui/tabs.rs 195 0
gartop/src/gui/app.rsmodified
@@ -1,18 +1,27 @@
11
 //! GUI application state and event loop
22
 
3
-use super::{graph::{DataSeries, LineGraph}, header::HeaderBar, theme::Theme};
3
+use super::{
4
+    graph::{DataSeries, LineGraph},
5
+    header::HeaderBar,
6
+    process_list::ProcessList,
7
+    tabs::{Tab, TabBar, TAB_BAR_HEIGHT},
8
+    theme::Theme,
9
+};
410
 use anyhow::Result;
5
-use gartk_core::{InputEvent, Key, Rect};
6
-use gartk_render::Renderer;
11
+use gartk_core::{InputEvent, Key, Point, Rect};
12
+use gartk_render::{Renderer, TextStyle};
713
 use gartk_x11::{Connection, EventLoop, EventLoopConfig, Window, WindowConfig};
8
-use gartop_ipc::{Command, CpuStats, MemoryStats, Response, StatusInfo};
14
+use gartop_ipc::{Command, CpuStats, MemoryStats, ProcessInfo, Response, SortField, StatusInfo};
915
 use std::io::{BufRead, BufReader, Write};
1016
 use std::os::unix::net::UnixStream;
1117
 use std::time::Instant;
1218
 use x11rb::protocol::xproto::{ConnectionExt, ImageFormat};
1319
 
1420
 /// Header bar height.
15
-const HEADER_HEIGHT: u32 = 36;
21
+const HEADER_HEIGHT: u32 = 40;
22
+
23
+/// Graph height.
24
+const GRAPH_HEIGHT: u32 = 150;
1625
 
1726
 /// Default window dimensions.
1827
 const DEFAULT_WIDTH: u32 = 800;
@@ -28,6 +37,8 @@ pub struct App {
2837
     gc: u32,
2938
     theme: Theme,
3039
     header: HeaderBar,
40
+    tab_bar: TabBar,
41
+    process_list: ProcessList,
3142
     should_quit: bool,
3243
     width: u32,
3344
     height: u32,
@@ -38,6 +49,7 @@ pub struct App {
3849
     memory_stats: Option<MemoryStats>,
3950
     cpu_history: Vec<CpuStats>,
4051
     memory_history: Vec<MemoryStats>,
52
+    processes: Vec<ProcessInfo>,
4153
 }
4254
 
4355
 impl App {
@@ -73,8 +85,10 @@ impl App {
7385
         let theme = Theme::default();
7486
         let renderer = Renderer::new(width, height)?;
7587
 
76
-        // Create header bar
88
+        // Create components
7789
         let header = HeaderBar::new(Rect::new(0, 0, width, HEADER_HEIGHT));
90
+        let tab_bar = TabBar::new(Rect::new(0, HEADER_HEIGHT as i32, width, TAB_BAR_HEIGHT));
91
+        let process_list = Self::create_process_list(width, height);
7892
 
7993
         // Check if daemon is available
8094
         let daemon_available = Self::check_daemon();
@@ -85,19 +99,29 @@ impl App {
8599
             gc,
86100
             theme,
87101
             header,
102
+            tab_bar,
103
+            process_list,
88104
             should_quit: false,
89105
             width,
90106
             height,
91107
             daemon_available,
92
-            last_refresh: Instant::now() - std::time::Duration::from_secs(10), // force immediate refresh
108
+            last_refresh: Instant::now() - std::time::Duration::from_secs(10),
93109
             status: None,
94110
             cpu_stats: None,
95111
             memory_stats: None,
96112
             cpu_history: Vec::new(),
97113
             memory_history: Vec::new(),
114
+            processes: Vec::new(),
98115
         })
99116
     }
100117
 
118
+    /// Create process list with correct bounds.
119
+    fn create_process_list(width: u32, height: u32) -> ProcessList {
120
+        let content_start = HEADER_HEIGHT + TAB_BAR_HEIGHT + GRAPH_HEIGHT + 24; // 24 for graph label
121
+        let list_height = height.saturating_sub(content_start);
122
+        ProcessList::new(Rect::new(0, content_start as i32, width, list_height))
123
+    }
124
+
101125
     /// Check if daemon is available by attempting a connection.
102126
     fn check_daemon() -> bool {
103127
         let path = gartop_ipc::socket_path();
@@ -114,17 +138,14 @@ impl App {
114138
     }
115139
 
116140
     /// Send a command to the daemon and get response.
117
-    /// Creates a fresh connection for each command since daemon closes after response.
118141
     fn send_command(&self, cmd: &Command) -> Option<Response> {
119142
         let path = gartop_ipc::socket_path();
120143
         let mut stream = UnixStream::connect(&path).ok()?;
121144
 
122
-        // Send command
123145
         let json = serde_json::to_string(cmd).ok()?;
124146
         writeln!(stream, "{}", json).ok()?;
125147
         stream.flush().ok()?;
126148
 
127
-        // Read response
128149
         let mut reader = BufReader::new(&stream);
129150
         let mut line = String::new();
130151
         reader.read_line(&mut line).ok()?;
@@ -174,7 +195,22 @@ impl App {
174195
             }
175196
         }
176197
 
177
-        // Update daemon availability based on whether any command succeeded
198
+        // Get processes sorted by current tab's resource
199
+        let sort_field = match self.tab_bar.active() {
200
+            Tab::Cpu => SortField::Cpu,
201
+            Tab::Memory => SortField::Memory,
202
+        };
203
+        if let Some(resp) = self.send_command(&Command::GetProcesses {
204
+            sort_by: Some(sort_field),
205
+            limit: Some(100),
206
+        }) {
207
+            if resp.success {
208
+                self.processes = resp.data.and_then(|d| serde_json::from_value(d).ok()).unwrap_or_default();
209
+                self.process_list.set_processes(self.processes.clone());
210
+                self.process_list.set_sort(sort_field);
211
+            }
212
+        }
213
+
178214
         self.daemon_available = any_success;
179215
         self.last_refresh = Instant::now();
180216
     }
@@ -198,157 +234,164 @@ impl App {
198234
         // Render header
199235
         self.header.render(&self.renderer, &self.theme)?;
200236
 
201
-        // Render main content area
202
-        let content_y = HEADER_HEIGHT as i32;
203
-        let content_height = self.height.saturating_sub(HEADER_HEIGHT);
204
-        let content_rect = Rect::new(0, content_y, self.width, content_height);
237
+        // Render tab bar
238
+        self.tab_bar.render(&self.renderer, &self.theme)?;
205239
 
206
-        self.renderer.fill_rect(content_rect, self.theme.panel_bg)?;
240
+        // Render content area
241
+        let content_y = (HEADER_HEIGHT + TAB_BAR_HEIGHT) as i32;
242
+        let content_height = self.height.saturating_sub(HEADER_HEIGHT + TAB_BAR_HEIGHT);
207243
 
208
-        // Show connection status or stats
209
-        self.render_content(content_rect)?;
244
+        if !self.daemon_available {
245
+            self.render_not_connected(content_y)?;
246
+        } else {
247
+            self.render_tab_content(content_y, content_height)?;
248
+        }
210249
 
211250
         self.renderer.flush();
212251
         Ok(())
213252
     }
214253
 
215
-    /// Render main content area.
216
-    fn render_content(&self, bounds: Rect) -> Result<()> {
217
-        use gartk_render::TextStyle;
218
-
254
+    /// Render "not connected" message.
255
+    fn render_not_connected(&self, content_y: i32) -> Result<()> {
219256
         let style = TextStyle {
220257
             font_family: "monospace".to_string(),
221
-            font_size: 12.0,
222
-            color: self.theme.text,
258
+            font_size: 13.0,
259
+            color: self.theme.swap_color,
223260
             ..Default::default()
224261
         };
225
-
226262
         let dim_style = TextStyle {
227263
             color: self.theme.text_secondary,
228264
             ..style.clone()
229265
         };
230266
 
231
-        let padding = 12;
232
-        let graph_height = 120u32;
233
-        let text_line_height = 18.0;
267
+        self.renderer.text("Not connected to daemon", 20.0, content_y as f64 + 40.0, &style)?;
268
+        self.renderer.text("Run: gartop daemon", 20.0, content_y as f64 + 65.0, &dim_style)?;
269
+        Ok(())
270
+    }
234271
 
235
-        if !self.daemon_available {
236
-            self.renderer.text(
237
-                "Not connected to daemon",
238
-                padding as f64,
239
-                bounds.y as f64 + 30.0,
240
-                &TextStyle {
241
-                    color: self.theme.swap_color,
242
-                    ..style.clone()
243
-                },
244
-            )?;
245
-            self.renderer.text(
246
-                "Run: gartop daemon",
247
-                padding as f64,
248
-                bounds.y as f64 + 50.0,
249
-                &dim_style,
250
-            )?;
251
-            return Ok(());
252
-        }
272
+    /// Render the content for the active tab.
273
+    fn render_tab_content(&self, content_y: i32, _content_height: u32) -> Result<()> {
274
+        let padding = 12;
275
+        let graph_width = self.width - (padding * 2) as u32;
253276
 
254277
         // Get Cairo context for graph rendering
255278
         let ctx = self.renderer.context()?;
256
-        let graph = LineGraph::default();
279
+        let graph = LineGraph {
280
+            fill_opacity: 0.25,
281
+            ..LineGraph::default()
282
+        };
257283
 
258
-        let mut y = bounds.y + padding as i32;
259
-        let graph_width = bounds.width - (padding * 2) as u32;
284
+        let mut y = content_y + padding as i32;
285
+
286
+        match self.tab_bar.active() {
287
+            Tab::Cpu => {
288
+                // CPU label
289
+                let cpu_label = if let Some(cpu) = &self.cpu_stats {
290
+                    format!("CPU: {:.1}%", cpu.usage_percent)
291
+                } else {
292
+                    "CPU: --".to_string()
293
+                };
294
+                self.renderer.text(
295
+                    &cpu_label,
296
+                    padding as f64,
297
+                    y as f64 + 14.0,
298
+                    &TextStyle {
299
+                        font_family: "monospace".to_string(),
300
+                        font_size: 12.0,
301
+                        color: self.theme.cpu_color,
302
+                        ..Default::default()
303
+                    },
304
+                )?;
305
+
306
+                // Per-core info on right
307
+                if let Some(cpu) = &self.cpu_stats {
308
+                    let cores = cpu.per_core.len();
309
+                    let core_info = format!("{} cores", cores);
310
+                    self.renderer.text(
311
+                        &core_info,
312
+                        (self.width - 80) as f64,
313
+                        y as f64 + 14.0,
314
+                        &TextStyle {
315
+                            font_family: "monospace".to_string(),
316
+                            font_size: 10.0,
317
+                            color: self.theme.text_secondary,
318
+                            ..Default::default()
319
+                        },
320
+                    )?;
321
+                }
322
+                y += 20;
260323
 
261
-        // CPU Section
262
-        let cpu_label = if let Some(cpu) = &self.cpu_stats {
263
-            format!("CPU: {:.1}%", cpu.usage_percent)
264
-        } else {
265
-            "CPU: --".to_string()
266
-        };
267
-        self.renderer.text(
268
-            &cpu_label,
269
-            padding as f64,
270
-            y as f64 + 14.0,
271
-            &TextStyle { color: self.theme.cpu_color, ..style.clone() },
272
-        )?;
273
-        y += 20;
274
-
275
-        // CPU Graph
276
-        let cpu_rect = Rect::new(padding as i32, y, graph_width, graph_height);
277
-        let mut cpu_series = DataSeries::new("CPU", self.theme.cpu_color);
278
-        cpu_series.set_values(self.cpu_history.iter().map(|s| s.usage_percent).collect());
279
-        graph.render(&ctx, cpu_rect, &[cpu_series], &self.theme);
280
-        y += graph_height as i32 + padding as i32;
281
-
282
-        // Memory Section
283
-        let mem_label = if let Some(mem) = &self.memory_stats {
284
-            format!(
285
-                "Memory: {:.1}% ({} / {})",
286
-                mem.usage_percent,
287
-                format_bytes(mem.used),
288
-                format_bytes(mem.total)
289
-            )
290
-        } else {
291
-            "Memory: --".to_string()
292
-        };
293
-        self.renderer.text(
294
-            &mem_label,
295
-            padding as f64,
296
-            y as f64 + 14.0,
297
-            &TextStyle { color: self.theme.memory_color, ..style.clone() },
298
-        )?;
299
-        y += 20;
300
-
301
-        // Memory Graph
302
-        let mem_rect = Rect::new(padding as i32, y, graph_width, graph_height);
303
-        let mut mem_series = DataSeries::new("Memory", self.theme.memory_color);
304
-        let mut swap_series = DataSeries::new("Swap", self.theme.swap_color);
305
-        mem_series.set_values(self.memory_history.iter().map(|s| s.usage_percent).collect());
306
-        swap_series.set_values(self.memory_history.iter().map(|s| {
307
-            if s.swap_total > 0 {
308
-                (s.swap_used as f64 / s.swap_total as f64) * 100.0
309
-            } else {
310
-                0.0
324
+                // CPU Graph
325
+                let graph_rect = Rect::new(padding as i32, y, graph_width, GRAPH_HEIGHT);
326
+                let mut cpu_series = DataSeries::new("CPU", self.theme.cpu_color);
327
+                cpu_series.set_values(self.cpu_history.iter().map(|s| s.usage_percent).collect());
328
+                graph.render(&ctx, graph_rect, &[cpu_series], &self.theme);
311329
             }
312
-        }).collect());
313
-        graph.render(&ctx, mem_rect, &[mem_series, swap_series], &self.theme);
314
-        y += graph_height as i32 + padding as i32;
315
-
316
-        // Additional stats text
317
-        if let Some(cpu) = &self.cpu_stats {
318
-            let cores_to_show = cpu.per_core.len().min(8);
319
-            let mut core_line = String::from("Cores: ");
320
-            for (i, usage) in cpu.per_core.iter().take(cores_to_show).enumerate() {
321
-                if i > 0 {
322
-                    core_line.push_str(" | ");
330
+
331
+            Tab::Memory => {
332
+                // Memory label
333
+                let mem_label = if let Some(mem) = &self.memory_stats {
334
+                    format!(
335
+                        "Memory: {:.1}% ({} / {})",
336
+                        mem.usage_percent,
337
+                        format_bytes(mem.used),
338
+                        format_bytes(mem.total)
339
+                    )
340
+                } else {
341
+                    "Memory: --".to_string()
342
+                };
343
+                self.renderer.text(
344
+                    &mem_label,
345
+                    padding as f64,
346
+                    y as f64 + 14.0,
347
+                    &TextStyle {
348
+                        font_family: "monospace".to_string(),
349
+                        font_size: 12.0,
350
+                        color: self.theme.memory_color,
351
+                        ..Default::default()
352
+                    },
353
+                )?;
354
+
355
+                // Swap info on right
356
+                if let Some(mem) = &self.memory_stats {
357
+                    let swap_pct = if mem.swap_total > 0 {
358
+                        (mem.swap_used as f64 / mem.swap_total as f64) * 100.0
359
+                    } else {
360
+                        0.0
361
+                    };
362
+                    let swap_info = format!("Swap: {:.1}%", swap_pct);
363
+                    self.renderer.text(
364
+                        &swap_info,
365
+                        (self.width - 80) as f64,
366
+                        y as f64 + 14.0,
367
+                        &TextStyle {
368
+                            font_family: "monospace".to_string(),
369
+                            font_size: 10.0,
370
+                            color: self.theme.swap_color,
371
+                            ..Default::default()
372
+                        },
373
+                    )?;
323374
                 }
324
-                core_line.push_str(&format!("{}:{:.0}%", i, usage));
325
-            }
326
-            if cpu.per_core.len() > cores_to_show {
327
-                core_line.push_str(&format!(" (+{} more)", cpu.per_core.len() - cores_to_show));
375
+                y += 20;
376
+
377
+                // Memory Graph
378
+                let graph_rect = Rect::new(padding as i32, y, graph_width, GRAPH_HEIGHT);
379
+                let mut mem_series = DataSeries::new("Memory", self.theme.memory_color);
380
+                let mut swap_series = DataSeries::new("Swap", self.theme.swap_color);
381
+                mem_series.set_values(self.memory_history.iter().map(|s| s.usage_percent).collect());
382
+                swap_series.set_values(self.memory_history.iter().map(|s| {
383
+                    if s.swap_total > 0 {
384
+                        (s.swap_used as f64 / s.swap_total as f64) * 100.0
385
+                    } else {
386
+                        0.0
387
+                    }
388
+                }).collect());
389
+                graph.render(&ctx, graph_rect, &[mem_series, swap_series], &self.theme);
328390
             }
329
-            self.renderer.text(&core_line, padding as f64, y as f64 + text_line_height, &dim_style)?;
330
-            y += text_line_height as i32 + 4;
331391
         }
332392
 
333
-        if let Some(mem) = &self.memory_stats {
334
-            let swap_percent = if mem.swap_total > 0 {
335
-                (mem.swap_used as f64 / mem.swap_total as f64) * 100.0
336
-            } else {
337
-                0.0
338
-            };
339
-            self.renderer.text(
340
-                &format!(
341
-                    "Swap: {:.1}% ({} / {}) | Available: {}",
342
-                    swap_percent,
343
-                    format_bytes(mem.swap_used),
344
-                    format_bytes(mem.swap_total),
345
-                    format_bytes(mem.available)
346
-                ),
347
-                padding as f64,
348
-                y as f64 + text_line_height,
349
-                &dim_style,
350
-            )?;
351
-        }
393
+        // Render process list
394
+        self.process_list.render(&self.renderer, &self.theme)?;
352395
 
353396
         Ok(())
354397
     }
@@ -390,11 +433,48 @@ impl App {
390433
         self.width = width;
391434
         self.height = height;
392435
         self.renderer.resize(width, height)?;
436
+
437
+        // Update component bounds
393438
         self.header = HeaderBar::new(Rect::new(0, 0, width, HEADER_HEIGHT));
439
+        self.tab_bar.set_bounds(Rect::new(0, HEADER_HEIGHT as i32, width, TAB_BAR_HEIGHT));
440
+        self.process_list = Self::create_process_list(width, height);
441
+        self.process_list.set_processes(self.processes.clone());
394442
 
395443
         Ok(())
396444
     }
397445
 
446
+    /// Handle mouse click.
447
+    fn handle_click(&mut self, pos: Point) -> bool {
448
+        // Check tab bar
449
+        if let Some(tab) = self.tab_bar.on_click(pos) {
450
+            if tab != self.tab_bar.active() {
451
+                self.tab_bar.set_active(tab);
452
+                // Re-sort processes for new tab
453
+                let sort_field = match tab {
454
+                    Tab::Cpu => SortField::Cpu,
455
+                    Tab::Memory => SortField::Memory,
456
+                };
457
+                self.process_list.set_sort(sort_field);
458
+                // Force refresh to get re-sorted processes
459
+                self.last_refresh = Instant::now() - std::time::Duration::from_secs(10);
460
+                return true;
461
+            }
462
+        }
463
+
464
+        // Check process list
465
+        if self.process_list.on_click(pos).is_some() {
466
+            return true;
467
+        }
468
+
469
+        false
470
+    }
471
+
472
+    /// Handle scroll.
473
+    fn handle_scroll(&mut self, delta: i32) -> bool {
474
+        self.process_list.on_scroll(delta);
475
+        true
476
+    }
477
+
398478
     /// Run the GUI event loop.
399479
     pub fn run(mut self) -> Result<()> {
400480
         let config = EventLoopConfig {
@@ -416,15 +496,61 @@ impl App {
416496
                     ev_loop.request_redraw();
417497
                 }
418498
 
499
+                InputEvent::MousePress(mouse_event) => {
500
+                    let pos = Point::new(mouse_event.position.x, mouse_event.position.y);
501
+                    if self.handle_click(pos) {
502
+                        ev_loop.request_redraw();
503
+                    }
504
+                }
505
+
506
+                InputEvent::MouseMove(mouse_event) => {
507
+                    let pos = Point::new(mouse_event.position.x, mouse_event.position.y);
508
+                    if self.tab_bar.on_mouse_move(pos) {
509
+                        ev_loop.request_redraw();
510
+                    }
511
+                }
512
+
513
+                InputEvent::Scroll(scroll_event) => {
514
+                    let delta = if scroll_event.delta_y > 0 { -1 } else { 1 };
515
+                    if self.handle_scroll(delta) {
516
+                        ev_loop.request_redraw();
517
+                    }
518
+                }
519
+
419520
                 InputEvent::Key(key_event) if key_event.pressed => {
420521
                     match key_event.key {
421522
                         Key::Escape | Key::Char('q') => {
422523
                             self.should_quit = true;
423524
                         }
424525
                         Key::Char('r') => {
425
-                            // Force refresh
426526
                             self.last_refresh = Instant::now() - std::time::Duration::from_secs(10);
427527
                         }
528
+                        Key::Char('1') => {
529
+                            self.tab_bar.set_active(Tab::Cpu);
530
+                            self.process_list.set_sort(SortField::Cpu);
531
+                            self.last_refresh = Instant::now() - std::time::Duration::from_secs(10);
532
+                            ev_loop.request_redraw();
533
+                        }
534
+                        Key::Char('2') => {
535
+                            self.tab_bar.set_active(Tab::Memory);
536
+                            self.process_list.set_sort(SortField::Memory);
537
+                            self.last_refresh = Instant::now() - std::time::Duration::from_secs(10);
538
+                            ev_loop.request_redraw();
539
+                        }
540
+                        Key::Tab => {
541
+                            let new_tab = match self.tab_bar.active() {
542
+                                Tab::Cpu => Tab::Memory,
543
+                                Tab::Memory => Tab::Cpu,
544
+                            };
545
+                            self.tab_bar.set_active(new_tab);
546
+                            let sort_field = match new_tab {
547
+                                Tab::Cpu => SortField::Cpu,
548
+                                Tab::Memory => SortField::Memory,
549
+                            };
550
+                            self.process_list.set_sort(sort_field);
551
+                            self.last_refresh = Instant::now() - std::time::Duration::from_secs(10);
552
+                            ev_loop.request_redraw();
553
+                        }
428554
                         _ => {}
429555
                     }
430556
                 }
@@ -434,14 +560,12 @@ impl App {
434560
                 }
435561
 
436562
                 InputEvent::Idle => {
437
-                    // Periodic refresh
438563
                     if self.last_refresh.elapsed().as_secs_f64() >= REFRESH_INTERVAL {
439564
                         if self.daemon_available {
440565
                             self.refresh_data();
441566
                             self.update_header();
442567
                             ev_loop.request_redraw();
443568
                         } else {
444
-                            // Try reconnecting
445569
                             self.daemon_available = Self::check_daemon();
446570
                             if self.daemon_available {
447571
                                 ev_loop.request_redraw();
@@ -454,7 +578,6 @@ impl App {
454578
                 _ => {}
455579
             }
456580
 
457
-            // Render if needed
458581
             if ev_loop.needs_redraw() {
459582
                 if let Err(e) = self.render() {
460583
                     tracing::error!("Render error: {}", e);
gartop/src/gui/graph.rsmodified
@@ -182,9 +182,9 @@ impl LineGraph {
182182
 
183183
             // Fill with transparent color
184184
             ctx.set_source_rgba(
185
-                series.color.r as f64 / 255.0,
186
-                series.color.g as f64 / 255.0,
187
-                series.color.b as f64 / 255.0,
185
+                series.color.r,
186
+                series.color.g,
187
+                series.color.b,
188188
                 self.fill_opacity,
189189
             );
190190
             let _ = ctx.fill();
@@ -198,9 +198,9 @@ impl LineGraph {
198198
 
199199
         // Stroke the line
200200
         ctx.set_source_rgba(
201
-            series.color.r as f64 / 255.0,
202
-            series.color.g as f64 / 255.0,
203
-            series.color.b as f64 / 255.0,
201
+            series.color.r,
202
+            series.color.g,
203
+            series.color.b,
204204
             1.0,
205205
         );
206206
         ctx.set_line_width(self.line_width);
@@ -224,9 +224,9 @@ impl LineGraph {
224224
             // Color box
225225
             ctx.rectangle(lx, y + 3.0, 8.0, 8.0);
226226
             ctx.set_source_rgba(
227
-                data.color.r as f64 / 255.0,
228
-                data.color.g as f64 / 255.0,
229
-                data.color.b as f64 / 255.0,
227
+                data.color.r,
228
+                data.color.g,
229
+                data.color.b,
230230
                 1.0,
231231
             );
232232
             let _ = ctx.fill();
gartop/src/gui/mod.rsmodified
@@ -3,10 +3,14 @@
33
 mod app;
44
 mod graph;
55
 mod header;
6
+mod process_list;
7
+mod tabs;
68
 pub mod theme;
79
 
810
 pub use app::App;
911
 pub use graph::{DataSeries, LineGraph};
12
+pub use process_list::ProcessList;
13
+pub use tabs::{Tab, TabBar, TAB_BAR_HEIGHT};
1014
 
1115
 use anyhow::Result;
1216
 
gartop/src/gui/process_list.rsadded
@@ -0,0 +1,261 @@
1
+//! Process list component for displaying running processes
2
+
3
+use gartk_core::{Point, Rect};
4
+use gartk_render::{Renderer, TextStyle};
5
+use gartop_ipc::{ProcessInfo, SortField};
6
+use super::theme::Theme;
7
+
8
+/// Row height for process list.
9
+const ROW_HEIGHT: u32 = 22;
10
+
11
+/// Header row height.
12
+const HEADER_HEIGHT: u32 = 24;
13
+
14
+/// Process list component.
15
+pub struct ProcessList {
16
+    bounds: Rect,
17
+    processes: Vec<ProcessInfo>,
18
+    scroll_offset: usize,
19
+    selected: Option<usize>,
20
+    sort_field: SortField,
21
+    visible_rows: usize,
22
+}
23
+
24
+impl ProcessList {
25
+    /// Create a new process list.
26
+    pub fn new(bounds: Rect) -> Self {
27
+        let visible_rows = ((bounds.height.saturating_sub(HEADER_HEIGHT)) / ROW_HEIGHT) as usize;
28
+        Self {
29
+            bounds,
30
+            processes: Vec::new(),
31
+            scroll_offset: 0,
32
+            selected: None,
33
+            sort_field: SortField::Cpu,
34
+            visible_rows,
35
+        }
36
+    }
37
+
38
+    /// Update bounds (on resize).
39
+    pub fn set_bounds(&mut self, bounds: Rect) {
40
+        self.bounds = bounds;
41
+        self.visible_rows = ((bounds.height.saturating_sub(HEADER_HEIGHT)) / ROW_HEIGHT) as usize;
42
+    }
43
+
44
+    /// Set processes to display.
45
+    pub fn set_processes(&mut self, processes: Vec<ProcessInfo>) {
46
+        self.processes = processes;
47
+        // Clamp scroll offset
48
+        if self.scroll_offset > self.max_scroll() {
49
+            self.scroll_offset = self.max_scroll();
50
+        }
51
+    }
52
+
53
+    /// Set sort field.
54
+    pub fn set_sort(&mut self, sort: SortField) {
55
+        self.sort_field = sort;
56
+    }
57
+
58
+    /// Get current sort field.
59
+    pub fn sort_field(&self) -> SortField {
60
+        self.sort_field
61
+    }
62
+
63
+    /// Maximum scroll offset.
64
+    fn max_scroll(&self) -> usize {
65
+        self.processes.len().saturating_sub(self.visible_rows)
66
+    }
67
+
68
+    /// Handle scroll (delta is number of rows to scroll, positive = down).
69
+    pub fn on_scroll(&mut self, delta: i32) {
70
+        if delta > 0 {
71
+            self.scroll_offset = (self.scroll_offset + delta as usize).min(self.max_scroll());
72
+        } else {
73
+            self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
74
+        }
75
+    }
76
+
77
+    /// Handle click, returns selected process PID if any.
78
+    pub fn on_click(&mut self, pos: Point) -> Option<i32> {
79
+        if !self.bounds.contains_point(pos) {
80
+            return None;
81
+        }
82
+
83
+        let local_y = pos.y - self.bounds.y;
84
+
85
+        // Check if clicking header
86
+        if local_y < HEADER_HEIGHT as i32 {
87
+            return None;
88
+        }
89
+
90
+        // Calculate row index
91
+        let row = ((local_y - HEADER_HEIGHT as i32) / ROW_HEIGHT as i32) as usize;
92
+        let process_idx = self.scroll_offset + row;
93
+
94
+        if process_idx < self.processes.len() {
95
+            self.selected = Some(process_idx);
96
+            return Some(self.processes[process_idx].pid);
97
+        }
98
+
99
+        None
100
+    }
101
+
102
+    /// Get selected process PID.
103
+    pub fn selected_pid(&self) -> Option<i32> {
104
+        self.selected.and_then(|idx| self.processes.get(idx).map(|p| p.pid))
105
+    }
106
+
107
+    /// Clear selection.
108
+    pub fn clear_selection(&mut self) {
109
+        self.selected = None;
110
+    }
111
+
112
+    /// Render the process list.
113
+    pub fn render(&self, renderer: &Renderer, theme: &Theme) -> anyhow::Result<()> {
114
+        // Background
115
+        renderer.fill_rect(self.bounds, theme.panel_bg)?;
116
+
117
+        let header_style = TextStyle {
118
+            font_family: "monospace".to_string(),
119
+            font_size: 11.0,
120
+            color: theme.text_secondary,
121
+            ..Default::default()
122
+        };
123
+
124
+        let text_style = TextStyle {
125
+            font_family: "monospace".to_string(),
126
+            font_size: 11.0,
127
+            color: theme.text,
128
+            ..Default::default()
129
+        };
130
+
131
+        let dim_style = TextStyle {
132
+            color: theme.text_secondary,
133
+            ..text_style.clone()
134
+        };
135
+
136
+        // Column positions
137
+        let x = self.bounds.x as f64;
138
+        let col_pid = x + 8.0;
139
+        let col_name = x + 70.0;
140
+        let col_cpu = x + 220.0;
141
+        let col_mem = x + 290.0;
142
+        let col_user = x + 380.0;
143
+
144
+        // Header
145
+        let header_y = self.bounds.y as f64 + 16.0;
146
+        renderer.text("PID", col_pid, header_y, &header_style)?;
147
+        renderer.text("Name", col_name, header_y, &header_style)?;
148
+
149
+        // Highlight active sort column
150
+        let sort_style = TextStyle {
151
+            color: match self.sort_field {
152
+                SortField::Cpu => theme.cpu_color,
153
+                SortField::Memory => theme.memory_color,
154
+                _ => theme.text_secondary,
155
+            },
156
+            ..header_style.clone()
157
+        };
158
+
159
+        if self.sort_field == SortField::Cpu {
160
+            renderer.text("CPU%", col_cpu, header_y, &sort_style)?;
161
+            renderer.text("Mem%", col_mem, header_y, &header_style)?;
162
+        } else {
163
+            renderer.text("CPU%", col_cpu, header_y, &header_style)?;
164
+            renderer.text("Mem%", col_mem, header_y, &sort_style)?;
165
+        }
166
+        renderer.text("User", col_user, header_y, &header_style)?;
167
+
168
+        // Header separator
169
+        let sep_y = (self.bounds.y + HEADER_HEIGHT as i32) as f64;
170
+        renderer.line(
171
+            x + 4.0,
172
+            sep_y,
173
+            (self.bounds.x + self.bounds.width as i32 - 4) as f64,
174
+            sep_y,
175
+            theme.border,
176
+            1.0,
177
+        )?;
178
+
179
+        // Process rows
180
+        let start_y = self.bounds.y + HEADER_HEIGHT as i32;
181
+        for (i, process) in self.processes.iter()
182
+            .skip(self.scroll_offset)
183
+            .take(self.visible_rows)
184
+            .enumerate()
185
+        {
186
+            let row_y = start_y + (i as i32 * ROW_HEIGHT as i32);
187
+            let text_y = row_y as f64 + 16.0;
188
+            let process_idx = self.scroll_offset + i;
189
+
190
+            // Selection highlight
191
+            if self.selected == Some(process_idx) {
192
+                let row_rect = Rect::new(
193
+                    self.bounds.x + 2,
194
+                    row_y + 2,
195
+                    self.bounds.width - 4,
196
+                    ROW_HEIGHT - 4,
197
+                );
198
+                renderer.fill_rounded_rect(row_rect, 2.0, theme.header_bg)?;
199
+            }
200
+
201
+            // PID
202
+            renderer.text(&process.pid.to_string(), col_pid, text_y, &dim_style)?;
203
+
204
+            // Name (truncate if too long)
205
+            let name = if process.name.len() > 18 {
206
+                format!("{}...", &process.name[..15])
207
+            } else {
208
+                process.name.clone()
209
+            };
210
+            renderer.text(&name, col_name, text_y, &text_style)?;
211
+
212
+            // CPU %
213
+            let cpu_style = if process.cpu_percent > 50.0 {
214
+                TextStyle { color: theme.cpu_color, ..text_style.clone() }
215
+            } else {
216
+                dim_style.clone()
217
+            };
218
+            renderer.text(&format!("{:.1}", process.cpu_percent), col_cpu, text_y, &cpu_style)?;
219
+
220
+            // Memory %
221
+            let mem_style = if process.memory_percent > 10.0 {
222
+                TextStyle { color: theme.memory_color, ..text_style.clone() }
223
+            } else {
224
+                dim_style.clone()
225
+            };
226
+            renderer.text(&format!("{:.1}", process.memory_percent), col_mem, text_y, &mem_style)?;
227
+
228
+            // User
229
+            let user = if process.user.len() > 10 {
230
+                format!("{}...", &process.user[..7])
231
+            } else {
232
+                process.user.clone()
233
+            };
234
+            renderer.text(&user, col_user, text_y, &dim_style)?;
235
+        }
236
+
237
+        // Scroll indicator if needed
238
+        if self.processes.len() > self.visible_rows {
239
+            let scroll_info = format!(
240
+                "{}-{} of {}",
241
+                self.scroll_offset + 1,
242
+                (self.scroll_offset + self.visible_rows).min(self.processes.len()),
243
+                self.processes.len()
244
+            );
245
+            let info_style = TextStyle {
246
+                font_size: 9.0,
247
+                ..dim_style
248
+            };
249
+            let info_x = (self.bounds.x + self.bounds.width as i32 - 80) as f64;
250
+            let info_y = self.bounds.y as f64 + 16.0;
251
+            renderer.text(&scroll_info, info_x, info_y, &info_style)?;
252
+        }
253
+
254
+        Ok(())
255
+    }
256
+
257
+    /// Get row height.
258
+    pub fn row_height(&self) -> u32 {
259
+        ROW_HEIGHT
260
+    }
261
+}
gartop/src/gui/tabs.rsadded
@@ -0,0 +1,195 @@
1
+//! Tab bar component for switching between CPU and Memory views
2
+
3
+use gartk_core::{Point, Rect};
4
+use gartk_render::{Renderer, TextStyle};
5
+use super::theme::Theme;
6
+
7
+/// Available tabs.
8
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9
+pub enum Tab {
10
+    #[default]
11
+    Cpu,
12
+    Memory,
13
+}
14
+
15
+impl Tab {
16
+    /// Get all tabs in order.
17
+    pub fn all() -> &'static [Tab] {
18
+        &[Tab::Cpu, Tab::Memory]
19
+    }
20
+
21
+    /// Get tab label.
22
+    pub fn label(&self) -> &'static str {
23
+        match self {
24
+            Tab::Cpu => "CPU",
25
+            Tab::Memory => "Memory",
26
+        }
27
+    }
28
+}
29
+
30
+/// Tab bar height.
31
+pub const TAB_BAR_HEIGHT: u32 = 32;
32
+
33
+/// Tab bar component.
34
+pub struct TabBar {
35
+    bounds: Rect,
36
+    active: Tab,
37
+    tab_bounds: Vec<Rect>,
38
+    hovered: Option<usize>,
39
+}
40
+
41
+impl TabBar {
42
+    /// Create a new tab bar.
43
+    pub fn new(bounds: Rect) -> Self {
44
+        let tab_bounds = Self::calculate_tab_bounds(bounds);
45
+        Self {
46
+            bounds,
47
+            active: Tab::default(),
48
+            tab_bounds,
49
+            hovered: None,
50
+        }
51
+    }
52
+
53
+    /// Calculate bounds for each tab.
54
+    fn calculate_tab_bounds(bounds: Rect) -> Vec<Rect> {
55
+        let tabs = Tab::all();
56
+        let tab_width = 100u32;
57
+        let tab_height = bounds.height - 4;
58
+        let mut result = Vec::with_capacity(tabs.len());
59
+
60
+        for (i, _) in tabs.iter().enumerate() {
61
+            let x = bounds.x + 8 + (i as i32 * (tab_width as i32 + 4));
62
+            let y = bounds.y + 2;
63
+            result.push(Rect::new(x, y, tab_width, tab_height));
64
+        }
65
+
66
+        result
67
+    }
68
+
69
+    /// Update bounds (on resize).
70
+    pub fn set_bounds(&mut self, bounds: Rect) {
71
+        self.bounds = bounds;
72
+        self.tab_bounds = Self::calculate_tab_bounds(bounds);
73
+    }
74
+
75
+    /// Get the active tab.
76
+    pub fn active(&self) -> Tab {
77
+        self.active
78
+    }
79
+
80
+    /// Set the active tab.
81
+    pub fn set_active(&mut self, tab: Tab) {
82
+        self.active = tab;
83
+    }
84
+
85
+    /// Handle click event, returns which tab was clicked (if any).
86
+    pub fn on_click(&self, pos: Point) -> Option<Tab> {
87
+        if !self.bounds.contains_point(pos) {
88
+            return None;
89
+        }
90
+
91
+        let tabs = Tab::all();
92
+        for (i, tab_rect) in self.tab_bounds.iter().enumerate() {
93
+            if tab_rect.contains_point(pos) {
94
+                return Some(tabs[i]);
95
+            }
96
+        }
97
+        None
98
+    }
99
+
100
+    /// Handle mouse move, returns true if hover state changed.
101
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
102
+        let old_hovered = self.hovered;
103
+
104
+        if !self.bounds.contains_point(pos) {
105
+            self.hovered = None;
106
+        } else {
107
+            self.hovered = None;
108
+            for (i, tab_rect) in self.tab_bounds.iter().enumerate() {
109
+                if tab_rect.contains_point(pos) {
110
+                    self.hovered = Some(i);
111
+                    break;
112
+                }
113
+            }
114
+        }
115
+
116
+        old_hovered != self.hovered
117
+    }
118
+
119
+    /// Render the tab bar.
120
+    pub fn render(&self, renderer: &Renderer, theme: &Theme) -> anyhow::Result<()> {
121
+        // Background
122
+        renderer.fill_rect(self.bounds, theme.panel_bg)?;
123
+
124
+        // Bottom border
125
+        renderer.line(
126
+            self.bounds.x as f64,
127
+            (self.bounds.y + self.bounds.height as i32) as f64,
128
+            (self.bounds.x + self.bounds.width as i32) as f64,
129
+            (self.bounds.y + self.bounds.height as i32) as f64,
130
+            theme.border,
131
+            1.0,
132
+        )?;
133
+
134
+        let tabs = Tab::all();
135
+        for (i, tab_rect) in self.tab_bounds.iter().enumerate() {
136
+            let tab = tabs[i];
137
+            let is_active = tab == self.active;
138
+            let is_hovered = self.hovered == Some(i);
139
+
140
+            // Tab background
141
+            let bg_color = if is_active {
142
+                theme.header_bg
143
+            } else if is_hovered {
144
+                theme.graph_bg
145
+            } else {
146
+                theme.panel_bg
147
+            };
148
+            renderer.fill_rounded_rect(*tab_rect, 4.0, bg_color)?;
149
+
150
+            // Tab text
151
+            let text_color = if is_active {
152
+                match tab {
153
+                    Tab::Cpu => theme.cpu_color,
154
+                    Tab::Memory => theme.memory_color,
155
+                }
156
+            } else {
157
+                theme.text_secondary
158
+            };
159
+
160
+            let style = TextStyle {
161
+                font_family: "monospace".to_string(),
162
+                font_size: 12.0,
163
+                color: text_color,
164
+                ..Default::default()
165
+            };
166
+
167
+            // Center text in tab
168
+            let text = tab.label();
169
+            let text_size = renderer.measure_text(text, &style)?;
170
+            let text_x = tab_rect.x as f64 + (tab_rect.width as f64 - text_size.width as f64) / 2.0;
171
+            let text_y = tab_rect.y as f64 + (tab_rect.height as f64 + style.font_size) / 2.0;
172
+            renderer.text(text, text_x, text_y, &style)?;
173
+
174
+            // Active indicator line
175
+            if is_active {
176
+                let indicator_y = (tab_rect.y + tab_rect.height as i32 - 2) as f64;
177
+                renderer.line(
178
+                    tab_rect.x as f64 + 4.0,
179
+                    indicator_y,
180
+                    (tab_rect.x + tab_rect.width as i32 - 4) as f64,
181
+                    indicator_y,
182
+                    text_color,
183
+                    2.0,
184
+                )?;
185
+            }
186
+        }
187
+
188
+        Ok(())
189
+    }
190
+
191
+    /// Get tab bar height.
192
+    pub fn height(&self) -> u32 {
193
+        self.bounds.height
194
+    }
195
+}