A ui toolkit for building gpui apps
rust gpui
at main 465 lines 13 kB view raw
1//! A trait-based theme system for gpuikit 2//! 3//! The `Themeable` trait defines the color contract that gpuikit components use. 4//! Consumers can implement this trait for their own theme types. 5 6use gpui::{hsla, App, Global, Hsla, SharedString}; 7use std::sync::Arc; 8 9/// Core theme trait that defines the color contract for UI components. 10/// 11/// Implement this trait for your own theme type to customize colors. 12/// Only a small set of "primitive" colors are required - everything else 13/// has sensible defaults derived from them. 14pub trait Themeable { 15 // === Required methods (the primitives) === 16 17 /// Primary foreground/text color 18 fn fg(&self) -> Hsla; 19 20 /// Primary background color 21 fn bg(&self) -> Hsla; 22 23 /// Surface color for cards, panels, elevated elements 24 fn surface(&self) -> Hsla; 25 26 /// Border color for dividers and boundaries 27 fn border(&self) -> Hsla; 28 29 /// Accent color for primary actions and focus states 30 fn accent(&self) -> Hsla; 31 32 // === Optional methods with defaults === 33 34 /// Muted foreground for secondary text 35 fn fg_muted(&self) -> Hsla { 36 self.fg().opacity(0.7) 37 } 38 39 /// Disabled foreground color 40 fn fg_disabled(&self) -> Hsla { 41 self.fg().opacity(0.4) 42 } 43 44 /// Secondary surface for nested panels 45 fn surface_secondary(&self) -> Hsla { 46 self.surface() 47 } 48 49 /// Tertiary surface for deeply nested elements 50 fn surface_tertiary(&self) -> Hsla { 51 self.surface_secondary() 52 } 53 54 /// Secondary border for hover states 55 fn border_secondary(&self) -> Hsla { 56 self.border() 57 } 58 59 /// Subtle border for minimal separation 60 fn border_subtle(&self) -> Hsla { 61 self.border().opacity(0.5) 62 } 63 64 /// Focus outline color 65 fn outline(&self) -> Hsla { 66 self.accent() 67 } 68 69 /// Accent background (for tags, badges) 70 fn accent_bg(&self) -> Hsla { 71 self.accent().opacity(0.15) 72 } 73 74 /// Accent background hover state 75 fn accent_bg_hover(&self) -> Hsla { 76 self.accent().opacity(0.25) 77 } 78 79 /// Danger/error color 80 fn danger(&self) -> Hsla { 81 hsla(0.0, 0.7, 0.5, 1.0) 82 } 83 84 /// Selection highlight color 85 fn selection(&self) -> Hsla { 86 self.accent().opacity(0.3) 87 } 88 89 // === Component-specific defaults === 90 91 fn button_bg(&self) -> Hsla { 92 self.surface() 93 } 94 95 fn button_bg_hover(&self) -> Hsla { 96 self.surface_secondary() 97 } 98 99 fn button_bg_active(&self) -> Hsla { 100 self.surface_tertiary() 101 } 102 103 fn button_border(&self) -> Hsla { 104 self.border() 105 } 106 107 fn input_bg(&self) -> Hsla { 108 self.surface() 109 } 110 111 fn input_border(&self) -> Hsla { 112 self.border() 113 } 114 115 fn input_border_hover(&self) -> Hsla { 116 self.border_secondary() 117 } 118 119 fn input_border_focused(&self) -> Hsla { 120 self.accent() 121 } 122} 123 124pub fn init(cx: &mut App) { 125 cx.set_global(GlobalTheme::default()); 126} 127 128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 129pub enum ThemeVariant { 130 #[default] 131 Dark, 132 Light, 133} 134 135/// A concrete theme implementation with stored color values. 136#[derive(Debug, Clone)] 137pub struct Theme { 138 pub name: SharedString, 139 pub variant: ThemeVariant, 140 141 // Primitives 142 fg_color: Hsla, 143 bg_color: Hsla, 144 surface_color: Hsla, 145 border_color: Hsla, 146 accent_color: Hsla, 147 148 // Overrides (None = use default from trait) 149 fg_muted_color: Option<Hsla>, 150 fg_disabled_color: Option<Hsla>, 151 surface_secondary_color: Option<Hsla>, 152 surface_tertiary_color: Option<Hsla>, 153 border_secondary_color: Option<Hsla>, 154 border_subtle_color: Option<Hsla>, 155 outline_color: Option<Hsla>, 156 accent_bg_color: Option<Hsla>, 157 accent_bg_hover_color: Option<Hsla>, 158 danger_color: Option<Hsla>, 159 selection_color: Option<Hsla>, 160 button_bg_color: Option<Hsla>, 161 button_bg_hover_color: Option<Hsla>, 162 button_bg_active_color: Option<Hsla>, 163 button_border_color: Option<Hsla>, 164 input_bg_color: Option<Hsla>, 165 input_border_color: Option<Hsla>, 166 input_border_hover_color: Option<Hsla>, 167 input_border_focused_color: Option<Hsla>, 168} 169 170impl Themeable for Theme { 171 fn fg(&self) -> Hsla { 172 self.fg_color 173 } 174 fn bg(&self) -> Hsla { 175 self.bg_color 176 } 177 fn surface(&self) -> Hsla { 178 self.surface_color 179 } 180 fn border(&self) -> Hsla { 181 self.border_color 182 } 183 fn accent(&self) -> Hsla { 184 self.accent_color 185 } 186 187 fn fg_muted(&self) -> Hsla { 188 self.fg_muted_color 189 .unwrap_or_else(|| self.fg().opacity(0.7)) 190 } 191 fn fg_disabled(&self) -> Hsla { 192 self.fg_disabled_color 193 .unwrap_or_else(|| self.fg().opacity(0.4)) 194 } 195 fn surface_secondary(&self) -> Hsla { 196 self.surface_secondary_color 197 .unwrap_or_else(|| self.surface()) 198 } 199 fn surface_tertiary(&self) -> Hsla { 200 self.surface_tertiary_color 201 .unwrap_or_else(|| self.surface_secondary()) 202 } 203 fn border_secondary(&self) -> Hsla { 204 self.border_secondary_color.unwrap_or_else(|| self.border()) 205 } 206 fn border_subtle(&self) -> Hsla { 207 self.border_subtle_color 208 .unwrap_or_else(|| self.border().opacity(0.5)) 209 } 210 fn outline(&self) -> Hsla { 211 self.outline_color.unwrap_or_else(|| self.accent()) 212 } 213 fn accent_bg(&self) -> Hsla { 214 self.accent_bg_color 215 .unwrap_or_else(|| self.accent().opacity(0.15)) 216 } 217 fn accent_bg_hover(&self) -> Hsla { 218 self.accent_bg_hover_color 219 .unwrap_or_else(|| self.accent().opacity(0.25)) 220 } 221 fn danger(&self) -> Hsla { 222 self.danger_color 223 .unwrap_or_else(|| hsla(0.0, 0.7, 0.5, 1.0)) 224 } 225 fn selection(&self) -> Hsla { 226 self.selection_color 227 .unwrap_or_else(|| self.accent().opacity(0.3)) 228 } 229 fn button_bg(&self) -> Hsla { 230 self.button_bg_color.unwrap_or_else(|| self.surface()) 231 } 232 fn button_bg_hover(&self) -> Hsla { 233 self.button_bg_hover_color 234 .unwrap_or_else(|| self.surface_secondary()) 235 } 236 fn button_bg_active(&self) -> Hsla { 237 self.button_bg_active_color 238 .unwrap_or_else(|| self.surface_tertiary()) 239 } 240 fn button_border(&self) -> Hsla { 241 self.button_border_color.unwrap_or_else(|| self.border()) 242 } 243 fn input_bg(&self) -> Hsla { 244 self.input_bg_color.unwrap_or_else(|| self.surface()) 245 } 246 fn input_border(&self) -> Hsla { 247 self.input_border_color.unwrap_or_else(|| self.border()) 248 } 249 fn input_border_hover(&self) -> Hsla { 250 self.input_border_hover_color 251 .unwrap_or_else(|| self.border_secondary()) 252 } 253 fn input_border_focused(&self) -> Hsla { 254 self.input_border_focused_color 255 .unwrap_or_else(|| self.accent()) 256 } 257} 258 259impl Theme { 260 /// Create a new theme with just the required primitives. 261 /// All other colors will use sensible defaults. 262 pub fn new( 263 name: impl Into<SharedString>, 264 variant: ThemeVariant, 265 fg: Hsla, 266 bg: Hsla, 267 surface: Hsla, 268 border: Hsla, 269 accent: Hsla, 270 ) -> Self { 271 Theme { 272 name: name.into(), 273 variant, 274 fg_color: fg, 275 bg_color: bg, 276 surface_color: surface, 277 border_color: border, 278 accent_color: accent, 279 fg_muted_color: None, 280 fg_disabled_color: None, 281 surface_secondary_color: None, 282 surface_tertiary_color: None, 283 border_secondary_color: None, 284 border_subtle_color: None, 285 outline_color: None, 286 accent_bg_color: None, 287 accent_bg_hover_color: None, 288 danger_color: None, 289 selection_color: None, 290 button_bg_color: None, 291 button_bg_hover_color: None, 292 button_bg_active_color: None, 293 button_border_color: None, 294 input_bg_color: None, 295 input_border_color: None, 296 input_border_hover_color: None, 297 input_border_focused_color: None, 298 } 299 } 300 301 /// Create a Gruvbox Dark theme 302 pub fn gruvbox_dark() -> Self { 303 let mut theme = Theme::new( 304 "Gruvbox Dark", 305 ThemeVariant::Dark, 306 parse_hex("#ebdbb2"), // fg 307 parse_hex("#282828"), // bg 308 parse_hex("#3c3836"), // surface 309 parse_hex("#504945"), // border 310 parse_hex("#8ec07c"), // accent 311 ); 312 theme.fg_muted_color = Some(parse_hex("#a89984")); 313 theme.fg_disabled_color = Some(parse_hex("#7c6f64")); 314 theme.surface_secondary_color = Some(parse_hex("#504945")); 315 theme.surface_tertiary_color = Some(parse_hex("#665c54")); 316 theme.border_secondary_color = Some(parse_hex("#7c6f64")); 317 theme.border_subtle_color = Some(parse_hex("#3c3836")); 318 theme.outline_color = Some(parse_hex("#458588")); 319 theme.danger_color = Some(parse_hex("#fb4934")); 320 theme.selection_color = Some(hsla(55.0 / 360.0, 0.56, 0.64, 0.25)); 321 theme.button_bg_color = Some(parse_hex("#504945")); 322 theme.button_bg_hover_color = Some(parse_hex("#665c54")); 323 theme.button_bg_active_color = Some(parse_hex("#7c6f64")); 324 theme.button_border_color = Some(parse_hex("#7c6f64")); 325 theme.input_border_hover_color = Some(parse_hex("#665c54")); 326 theme 327 } 328 329 /// Create a Gruvbox Light theme 330 pub fn gruvbox_light() -> Self { 331 let mut theme = Theme::new( 332 "Gruvbox Light", 333 ThemeVariant::Light, 334 parse_hex("#3c3836"), // fg 335 parse_hex("#fbf1c7"), // bg 336 parse_hex("#ebdbb2"), // surface 337 parse_hex("#d5c4a1"), // border 338 parse_hex("#427b58"), // accent 339 ); 340 theme.fg_muted_color = Some(parse_hex("#665c54")); 341 theme.fg_disabled_color = Some(parse_hex("#a89984")); 342 theme.surface_secondary_color = Some(parse_hex("#d5c4a1")); 343 theme.surface_tertiary_color = Some(parse_hex("#bdae93")); 344 theme.border_secondary_color = Some(parse_hex("#a89984")); 345 theme.border_subtle_color = Some(parse_hex("#ebdbb2")); 346 theme.outline_color = Some(parse_hex("#076678")); 347 theme.danger_color = Some(parse_hex("#cc241d")); 348 theme.selection_color = Some(hsla(48.0 / 360.0, 0.87, 0.61, 0.15)); 349 theme.button_bg_color = Some(parse_hex("#ebdbb2")); 350 theme.button_bg_hover_color = Some(parse_hex("#d5c4a1")); 351 theme.button_bg_active_color = Some(parse_hex("#bdae93")); 352 theme.button_border_color = Some(parse_hex("#a89984")); 353 theme.input_border_hover_color = Some(parse_hex("#bdae93")); 354 theme 355 } 356 357 pub fn get_global(cx: &App) -> &Arc<Theme> { 358 &cx.global::<GlobalTheme>().0 359 } 360} 361 362impl Default for Theme { 363 fn default() -> Self { 364 Theme::gruvbox_dark() 365 } 366} 367 368#[derive(Clone, Debug)] 369pub struct GlobalTheme(pub Arc<Theme>); 370 371impl Global for GlobalTheme {} 372 373impl Default for GlobalTheme { 374 fn default() -> Self { 375 GlobalTheme(Arc::new(Theme::default())) 376 } 377} 378 379/// Trait for accessing the current theme from an App context 380pub trait ActiveTheme { 381 fn theme(&self) -> &Arc<Theme>; 382} 383 384impl ActiveTheme for App { 385 fn theme(&self) -> &Arc<Theme> { 386 &self.global::<GlobalTheme>().0 387 } 388} 389 390fn parse_hex(hex: &str) -> Hsla { 391 let hex = hex.trim_start_matches('#'); 392 393 let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); 394 let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); 395 let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); 396 397 let r = r as f32 / 255.0; 398 let g = g as f32 / 255.0; 399 let b = b as f32 / 255.0; 400 401 let max = r.max(g).max(b); 402 let min = r.min(g).min(b); 403 let delta = max - min; 404 405 let lightness = (max + min) / 2.0; 406 407 let saturation = if delta == 0.0 { 408 0.0 409 } else { 410 delta / (1.0 - (2.0 * lightness - 1.0).abs()) 411 }; 412 413 let hue = if delta == 0.0 { 414 0.0 415 } else if max == r { 416 60.0 * (((g - b) / delta) % 6.0) 417 } else if max == g { 418 60.0 * ((b - r) / delta + 2.0) 419 } else { 420 60.0 * ((r - g) / delta + 4.0) 421 }; 422 423 let hue = if hue < 0.0 { hue + 360.0 } else { hue }; 424 425 gpui::hsla(hue / 360.0, saturation, lightness, 1.0) 426} 427 428#[cfg(test)] 429mod tests { 430 use super::*; 431 432 #[test] 433 fn test_default_theme() { 434 let theme = Theme::default(); 435 assert_eq!(theme.name, SharedString::from("Gruvbox Dark")); 436 assert_eq!(theme.variant, ThemeVariant::Dark); 437 } 438 439 #[test] 440 fn test_minimal_theme() { 441 // Create a theme with just primitives - all else uses defaults 442 let theme = Theme::new( 443 "Minimal", 444 ThemeVariant::Dark, 445 parse_hex("#ffffff"), 446 parse_hex("#000000"), 447 parse_hex("#111111"), 448 parse_hex("#333333"), 449 parse_hex("#0066cc"), 450 ); 451 452 // Derived values should work 453 assert_eq!(theme.button_bg(), theme.surface()); 454 assert_eq!(theme.input_border_focused(), theme.accent()); 455 } 456 457 #[test] 458 fn test_hex_parsing() { 459 let color = parse_hex("#ffffff"); 460 assert_eq!(color.l, 1.0); 461 462 let color = parse_hex("#000000"); 463 assert_eq!(color.l, 0.0); 464 } 465}