Skip to main content

geekmagic_common/
disk_render.rs

1use std::f64::consts::PI;
2use std::process::Command;
3
4use ab_glyph::{FontRef, PxScale};
5use anyhow::{Context, Result};
6use image::{Rgba, RgbaImage};
7use imageproc::drawing::draw_text_mut;
8
9const W: u32 = 240;
10const H: u32 = 240;
11
12const BG: Rgba<u8> = Rgba([12, 12, 16, 255]);
13const TEXT_PRIMARY: Rgba<u8> = Rgba([240, 240, 245, 255]);
14const TEXT_DIM: Rgba<u8> = Rgba([113, 113, 122, 255]);
15const TEXT_MUTED: Rgba<u8> = Rgba([161, 161, 170, 255]);
16const SEPARATOR: Rgba<u8> = Rgba([35, 35, 45, 255]);
17
18const PIE_USED: Rgba<u8> = Rgba([99, 102, 241, 255]);
19const PIE_USED_2: Rgba<u8> = Rgba([139, 92, 246, 255]);
20const PIE_FREE: Rgba<u8> = Rgba([34, 197, 94, 255]);
21const PIE_FREE_2: Rgba<u8> = Rgba([16, 185, 129, 255]);
22const PIE_BG: Rgba<u8> = Rgba([30, 30, 40, 255]);
23
24const FONT_BYTES: &[u8] = include_bytes!("../fonts/Inter-Regular.ttf");
25const FONT_BOLD_BYTES: &[u8] = include_bytes!("../fonts/Inter-Bold.ttf");
26
27pub struct DiskInfo {
28    pub total_bytes: u64,
29    pub free_bytes: u64,
30    pub used_bytes: u64,
31}
32
33pub fn get_disk_info() -> Result<DiskInfo> {
34    let output = Command::new("diskutil")
35        .args(["info", "/"])
36        .output()
37        .context("failed to run diskutil")?;
38
39    let stdout = String::from_utf8_lossy(&output.stdout);
40
41    let total = extract_bytes(&stdout, "Container Total Space:")
42        .or_else(|| extract_bytes(&stdout, "Disk Size:"))
43        .context("could not find total space")?;
44
45    let free =
46        extract_bytes(&stdout, "Container Free Space:").context("could not find free space")?;
47
48    Ok(DiskInfo {
49        total_bytes: total,
50        free_bytes: free,
51        used_bytes: total.saturating_sub(free),
52    })
53}
54
55fn extract_bytes(text: &str, label: &str) -> Option<u64> {
56    for line in text.lines() {
57        if line.contains(label) {
58            if let Some(open) = line.find('(') {
59                let after_paren = &line[open + 1..];
60                let num_str: String = after_paren
61                    .chars()
62                    .take_while(|c| c.is_ascii_digit())
63                    .collect();
64                if !num_str.is_empty() {
65                    return num_str.parse().ok();
66                }
67            }
68        }
69    }
70    None
71}
72
73pub fn format_size(bytes: u64) -> String {
74    let gb = bytes as f64 / 1_000_000_000.0;
75    if gb >= 1000.0 {
76        format!("{:.1} TB", gb / 1000.0)
77    } else if gb >= 100.0 {
78        format!("{:.0} GB", gb)
79    } else if gb >= 10.0 {
80        format!("{:.1} GB", gb)
81    } else {
82        format!("{:.2} GB", gb)
83    }
84}
85
86fn lerp_color(a: Rgba<u8>, b: Rgba<u8>, t: f32) -> Rgba<u8> {
87    let t = t.clamp(0.0, 1.0);
88    Rgba([
89        (a[0] as f32 + (b[0] as f32 - a[0] as f32) * t) as u8,
90        (a[1] as f32 + (b[1] as f32 - a[1] as f32) * t) as u8,
91        (a[2] as f32 + (b[2] as f32 - a[2] as f32) * t) as u8,
92        255,
93    ])
94}
95
96fn approx_text_width(text: &str, scale: f32) -> i32 {
97    let char_w = scale * 0.55;
98    let mut w = 0.0f32;
99    for ch in text.chars() {
100        w += match ch {
101            '.' | ':' | '!' | '|' | 'i' | 'l' | '1' => char_w * 0.55,
102            'm' | 'w' | 'M' | 'W' => char_w * 1.25,
103            ' ' => char_w * 0.6,
104            '%' => char_w * 1.1,
105            _ => char_w,
106        };
107    }
108    w.ceil() as i32
109}
110
111fn draw_text_centered(
112    img: &mut RgbaImage,
113    color: Rgba<u8>,
114    center_x: i32,
115    y: i32,
116    scale: f32,
117    font: &FontRef,
118    text: &str,
119) {
120    let w = approx_text_width(text, scale);
121    draw_text_mut(
122        img,
123        color,
124        center_x - w / 2,
125        y,
126        PxScale::from(scale),
127        font,
128        text,
129    );
130}
131
132fn draw_text_right(
133    img: &mut RgbaImage,
134    color: Rgba<u8>,
135    right_x: i32,
136    y: i32,
137    scale: f32,
138    font: &FontRef,
139    text: &str,
140) {
141    let w = approx_text_width(text, scale);
142    draw_text_mut(img, color, right_x - w, y, PxScale::from(scale), font, text);
143}
144
145fn draw_rounded_rect(img: &mut RgbaImage, x: i32, y: i32, w: u32, h: u32, r: u32, color: Rgba<u8>) {
146    for px in 0..w {
147        for py in 0..h {
148            if is_inside_rounded(px, py, w, h, r) {
149                let abs_x = x as u32 + px;
150                let abs_y = y as u32 + py;
151                if abs_x < W && abs_y < H {
152                    img.put_pixel(abs_x, abs_y, color);
153                }
154            }
155        }
156    }
157}
158
159fn is_inside_rounded(px: u32, py: u32, w: u32, h: u32, r: u32) -> bool {
160    if r == 0 || w == 0 || h == 0 {
161        return true;
162    }
163    let r = r.min(w / 2).min(h / 2);
164    let corners = [
165        (r, r),
166        (w.saturating_sub(r + 1), r),
167        (r, h.saturating_sub(r + 1)),
168        (w.saturating_sub(r + 1), h.saturating_sub(r + 1)),
169    ];
170    for &(cx, cy) in &corners {
171        let in_corner_x = if px <= cx {
172            px < r
173        } else {
174            px > w.saturating_sub(r + 1)
175        };
176        let in_corner_y = if py <= cy {
177            py < r
178        } else {
179            py > h.saturating_sub(r + 1)
180        };
181        if in_corner_x && in_corner_y {
182            let dx = if px < cx { cx - px } else { px - cx };
183            let dy = if py < cy { cy - py } else { py - cy };
184            if dx * dx + dy * dy > r * r {
185                return false;
186            }
187        }
188    }
189    true
190}
191
192pub fn render_disk(info: &DiskInfo) -> Result<RgbaImage> {
193    let font = FontRef::try_from_slice(FONT_BYTES)?;
194    let font_bold = FontRef::try_from_slice(FONT_BOLD_BYTES)?;
195    let mut img = RgbaImage::from_pixel(W, H, BG);
196
197    let mx = 16i32;
198    let right_edge = W as i32 - mx;
199    let content_w = (right_edge - mx) as u32;
200
201    // Header
202    let header_y = 10;
203    draw_text_mut(
204        &mut img,
205        TEXT_PRIMARY,
206        mx,
207        header_y,
208        PxScale::from(17.0),
209        &font_bold,
210        "Macintosh HD",
211    );
212    let total_text = format_size(info.total_bytes);
213    draw_text_right(
214        &mut img,
215        TEXT_DIM,
216        right_edge,
217        header_y + 1,
218        15.0,
219        &font,
220        &total_text,
221    );
222
223    draw_rounded_rect(&mut img, mx, 33, content_w, 1, 0, SEPARATOR);
224
225    // Pie chart
226    let pie_cx = 120.0f64;
227    let pie_cy = 118.0f64;
228    let pie_r_outer = 68.0f64;
229    let pie_r_inner = 42.0f64;
230
231    let used_frac = info.used_bytes as f64 / info.total_bytes as f64;
232    let free_frac = 1.0 - used_frac;
233    let used_angle = used_frac * 2.0 * PI;
234
235    for py in 0..H {
236        for px in 0..W {
237            let dx = px as f64 - pie_cx;
238            let dy = py as f64 - pie_cy;
239            let dist = (dx * dx + dy * dy).sqrt();
240
241            if dist >= pie_r_inner && dist <= pie_r_outer {
242                let angle = (dx.atan2(-dy) + 2.0 * PI) % (2.0 * PI);
243
244                let edge_outer = (pie_r_outer - dist).clamp(0.0, 1.0) as f32;
245                let edge_inner = (dist - pie_r_inner).clamp(0.0, 1.0) as f32;
246                let aa = edge_outer.min(edge_inner);
247
248                let base_color = if angle < used_angle {
249                    let t = (angle / used_angle) as f32;
250                    lerp_color(PIE_USED, PIE_USED_2, t)
251                } else {
252                    let t = ((angle - used_angle) / (2.0 * PI - used_angle)) as f32;
253                    lerp_color(PIE_FREE, PIE_FREE_2, t)
254                };
255
256                let depth = ((dist - pie_r_inner) / (pie_r_outer - pie_r_inner)) as f32;
257                let lit = lerp_color(
258                    Rgba([
259                        (base_color[0] as f32 * 0.8) as u8,
260                        (base_color[1] as f32 * 0.8) as u8,
261                        (base_color[2] as f32 * 0.8) as u8,
262                        255,
263                    ]),
264                    base_color,
265                    depth,
266                );
267
268                let blended = lerp_color(BG, lit, aa);
269                img.put_pixel(px, py, blended);
270            } else if dist < pie_r_inner && dist >= pie_r_inner - 1.0 {
271                let aa = (pie_r_inner - dist).clamp(0.0, 1.0) as f32;
272                let blended = lerp_color(BG, PIE_BG, aa * 0.3);
273                img.put_pixel(px, py, blended);
274            }
275        }
276    }
277
278    // Center text: free percentage
279    let free_pct = (free_frac * 100.0).round() as i32;
280    let pct_text = format!("{free_pct}%");
281    draw_text_centered(
282        &mut img,
283        TEXT_PRIMARY,
284        pie_cx as i32,
285        pie_cy as i32 - 16,
286        30.0,
287        &font_bold,
288        &pct_text,
289    );
290    draw_text_centered(
291        &mut img,
292        TEXT_MUTED,
293        pie_cx as i32,
294        pie_cy as i32 + 12,
295        13.0,
296        &font,
297        "free",
298    );
299
300    // Bottom area: legend with prominent GB values
301    let legend_y = 192;
302    let col1_x = mx + 10;
303    let col2_x = 132;
304
305    // Used
306    draw_rounded_rect(&mut img, col1_x, legend_y + 4, 10, 10, 3, PIE_USED);
307    draw_text_mut(
308        &mut img,
309        TEXT_MUTED,
310        col1_x + 14,
311        legend_y,
312        PxScale::from(13.0),
313        &font,
314        "Used",
315    );
316    let used_text = format_size(info.used_bytes);
317    draw_text_mut(
318        &mut img,
319        TEXT_PRIMARY,
320        col1_x + 14,
321        legend_y + 16,
322        PxScale::from(22.0),
323        &font_bold,
324        &used_text,
325    );
326
327    // Free
328    draw_rounded_rect(&mut img, col2_x, legend_y + 4, 10, 10, 3, PIE_FREE);
329    draw_text_mut(
330        &mut img,
331        TEXT_MUTED,
332        col2_x + 14,
333        legend_y,
334        PxScale::from(13.0),
335        &font,
336        "Free",
337    );
338    let free_text = format_size(info.free_bytes);
339    draw_text_mut(
340        &mut img,
341        TEXT_PRIMARY,
342        col2_x + 14,
343        legend_y + 16,
344        PxScale::from(22.0),
345        &font_bold,
346        &free_text,
347    );
348
349    Ok(img)
350}