1use 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 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
52pub 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
177pub fn paint(painter: &egui::Painter, icon: Icon, rect: Rect, colour: Color32) {
179 painter.extend(shapes(icon, rect, colour));
180}
181
182pub 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
189pub 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}