1use eframe::egui::{self, Color32, Rounding, Stroke, Style, Visuals};
9
10pub mod spacing {
15 pub const XS: f32 = 4.0;
17
18 pub const SM: f32 = 8.0;
20
21 pub const MD: f32 = 12.0;
23
24 pub const LG: f32 = 16.0;
26
27 pub const XL: f32 = 24.0;
29
30 pub const XXL: f32 = 32.0;
32}
33
34pub mod rounding {
36 pub const CARD: f32 = 8.0;
38
39 pub const BUTTON: f32 = 4.0;
41
42 pub const SMALL: f32 = 2.0;
44
45 pub const NONE: f32 = 0.0;
47}
48
49pub mod shadow {
54 use super::Color32;
55 use eframe::egui::Shadow;
56
57 const SHADOW_WARM: Color32 = Color32::from_rgba_premultiplied(40, 30, 20, 255);
61
62 pub fn subtle() -> Shadow {
64 Shadow {
65 offset: [0.0, 1.0].into(),
66 blur: 3.0,
67 spread: 0.0,
68 color: Color32::from_rgba_premultiplied(
70 SHADOW_WARM.r(),
71 SHADOW_WARM.g(),
72 SHADOW_WARM.b(),
73 12,
74 ),
75 }
76 }
77
78 pub fn medium() -> Shadow {
80 Shadow {
81 offset: [0.0, 2.0].into(),
82 blur: 8.0,
83 spread: 0.0,
84 color: Color32::from_rgba_premultiplied(
86 SHADOW_WARM.r(),
87 SHADOW_WARM.g(),
88 SHADOW_WARM.b(),
89 18,
90 ),
91 }
92 }
93
94 pub fn elevated() -> Shadow {
96 Shadow {
97 offset: [0.0, 4.0].into(),
98 blur: 16.0,
99 spread: 0.0,
100 color: Color32::from_rgba_premultiplied(
102 SHADOW_WARM.r(),
103 SHADOW_WARM.g(),
104 SHADOW_WARM.b(),
105 24,
106 ),
107 }
108 }
109
110 #[cfg(test)]
112 pub fn shadow_warm_color() -> Color32 {
113 SHADOW_WARM
114 }
115}
116
117pub mod colors {
122 use super::Color32;
123
124 pub const BACKGROUND: Color32 = Color32::from_rgb(250, 249, 247);
131
132 pub const SURFACE: Color32 = Color32::from_rgb(255, 255, 255);
134
135 pub const SURFACE_ELEVATED: Color32 = Color32::from_rgb(255, 255, 255);
137
138 pub const SURFACE_HOVER: Color32 = Color32::from_rgb(245, 243, 239);
140
141 pub const SURFACE_SELECTED: Color32 = Color32::from_rgb(238, 235, 229);
143
144 pub const TEXT_PRIMARY: Color32 = Color32::from_rgb(28, 28, 30);
151
152 pub const TEXT_SECONDARY: Color32 = Color32::from_rgb(99, 99, 102);
154
155 pub const TEXT_MUTED: Color32 = Color32::from_rgb(142, 142, 147);
157
158 pub const TEXT_DISABLED: Color32 = Color32::from_rgb(174, 174, 178);
160
161 pub const BORDER: Color32 = Color32::from_rgb(232, 229, 222);
167
168 pub const BORDER_FOCUSED: Color32 = Color32::from_rgb(205, 200, 190);
170
171 pub const SEPARATOR: Color32 = Color32::from_rgb(232, 229, 222);
173
174 pub const ACCENT: Color32 = Color32::from_rgb(0, 122, 255);
180
181 pub const ACCENT_HOVER: Color32 = Color32::from_rgb(0, 111, 230);
183
184 pub const ACCENT_ACTIVE: Color32 = Color32::from_rgb(0, 100, 210);
186
187 pub const ACCENT_SUBTLE: Color32 = Color32::from_rgb(230, 244, 255);
189
190 pub const STATUS_RUNNING: Color32 = Color32::from_rgb(0, 149, 255);
196
197 pub const STATUS_SUCCESS: Color32 = Color32::from_rgb(52, 199, 89);
199
200 pub const STATUS_WARNING: Color32 = Color32::from_rgb(255, 149, 0);
202
203 pub const STATUS_ERROR: Color32 = Color32::from_rgb(255, 59, 48);
205
206 pub const STATUS_IDLE: Color32 = Color32::from_rgb(142, 142, 147);
208
209 pub const STATUS_CORRECTING: Color32 = Color32::from_rgb(255, 94, 58);
211
212 pub const STATUS_RUNNING_BG: Color32 = Color32::from_rgb(230, 244, 255);
218
219 pub const STATUS_SUCCESS_BG: Color32 = Color32::from_rgb(232, 250, 238);
221
222 pub const STATUS_WARNING_BG: Color32 = Color32::from_rgb(255, 244, 230);
224
225 pub const STATUS_ERROR_BG: Color32 = Color32::from_rgb(255, 235, 234);
227
228 pub const STATUS_IDLE_BG: Color32 = Color32::from_rgb(245, 243, 239);
230
231 pub const STATUS_CORRECTING_BG: Color32 = Color32::from_rgb(255, 237, 230);
233}
234
235pub fn configure_visuals() -> Visuals {
243 let mut visuals = Visuals::light();
244
245 visuals.window_fill = colors::SURFACE;
247 visuals.panel_fill = colors::BACKGROUND;
248 visuals.faint_bg_color = colors::SURFACE_HOVER;
249 visuals.extreme_bg_color = colors::SURFACE;
250 visuals.code_bg_color = Color32::from_rgb(248, 246, 242);
251
252 visuals.selection.bg_fill = colors::ACCENT_SUBTLE;
254 visuals.selection.stroke = Stroke::new(1.0, colors::ACCENT);
255
256 visuals.hyperlink_color = colors::ACCENT;
258
259 visuals.window_shadow = shadow::elevated();
261 visuals.popup_shadow = shadow::medium();
262
263 visuals.window_stroke = Stroke::new(1.0, colors::BORDER);
265
266 visuals.window_rounding = Rounding::same(rounding::CARD);
268 visuals.menu_rounding = Rounding::same(rounding::BUTTON);
269
270 visuals.text_cursor.stroke = Stroke::new(2.0, colors::ACCENT);
272
273 configure_widget_visuals(&mut visuals);
275
276 visuals
277}
278
279fn configure_widget_visuals(visuals: &mut Visuals) {
281 visuals.widgets.noninteractive.bg_fill = colors::SURFACE;
283 visuals.widgets.noninteractive.weak_bg_fill = colors::SURFACE_HOVER;
284 visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, colors::BORDER);
285 visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, colors::TEXT_PRIMARY);
286 visuals.widgets.noninteractive.rounding = Rounding::same(rounding::BUTTON);
287
288 visuals.widgets.inactive.bg_fill = colors::SURFACE;
290 visuals.widgets.inactive.weak_bg_fill = colors::SURFACE;
291 visuals.widgets.inactive.bg_stroke = Stroke::new(1.0, colors::BORDER);
292 visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, colors::TEXT_PRIMARY);
293 visuals.widgets.inactive.rounding = Rounding::same(rounding::BUTTON);
294
295 visuals.widgets.hovered.bg_fill = colors::SURFACE_HOVER;
297 visuals.widgets.hovered.weak_bg_fill = colors::SURFACE_HOVER;
298 visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, colors::BORDER_FOCUSED);
299 visuals.widgets.hovered.fg_stroke = Stroke::new(1.5, colors::TEXT_PRIMARY);
300 visuals.widgets.hovered.rounding = Rounding::same(rounding::BUTTON);
301
302 visuals.widgets.active.bg_fill = colors::SURFACE_SELECTED;
304 visuals.widgets.active.weak_bg_fill = colors::SURFACE_SELECTED;
305 visuals.widgets.active.bg_stroke = Stroke::new(1.0, colors::ACCENT);
306 visuals.widgets.active.fg_stroke = Stroke::new(2.0, colors::TEXT_PRIMARY);
307 visuals.widgets.active.rounding = Rounding::same(rounding::BUTTON);
308
309 visuals.widgets.open.bg_fill = colors::SURFACE;
311 visuals.widgets.open.weak_bg_fill = colors::SURFACE_HOVER;
312 visuals.widgets.open.bg_stroke = Stroke::new(1.0, colors::ACCENT);
313 visuals.widgets.open.fg_stroke = Stroke::new(1.0, colors::TEXT_PRIMARY);
314 visuals.widgets.open.rounding = Rounding::same(rounding::BUTTON);
315}
316
317pub fn configure_style() -> Style {
319 let default_style = Style::default();
321 let mut style_spacing = default_style.spacing.clone();
322
323 style_spacing.item_spacing = egui::vec2(spacing::SM, spacing::XS);
325 style_spacing.window_margin = egui::Margin::same(spacing::LG);
326 style_spacing.button_padding = egui::vec2(spacing::MD, 6.0);
327 style_spacing.menu_margin = egui::Margin::same(spacing::SM);
328 style_spacing.indent = spacing::LG;
329 style_spacing.scroll = egui::style::ScrollStyle {
330 floating: true,
331 bar_width: spacing::SM,
332 floating_allocated_width: 0.0,
334 bar_inner_margin: spacing::XS,
335 bar_outer_margin: spacing::XS,
336 ..Default::default()
337 };
338 style_spacing.combo_width = 100.0;
340
341 let mut interaction = default_style.interaction.clone();
343 interaction.selectable_labels = true;
344 interaction.multi_widget_text_select = true;
345
346 Style {
347 visuals: configure_visuals(),
348 spacing: style_spacing,
349 interaction,
350 animation_time: 0.1,
356 ..Default::default()
357 }
358}
359
360pub fn init(ctx: &egui::Context) {
365 ctx.set_style(configure_style());
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn test_spacing_scale() {
374 assert!(spacing::XS < spacing::SM);
376 assert!(spacing::SM < spacing::MD);
377 assert!(spacing::MD < spacing::LG);
378 assert!(spacing::LG < spacing::XL);
379 assert!(spacing::XL < spacing::XXL);
380 }
381
382 #[test]
383 fn test_shadows() {
384 let subtle = shadow::subtle();
385 let medium = shadow::medium();
386 let elevated = shadow::elevated();
387
388 assert!(subtle.color.a() <= 15);
390 assert!(medium.color.a() <= 20);
391 assert!(elevated.color.a() <= 30);
392
393 assert!(subtle.blur < medium.blur);
395 assert!(medium.blur < elevated.blur);
396
397 let warm = shadow::shadow_warm_color();
399 assert!(warm.r() > warm.g() && warm.g() > warm.b());
400 }
401
402 #[test]
403 fn test_text_contrast() {
404 let text_lum = colors::TEXT_PRIMARY.r() as u32
406 + colors::TEXT_PRIMARY.g() as u32
407 + colors::TEXT_PRIMARY.b() as u32;
408 let bg_lum = colors::BACKGROUND.r() as u32
409 + colors::BACKGROUND.g() as u32
410 + colors::BACKGROUND.b() as u32;
411 let surface_lum =
412 colors::SURFACE.r() as u32 + colors::SURFACE.g() as u32 + colors::SURFACE.b() as u32;
413
414 assert!(
415 bg_lum - text_lum > 400,
416 "Need contrast > 400 against background"
417 );
418 assert!(
419 surface_lum - text_lum > 500,
420 "Need contrast > 500 against surface"
421 );
422 }
423
424 #[test]
425 fn test_configure_visuals() {
426 let visuals = configure_visuals();
427
428 assert!(!visuals.dark_mode);
429 assert_eq!(visuals.window_fill, colors::SURFACE);
430 assert_eq!(visuals.panel_fill, colors::BACKGROUND);
431 assert_eq!(visuals.window_rounding, Rounding::same(rounding::CARD));
432 assert_eq!(visuals.widgets.hovered.bg_fill, colors::SURFACE_HOVER);
433 assert_eq!(visuals.widgets.active.bg_fill, colors::SURFACE_SELECTED);
434 assert_eq!(visuals.selection.bg_fill, colors::ACCENT_SUBTLE);
435 }
436
437 #[test]
438 fn test_configure_style() {
439 let style = configure_style();
440
441 assert!(style.animation_time > 0.0 && style.animation_time <= 0.2);
442 assert!(style.spacing.scroll.floating);
443 assert_eq!(style.visuals.window_fill, colors::SURFACE);
444 }
445
446 #[test]
447 fn test_warm_color_palette() {
448 let warm_colors = [
450 ("BACKGROUND", colors::BACKGROUND),
451 ("SURFACE_HOVER", colors::SURFACE_HOVER),
452 ("SURFACE_SELECTED", colors::SURFACE_SELECTED),
453 ("BORDER", colors::BORDER),
454 ("SEPARATOR", colors::SEPARATOR),
455 ];
456
457 for (name, color) in warm_colors {
458 assert!(
459 color.r() >= color.g() && color.g() >= color.b(),
460 "{} should have warm tones (R >= G >= B), got RGB({}, {}, {})",
461 name,
462 color.r(),
463 color.g(),
464 color.b()
465 );
466 }
467
468 assert_eq!(colors::BACKGROUND, Color32::from_rgb(250, 249, 247));
470 }
471
472 #[test]
473 fn test_status_colors_distinct() {
474 let status_colors = [
476 colors::STATUS_RUNNING,
477 colors::STATUS_SUCCESS,
478 colors::STATUS_WARNING,
479 colors::STATUS_ERROR,
480 colors::STATUS_IDLE,
481 ];
482
483 for i in 0..status_colors.len() {
484 for j in (i + 1)..status_colors.len() {
485 assert_ne!(status_colors[i], status_colors[j]);
486 }
487 }
488 }
489}