gardesk/gartop / b4e3ac2

Browse files

add basic gartk GUI with daemon connection and CPU/memory display

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b4e3ac25cc161d0d05db011109317bb65a33c43a
Parents
efc2b7e
Tree
90c86b1

5 changed files

StatusFile+-
A gartop/src/gui/app.rs 454 0
A gartop/src/gui/header.rs 121 0
M gartop/src/gui/mod.rs 9 2
A gartop/src/gui/theme.rs 38 0
M gartop/src/ipc/mod.rs 1 1
gartop/src/gui/app.rsadded
@@ -0,0 +1,454 @@
1
+//! GUI application state and event loop
2
+
3
+use super::{header::HeaderBar, theme::Theme};
4
+use anyhow::Result;
5
+use gartk_core::{InputEvent, Key, Rect};
6
+use gartk_render::Renderer;
7
+use gartk_x11::{Connection, EventLoop, EventLoopConfig, Window, WindowConfig};
8
+use gartop_ipc::{Command, CpuStats, MemoryStats, Response, StatusInfo};
9
+use std::io::{BufRead, BufReader, Write};
10
+use std::os::unix::net::UnixStream;
11
+use std::time::Instant;
12
+use x11rb::protocol::xproto::{ConnectionExt, ImageFormat};
13
+
14
+/// Header bar height.
15
+const HEADER_HEIGHT: u32 = 36;
16
+
17
+/// Default window dimensions.
18
+const DEFAULT_WIDTH: u32 = 800;
19
+const DEFAULT_HEIGHT: u32 = 600;
20
+
21
+/// Data refresh interval (seconds).
22
+const REFRESH_INTERVAL: f64 = 1.0;
23
+
24
+/// GUI application.
25
+pub struct App {
26
+    window: Window,
27
+    renderer: Renderer,
28
+    gc: u32,
29
+    theme: Theme,
30
+    header: HeaderBar,
31
+    should_quit: bool,
32
+    width: u32,
33
+    height: u32,
34
+    daemon_conn: Option<UnixStream>,
35
+    last_refresh: Instant,
36
+    status: Option<StatusInfo>,
37
+    cpu_stats: Option<CpuStats>,
38
+    memory_stats: Option<MemoryStats>,
39
+}
40
+
41
+impl App {
42
+    /// Create a new GUI application.
43
+    pub fn new() -> Result<Self> {
44
+        let conn = Connection::connect(None)?;
45
+
46
+        // Get primary monitor for centering
47
+        let monitor = gartk_x11::primary_monitor(&conn)?;
48
+
49
+        let width = DEFAULT_WIDTH.min(monitor.rect.width);
50
+        let height = DEFAULT_HEIGHT.min(monitor.rect.height);
51
+        let x = monitor.rect.x + (monitor.rect.width as i32 - width as i32) / 2;
52
+        let y = monitor.rect.y + (monitor.rect.height as i32 - height as i32) / 2;
53
+
54
+        let window = Window::create(
55
+            conn.clone(),
56
+            WindowConfig::default()
57
+                .title("gartop")
58
+                .class("gartop")
59
+                .position(x, y)
60
+                .size(width, height)
61
+                .transparent(false),
62
+        )?;
63
+        conn.flush()?;
64
+
65
+        // Create graphics context for blitting
66
+        let gc = conn.generate_id()?;
67
+        conn.inner().create_gc(gc, window.id(), &Default::default())?;
68
+        conn.flush()?;
69
+
70
+        // Create renderer
71
+        let theme = Theme::default();
72
+        let renderer = Renderer::new(width, height)?;
73
+
74
+        // Create header bar
75
+        let header = HeaderBar::new(Rect::new(0, 0, width, HEADER_HEIGHT));
76
+
77
+        // Try to connect to daemon
78
+        let daemon_conn = Self::connect_daemon();
79
+
80
+        Ok(Self {
81
+            window,
82
+            renderer,
83
+            gc,
84
+            theme,
85
+            header,
86
+            should_quit: false,
87
+            width,
88
+            height,
89
+            daemon_conn,
90
+            last_refresh: Instant::now() - std::time::Duration::from_secs(10), // force immediate refresh
91
+            status: None,
92
+            cpu_stats: None,
93
+            memory_stats: None,
94
+        })
95
+    }
96
+
97
+    /// Connect to the daemon, returns None if connection fails.
98
+    fn connect_daemon() -> Option<UnixStream> {
99
+        let path = gartop_ipc::socket_path();
100
+        match UnixStream::connect(&path) {
101
+            Ok(stream) => {
102
+                stream.set_nonblocking(false).ok()?;
103
+                tracing::info!("Connected to daemon at {}", path.display());
104
+                Some(stream)
105
+            }
106
+            Err(e) => {
107
+                tracing::warn!("Failed to connect to daemon: {}", e);
108
+                None
109
+            }
110
+        }
111
+    }
112
+
113
+    /// Send a command to the daemon and get response.
114
+    fn send_command(&mut self, cmd: &Command) -> Option<Response> {
115
+        let stream = self.daemon_conn.as_mut()?;
116
+
117
+        // Send command
118
+        let json = serde_json::to_string(cmd).ok()?;
119
+        writeln!(stream, "{}", json).ok()?;
120
+        stream.flush().ok()?;
121
+
122
+        // Read response
123
+        let mut reader = BufReader::new(stream.try_clone().ok()?);
124
+        let mut line = String::new();
125
+        reader.read_line(&mut line).ok()?;
126
+
127
+        serde_json::from_str(&line).ok()
128
+    }
129
+
130
+    /// Refresh data from daemon.
131
+    fn refresh_data(&mut self) {
132
+        // Get status
133
+        if let Some(resp) = self.send_command(&Command::Status) {
134
+            if resp.success {
135
+                self.status = resp.data.and_then(|d| serde_json::from_value(d).ok());
136
+            }
137
+        }
138
+
139
+        // Get CPU stats
140
+        if let Some(resp) = self.send_command(&Command::GetCpu) {
141
+            if resp.success {
142
+                self.cpu_stats = resp.data.and_then(|d| serde_json::from_value(d).ok());
143
+            }
144
+        }
145
+
146
+        // Get memory stats
147
+        if let Some(resp) = self.send_command(&Command::GetMemory) {
148
+            if resp.success {
149
+                self.memory_stats = resp.data.and_then(|d| serde_json::from_value(d).ok());
150
+            }
151
+        }
152
+
153
+        self.last_refresh = Instant::now();
154
+    }
155
+
156
+    /// Update header bar with current stats.
157
+    fn update_header(&mut self) {
158
+        let uptime = self.status.as_ref().map(|s| s.uptime_secs).unwrap_or(0);
159
+        let cpu = self.cpu_stats.as_ref().map(|s| s.usage_percent as f32).unwrap_or(0.0);
160
+        let mem = self.memory_stats.as_ref().map(|s| s.usage_percent as f32).unwrap_or(0.0);
161
+        self.header.update(uptime, cpu, mem);
162
+    }
163
+
164
+    /// Render the entire UI.
165
+    fn render(&mut self) -> Result<()> {
166
+        // Clear background
167
+        self.renderer.fill_rect(
168
+            Rect::new(0, 0, self.width, self.height),
169
+            self.theme.background,
170
+        )?;
171
+
172
+        // Render header
173
+        self.header.render(&self.renderer, &self.theme)?;
174
+
175
+        // Render main content area
176
+        let content_y = HEADER_HEIGHT as i32;
177
+        let content_height = self.height.saturating_sub(HEADER_HEIGHT);
178
+        let content_rect = Rect::new(0, content_y, self.width, content_height);
179
+
180
+        self.renderer.fill_rect(content_rect, self.theme.panel_bg)?;
181
+
182
+        // Show connection status or stats
183
+        self.render_content(content_rect)?;
184
+
185
+        self.renderer.flush();
186
+        Ok(())
187
+    }
188
+
189
+    /// Render main content area.
190
+    fn render_content(&self, bounds: Rect) -> Result<()> {
191
+        use gartk_render::TextStyle;
192
+
193
+        let style = TextStyle {
194
+            font_family: "monospace".to_string(),
195
+            font_size: 13.0,
196
+            color: self.theme.text,
197
+            ..Default::default()
198
+        };
199
+
200
+        let dim_style = TextStyle {
201
+            color: self.theme.text_secondary,
202
+            ..style.clone()
203
+        };
204
+
205
+        let mut y = bounds.y as f64 + 30.0;
206
+        let x = 20.0;
207
+        let line_height = 24.0;
208
+
209
+        if self.daemon_conn.is_none() {
210
+            self.renderer.text(
211
+                "Not connected to daemon",
212
+                x,
213
+                y,
214
+                &TextStyle {
215
+                    color: self.theme.swap_color, // yellow
216
+                    ..style.clone()
217
+                },
218
+            )?;
219
+            y += line_height;
220
+            self.renderer.text(
221
+                "Run: gartop daemon",
222
+                x,
223
+                y,
224
+                &dim_style,
225
+            )?;
226
+            return Ok(());
227
+        }
228
+
229
+        // CPU info
230
+        if let Some(cpu) = &self.cpu_stats {
231
+            self.renderer.text(
232
+                &format!("CPU Usage: {:.1}%", cpu.usage_percent),
233
+                x,
234
+                y,
235
+                &TextStyle {
236
+                    color: self.theme.cpu_color,
237
+                    ..style.clone()
238
+                },
239
+            )?;
240
+            y += line_height;
241
+
242
+            // Per-core display (first 8 cores max)
243
+            let cores_to_show = cpu.per_core.len().min(8);
244
+            let mut core_line = String::from("Cores: ");
245
+            for (i, usage) in cpu.per_core.iter().take(cores_to_show).enumerate() {
246
+                if i > 0 {
247
+                    core_line.push_str(" | ");
248
+                }
249
+                core_line.push_str(&format!("{}:{:.0}%", i, usage));
250
+            }
251
+            if cpu.per_core.len() > cores_to_show {
252
+                core_line.push_str(&format!(" (+{} more)", cpu.per_core.len() - cores_to_show));
253
+            }
254
+            self.renderer.text(&core_line, x, y, &dim_style)?;
255
+            y += line_height * 1.5;
256
+        }
257
+
258
+        // Memory info
259
+        if let Some(mem) = &self.memory_stats {
260
+            self.renderer.text(
261
+                &format!(
262
+                    "Memory: {:.1}% ({} / {})",
263
+                    mem.usage_percent,
264
+                    format_bytes(mem.used),
265
+                    format_bytes(mem.total)
266
+                ),
267
+                x,
268
+                y,
269
+                &TextStyle {
270
+                    color: self.theme.memory_color,
271
+                    ..style.clone()
272
+                },
273
+            )?;
274
+            y += line_height;
275
+
276
+            // Calculate swap percentage
277
+            let swap_percent = if mem.swap_total > 0 {
278
+                (mem.swap_used as f64 / mem.swap_total as f64) * 100.0
279
+            } else {
280
+                0.0
281
+            };
282
+
283
+            self.renderer.text(
284
+                &format!(
285
+                    "Swap: {:.1}% ({} / {})",
286
+                    swap_percent,
287
+                    format_bytes(mem.swap_used),
288
+                    format_bytes(mem.swap_total)
289
+                ),
290
+                x,
291
+                y,
292
+                &TextStyle {
293
+                    color: self.theme.swap_color,
294
+                    ..style.clone()
295
+                },
296
+            )?;
297
+            y += line_height;
298
+
299
+            self.renderer.text(
300
+                &format!(
301
+                    "Available: {} | Free: {}",
302
+                    format_bytes(mem.available),
303
+                    format_bytes(mem.free)
304
+                ),
305
+                x,
306
+                y,
307
+                &dim_style,
308
+            )?;
309
+        }
310
+
311
+        Ok(())
312
+    }
313
+
314
+    /// Blit surface to window.
315
+    fn blit(&mut self) -> Result<()> {
316
+        let data = {
317
+            let surface = self.renderer.surface_mut();
318
+            let data_ref = surface
319
+                .data()
320
+                .map_err(|e| anyhow::anyhow!("Failed to get surface data: {}", e))?;
321
+            data_ref.to_vec()
322
+        };
323
+
324
+        let conn = self.window.connection();
325
+        conn.inner().put_image(
326
+            ImageFormat::Z_PIXMAP,
327
+            self.window.id(),
328
+            self.gc,
329
+            self.width as u16,
330
+            self.height as u16,
331
+            0,
332
+            0,
333
+            0,
334
+            self.window.depth(),
335
+            &data,
336
+        )?;
337
+        conn.flush()?;
338
+
339
+        Ok(())
340
+    }
341
+
342
+    /// Handle window resize.
343
+    fn handle_resize(&mut self, width: u32, height: u32) -> Result<()> {
344
+        if width == self.width && height == self.height {
345
+            return Ok(());
346
+        }
347
+
348
+        self.width = width;
349
+        self.height = height;
350
+        self.renderer.resize(width, height)?;
351
+        self.header = HeaderBar::new(Rect::new(0, 0, width, HEADER_HEIGHT));
352
+
353
+        Ok(())
354
+    }
355
+
356
+    /// Run the GUI event loop.
357
+    pub fn run(mut self) -> Result<()> {
358
+        let config = EventLoopConfig {
359
+            fps: 30,
360
+            continuous_redraw: false,
361
+        };
362
+        let mut event_loop = EventLoop::new(&self.window, config)?;
363
+
364
+        event_loop.run(|ev_loop, event| {
365
+            match event {
366
+                InputEvent::Expose => {
367
+                    ev_loop.request_redraw();
368
+                }
369
+
370
+                InputEvent::Resize { width, height } => {
371
+                    if let Err(e) = self.handle_resize(width, height) {
372
+                        tracing::error!("Resize error: {}", e);
373
+                    }
374
+                    ev_loop.request_redraw();
375
+                }
376
+
377
+                InputEvent::Key(key_event) if key_event.pressed => {
378
+                    match key_event.key {
379
+                        Key::Escape | Key::Char('q') => {
380
+                            self.should_quit = true;
381
+                        }
382
+                        Key::Char('r') => {
383
+                            // Force refresh
384
+                            self.last_refresh = Instant::now() - std::time::Duration::from_secs(10);
385
+                        }
386
+                        _ => {}
387
+                    }
388
+                }
389
+
390
+                InputEvent::CloseRequested => {
391
+                    self.should_quit = true;
392
+                }
393
+
394
+                InputEvent::Idle => {
395
+                    // Periodic refresh
396
+                    if self.last_refresh.elapsed().as_secs_f64() >= REFRESH_INTERVAL {
397
+                        if self.daemon_conn.is_some() {
398
+                            self.refresh_data();
399
+                            self.update_header();
400
+                            ev_loop.request_redraw();
401
+                        } else {
402
+                            // Try reconnecting
403
+                            self.daemon_conn = Self::connect_daemon();
404
+                            if self.daemon_conn.is_some() {
405
+                                ev_loop.request_redraw();
406
+                            }
407
+                            self.last_refresh = Instant::now();
408
+                        }
409
+                    }
410
+                }
411
+
412
+                _ => {}
413
+            }
414
+
415
+            // Render if needed
416
+            if ev_loop.needs_redraw() {
417
+                if let Err(e) = self.render() {
418
+                    tracing::error!("Render error: {}", e);
419
+                }
420
+                if let Err(e) = self.blit() {
421
+                    tracing::error!("Blit error: {}", e);
422
+                }
423
+                ev_loop.redraw_done();
424
+            }
425
+
426
+            Ok(!self.should_quit)
427
+        })?;
428
+
429
+        Ok(())
430
+    }
431
+}
432
+
433
+impl Drop for App {
434
+    fn drop(&mut self) {
435
+        let _ = self.window.connection().inner().free_gc(self.gc);
436
+    }
437
+}
438
+
439
+/// Format bytes to human-readable string.
440
+fn format_bytes(bytes: u64) -> String {
441
+    const KIB: u64 = 1024;
442
+    const MIB: u64 = KIB * 1024;
443
+    const GIB: u64 = MIB * 1024;
444
+
445
+    if bytes >= GIB {
446
+        format!("{:.1} GiB", bytes as f64 / GIB as f64)
447
+    } else if bytes >= MIB {
448
+        format!("{:.1} MiB", bytes as f64 / MIB as f64)
449
+    } else if bytes >= KIB {
450
+        format!("{:.1} KiB", bytes as f64 / KIB as f64)
451
+    } else {
452
+        format!("{} B", bytes)
453
+    }
454
+}
gartop/src/gui/header.rsadded
@@ -0,0 +1,121 @@
1
+//! Header bar component
2
+
3
+use gartk_core::Rect;
4
+use gartk_render::{Renderer, TextStyle};
5
+use super::theme::Theme;
6
+
7
+/// Header bar showing app title and stats summary.
8
+pub struct HeaderBar {
9
+    bounds: Rect,
10
+    uptime: String,
11
+    cpu_usage: f32,
12
+    memory_usage: f32,
13
+}
14
+
15
+impl HeaderBar {
16
+    /// Create a new header bar.
17
+    pub fn new(bounds: Rect) -> Self {
18
+        Self {
19
+            bounds,
20
+            uptime: String::from("0s"),
21
+            cpu_usage: 0.0,
22
+            memory_usage: 0.0,
23
+        }
24
+    }
25
+
26
+    /// Update header with current stats.
27
+    pub fn update(&mut self, uptime_secs: u64, cpu: f32, memory: f32) {
28
+        self.uptime = format_uptime(uptime_secs);
29
+        self.cpu_usage = cpu;
30
+        self.memory_usage = memory;
31
+    }
32
+
33
+    /// Render the header bar.
34
+    pub fn render(&self, renderer: &Renderer, theme: &Theme) -> anyhow::Result<()> {
35
+        // Background
36
+        renderer.fill_rect(self.bounds, theme.header_bg)?;
37
+
38
+        // Title
39
+        let title_style = TextStyle {
40
+            font_family: "monospace".to_string(),
41
+            font_size: 14.0,
42
+            color: theme.text,
43
+            ..Default::default()
44
+        };
45
+        renderer.text("gartop", 16.0, self.bounds.y as f64 + 20.0, &title_style)?;
46
+
47
+        // Stats summary on right side
48
+        let stats_style = TextStyle {
49
+            font_family: "monospace".to_string(),
50
+            font_size: 12.0,
51
+            color: theme.text_secondary,
52
+            ..Default::default()
53
+        };
54
+
55
+        // CPU indicator
56
+        let cpu_text = format!("CPU: {:.1}%", self.cpu_usage);
57
+        let cpu_style = TextStyle {
58
+            color: theme.cpu_color,
59
+            ..stats_style.clone()
60
+        };
61
+
62
+        // Memory indicator
63
+        let mem_text = format!("MEM: {:.1}%", self.memory_usage);
64
+        let mem_style = TextStyle {
65
+            color: theme.memory_color,
66
+            ..stats_style.clone()
67
+        };
68
+
69
+        // Uptime
70
+        let uptime_text = format!("up {}", self.uptime);
71
+
72
+        // Position from right side
73
+        let right_margin = 16.0;
74
+        let spacing = 20.0;
75
+        let y = self.bounds.y as f64 + 20.0;
76
+
77
+        let uptime_width = renderer.measure_text(&uptime_text, &stats_style)?.width as f64;
78
+        let mem_width = renderer.measure_text(&mem_text, &mem_style)?.width as f64;
79
+        let cpu_width = renderer.measure_text(&cpu_text, &cpu_style)?.width as f64;
80
+
81
+        let right_edge = (self.bounds.x + self.bounds.width as i32) as f64;
82
+
83
+        let uptime_x = right_edge - right_margin - uptime_width;
84
+        let mem_x = uptime_x - spacing - mem_width;
85
+        let cpu_x = mem_x - spacing - cpu_width;
86
+
87
+        renderer.text(&cpu_text, cpu_x, y, &cpu_style)?;
88
+        renderer.text(&mem_text, mem_x, y, &mem_style)?;
89
+        renderer.text(&uptime_text, uptime_x, y, &stats_style)?;
90
+
91
+        // Bottom border
92
+        renderer.line(
93
+            self.bounds.x as f64,
94
+            (self.bounds.y + self.bounds.height as i32) as f64,
95
+            right_edge,
96
+            (self.bounds.y + self.bounds.height as i32) as f64,
97
+            theme.border,
98
+            1.0,
99
+        )?;
100
+
101
+        Ok(())
102
+    }
103
+
104
+    /// Get header height.
105
+    pub fn height(&self) -> u32 {
106
+        self.bounds.height
107
+    }
108
+}
109
+
110
+/// Format uptime seconds to human-readable string.
111
+fn format_uptime(secs: u64) -> String {
112
+    if secs < 60 {
113
+        format!("{}s", secs)
114
+    } else if secs < 3600 {
115
+        format!("{}m {}s", secs / 60, secs % 60)
116
+    } else if secs < 86400 {
117
+        format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
118
+    } else {
119
+        format!("{}d {}h", secs / 86400, (secs % 86400) / 3600)
120
+    }
121
+}
gartop/src/gui/mod.rsmodified
@@ -1,10 +1,17 @@
11
 //! GUI implementation using gartk
