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 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 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 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 let legend_y = 192;
302 let col1_x = mx + 10;
303 let col2_x = 132;
304
305 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 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}