Skip to main content

planarian_tray/
icon.rs

1//! The indicator's icon: the identity kit's micro mark (the variant drawn for
2//! 16–32 px, with larger eye cutouts), white for the dark panel, with a small
3//! dot for the state: none when all is well, blue-grey while working, amber
4//! for attention, red for a fault. Rendered here as ARGB pixmaps, so it needs
5//! no icon theme and keeps the eye cutouts transparent.
6
7use crate::state::Level;
8use tiny_skia::{Color, FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
9
10const MARK: &str = include_str!("../../../assets/identity/marks/planarian-mark-micro-currentcolor.svg");
11/// Sizes panels ask for.
12pub const SIZES: [u32; 6] = [16, 22, 24, 32, 48, 64];
13
14/// The mark's path in its 1024-square box (absolute M, C and Z only).
15fn mark_path() -> Option<tiny_skia::Path> {
16    let start = MARK.find(" d=\"")? + 4;
17    let d = &MARK[start..start + MARK[start..].find('"')?];
18    let mut b = PathBuilder::new();
19    let mut nums: Vec<f32> = vec![];
20    let mut cmd = ' ';
21    let flush = |cmd: char, nums: &mut Vec<f32>, b: &mut PathBuilder| -> Option<()> {
22        match cmd {
23            'M' if nums.len() == 2 => b.move_to(nums[0], nums[1]),
24            'C' if nums.len().is_multiple_of(6) => {
25                for c in nums.chunks(6) {
26                    b.cubic_to(c[0], c[1], c[2], c[3], c[4], c[5]);
27                }
28            }
29            'Z' | ' ' if nums.is_empty() => {}
30            _ => return None,
31        }
32        nums.clear();
33        Some(())
34    };
35    for tok in d.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()) {
36        let first = tok.chars().next()?;
37        if first.is_ascii_alphabetic() {
38            flush(cmd, &mut nums, &mut b)?;
39            if first == 'Z' {
40                b.close();
41            }
42            cmd = first;
43            if tok.len() > 1 {
44                nums.push(tok[1..].parse().ok()?);
45            }
46        } else {
47            nums.push(tok.parse().ok()?);
48        }
49    }
50    flush(cmd, &mut nums, &mut b)?;
51    b.finish()
52}
53
54fn dot_color(level: Level) -> Option<Color> {
55    match level {
56        Level::Ok => None,
57        Level::Working => Some(Color::from_rgba8(0x8f, 0xb0, 0xd8, 0xff)),
58        Level::Attention => Some(Color::from_rgba8(0xe0, 0xa4, 0x3a, 0xff)),
59        Level::Fault => Some(Color::from_rgba8(0xe0, 0x52, 0x4f, 0xff)),
60    }
61}
62
63/// The icon at `size` px, as ARGB32 in network byte order (what the
64/// StatusNotifierItem `IconPixmap` property carries), not premultiplied.
65pub fn render(level: Level, size: u32) -> Option<(u32, u32, Vec<u8>)> {
66    let mut px = Pixmap::new(size, size)?;
67    let s = size as f32;
68    let mut paint = Paint { anti_alias: true, ..Paint::default() };
69    paint.set_color(Color::WHITE);
70    let path = mark_path()?;
71    px.fill_path(&path, &paint, FillRule::EvenOdd, Transform::from_scale(s / 1024.0, s / 1024.0), None);
72    if let Some(c) = dot_color(level) {
73        // Lower right, ringed in the panel's charcoal so it reads on the white body.
74        let r = (s * 0.2).max(3.0);
75        let (cx, cy) = (s - r - s * 0.02, s - r - s * 0.02);
76        let dot = PathBuilder::from_circle(cx, cy, r)?;
77        paint.set_color(c);
78        px.fill_path(&dot, &paint, FillRule::Winding, Transform::identity(), None);
79        paint.set_color(Color::from_rgba8(0x15, 0x17, 0x19, 0xff));
80        let ring = Stroke { width: (s * 0.06).max(1.0), ..Stroke::default() };
81        px.stroke_path(&dot, &paint, &ring, Transform::identity(), None);
82    }
83    let mut argb = Vec::with_capacity((size * size * 4) as usize);
84    for p in px.pixels() {
85        let c = p.demultiply();
86        argb.extend_from_slice(&[c.alpha(), c.red(), c.green(), c.blue()]);
87    }
88    Some((size, size, argb))
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn at(icon: &(u32, u32, Vec<u8>), x: u32, y: u32) -> [u8; 4] {
96        let i = ((y * icon.0 + x) * 4) as usize;
97        icon.2[i..i + 4].try_into().unwrap()
98    }
99
100    #[test]
101    fn the_mark_parses_and_draws_white_with_transparent_corners() {
102        assert!(mark_path().is_some());
103        let ok = render(Level::Ok, 64).unwrap();
104        assert_eq!(ok.2.len(), 64 * 64 * 4);
105        assert_eq!(at(&ok, 0, 0)[0], 0, "corner is transparent");
106        // Some opaque white pixels (the body), none coloured.
107        let white = ok.2.chunks(4).filter(|p| p[0] == 255 && p[1] == 255 && p[2] == 255 && p[3] == 255).count();
108        assert!(white > 64 * 64 / 10, "{white} white pixels");
109        assert!(ok.2.chunks(4).all(|p| p[0] == 0 || (p[1] == p[2] && p[2] == p[3])), "monochrome when ok");
110    }
111
112    /// `PLANARIAN_TRAY_DUMP=dir cargo test -p planarian-tray`: writes each
113    /// state's 64 px icon as raw RGBA (`convert -size 64x64 -depth 8 rgba:f f.png`).
114    #[test]
115    fn dump_for_inspection() {
116        let Ok(dir) = std::env::var("PLANARIAN_TRAY_DUMP") else { return };
117        for l in [Level::Ok, Level::Working, Level::Attention, Level::Fault] {
118            let (_, _, argb) = render(l, 64).unwrap();
119            let rgba: Vec<u8> = argb.chunks(4).flat_map(|p| [p[1], p[2], p[3], p[0]]).collect();
120            std::fs::write(format!("{dir}/{l:?}.rgba"), rgba).unwrap();
121        }
122    }
123
124    #[test]
125    fn the_dot_shows_the_state() {
126        let r = 64.0 * 0.2;
127        let c = (64.0 - r - 64.0 * 0.02) as u32;
128        assert_eq!(at(&render(Level::Fault, 64).unwrap(), c, c), [0xff, 0xe0, 0x52, 0x4f]);
129        assert_eq!(at(&render(Level::Attention, 64).unwrap(), c, c), [0xff, 0xe0, 0xa4, 0x3a]);
130        assert_ne!(at(&render(Level::Ok, 64).unwrap(), c, c), [0xff, 0xe0, 0x52, 0x4f]);
131        for s in SIZES {
132            assert!(render(Level::Working, s).is_some());
133        }
134    }
135}