1use gpui::{Hsla, Rgba};
9
10fn rgb(r: u8, g: u8, b: u8) -> Hsla {
21 Rgba {
22 r: r as f32 / 255.0,
23 g: g as f32 / 255.0,
24 b: b as f32 / 255.0,
25 a: 1.0,
26 }
27 .into()
28}
29
30fn rgba(r: u8, g: u8, b: u8, a: f32) -> Hsla {
31 Rgba {
32 r: r as f32 / 255.0,
33 g: g as f32 / 255.0,
34 b: b as f32 / 255.0,
35 a,
36 }
37 .into()
38}
39
40fn lighten(base: Hsla, factor: f32) -> Hsla {
42 base.blend(gpui::white().opacity(factor))
43}
44
45#[allow(dead_code)]
46fn darken(base: Hsla, factor: f32) -> Hsla {
47 base.blend(gpui::black().opacity(factor))
48}
49
50#[derive(Clone)]
55pub struct ColorFamily {
56 pub base: Hsla,
57 pub hover: Hsla,
58 pub active: Hsla,
59 pub suppl: Hsla,
60 pub light_9: Hsla,
62 pub light_8: Hsla,
64 pub light_7: Hsla,
66}
67
68impl ColorFamily {
69 fn new(base: Hsla, hover: Hsla, active: Hsla, suppl: Hsla) -> Self {
70 Self {
71 base,
72 hover,
73 active,
74 suppl,
75 light_9: lighten(base, 0.9),
76 light_8: lighten(base, 0.8),
77 light_7: lighten(base, 0.7),
78 }
79 }
80
81 fn new_dark(base: Hsla, hover: Hsla, active: Hsla, suppl: Hsla) -> Self {
82 Self {
83 base,
84 hover,
85 active,
86 suppl,
87 light_9: base.opacity(0.16),
92 light_8: base.opacity(0.22),
93 light_7: base.opacity(0.30),
94 }
95 }
96}
97
98#[derive(Clone)]
103pub struct NeutralTokens {
104 pub body: Hsla,
105 pub card: Hsla,
106 pub modal: Hsla,
107 pub popover: Hsla,
108 pub inverted: Hsla,
109
110 pub text_1: Hsla,
111 pub text_2: Hsla,
112 pub text_3: Hsla,
113 pub text_disabled: Hsla,
114 pub placeholder: Hsla,
115 pub icon: Hsla,
116
117 pub border: Hsla,
118 pub divider: Hsla,
119
120 pub hover: Hsla,
121 pub pressed: Hsla,
122
123 pub rail: Hsla,
124
125 pub overlay: Hsla,
126 pub mask: Hsla,
127}
128
129#[derive(Clone)]
134pub struct Spacing {
135 pub xs: f32,
136 pub sm: f32,
137 pub md: f32,
138 pub lg: f32,
139 pub xl: f32,
140}
141
142#[derive(Clone)]
143pub struct Radius {
144 pub sm: f32,
145 pub md: f32,
146 pub lg: f32,
147 pub full: f32,
148}
149
150#[derive(Clone)]
151pub struct FontSize {
152 pub xs: f32,
153 pub sm: f32,
154 pub md: f32,
155 pub lg: f32,
156 pub xl: f32,
157}
158
159#[derive(Clone)]
164pub struct SecondaryColors {
165 pub bg: Hsla,
166 pub hover: Hsla,
167 pub pressed: Hsla,
168}
169
170#[derive(Clone)]
171pub struct Theme {
172 pub name: String,
173 pub spacing: Spacing,
174 pub radius: Radius,
175 pub font_size: FontSize,
176
177 pub primary: ColorFamily,
179 pub info: ColorFamily,
180 pub success: ColorFamily,
181 pub warning: ColorFamily,
182 pub danger: ColorFamily,
183
184 pub neutral: NeutralTokens,
186
187 pub secondary: SecondaryColors,
189
190 pub shadow_1: &'static str,
192 pub shadow_2: &'static str,
193 pub shadow_3: &'static str,
194}
195
196impl Default for Theme {
197 fn default() -> Self {
198 Self::light()
199 }
200}
201
202impl Theme {
203 pub fn light() -> Self {
207 Self {
208 name: "light".into(),
209 spacing: Spacing {
210 xs: 4.0,
211 sm: 8.0,
212 md: 12.0,
213 lg: 20.0,
214 xl: 32.0,
215 },
216 radius: Radius {
217 sm: 2.0,
218 md: 4.0,
219 lg: 8.0,
220 full: 9999.0,
221 },
222 font_size: FontSize {
223 xs: 10.0,
224 sm: 12.0,
225 md: 14.0,
226 lg: 16.0,
227 xl: 20.0,
228 },
229
230 primary: ColorFamily::new(
231 rgb(24, 160, 88), rgb(54, 173, 106), rgb(12, 122, 67), rgb(54, 173, 106), ),
236 info: ColorFamily::new(
237 rgb(32, 128, 240), rgb(64, 152, 252), rgb(16, 96, 201), rgb(64, 152, 252), ),
242 success: ColorFamily::new(
243 rgb(24, 160, 88), rgb(54, 173, 106), rgb(12, 122, 67), rgb(54, 173, 106), ),
248 warning: ColorFamily::new(
249 rgb(240, 160, 32), rgb(252, 176, 64), rgb(201, 124, 16), rgb(252, 176, 64), ),
254 danger: ColorFamily::new(
255 rgb(208, 48, 80), rgb(222, 87, 109), rgb(171, 31, 63), rgb(222, 87, 109), ),
260
261 neutral: NeutralTokens {
262 body: rgb(255, 255, 255),
263 card: rgb(255, 255, 255),
264 modal: rgb(255, 255, 255),
265 popover: rgb(255, 255, 255),
266 inverted: rgb(0, 20, 40),
267
268 text_1: rgb(31, 34, 37),
269 text_2: rgb(51, 54, 57),
270 text_3: rgb(118, 124, 130),
271 text_disabled: rgba(194, 194, 194, 1.0),
272 placeholder: rgba(194, 194, 194, 1.0),
273 icon: rgba(31, 34, 37, 1.0),
274
275 border: rgb(224, 224, 230),
276 divider: rgb(239, 239, 245),
277
278 hover: rgb(243, 243, 245),
279 pressed: rgb(237, 237, 239),
280
281 rail: rgb(219, 219, 223),
282
283 overlay: rgba(0, 0, 0, 0.50),
284 mask: rgba(255, 255, 255, 0.90),
285 },
286 secondary: SecondaryColors {
288 bg: rgba(46, 51, 56, 0.05),
289 hover: rgba(46, 51, 56, 0.09),
290 pressed: rgba(46, 51, 56, 0.13),
291 },
292
293 shadow_1: "0 1px 2px -2px rgba(0,0,0,.08), 0 3px 6px 0 rgba(0,0,0,.06), 0 5px 12px 4px rgba(0,0,0,.04)",
294 shadow_2: "0 3px 6px -4px rgba(0,0,0,.12), 0 6px 16px 0 rgba(0,0,0,.08), 0 9px 28px 8px rgba(0,0,0,.05)",
295 shadow_3: "0 6px 16px -9px rgba(0,0,0,.08), 0 9px 28px 0 rgba(0,0,0,.05), 0 12px 48px 16px rgba(0,0,0,.03)",
296 }
297 }
298
299 pub fn dark() -> Self {
303 Self {
304 name: "dark".into(),
305 spacing: Spacing {
306 xs: 4.0,
307 sm: 8.0,
308 md: 12.0,
309 lg: 20.0,
310 xl: 32.0,
311 },
312 radius: Radius {
313 sm: 2.0,
314 md: 4.0,
315 lg: 8.0,
316 full: 9999.0,
317 },
318 font_size: FontSize {
319 xs: 12.0,
320 sm: 14.0,
321 md: 14.0,
322 lg: 15.0,
323 xl: 16.0,
324 },
325
326 primary: ColorFamily::new_dark(
327 rgb(99, 226, 183), rgb(127, 231, 196), rgb(90, 206, 167), rgb(42, 148, 125), ),
332 info: ColorFamily::new_dark(
333 rgb(112, 192, 232), rgb(138, 203, 236), rgb(102, 175, 211), rgb(56, 137, 197), ),
338 success: ColorFamily::new_dark(
339 rgb(99, 226, 183), rgb(127, 231, 196), rgb(90, 206, 167), rgb(42, 148, 125), ),
344 warning: ColorFamily::new_dark(
345 rgb(242, 201, 125), rgb(245, 213, 153), rgb(230, 194, 96), rgb(240, 138, 0), ),
350 danger: ColorFamily::new_dark(
351 rgb(232, 128, 128), rgb(233, 139, 139), rgb(229, 114, 114), rgb(208, 58, 82), ),
356
357 neutral: NeutralTokens {
358 body: rgb(16, 16, 20), card: rgb(24, 24, 28), modal: rgb(44, 44, 50), popover: rgb(72, 72, 78), inverted: rgb(255, 255, 255),
363
364 text_1: rgba(255, 255, 255, 0.90),
365 text_2: rgba(255, 255, 255, 0.82),
366 text_3: rgba(255, 255, 255, 0.52),
367 text_disabled: rgba(255, 255, 255, 0.38),
368 placeholder: rgba(255, 255, 255, 0.38),
369 icon: rgba(255, 255, 255, 0.38),
370
371 border: rgba(255, 255, 255, 0.24),
372 divider: rgba(255, 255, 255, 0.09),
373
374 hover: rgba(255, 255, 255, 0.09),
375 pressed: rgba(255, 255, 255, 0.05),
376
377 rail: rgba(255, 255, 255, 0.20),
378
379 overlay: rgba(0, 0, 0, 0.60),
380 mask: rgba(0, 0, 0, 0.70),
381 },
382
383 shadow_1: "0 1px 2px -2px rgba(0,0,0,.24), 0 3px 6px 0 rgba(0,0,0,.18), 0 5px 12px 4px rgba(0,0,0,.12)",
384 shadow_2: "0 3px 6px -4px rgba(0,0,0,.24), 0 6px 12px 0 rgba(0,0,0,.16), 0 9px 18px 8px rgba(0,0,0,.10)",
385 shadow_3: "0 6px 16px -9px rgba(0,0,0,.08), 0 9px 28px 0 rgba(0,0,0,.05), 0 12px 48px 16px rgba(0,0,0,.03)",
386
387 secondary: SecondaryColors {
388 bg: rgba(255, 255, 255, 0.08),
389 hover: rgba(255, 255, 255, 0.12),
390 pressed: rgba(255, 255, 255, 0.16),
391 },
392 }
393 }
394
395 pub fn color_by_variant(
399 &self,
400 variant: ButtonVariant,
401 secondary: bool,
402 background: bool,
403 border: bool,
404 ) -> ButtonVariantColors {
405 if secondary {
406 return self.secondary_colors(variant, background, border);
407 }
408
409 match variant {
411 ButtonVariant::Default => ButtonVariantColors {
412 bg: rgba(0, 0, 0, 0.0),
413 hover_bg: self.secondary.hover,
414 active_bg: self.secondary.pressed,
415 text: self.neutral.text_2,
416 border: self.neutral.border,
417 text_hover: self.primary.base,
418 border_hover: self.primary.base,
419 },
420 ButtonVariant::Tertiary => ButtonVariantColors {
421 bg: self.secondary.bg,
422 hover_bg: self.secondary.hover,
423 active_bg: self.secondary.pressed,
424 text: self.neutral.text_2,
425 border: rgba(0, 0, 0, 0.0),
426 text_hover: self.neutral.text_1,
427 border_hover: rgba(0, 0, 0, 0.0),
428 },
429 ButtonVariant::Text => ButtonVariantColors {
430 bg: rgba(0, 0, 0, 0.0),
431 hover_bg: self.secondary.hover,
432 active_bg: self.secondary.pressed,
433 text: self.neutral.text_2,
434 border: rgba(0, 0, 0, 0.0),
435 text_hover: self.primary.base,
436 border_hover: rgba(0, 0, 0, 0.0),
437 },
438 ButtonVariant::Primary => self.filled_colors(&self.primary),
439 ButtonVariant::Info => self.filled_colors(&self.info),
440 ButtonVariant::Success => self.filled_colors(&self.success),
441 ButtonVariant::Warning => self.filled_colors(&self.warning),
442 ButtonVariant::Danger => self.filled_colors(&self.danger),
443 }
444 }
445
446 fn secondary_colors(
449 &self,
450 variant: ButtonVariant,
451 show_bg: bool,
452 show_border: bool,
453 ) -> ButtonVariantColors {
454 match variant {
455 ButtonVariant::Default => ButtonVariantColors {
456 bg: if show_bg {
457 self.secondary.bg
458 } else {
459 rgba(0, 0, 0, 0.0)
460 },
461 hover_bg: self.secondary.hover,
462 active_bg: self.secondary.pressed,
463 text: self.neutral.text_2,
464 border: if show_border {
465 self.neutral.border
466 } else {
467 rgba(0, 0, 0, 0.0)
468 },
469 text_hover: self.primary.base,
470 border_hover: self.primary.base,
471 },
472 ButtonVariant::Tertiary => ButtonVariantColors {
473 bg: if show_bg {
474 self.secondary.bg
475 } else {
476 rgba(0, 0, 0, 0.0)
477 },
478 hover_bg: self.secondary.hover,
479 active_bg: self.secondary.pressed,
480 text: self.neutral.text_2,
481 border: if show_border {
482 self.neutral.border
483 } else {
484 rgba(0, 0, 0, 0.0)
485 },
486 text_hover: self.neutral.text_1,
487 border_hover: rgba(0, 0, 0, 0.0),
488 },
489 ButtonVariant::Text => ButtonVariantColors {
490 bg: rgba(0, 0, 0, 0.0),
491 hover_bg: self.secondary.hover,
492 active_bg: self.secondary.pressed,
493 text: self.neutral.text_2,
494 border: rgba(0, 0, 0, 0.0),
495 text_hover: self.primary.base,
496 border_hover: rgba(0, 0, 0, 0.0),
497 },
498 ButtonVariant::Primary => self.secondary_family(&self.primary, show_bg, show_border),
499 ButtonVariant::Info => self.secondary_family(&self.info, show_bg, show_border),
500 ButtonVariant::Success => self.secondary_family(&self.success, show_bg, show_border),
501 ButtonVariant::Warning => self.secondary_family(&self.warning, show_bg, show_border),
502 ButtonVariant::Danger => self.secondary_family(&self.danger, show_bg, show_border),
503 }
504 }
505
506 fn secondary_family(
507 &self,
508 family: &ColorFamily,
509 show_bg: bool,
510 show_border: bool,
511 ) -> ButtonVariantColors {
512 ButtonVariantColors {
513 bg: if show_bg {
514 family.light_9
515 } else {
516 rgba(0, 0, 0, 0.0)
517 },
518 hover_bg: family.light_8,
519 active_bg: family.light_7,
520 text: family.base,
521 border: if show_border {
522 family.base
523 } else {
524 rgba(0, 0, 0, 0.0)
525 },
526 text_hover: family.hover,
527 border_hover: family.hover,
528 }
529 }
530
531 fn filled_colors(&self, family: &ColorFamily) -> ButtonVariantColors {
532 let hover = family.base.blend(gpui::black().opacity(0.10));
533 let active = family.base.blend(gpui::black().opacity(0.25));
534 ButtonVariantColors {
535 bg: family.base,
536 hover_bg: hover,
537 active_bg: active,
538 text: rgb(255, 255, 255),
539 border: family.base,
540 text_hover: rgb(255, 255, 255),
541 border_hover: hover,
542 }
543 }
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub enum ButtonVariant {
552 Default,
553 Tertiary,
554 Text,
555 Primary,
556 Info,
557 Success,
558 Warning,
559 Danger,
560}
561
562pub struct ButtonVariantColors {
563 pub bg: Hsla,
564 pub hover_bg: Hsla,
565 pub active_bg: Hsla,
566 pub text: Hsla,
567 pub border: Hsla,
568 pub text_hover: Hsla,
569 pub border_hover: Hsla,
570}
571
572#[derive(Debug, Clone, Copy, PartialEq, Eq)]
573pub enum ButtonSize {
574 Small,
575 Default,
576 Large,
577}
578
579impl ButtonSize {
580 pub fn height(&self) -> f32 {
581 match self {
582 ButtonSize::Small => 28.0, ButtonSize::Default => 34.0, ButtonSize::Large => 40.0, }
586 }
587
588 pub fn padding_x(&self) -> f32 {
589 match self {
590 ButtonSize::Small => 12.0, ButtonSize::Default => 14.0, ButtonSize::Large => 18.0, }
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use gpui::Rgba;
601
602 fn rgba_color(color: Hsla) -> Rgba {
603 color.into()
604 }
605
606 #[test]
607 fn filled_button_hover_and_active_backgrounds_get_progressively_darker() {
608 let theme = Theme::light();
609 let colors = theme.color_by_variant(ButtonVariant::Primary, false, true, true);
610
611 let bg = rgba_color(colors.bg);
612 let hover = rgba_color(colors.hover_bg);
613 let active = rgba_color(colors.active_bg);
614
615 assert!(hover.r < bg.r, "hover red channel should be darker");
616 assert!(hover.g < bg.g, "hover green channel should be darker");
617 assert!(hover.b < bg.b, "hover blue channel should be darker");
618 assert!(
619 active.r < hover.r,
620 "active red channel should be darker than hover"
621 );
622 assert!(
623 active.g < hover.g,
624 "active green channel should be darker than hover"
625 );
626 assert!(
627 active.b < hover.b,
628 "active blue channel should be darker than hover"
629 );
630 }
631
632 #[test]
633 fn dark_semantic_subtle_backgrounds_remain_translucent() {
634 let theme = Theme::dark();
635
636 assert!(theme.primary.light_9.a < 0.2);
637 assert!(theme.primary.light_8.a > theme.primary.light_9.a);
638 assert!(theme.primary.light_7.a > theme.primary.light_8.a);
639 assert_eq!(theme.primary.light_9.h, theme.primary.base.h);
640 }
641
642 #[test]
643 fn light_semantic_subtle_backgrounds_remain_opaque_tints() {
644 let theme = Theme::light();
645
646 assert_eq!(theme.primary.light_9.a, 1.0);
647 assert!(theme.primary.light_9.l > theme.primary.base.l);
648 }
649
650 #[test]
651 fn default_button_hover_and_active_backgrounds_are_visible_overlays() {
652 let theme = Theme::light();
653 let colors = theme.color_by_variant(ButtonVariant::Default, false, true, true);
654
655 let bg = rgba_color(colors.bg);
656 let hover = rgba_color(colors.hover_bg);
657 let active = rgba_color(colors.active_bg);
658
659 assert_eq!(
660 bg.a, 0.0,
661 "default button base background should stay transparent"
662 );
663 assert!(hover.a > bg.a, "hover background should be visible");
664 assert!(
665 active.a > hover.a,
666 "active background should be stronger than hover"
667 );
668 }
669}