Skip to main content

studio_worker/ui/
icons.rs

1//! Line icons painted from geometry, so they stay crisp at any scale,
2//! take the theme's colours and need no icon font.
3
4use eframe::egui::{self, vec2, Color32, Pos2, Rect, Shape};
5
6use crate::types::TaskKind;
7
8use super::theme::stroke;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum Icon {
12    Jobs,
13    Models,
14    Worker,
15    Logs,
16    Config,
17    Image,
18    Chat,
19    Microphone,
20    Speaker,
21    Film,
22    Pin,
23}
24
25impl Icon {
26    pub const ALL: [Icon; 11] = [
27        Icon::Jobs,
28        Icon::Models,
29        Icon::Worker,
30        Icon::Logs,
31        Icon::Config,
32        Icon::Image,
33        Icon::Chat,
34        Icon::Microphone,
35        Icon::Speaker,
36        Icon::Film,
37        Icon::Pin,
38    ];
39
40    /// The glyph of a job kind.
41    pub fn of_kind(kind: TaskKind) -> Self {
42        match kind {
43            TaskKind::Image => Icon::Image,
44            TaskKind::Llm => Icon::Chat,
45            TaskKind::AudioStt => Icon::Microphone,
46            TaskKind::AudioTts => Icon::Speaker,
47            TaskKind::Video => Icon::Film,
48        }
49    }
50}
51
52/// The shapes of `icon` drawn inside `rect` in `colour`.  Geometry is laid
53/// out on a 24-unit grid with a 2-unit margin, like common line-icon sets.
54pub fn shapes(icon: Icon, rect: Rect, colour: Color32) -> Vec<Shape> {
55    let unit = rect.width().min(rect.height()) / 24.0;
56    let origin = rect.center() - vec2(12.0, 12.0) * unit;
57    let p = |x: f32, y: f32| origin + vec2(x, y) * unit;
58    let line = stroke((1.6 * unit).max(1.0), colour);
59    let seg = |a: Pos2, b: Pos2| Shape::line_segment([a, b], line);
60    let path = |points: Vec<Pos2>| Shape::line(points, line);
61    let closed = |points: Vec<Pos2>| Shape::closed_line(points, line);
62    let rounded = |min: Pos2, max: Pos2, r: f32| {
63        Shape::rect_stroke(
64            Rect::from_min_max(min, max),
65            egui::CornerRadius::same((r * unit) as u8),
66            line,
67            egui::StrokeKind::Middle,
68        )
69    };
70    let circle = |c: Pos2, r: f32| Shape::circle_stroke(c, r * unit, line);
71    let dot = |c: Pos2, r: f32| Shape::circle_filled(c, r * unit, colour);
72
73    match icon {
74        Icon::Jobs => vec![
75            rounded(p(4.0, 8.0), p(20.0, 20.0), 2.0),
76            seg(p(6.0, 5.0), p(18.0, 5.0)),
77            seg(p(8.0, 2.5), p(16.0, 2.5)),
78        ],
79        Icon::Models => vec![
80            closed(vec![
81                p(12.0, 2.5),
82                p(20.5, 7.0),
83                p(20.5, 17.0),
84                p(12.0, 21.5),
85                p(3.5, 17.0),
86                p(3.5, 7.0),
87            ]),
88            path(vec![p(3.5, 7.0), p(12.0, 11.5), p(20.5, 7.0)]),
89            seg(p(12.0, 11.5), p(12.0, 21.5)),
90        ],
91        Icon::Worker => vec![
92            rounded(p(2.5, 4.0), p(21.5, 20.0), 3.0),
93            path(vec![
94                p(5.5, 12.5),
95                p(9.0, 12.5),
96                p(10.5, 8.0),
97                p(13.5, 16.5),
98                p(15.0, 12.5),
99                p(18.5, 12.5),
100            ]),
101        ],
102        Icon::Logs => vec![
103            seg(p(4.0, 5.0), p(20.0, 5.0)),
104            seg(p(4.0, 10.0), p(16.0, 10.0)),
105            seg(p(4.0, 15.0), p(20.0, 15.0)),
106            seg(p(4.0, 20.0), p(12.0, 20.0)),
107        ],
108        Icon::Config => vec![
109            seg(p(3.0, 6.0), p(21.0, 6.0)),
110            seg(p(3.0, 12.0), p(21.0, 12.0)),
111            seg(p(3.0, 18.0), p(21.0, 18.0)),
112            dot(p(8.0, 6.0), 2.4),
113            dot(p(16.0, 12.0), 2.4),
114            dot(p(10.0, 18.0), 2.4),
115        ],
116        Icon::Image => vec![
117            rounded(p(3.0, 4.0), p(21.0, 20.0), 2.0),
118            path(vec![
119                p(3.5, 17.0),
120                p(9.0, 11.0),
121                p(13.0, 15.0),
122                p(16.0, 12.5),
123                p(20.5, 17.0),
124            ]),
125            circle(p(15.5, 8.5), 1.8),
126        ],
127        Icon::Chat => vec![
128            closed(vec![
129                p(3.0, 4.0),
130                p(21.0, 4.0),
131                p(21.0, 16.0),
132                p(11.0, 16.0),
133                p(6.0, 20.5),
134                p(6.0, 16.0),
135                p(3.0, 16.0),
136            ]),
137            seg(p(7.0, 8.5), p(17.0, 8.5)),
138            seg(p(7.0, 12.0), p(14.0, 12.0)),
139        ],
140        Icon::Microphone => vec![
141            rounded(p(9.0, 2.5), p(15.0, 14.5), 3.0),
142            path(vec![
143                p(5.5, 11.0),
144                p(6.5, 15.0),
145                p(9.0, 17.3),
146                p(12.0, 18.0),
147                p(15.0, 17.3),
148                p(17.5, 15.0),
149                p(18.5, 11.0),
150            ]),
151            seg(p(12.0, 18.0), p(12.0, 21.5)),
152        ],
153        Icon::Speaker => vec![
154            closed(vec![
155                p(3.0, 9.0),
156                p(7.0, 9.0),
157                p(12.0, 4.5),
158                p(12.0, 19.5),
159                p(7.0, 15.0),
160                p(3.0, 15.0),
161            ]),
162            path(vec![p(15.0, 9.0), p(16.2, 12.0), p(15.0, 15.0)]),
163            path(vec![p(18.0, 6.0), p(20.5, 12.0), p(18.0, 18.0)]),
164        ],
165        Icon::Film => vec![
166            rounded(p(2.5, 4.5), p(21.5, 19.5), 2.0),
167            closed(vec![p(10.0, 8.5), p(16.0, 12.0), p(10.0, 15.5)]),
168        ],
169        Icon::Pin => vec![
170            circle(p(12.0, 8.5), 5.0),
171            dot(p(12.0, 8.5), 1.6),
172            path(vec![p(8.5, 12.5), p(12.0, 21.5), p(15.5, 12.5)]),
173        ],
174    }
175}
176
177/// Paint `icon` into `rect`.
178pub fn paint(painter: &egui::Painter, icon: Icon, rect: Rect, colour: Color32) {
179    painter.extend(shapes(icon, rect, colour));
180}
181
182/// Allocate a square of `side` points and paint `icon` in it.
183pub fn show(ui: &mut egui::Ui, icon: Icon, side: f32, colour: Color32) -> egui::Response {
184    let (rect, response) = ui.allocate_exact_size(vec2(side, side), egui::Sense::hover());
185    paint(ui.painter(), icon, rect, colour);
186    response
187}
188
189/// A square at `center` of `side` points.
190pub fn square(center: Pos2, side: f32) -> Rect {
191    Rect::from_center_size(center, vec2(side, side))
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use eframe::egui::pos2;
198
199    #[test]
200    fn every_icon_stays_inside_its_box() {
201        let rect = square(pos2(40.0, 40.0), 24.0);
202        for icon in Icon::ALL {
203            let shapes = shapes(icon, rect, Color32::WHITE);
204            assert!(!shapes.is_empty(), "{icon:?} draws something");
205            for shape in shapes {
206                let bounds = shape.visual_bounding_rect();
207                assert!(
208                    rect.expand(1.0).contains_rect(bounds),
209                    "{icon:?} spills out: {bounds:?} outside {rect:?}"
210                );
211            }
212        }
213    }
214
215    #[test]
216    fn every_job_kind_has_its_own_glyph() {
217        use std::collections::HashSet;
218        let glyphs: HashSet<_> = TaskKind::ALL.iter().map(|k| Icon::of_kind(*k)).collect();
219        assert_eq!(glyphs.len(), TaskKind::ALL.len());
220    }
221
222    #[test]
223    fn icons_paint_without_panicking() {
224        egui::__run_test_ui(|ui| {
225            for icon in Icon::ALL {
226                show(ui, icon, 20.0, Color32::GRAY);
227            }
228        });
229    }
230}