22
 
3
+mod app;
4
+mod header;
5
+pub mod theme;
6
+
7
+pub use app::App;
8
+pub use theme::Theme;
9
+
310
 use anyhow::Result;
411
 
512
 /// Run the gartop GUI.
613
 pub async fn run() -> Result<()> {
7
-    // TODO: Sprint 4 - implement GUI
8
-    tracing::info!("GUI not yet implemented");
14
+    let app = App::new()?;
15
+    app.run()?;
916
     Ok(())
1017
 }
gartop/src/gui/theme.rsadded
@@ -0,0 +1,38 @@
1
+//! Theme and styling for gartop GUI
2
+
3
+use gartk_core::Color;
4
+
5
+/// UI theme colors.
6
+pub struct Theme {
7
+    pub background: Color,
8
+    pub panel_bg: Color,
9
+    pub header_bg: Color,
10
+    pub text: Color,
11
+    pub text_secondary: Color,
12
+    pub accent: Color,
13
+    pub cpu_color: Color,
14
+    pub memory_color: Color,
15
+    pub swap_color: Color,
16
+    pub border: Color,
17
+    pub graph_bg: Color,
18
+    pub graph_grid: Color,
19
+}
20
+
21
+impl Default for Theme {
22
+    fn default() -> Self {
23
+        Self {
24
+            background: Color::from_hex("#1e1e2e").unwrap_or(Color::BLACK),
25
+            panel_bg: Color::from_hex("#313244").unwrap_or(Color::BLACK),
26
+            header_bg: Color::from_hex("#45475a").unwrap_or(Color::BLACK),
27
+            text: Color::from_hex("#cdd6f4").unwrap_or(Color::WHITE),
28
+            text_secondary: Color::from_hex("#a6adc8").unwrap_or(Color::WHITE),
29
+            accent: Color::from_hex("#89b4fa").unwrap_or(Color::WHITE),
30
+            cpu_color: Color::from_hex("#f38ba8").unwrap_or(Color::WHITE),
31
+            memory_color: Color::from_hex("#a6e3a1").unwrap_or(Color::WHITE),
32
+            swap_color: Color::from_hex("#f9e2af").unwrap_or(Color::WHITE),
33
+            border: Color::from_hex("#585b70").unwrap_or(Color::WHITE),
34
+            graph_bg: Color::from_hex("#11111b").unwrap_or(Color::BLACK),
35
+            graph_grid: Color::from_hex("#313244").unwrap_or(Color::BLACK),
36
+        }
37
+    }
38
+}
gartop/src/ipc/mod.rsmodified
@@ -7,4 +7,4 @@ pub use client::ClientHandler;
77
 pub use server::IpcServer;
88
 
99
 // Re-export from gartop-ipc
10
-pub use gartop_ipc::{Command, Event, Response};
10
+pub use gartop_ipc::{Command, Response};