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 {
57 pub base: Hsla,
59 pub hover: Hsla,
61 pub active: Hsla,
63 pub suppl: Hsla,
65 pub light_9: Hsla,
67 pub light_8: Hsla,
69 pub light_7: Hsla,
71}
72
73impl ColorFamily {
74 fn new(base: Hsla, hover: Hsla, active: Hsla, suppl: Hsla) -> Self {
75 Self {
76 base,
77 hover,
78 active,
79 suppl,
80 light_9: lighten(base, 0.9),
81 light_8: lighten(base, 0.8),
82 light_7: lighten(base, 0.7),
83 }
84 }
85
86 fn new_dark(base: Hsla, hover: Hsla, active: Hsla, suppl: Hsla) -> Self {
87 Self {
88 base,
89 hover,
90 active,
91 suppl,
92 light_9: base.opacity(0.16),
97 light_8: base.opacity(0.22),
98 light_7: base.opacity(0.30),
99 }
100 }
101}
102
103#[derive(Clone)]
108pub struct NeutralTokens {
110 pub body: Hsla,
112 pub card: Hsla,
114 pub modal: Hsla,
116 pub popover: Hsla,
118 pub inverted: Hsla,
120
121 pub text_1: Hsla,
123 pub text_2: Hsla,
125 pub text_3: Hsla,
127 pub text_disabled: Hsla,
129 pub placeholder: Hsla,
131 pub icon: Hsla,
133
134 pub border: Hsla,
136 pub divider: Hsla,
138
139 pub hover: Hsla,
141 pub pressed: Hsla,
143
144 pub rail: Hsla,
146
147 pub overlay: Hsla,
149 pub mask: Hsla,
151}
152
153#[derive(Clone)]
158pub struct Spacing {
160 pub xs: f32,
162 pub sm: f32,
164 pub md: f32,
166 pub lg: f32,
168 pub xl: f32,
170}
171
172#[derive(Clone)]
173pub struct Radius {
175 pub sm: f32,
177 pub md: f32,
179 pub lg: f32,
181 pub full: f32,
183}
184
185#[derive(Clone)]
186pub struct FontSize {
188 pub xs: f32,
190 pub sm: f32,
192 pub md: f32,
194 pub lg: f32,
196 pub xl: f32,
198}
199
200#[derive(Clone)]
205pub struct SecondaryColors {
207 pub bg: Hsla,
209 pub hover: Hsla,
211 pub pressed: Hsla,
213}
214
215#[derive(Clone)]
216pub struct Theme {
218 pub name: String,
220 pub spacing: Spacing,
222 pub radius: Radius,
224 pub font_size: FontSize,
226
227 pub primary: ColorFamily,
230 pub info: ColorFamily,
232 pub success: ColorFamily,
234 pub warning: ColorFamily,
236 pub danger: ColorFamily,
238
239 pub neutral: NeutralTokens,
242
243 pub secondary: SecondaryColors,
246
247 pub shadow_1: &'static str,
250 pub shadow_2: &'static str,
252 pub shadow_3: &'static str,
254}
255
256impl Default for Theme {
257 fn default() -> Self {
258 Self::light()
259 }
260}
261
262impl Theme {
263 pub fn light() -> Self {
268 Self {
269 name: "light".into(),
270 spacing: Spacing {
271 xs: 4.0,
272 sm: 8.0,
273 md: 12.0,
274 lg: 20.0,
275 xl: 32.0,
276 },
277 radius: Radius {
278 sm: 2.0,
279 md: 4.0,
280 lg: 8.0,
281 full: 9999.0,
282 },
283 font_size: FontSize {
284 xs: 10.0,
285 sm: 12.0,
286 md: 14.0,
287 lg: 16.0,
288 xl: 20.0,
289 },
290
291 primary: ColorFamily::new(
292 rgb(24, 160, 88), rgb(54, 173, 106), rgb(12, 122, 67), rgb(54, 173, 106), ),
297 info: ColorFamily::new(
298 rgb(32, 128, 240), rgb(64, 152, 252), rgb(16, 96, 201), rgb(64, 152, 252), ),
303 success: ColorFamily::new(
304 rgb(24, 160, 88), rgb(54, 173, 106), rgb(12, 122, 67), rgb(54, 173, 106), ),
309 warning: ColorFamily::new(
310 rgb(240, 160, 32), rgb(252, 176, 64), rgb(201, 124, 16), rgb(252, 176, 64), ),
315 danger: ColorFamily::new(
316 rgb(208, 48, 80), rgb(222, 87, 109), rgb(171, 31, 63), rgb(222, 87, 109), ),
321
322 neutral: NeutralTokens {
323 body: rgb(255, 255, 255),
324 card: rgb(255, 255, 255),
325 modal: rgb(255, 255, 255),
326 popover: rgb(255, 255, 255),
327 inverted: rgb(0, 20, 40),
328
329 text_1: rgb(31, 34, 37),
330 text_2: rgb(51, 54, 57),
331 text_3: rgb(118, 124, 130),
332 text_disabled: rgba(194, 194, 194, 1.0),
333 placeholder: rgba(194, 194, 194, 1.0),
334 icon: rgba(31, 34, 37, 1.0),
335
336 border: rgb(224, 224, 230),
337 divider: rgb(239, 239, 245),
338
339 hover: rgb(243, 243, 245),
340 pressed: rgb(237, 237, 239),
341
342 rail: rgb(219, 219, 223),
343
344 overlay: rgba(0, 0, 0, 0.50),
345 mask: rgba(255, 255, 255, 0.90),
346 },
347 secondary: SecondaryColors {
349 bg: rgba(46, 51, 56, 0.05),
350 hover: rgba(46, 51, 56, 0.09),
351 pressed: rgba(46, 51, 56, 0.13),
352 },
353
354 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)",
355 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)",
356 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)",
357 }
358 }
359
360 pub fn dark() -> Self {
365 Self {
366 name: "dark".into(),
367 spacing: Spacing {
368 xs: 4.0,
369 sm: 8.0,
370 md: 12.0,
371 lg: 20.0,
372 xl: 32.0,
373 },
374 radius: Radius {
375 sm: 2.0,
376 md: 4.0,
377 lg: 8.0,
378 full: 9999.0,
379 },
380 font_size: FontSize {
381 xs: 12.0,
382 sm: 14.0,
383 md: 14.0,
384 lg: 15.0,
385 xl: 16.0,
386 },
387
388 primary: ColorFamily::new_dark(
389 rgb(99, 226, 183), rgb(127, 231, 196), rgb(90, 206, 167), rgb(42, 148, 125), ),
394 info: ColorFamily::new_dark(
395 rgb(112, 192, 232), rgb(138, 203, 236), rgb(102, 175, 211), rgb(56, 137, 197), ),
400 success: ColorFamily::new_dark(
401 rgb(99, 226, 183), rgb(127, 231, 196), rgb(90, 206, 167), rgb(42, 148, 125), ),
406 warning: ColorFamily::new_dark(
407 rgb(242, 201, 125), rgb(245, 213, 153), rgb(230, 194, 96), rgb(240, 138, 0), ),
412 danger: ColorFamily::new_dark(
413 rgb(232, 128, 128), rgb(233, 139, 139), rgb(229, 114, 114), rgb(208, 58, 82), ),
418
419 neutral: NeutralTokens {
420 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),
425
426 text_1: rgba(255, 255, 255, 0.90),
427 text_2: rgba(255, 255, 255, 0.82),
428 text_3: rgba(255, 255, 255, 0.52),
429 text_disabled: rgba(255, 255, 255, 0.38),
430 placeholder: rgba(255, 255, 255, 0.38),
431 icon: rgba(255, 255, 255, 0.38),
432
433 border: rgba(255, 255, 255, 0.24),
434 divider: rgba(255, 255, 255, 0.09),
435
436 hover: rgba(255, 255, 255, 0.09),
437 pressed: rgba(255, 255, 255, 0.05),
438
439 rail: rgba(255, 255, 255, 0.20),
440
441 overlay: rgba(0, 0, 0, 0.60),
442 mask: rgba(0, 0, 0, 0.70),
443 },
444
445 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)",
446 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)",
447 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)",
448
449 secondary: SecondaryColors {
450 bg: rgba(255, 255, 255, 0.08),
451 hover: rgba(255, 255, 255, 0.12),
452 pressed: rgba(255, 255, 255, 0.16),
453 },
454 }
455 }
456
457 pub fn color_by_variant(
462 &self,
463 variant: ButtonVariant,
464 secondary: bool,
465 background: bool,
466 border: bool,
467 ) -> ButtonVariantColors {
468 if secondary {
469 return self.secondary_colors(variant, background, border);
470 }
471
472 match variant {
474 ButtonVariant::Default => ButtonVariantColors {
475 bg: rgba(0, 0, 0, 0.0),
476 hover_bg: self.secondary.hover,
477 active_bg: self.secondary.pressed,
478 text: self.neutral.text_2,
479 border: self.neutral.border,
480 text_hover: self.primary.base,
481 border_hover: self.primary.base,
482 },
483 ButtonVariant::Tertiary => ButtonVariantColors {
484 bg: self.secondary.bg,
485 hover_bg: self.secondary.hover,
486 active_bg: self.secondary.pressed,
487 text: self.neutral.text_2,
488 border: rgba(0, 0, 0, 0.0),
489 text_hover: self.neutral.text_1,
490 border_hover: rgba(0, 0, 0, 0.0),
491 },
492 ButtonVariant::Text => ButtonVariantColors {
493 bg: rgba(0, 0, 0, 0.0),
494 hover_bg: self.secondary.hover,
495 active_bg: self.secondary.pressed,
496 text: self.neutral.text_2,
497 border: rgba(0, 0, 0, 0.0),
498 text_hover: self.primary.base,
499 border_hover: rgba(0, 0, 0, 0.0),
500 },
501 ButtonVariant::Primary => self.filled_colors(&self.primary),
502 ButtonVariant::Info => self.filled_colors(&self.info),
503 ButtonVariant::Success => self.filled_colors(&self.success),
504 ButtonVariant::Warning => self.filled_colors(&self.warning),
505 ButtonVariant::Danger => self.filled_colors(&self.danger),
506 }
507 }
508
509 fn secondary_colors(
512 &self,
513 variant: ButtonVariant,
514 show_bg: bool,
515 show_border: bool,
516 ) -> ButtonVariantColors {
517 match variant {
518 ButtonVariant::Default => ButtonVariantColors {
519 bg: if show_bg {
520 self.secondary.bg
521 } else {
522 rgba(0, 0, 0, 0.0)
523 },
524 hover_bg: self.secondary.hover,
525 active_bg: self.secondary.pressed,
526 text: self.neutral.text_2,
527 border: if show_border {
528 self.neutral.border
529 } else {
530 rgba(0, 0, 0, 0.0)
531 },
532 text_hover: self.primary.base,
533 border_hover: self.primary.base,
534 },
535 ButtonVariant::Tertiary => ButtonVariantColors {
536 bg: if show_bg {
537 self.secondary.bg
538 } else {
539 rgba(0, 0, 0, 0.0)
540 },
541 hover_bg: self.secondary.hover,
542 active_bg: self.secondary.pressed,
543 text: self.neutral.text_2,
544 border: if show_border {
545 self.neutral.border
546 } else {
547 rgba(0, 0, 0, 0.0)
548 },
549 text_hover: self.neutral.text_1,
550 border_hover: rgba(0, 0, 0, 0.0),
551 },
552 ButtonVariant::Text => ButtonVariantColors {
553 bg: rgba(0, 0, 0, 0.0),
554 hover_bg: self.secondary.hover,
555 active_bg: self.secondary.pressed,
556 text: self.neutral.text_2,
557 border: rgba(0, 0, 0, 0.0),
558 text_hover: self.primary.base,
559 border_hover: rgba(0, 0, 0, 0.0),
560 },
561 ButtonVariant::Primary => self.secondary_family(&self.primary, show_bg, show_border),
562 ButtonVariant::Info => self.secondary_family(&self.info, show_bg, show_border),
563 ButtonVariant::Success => self.secondary_family(&self.success, show_bg, show_border),
564 ButtonVariant::Warning => self.secondary_family(&self.warning, show_bg, show_border),
565 ButtonVariant::Danger => self.secondary_family(&self.danger, show_bg, show_border),
566 }
567 }
568
569 fn secondary_family(
570 &self,
571 family: &ColorFamily,
572 show_bg: bool,
573 show_border: bool,
574 ) -> ButtonVariantColors {
575 ButtonVariantColors {
576 bg: if show_bg {
577 family.light_9
578 } else {
579 rgba(0, 0, 0, 0.0)
580 },
581 hover_bg: family.light_8,
582 active_bg: family.light_7,
583 text: family.base,
584 border: if show_border {
585 family.base
586 } else {
587 rgba(0, 0, 0, 0.0)
588 },
589 text_hover: family.hover,
590 border_hover: family.hover,
591 }
592 }
593
594 fn filled_colors(&self, family: &ColorFamily) -> ButtonVariantColors {
595 let hover = family.base.blend(gpui::black().opacity(0.10));
596 let active = family.base.blend(gpui::black().opacity(0.25));
597 ButtonVariantColors {
598 bg: family.base,
599 hover_bg: hover,
600 active_bg: active,
601 text: rgb(255, 255, 255),
602 border: family.base,
603 text_hover: rgb(255, 255, 255),
604 border_hover: hover,
605 }
606 }
607}
608
609#[derive(Debug, Clone, Copy, PartialEq, Eq)]
614pub enum ButtonVariant {
616 Default,
618 Tertiary,
620 Text,
622 Primary,
624 Info,
626 Success,
628 Warning,
630 Danger,
632}
633
634pub struct ButtonVariantColors {
636 pub bg: Hsla,
638 pub hover_bg: Hsla,
640 pub active_bg: Hsla,
642 pub text: Hsla,
644 pub border: Hsla,
646 pub text_hover: Hsla,
648 pub border_hover: Hsla,
650}
651
652#[derive(Debug, Clone, Copy, PartialEq, Eq)]
653pub enum ButtonSize {
655 Small,
657 Default,
659 Large,
661}
662
663impl ButtonSize {
664 pub fn height(&self) -> f32 {
666 match self {
667 ButtonSize::Small => 28.0, ButtonSize::Default => 34.0, ButtonSize::Large => 40.0, }
671 }
672
673 pub fn padding_x(&self) -> f32 {
675 match self {
676 ButtonSize::Small => 12.0, ButtonSize::Default => 14.0, ButtonSize::Large => 18.0, }
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use gpui::Rgba;
687
688 fn rgba_color(color: Hsla) -> Rgba {
689 color.into()
690 }
691
692 #[test]
693 fn filled_button_hover_and_active_backgrounds_get_progressively_darker() {
694 let theme = Theme::light();
695 let colors = theme.color_by_variant(ButtonVariant::Primary, false, true, true);
696
697 let bg = rgba_color(colors.bg);
698 let hover = rgba_color(colors.hover_bg);
699 let active = rgba_color(colors.active_bg);
700
701 assert!(hover.r < bg.r, "hover red channel should be darker");
702 assert!(hover.g < bg.g, "hover green channel should be darker");
703 assert!(hover.b < bg.b, "hover blue channel should be darker");
704 assert!(
705 active.r < hover.r,
706 "active red channel should be darker than hover"
707 );
708 assert!(
709 active.g < hover.g,
710 "active green channel should be darker than hover"
711 );
712 assert!(
713 active.b < hover.b,
714 "active blue channel should be darker than hover"
715 );
716 }
717
718 #[test]
719 fn dark_semantic_subtle_backgrounds_remain_translucent() {
720 let theme = Theme::dark();
721
722 assert!(theme.primary.light_9.a < 0.2);
723 assert!(theme.primary.light_8.a > theme.primary.light_9.a);
724 assert!(theme.primary.light_7.a > theme.primary.light_8.a);
725 assert_eq!(theme.primary.light_9.h, theme.primary.base.h);
726 }
727
728 #[test]
729 fn light_semantic_subtle_backgrounds_remain_opaque_tints() {
730 let theme = Theme::light();
731
732 assert_eq!(theme.primary.light_9.a, 1.0);
733 assert!(theme.primary.light_9.l > theme.primary.base.l);
734 }
735
736 #[test]
737 fn default_button_hover_and_active_backgrounds_are_visible_overlays() {
738 let theme = Theme::light();
739 let colors = theme.color_by_variant(ButtonVariant::Default, false, true, true);
740
741 let bg = rgba_color(colors.bg);
742 let hover = rgba_color(colors.hover_bg);
743 let active = rgba_color(colors.active_bg);
744
745 assert_eq!(
746 bg.a, 0.0,
747 "default button base background should stay transparent"
748 );
749 assert!(hover.a > bg.a, "hover background should be visible");
750 assert!(
751 active.a > hover.a,
752 "active background should be stronger than hover"
753 );
754 }
755}