Skip to main content

autom8/ui/gui/
theme.rs

1//! Theme and color system for the GUI.
2//!
3//! This module provides a cohesive color system and egui Visuals configuration
4//! that achieves a warm, approachable aesthetic inspired by the Claude desktop
5//! application. Uses a warm beige/cream palette with proper semantic colors
6//! for status states and UI elements.
7
8use eframe::egui::{self, Color32, Rounding, Stroke, Style, Visuals};
9
10/// Spacing scale for consistent layout throughout the application.
11///
12/// Use these constants instead of arbitrary pixel values to ensure
13/// visual consistency and a cohesive rhythm across all UI elements.
14pub mod spacing {
15    /// Extra small spacing (4px) - tight spacing between related elements.
16    pub const XS: f32 = 4.0;
17
18    /// Small spacing (8px) - standard spacing between related elements.
19    pub const SM: f32 = 8.0;
20
21    /// Medium spacing (12px) - spacing between sections within a component.
22    pub const MD: f32 = 12.0;
23
24    /// Standard spacing (16px) - standard component padding, gaps between cards.
25    pub const LG: f32 = 16.0;
26
27    /// Large spacing (24px) - spacing between major sections.
28    pub const XL: f32 = 24.0;
29
30    /// Extra large spacing (32px) - large gaps, page-level margins.
31    pub const XXL: f32 = 32.0;
32}
33
34/// Corner rounding values for consistent UI elements.
35pub mod rounding {
36    /// Rounding for cards and panels (8px).
37    pub const CARD: f32 = 8.0;
38
39    /// Rounding for buttons and inputs (4px).
40    pub const BUTTON: f32 = 4.0;
41
42    /// Rounding for small elements like badges (2px).
43    pub const SMALL: f32 = 2.0;
44
45    /// No rounding (0px).
46    pub const NONE: f32 = 0.0;
47}
48
49/// Shadow depths for elevated surfaces.
50///
51/// Shadows use warm-tinted colors (slight brown/amber tint) to complement
52/// the warm cream color palette, avoiding harsh pure-black shadows.
53pub mod shadow {
54    use super::Color32;
55    use eframe::egui::Shadow;
56
57    /// Warm shadow base color - a very subtle warm brown.
58    /// Uses RGB values that lean warm (R > G > B) even at low alpha.
59    /// This creates softer shadows that complement the warm palette.
60    const SHADOW_WARM: Color32 = Color32::from_rgba_premultiplied(40, 30, 20, 255);
61
62    /// Subtle shadow for slightly elevated elements.
63    pub fn subtle() -> Shadow {
64        Shadow {
65            offset: [0.0, 1.0].into(),
66            blur: 3.0,
67            spread: 0.0,
68            // Warm brown shadow at low opacity
69            color: Color32::from_rgba_premultiplied(
70                SHADOW_WARM.r(),
71                SHADOW_WARM.g(),
72                SHADOW_WARM.b(),
73                12,
74            ),
75        }
76    }
77
78    /// Medium shadow for cards and panels.
79    pub fn medium() -> Shadow {
80        Shadow {
81            offset: [0.0, 2.0].into(),
82            blur: 8.0,
83            spread: 0.0,
84            // Warm brown shadow at medium opacity
85            color: Color32::from_rgba_premultiplied(
86                SHADOW_WARM.r(),
87                SHADOW_WARM.g(),
88                SHADOW_WARM.b(),
89                18,
90            ),
91        }
92    }
93
94    /// Elevated shadow for modals and popovers.
95    pub fn elevated() -> Shadow {
96        Shadow {
97            offset: [0.0, 4.0].into(),
98            blur: 16.0,
99            spread: 0.0,
100            // Warm brown shadow at higher opacity
101            color: Color32::from_rgba_premultiplied(
102                SHADOW_WARM.r(),
103                SHADOW_WARM.g(),
104                SHADOW_WARM.b(),
105                24,
106            ),
107        }
108    }
109
110    /// Returns the warm shadow base color for testing.
111    #[cfg(test)]
112    pub fn shadow_warm_color() -> Color32 {
113        SHADOW_WARM
114    }
115}
116
117/// Semantic color palette for the light theme.
118///
119/// Colors use a warm beige/cream palette inspired by the Claude desktop
120/// application, with careful attention to contrast ratios for accessibility.
121pub mod colors {
122    use super::Color32;
123
124    // ==========================================================================
125    // Background Colors
126    // ==========================================================================
127
128    /// Primary window background - warm cream.
129    /// Inspired by Claude's warm, approachable aesthetic (~#FAF9F7).
130    pub const BACKGROUND: Color32 = Color32::from_rgb(250, 249, 247);
131
132    /// Surface color for cards and panels - white.
133    pub const SURFACE: Color32 = Color32::from_rgb(255, 255, 255);
134
135    /// Elevated surface for modals and popovers.
136    pub const SURFACE_ELEVATED: Color32 = Color32::from_rgb(255, 255, 255);
137
138    /// Subtle background for hover states - warm beige tint.
139    pub const SURFACE_HOVER: Color32 = Color32::from_rgb(245, 243, 239);
140
141    /// Background for selected/active items - warm beige.
142    pub const SURFACE_SELECTED: Color32 = Color32::from_rgb(238, 235, 229);
143
144    // ==========================================================================
145    // Text Colors
146    // ==========================================================================
147
148    /// Primary text color - dark gray for good contrast.
149    /// WCAG AA compliant against BACKGROUND and SURFACE.
150    pub const TEXT_PRIMARY: Color32 = Color32::from_rgb(28, 28, 30);
151
152    /// Secondary text color - medium gray for less emphasis.
153    pub const TEXT_SECONDARY: Color32 = Color32::from_rgb(99, 99, 102);
154
155    /// Muted text color - light gray for tertiary information.
156    pub const TEXT_MUTED: Color32 = Color32::from_rgb(142, 142, 147);
157
158    /// Disabled text color.
159    pub const TEXT_DISABLED: Color32 = Color32::from_rgb(174, 174, 178);
160
161    // ==========================================================================
162    // Border and Separator Colors
163    // ==========================================================================
164
165    /// Subtle border color for cards and inputs - warm gray.
166    pub const BORDER: Color32 = Color32::from_rgb(232, 229, 222);
167
168    /// Stronger border for focused elements - warm gray.
169    pub const BORDER_FOCUSED: Color32 = Color32::from_rgb(205, 200, 190);
170
171    /// Separator line color - warm gray.
172    pub const SEPARATOR: Color32 = Color32::from_rgb(232, 229, 222);
173
174    // ==========================================================================
175    // Accent Colors
176    // ==========================================================================
177
178    /// Primary accent color - blue (similar to macOS system blue).
179    pub const ACCENT: Color32 = Color32::from_rgb(0, 122, 255);
180
181    /// Accent color for hover state.
182    pub const ACCENT_HOVER: Color32 = Color32::from_rgb(0, 111, 230);
183
184    /// Accent color for active/pressed state.
185    pub const ACCENT_ACTIVE: Color32 = Color32::from_rgb(0, 100, 210);
186
187    /// Light accent for backgrounds.
188    pub const ACCENT_SUBTLE: Color32 = Color32::from_rgb(230, 244, 255);
189
190    // ==========================================================================
191    // Status Colors
192    // ==========================================================================
193
194    /// Running state - blue/cyan (indicates active work).
195    pub const STATUS_RUNNING: Color32 = Color32::from_rgb(0, 149, 255);
196
197    /// Success state - green (indicates completion).
198    pub const STATUS_SUCCESS: Color32 = Color32::from_rgb(52, 199, 89);
199
200    /// Warning state - amber (indicates attention needed).
201    pub const STATUS_WARNING: Color32 = Color32::from_rgb(255, 149, 0);
202
203    /// Error state - red (indicates failure).
204    pub const STATUS_ERROR: Color32 = Color32::from_rgb(255, 59, 48);
205
206    /// Idle state - gray (indicates inactive).
207    pub const STATUS_IDLE: Color32 = Color32::from_rgb(142, 142, 147);
208
209    /// Correcting state - orange (attention needed, distinct from warning amber).
210    pub const STATUS_CORRECTING: Color32 = Color32::from_rgb(255, 94, 58);
211
212    // ==========================================================================
213    // Status Background Colors (for badges/highlights)
214    // ==========================================================================
215
216    /// Running state background - light blue.
217    pub const STATUS_RUNNING_BG: Color32 = Color32::from_rgb(230, 244, 255);
218
219    /// Success state background - light green.
220    pub const STATUS_SUCCESS_BG: Color32 = Color32::from_rgb(232, 250, 238);
221
222    /// Warning state background - light amber.
223    pub const STATUS_WARNING_BG: Color32 = Color32::from_rgb(255, 244, 230);
224
225    /// Error state background - light red.
226    pub const STATUS_ERROR_BG: Color32 = Color32::from_rgb(255, 235, 234);
227
228    /// Idle state background - warm light beige.
229    pub const STATUS_IDLE_BG: Color32 = Color32::from_rgb(245, 243, 239);
230
231    /// Correcting state background - light orange.
232    pub const STATUS_CORRECTING_BG: Color32 = Color32::from_rgb(255, 237, 230);
233}
234
235// Note: The Status enum is defined in the components module to avoid duplication.
236// Use `crate::ui::gui::components::Status` for status state to color mapping.
237
238/// Configure egui Visuals for the light theme.
239///
240/// This creates a custom Visuals configuration that matches the macOS
241/// aesthetic with proper colors, rounding, and shadows.
242pub fn configure_visuals() -> Visuals {
243    let mut visuals = Visuals::light();
244
245    // Window and panel backgrounds
246    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    // Selection colors
253    visuals.selection.bg_fill = colors::ACCENT_SUBTLE;
254    visuals.selection.stroke = Stroke::new(1.0, colors::ACCENT);
255
256    // Hyperlink color
257    visuals.hyperlink_color = colors::ACCENT;
258
259    // Window shadow (for popups/menus)
260    visuals.window_shadow = shadow::elevated();
261    visuals.popup_shadow = shadow::medium();
262
263    // Window stroke (border)
264    visuals.window_stroke = Stroke::new(1.0, colors::BORDER);
265
266    // Corner rounding
267    visuals.window_rounding = Rounding::same(rounding::CARD);
268    visuals.menu_rounding = Rounding::same(rounding::BUTTON);
269
270    // Text cursor
271    visuals.text_cursor.stroke = Stroke::new(2.0, colors::ACCENT);
272
273    // Widget visuals
274    configure_widget_visuals(&mut visuals);
275
276    visuals
277}
278
279/// Configure widget-specific visuals.
280fn configure_widget_visuals(visuals: &mut Visuals) {
281    // Noninteractive widgets (labels, etc.)
282    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    // Inactive widgets (not hovered, not clicked)
289    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    // Hovered widgets
296    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    // Active/pressed widgets
303    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    // Open widgets (combo boxes, menus when open)
310    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
317/// Configure the egui Style with additional settings.
318pub fn configure_style() -> Style {
319    // Get default style and modify spacing
320    let default_style = Style::default();
321    let mut style_spacing = default_style.spacing.clone();
322
323    // Use our spacing scale for consistency
324    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        // Smoother scroll animation
333        floating_allocated_width: 0.0,
334        bar_inner_margin: spacing::XS,
335        bar_outer_margin: spacing::XS,
336        ..Default::default()
337    };
338    // Ensure consistent spacing for combo boxes and menus
339    style_spacing.combo_width = 100.0;
340
341    // Modify interaction settings
342    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        // Enable smooth animations for hover/click state transitions.
351        // This affects widget state changes (hover, press, etc.) with a
352        // ~100ms ease-in animation for polished visual feedback.
353        // Note: egui doesn't support panel appearance/disappearance animations,
354        // but scroll areas and widget state changes will be smoothly animated.
355        animation_time: 0.1,
356        ..Default::default()
357    }
358}
359
360/// Initialize the theme for the application.
361///
362/// Call this during app initialization (in the CreationContext callback)
363/// to apply the custom theme globally.
364pub 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        // Verify spacing scale is monotonically increasing
375        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        // Verify shadow alpha values are appropriately subtle
389        assert!(subtle.color.a() <= 15);
390        assert!(medium.color.a() <= 20);
391        assert!(elevated.color.a() <= 30);
392
393        // Verify blur increases with elevation
394        assert!(subtle.blur < medium.blur);
395        assert!(medium.blur < elevated.blur);
396
397        // Verify warm tones
398        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        // Primary text should have good contrast against both background and surface
405        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        // All these colors should have warm tones (R >= G >= B)
449        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        // BACKGROUND should be the specific warm cream color
469        assert_eq!(colors::BACKGROUND, Color32::from_rgb(250, 249, 247));
470    }
471
472    #[test]
473    fn test_status_colors_distinct() {
474        // Status colors should be visually distinct
475        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}