Add theme preview (#20039)

Nate Butler created

This PR adds a theme preview tab to help get an at a glance overview of
the styles in a theme.

![CleanShot 2024-10-31 at 11 27
18@2x](https://github.com/user-attachments/assets/798e97cf-9f80-4994-b2fd-ac1dcd58e4d9)

You can open it using `debug: open theme preview`.

The next major theme preview PR will move this into it's own crate, as
it will grow substantially as we add content.

Next for theme preview:

- Update layout to two columns, with controls on the right for selecting
theme, layer/elevation-index, etc.
- Cover more UI elements in preview
- Display theme colors in a more helpful way
- Add syntax & markdown previews


Release Notes:

- Added a way to preview the current theme's styles with the `debug:
open theme preview` command.

Change summary

Cargo.lock                            |   1 
crates/gpui/src/color.rs              |  15 
crates/theme/Cargo.toml               |   1 
crates/theme/src/styles/colors.rs     | 237 +++++++++++++++
crates/ui/src/styles/elevation.rs     |  35 ++
crates/ui/src/utils.rs                |   2 
crates/ui/src/utils/color_contrast.rs |  70 ++++
crates/workspace/src/theme_preview.rs | 454 +++++++++++++++++++++++++++++
crates/workspace/src/workspace.rs     |   2 
9 files changed, 813 insertions(+), 4 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -12093,6 +12093,7 @@ dependencies = [
  "serde_json_lenient",
  "serde_repr",
  "settings",
+ "strum 0.25.0",
  "util",
  "uuid",
 ]

crates/gpui/src/color.rs 🔗

@@ -1,7 +1,7 @@
 use anyhow::{bail, Context};
 use serde::de::{self, Deserialize, Deserializer, Visitor};
 use std::{
-    fmt,
+    fmt::{self, Display, Formatter},
     hash::{Hash, Hasher},
 };
 
@@ -279,6 +279,19 @@ impl Hash for Hsla {
     }
 }
 
+impl Display for Hsla {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "hsla({:.2}, {:.2}%, {:.2}%, {:.2})",
+            self.h * 360.,
+            self.s * 100.,
+            self.l * 100.,
+            self.a
+        )
+    }
+}
+
 /// Construct an [`Hsla`] object from plain values
 pub fn hsla(h: f32, s: f32, l: f32, a: f32) -> Hsla {
     Hsla {

crates/theme/Cargo.toml 🔗

@@ -35,6 +35,7 @@ serde_json.workspace = true
 serde_json_lenient.workspace = true
 serde_repr.workspace = true
 settings.workspace = true
+strum.workspace = true
 util.workspace = true
 uuid.workspace = true
 

crates/theme/src/styles/colors.rs 🔗

@@ -1,11 +1,13 @@
 #![allow(missing_docs)]
 
-use gpui::{Hsla, WindowBackgroundAppearance};
+use gpui::{Hsla, SharedString, WindowBackgroundAppearance, WindowContext};
 use refineable::Refineable;
 use std::sync::Arc;
+use strum::{AsRefStr, EnumIter, IntoEnumIterator};
 
 use crate::{
-    AccentColors, PlayerColors, StatusColors, StatusColorsRefinement, SyntaxTheme, SystemColors,
+    AccentColors, ActiveTheme, PlayerColors, StatusColors, StatusColorsRefinement, SyntaxTheme,
+    SystemColors,
 };
 
 #[derive(Refineable, Clone, Debug, PartialEq)]
@@ -249,6 +251,237 @@ pub struct ThemeColors {
     pub link_text_hover: Hsla,
 }
 
+#[derive(EnumIter, Debug, Clone, Copy, AsRefStr)]
+#[strum(serialize_all = "snake_case")]
+pub enum ThemeColorField {
+    Border,
+    BorderVariant,
+    BorderFocused,
+    BorderSelected,
+    BorderTransparent,
+    BorderDisabled,
+    ElevatedSurfaceBackground,
+    SurfaceBackground,
+    Background,
+    ElementBackground,
+    ElementHover,
+    ElementActive,
+    ElementSelected,
+    ElementDisabled,
+    DropTargetBackground,
+    GhostElementBackground,
+    GhostElementHover,
+    GhostElementActive,
+    GhostElementSelected,
+    GhostElementDisabled,
+    Text,
+    TextMuted,
+    TextPlaceholder,
+    TextDisabled,
+    TextAccent,
+    Icon,
+    IconMuted,
+    IconDisabled,
+    IconPlaceholder,
+    IconAccent,
+    StatusBarBackground,
+    TitleBarBackground,
+    TitleBarInactiveBackground,
+    ToolbarBackground,
+    TabBarBackground,
+    TabInactiveBackground,
+    TabActiveBackground,
+    SearchMatchBackground,
+    PanelBackground,
+    PanelFocusedBorder,
+    PanelIndentGuide,
+    PanelIndentGuideHover,
+    PanelIndentGuideActive,
+    PaneFocusedBorder,
+    PaneGroupBorder,
+    ScrollbarThumbBackground,
+    ScrollbarThumbHoverBackground,
+    ScrollbarThumbBorder,
+    ScrollbarTrackBackground,
+    ScrollbarTrackBorder,
+    EditorForeground,
+    EditorBackground,
+    EditorGutterBackground,
+    EditorSubheaderBackground,
+    EditorActiveLineBackground,
+    EditorHighlightedLineBackground,
+    EditorLineNumber,
+    EditorActiveLineNumber,
+    EditorInvisible,
+    EditorWrapGuide,
+    EditorActiveWrapGuide,
+    EditorIndentGuide,
+    EditorIndentGuideActive,
+    EditorDocumentHighlightReadBackground,
+    EditorDocumentHighlightWriteBackground,
+    EditorDocumentHighlightBracketBackground,
+    TerminalBackground,
+    TerminalForeground,
+    TerminalBrightForeground,
+    TerminalDimForeground,
+    TerminalAnsiBackground,
+    TerminalAnsiBlack,
+    TerminalAnsiBrightBlack,
+    TerminalAnsiDimBlack,
+    TerminalAnsiRed,
+    TerminalAnsiBrightRed,
+    TerminalAnsiDimRed,
+    TerminalAnsiGreen,
+    TerminalAnsiBrightGreen,
+    TerminalAnsiDimGreen,
+    TerminalAnsiYellow,
+    TerminalAnsiBrightYellow,
+    TerminalAnsiDimYellow,
+    TerminalAnsiBlue,
+    TerminalAnsiBrightBlue,
+    TerminalAnsiDimBlue,
+    TerminalAnsiMagenta,
+    TerminalAnsiBrightMagenta,
+    TerminalAnsiDimMagenta,
+    TerminalAnsiCyan,
+    TerminalAnsiBrightCyan,
+    TerminalAnsiDimCyan,
+    TerminalAnsiWhite,
+    TerminalAnsiBrightWhite,
+    TerminalAnsiDimWhite,
+    LinkTextHover,
+}
+
+impl ThemeColors {
+    pub fn color(&self, field: ThemeColorField) -> Hsla {
+        match field {
+            ThemeColorField::Border => self.border,
+            ThemeColorField::BorderVariant => self.border_variant,
+            ThemeColorField::BorderFocused => self.border_focused,
+            ThemeColorField::BorderSelected => self.border_selected,
+            ThemeColorField::BorderTransparent => self.border_transparent,
+            ThemeColorField::BorderDisabled => self.border_disabled,
+            ThemeColorField::ElevatedSurfaceBackground => self.elevated_surface_background,
+            ThemeColorField::SurfaceBackground => self.surface_background,
+            ThemeColorField::Background => self.background,
+            ThemeColorField::ElementBackground => self.element_background,
+            ThemeColorField::ElementHover => self.element_hover,
+            ThemeColorField::ElementActive => self.element_active,
+            ThemeColorField::ElementSelected => self.element_selected,
+            ThemeColorField::ElementDisabled => self.element_disabled,
+            ThemeColorField::DropTargetBackground => self.drop_target_background,
+            ThemeColorField::GhostElementBackground => self.ghost_element_background,
+            ThemeColorField::GhostElementHover => self.ghost_element_hover,
+            ThemeColorField::GhostElementActive => self.ghost_element_active,
+            ThemeColorField::GhostElementSelected => self.ghost_element_selected,
+            ThemeColorField::GhostElementDisabled => self.ghost_element_disabled,
+            ThemeColorField::Text => self.text,
+            ThemeColorField::TextMuted => self.text_muted,
+            ThemeColorField::TextPlaceholder => self.text_placeholder,
+            ThemeColorField::TextDisabled => self.text_disabled,
+            ThemeColorField::TextAccent => self.text_accent,
+            ThemeColorField::Icon => self.icon,
+            ThemeColorField::IconMuted => self.icon_muted,
+            ThemeColorField::IconDisabled => self.icon_disabled,
+            ThemeColorField::IconPlaceholder => self.icon_placeholder,
+            ThemeColorField::IconAccent => self.icon_accent,
+            ThemeColorField::StatusBarBackground => self.status_bar_background,
+            ThemeColorField::TitleBarBackground => self.title_bar_background,
+            ThemeColorField::TitleBarInactiveBackground => self.title_bar_inactive_background,
+            ThemeColorField::ToolbarBackground => self.toolbar_background,
+            ThemeColorField::TabBarBackground => self.tab_bar_background,
+            ThemeColorField::TabInactiveBackground => self.tab_inactive_background,
+            ThemeColorField::TabActiveBackground => self.tab_active_background,
+            ThemeColorField::SearchMatchBackground => self.search_match_background,
+            ThemeColorField::PanelBackground => self.panel_background,
+            ThemeColorField::PanelFocusedBorder => self.panel_focused_border,
+            ThemeColorField::PanelIndentGuide => self.panel_indent_guide,
+            ThemeColorField::PanelIndentGuideHover => self.panel_indent_guide_hover,
+            ThemeColorField::PanelIndentGuideActive => self.panel_indent_guide_active,
+            ThemeColorField::PaneFocusedBorder => self.pane_focused_border,
+            ThemeColorField::PaneGroupBorder => self.pane_group_border,
+            ThemeColorField::ScrollbarThumbBackground => self.scrollbar_thumb_background,
+            ThemeColorField::ScrollbarThumbHoverBackground => self.scrollbar_thumb_hover_background,
+            ThemeColorField::ScrollbarThumbBorder => self.scrollbar_thumb_border,
+            ThemeColorField::ScrollbarTrackBackground => self.scrollbar_track_background,
+            ThemeColorField::ScrollbarTrackBorder => self.scrollbar_track_border,
+            ThemeColorField::EditorForeground => self.editor_foreground,
+            ThemeColorField::EditorBackground => self.editor_background,
+            ThemeColorField::EditorGutterBackground => self.editor_gutter_background,
+            ThemeColorField::EditorSubheaderBackground => self.editor_subheader_background,
+            ThemeColorField::EditorActiveLineBackground => self.editor_active_line_background,
+            ThemeColorField::EditorHighlightedLineBackground => {
+                self.editor_highlighted_line_background
+            }
+            ThemeColorField::EditorLineNumber => self.editor_line_number,
+            ThemeColorField::EditorActiveLineNumber => self.editor_active_line_number,
+            ThemeColorField::EditorInvisible => self.editor_invisible,
+            ThemeColorField::EditorWrapGuide => self.editor_wrap_guide,
+            ThemeColorField::EditorActiveWrapGuide => self.editor_active_wrap_guide,
+            ThemeColorField::EditorIndentGuide => self.editor_indent_guide,
+            ThemeColorField::EditorIndentGuideActive => self.editor_indent_guide_active,
+            ThemeColorField::EditorDocumentHighlightReadBackground => {
+                self.editor_document_highlight_read_background
+            }
+            ThemeColorField::EditorDocumentHighlightWriteBackground => {
+                self.editor_document_highlight_write_background
+            }
+            ThemeColorField::EditorDocumentHighlightBracketBackground => {
+                self.editor_document_highlight_bracket_background
+            }
+            ThemeColorField::TerminalBackground => self.terminal_background,
+            ThemeColorField::TerminalForeground => self.terminal_foreground,
+            ThemeColorField::TerminalBrightForeground => self.terminal_bright_foreground,
+            ThemeColorField::TerminalDimForeground => self.terminal_dim_foreground,
+            ThemeColorField::TerminalAnsiBackground => self.terminal_ansi_background,
+            ThemeColorField::TerminalAnsiBlack => self.terminal_ansi_black,
+            ThemeColorField::TerminalAnsiBrightBlack => self.terminal_ansi_bright_black,
+            ThemeColorField::TerminalAnsiDimBlack => self.terminal_ansi_dim_black,
+            ThemeColorField::TerminalAnsiRed => self.terminal_ansi_red,
+            ThemeColorField::TerminalAnsiBrightRed => self.terminal_ansi_bright_red,
+            ThemeColorField::TerminalAnsiDimRed => self.terminal_ansi_dim_red,
+            ThemeColorField::TerminalAnsiGreen => self.terminal_ansi_green,
+            ThemeColorField::TerminalAnsiBrightGreen => self.terminal_ansi_bright_green,
+            ThemeColorField::TerminalAnsiDimGreen => self.terminal_ansi_dim_green,
+            ThemeColorField::TerminalAnsiYellow => self.terminal_ansi_yellow,
+            ThemeColorField::TerminalAnsiBrightYellow => self.terminal_ansi_bright_yellow,
+            ThemeColorField::TerminalAnsiDimYellow => self.terminal_ansi_dim_yellow,
+            ThemeColorField::TerminalAnsiBlue => self.terminal_ansi_blue,
+            ThemeColorField::TerminalAnsiBrightBlue => self.terminal_ansi_bright_blue,
+            ThemeColorField::TerminalAnsiDimBlue => self.terminal_ansi_dim_blue,
+            ThemeColorField::TerminalAnsiMagenta => self.terminal_ansi_magenta,
+            ThemeColorField::TerminalAnsiBrightMagenta => self.terminal_ansi_bright_magenta,
+            ThemeColorField::TerminalAnsiDimMagenta => self.terminal_ansi_dim_magenta,
+            ThemeColorField::TerminalAnsiCyan => self.terminal_ansi_cyan,
+            ThemeColorField::TerminalAnsiBrightCyan => self.terminal_ansi_bright_cyan,
+            ThemeColorField::TerminalAnsiDimCyan => self.terminal_ansi_dim_cyan,
+            ThemeColorField::TerminalAnsiWhite => self.terminal_ansi_white,
+            ThemeColorField::TerminalAnsiBrightWhite => self.terminal_ansi_bright_white,
+            ThemeColorField::TerminalAnsiDimWhite => self.terminal_ansi_dim_white,
+            ThemeColorField::LinkTextHover => self.link_text_hover,
+        }
+    }
+
+    pub fn iter(&self) -> impl Iterator<Item = (ThemeColorField, Hsla)> + '_ {
+        ThemeColorField::iter().map(move |field| (field, self.color(field)))
+    }
+
+    pub fn to_vec(&self) -> Vec<(ThemeColorField, Hsla)> {
+        self.iter().collect()
+    }
+}
+
+pub fn all_theme_colors(cx: &WindowContext) -> Vec<(Hsla, SharedString)> {
+    let theme = cx.theme();
+    ThemeColorField::iter()
+        .map(|field| {
+            let color = theme.colors().color(field);
+            let name = field.as_ref().to_string();
+            (color, SharedString::from(name))
+        })
+        .collect()
+}
+
 #[derive(Refineable, Clone, PartialEq)]
 pub struct ThemeStyles {
     /// The background appearance of the window.

crates/ui/src/styles/elevation.rs 🔗

@@ -1,5 +1,8 @@
-use gpui::{hsla, point, px, BoxShadow};
+use std::fmt::{self, Display, Formatter};
+
+use gpui::{hsla, point, px, BoxShadow, Hsla, WindowContext};
 use smallvec::{smallvec, SmallVec};
+use theme::ActiveTheme;
 
 /// Today, elevation is primarily used to add shadows to elements, and set the correct background for elements like buttons.
 ///
@@ -15,6 +18,8 @@ pub enum ElevationIndex {
     Background,
     /// The primary surface – Contains panels, panes, containers, etc.
     Surface,
+    /// The same elevation as the primary surface, but used for the editable areas, like buffers
+    EditorSurface,
     /// A surface that is elevated above the primary surface. but below washes, models, and dragged elements.
     ElevatedSurface,
     /// A surface that is above all non-modal surfaces, and separates the app from focused intents, like dialogs, alerts, modals, etc.
@@ -25,11 +30,26 @@ pub enum ElevationIndex {
     DraggedElement,
 }
 
+impl Display for ElevationIndex {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            ElevationIndex::Background => write!(f, "Background"),
+            ElevationIndex::Surface => write!(f, "Surface"),
+            ElevationIndex::EditorSurface => write!(f, "Editor Surface"),
+            ElevationIndex::ElevatedSurface => write!(f, "Elevated Surface"),
+            ElevationIndex::Wash => write!(f, "Wash"),
+            ElevationIndex::ModalSurface => write!(f, "Modal Surface"),
+            ElevationIndex::DraggedElement => write!(f, "Dragged Element"),
+        }
+    }
+}
+
 impl ElevationIndex {
     /// Returns an appropriate shadow for the given elevation index.
     pub fn shadow(self) -> SmallVec<[BoxShadow; 2]> {
         match self {
             ElevationIndex::Surface => smallvec![],
+            ElevationIndex::EditorSurface => smallvec![],
 
             ElevationIndex::ElevatedSurface => smallvec![BoxShadow {
                 color: hsla(0., 0., 0., 0.12),
@@ -62,4 +82,17 @@ impl ElevationIndex {
             _ => smallvec![],
         }
     }
+
+    /// Returns the background color for the given elevation index.
+    pub fn bg(&self, cx: &WindowContext) -> Hsla {
+        match self {
+            ElevationIndex::Background => cx.theme().colors().background,
+            ElevationIndex::Surface => cx.theme().colors().surface_background,
+            ElevationIndex::EditorSurface => cx.theme().colors().editor_background,
+            ElevationIndex::ElevatedSurface => cx.theme().colors().elevated_surface_background,
+            ElevationIndex::Wash => gpui::transparent_black(),
+            ElevationIndex::ModalSurface => cx.theme().colors().elevated_surface_background,
+            ElevationIndex::DraggedElement => gpui::transparent_black(),
+        }
+    }
 }

crates/ui/src/utils.rs 🔗

@@ -1,7 +1,9 @@
 //! UI-related utilities
 
+mod color_contrast;
 mod format_distance;
 mod with_rem_size;
 
+pub use color_contrast::*;
 pub use format_distance::*;
 pub use with_rem_size::*;

crates/ui/src/utils/color_contrast.rs 🔗

@@ -0,0 +1,70 @@
+use gpui::{Hsla, Rgba};
+
+/// Calculates the contrast ratio between two colors according to WCAG 2.0 standards.
+///
+/// The formula used is:
+/// (L1 + 0.05) / (L2 + 0.05), where L1 is the lighter of the two luminances and L2 is the darker.
+///
+/// Returns a float representing the contrast ratio. A higher value indicates more contrast.
+/// The range of the returned value is 1 to 21 (commonly written as 1:1 to 21:1).
+pub fn calculate_contrast_ratio(fg: Hsla, bg: Hsla) -> f32 {
+    let l1 = relative_luminance(fg);
+    let l2 = relative_luminance(bg);
+
+    let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
+
+    (lighter + 0.05) / (darker + 0.05)
+}
+
+/// Calculates the relative luminance of a color.
+///
+/// The relative luminance is the relative brightness of any point in a colorspace,
+/// normalized to 0 for darkest black and 1 for lightest white.
+fn relative_luminance(color: Hsla) -> f32 {
+    let rgba: Rgba = color.into();
+    let r = linearize(rgba.r);
+    let g = linearize(rgba.g);
+    let b = linearize(rgba.b);
+
+    0.2126 * r + 0.7152 * g + 0.0722 * b
+}
+
+/// Linearizes an RGB component.
+fn linearize(component: f32) -> f32 {
+    if component <= 0.03928 {
+        component / 12.92
+    } else {
+        ((component + 0.055) / 1.055).powf(2.4)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use gpui::hsla;
+
+    use super::*;
+
+    // Test the contrast ratio formula with some common color combinations to
+    // prevent regressions in either the color conversions or the formula itself.
+    #[test]
+    fn test_contrast_ratio_formula() {
+        // White on Black (should be close to 21:1)
+        let white = hsla(0.0, 0.0, 1.0, 1.0);
+        let black = hsla(0.0, 0.0, 0.0, 1.0);
+        assert!((calculate_contrast_ratio(white, black) - 21.0).abs() < 0.1);
+
+        // Black on White (should be close to 21:1)
+        assert!((calculate_contrast_ratio(black, white) - 21.0).abs() < 0.1);
+
+        // Mid-gray on Black (should be close to 5.32:1)
+        let mid_gray = hsla(0.0, 0.0, 0.5, 1.0);
+        assert!((calculate_contrast_ratio(mid_gray, black) - 5.32).abs() < 0.1);
+
+        // White on Mid-gray (should be close to 3.95:1)
+        assert!((calculate_contrast_ratio(white, mid_gray) - 3.95).abs() < 0.1);
+
+        // Same color (should be 1:1)
+        let red = hsla(0.0, 1.0, 0.5, 1.0);
+        assert!((calculate_contrast_ratio(red, red) - 1.0).abs() < 0.01);
+    }
+}

crates/workspace/src/theme_preview.rs 🔗

@@ -0,0 +1,454 @@
+#![allow(unused, dead_code)]
+use gpui::{actions, AppContext, EventEmitter, FocusHandle, FocusableView, Hsla};
+use theme::all_theme_colors;
+use ui::{
+    prelude::*, utils::calculate_contrast_ratio, AudioStatus, Availability, Avatar,
+    AvatarAudioStatusIndicator, AvatarAvailabilityIndicator, ButtonLike, ElevationIndex, Facepile,
+    TintColor, Tooltip,
+};
+
+use crate::{Item, Workspace};
+
+actions!(debug, [OpenThemePreview]);
+
+pub fn init(cx: &mut AppContext) {
+    cx.observe_new_views(|workspace: &mut Workspace, _| {
+        workspace.register_action(|workspace, _: &OpenThemePreview, cx| {
+            let theme_preview = cx.new_view(ThemePreview::new);
+            workspace.add_item_to_active_pane(Box::new(theme_preview), None, true, cx)
+        });
+    })
+    .detach();
+}
+
+struct ThemePreview {
+    focus_handle: FocusHandle,
+}
+
+impl ThemePreview {
+    pub fn new(cx: &mut ViewContext<Self>) -> Self {
+        Self {
+            focus_handle: cx.focus_handle(),
+        }
+    }
+}
+
+impl EventEmitter<()> for ThemePreview {}
+
+impl FocusableView for ThemePreview {
+    fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+impl ThemePreview {}
+
+impl Item for ThemePreview {
+    type Event = ();
+
+    fn to_item_events(_: &Self::Event, _: impl FnMut(crate::item::ItemEvent)) {}
+
+    fn tab_content_text(&self, cx: &WindowContext) -> Option<SharedString> {
+        let name = cx.theme().name.clone();
+        Some(format!("{} Preview", name).into())
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        None
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: Option<crate::WorkspaceId>,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<gpui::View<Self>>
+    where
+        Self: Sized,
+    {
+        Some(cx.new_view(Self::new))
+    }
+}
+
+const AVATAR_URL: &str = "https://avatars.githubusercontent.com/u/1714999?v=4";
+
+impl ThemePreview {
+    fn preview_bg(cx: &WindowContext) -> Hsla {
+        cx.theme().colors().editor_background
+    }
+
+    fn render_avatars(&self, cx: &ViewContext<Self>) -> impl IntoElement {
+        v_flex()
+            .gap_1()
+            .child(
+                Headline::new("Avatars")
+                    .size(HeadlineSize::Small)
+                    .color(Color::Muted),
+            )
+            .child(
+                h_flex()
+                    .items_start()
+                    .gap_4()
+                    .child(Avatar::new(AVATAR_URL).size(px(24.)))
+                    .child(Avatar::new(AVATAR_URL).size(px(24.)).grayscale(true))
+                    .child(
+                        Avatar::new(AVATAR_URL)
+                            .size(px(24.))
+                            .indicator(AvatarAudioStatusIndicator::new(AudioStatus::Muted)),
+                    )
+                    .child(
+                        Avatar::new(AVATAR_URL)
+                            .size(px(24.))
+                            .indicator(AvatarAudioStatusIndicator::new(AudioStatus::Deafened)),
+                    )
+                    .child(
+                        Avatar::new(AVATAR_URL)
+                            .size(px(24.))
+                            .indicator(AvatarAvailabilityIndicator::new(Availability::Free)),
+                    )
+                    .child(
+                        Avatar::new(AVATAR_URL)
+                            .size(px(24.))
+                            .indicator(AvatarAvailabilityIndicator::new(Availability::Free)),
+                    )
+                    .child(
+                        Facepile::empty()
+                            .child(
+                                Avatar::new(AVATAR_URL)
+                                    .border_color(Self::preview_bg(cx))
+                                    .size(px(22.))
+                                    .into_any_element(),
+                            )
+                            .child(
+                                Avatar::new(AVATAR_URL)
+                                    .border_color(Self::preview_bg(cx))
+                                    .size(px(22.))
+                                    .into_any_element(),
+                            )
+                            .child(
+                                Avatar::new(AVATAR_URL)
+                                    .border_color(Self::preview_bg(cx))
+                                    .size(px(22.))
+                                    .into_any_element(),
+                            )
+                            .child(
+                                Avatar::new(AVATAR_URL)
+                                    .border_color(Self::preview_bg(cx))
+                                    .size(px(22.))
+                                    .into_any_element(),
+                            ),
+                    ),
+            )
+    }
+
+    fn render_buttons(&self, layer: ElevationIndex, cx: &ViewContext<Self>) -> impl IntoElement {
+        v_flex()
+            .gap_1()
+            .child(
+                Headline::new("Buttons")
+                    .size(HeadlineSize::Small)
+                    .color(Color::Muted),
+            )
+            .child(
+                h_flex()
+                    .items_start()
+                    .gap_px()
+                    .child(
+                        IconButton::new("icon_button_transparent", IconName::Check)
+                            .style(ButtonStyle::Transparent),
+                    )
+                    .child(
+                        IconButton::new("icon_button_subtle", IconName::Check)
+                            .style(ButtonStyle::Subtle),
+                    )
+                    .child(
+                        IconButton::new("icon_button_filled", IconName::Check)
+                            .style(ButtonStyle::Filled),
+                    )
+                    .child(
+                        IconButton::new("icon_button_selected_accent", IconName::Check)
+                            .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                            .selected(true),
+                    )
+                    .child(IconButton::new("icon_button_selected", IconName::Check).selected(true))
+                    .child(
+                        IconButton::new("icon_button_positive", IconName::Check)
+                            .style(ButtonStyle::Tinted(TintColor::Positive)),
+                    )
+                    .child(
+                        IconButton::new("icon_button_warning", IconName::Check)
+                            .style(ButtonStyle::Tinted(TintColor::Warning)),
+                    )
+                    .child(
+                        IconButton::new("icon_button_negative", IconName::Check)
+                            .style(ButtonStyle::Tinted(TintColor::Negative)),
+                    ),
+            )
+            .child(
+                h_flex()
+                    .gap_px()
+                    .child(
+                        Button::new("button_transparent", "Transparent")
+                            .style(ButtonStyle::Transparent),
+                    )
+                    .child(Button::new("button_subtle", "Subtle").style(ButtonStyle::Subtle))
+                    .child(Button::new("button_filled", "Filled").style(ButtonStyle::Filled))
+                    .child(
+                        Button::new("button_selected", "Selected")
+                            .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                            .selected(true),
+                    )
+                    .child(
+                        Button::new("button_selected_tinted", "Selected (Tinted)")
+                            .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                            .selected(true),
+                    )
+                    .child(
+                        Button::new("button_positive", "Tint::Positive")
+                            .style(ButtonStyle::Tinted(TintColor::Positive)),
+                    )
+                    .child(
+                        Button::new("button_warning", "Tint::Warning")
+                            .style(ButtonStyle::Tinted(TintColor::Warning)),
+                    )
+                    .child(
+                        Button::new("button_negative", "Tint::Negative")
+                            .style(ButtonStyle::Tinted(TintColor::Negative)),
+                    ),
+            )
+    }
+
+    fn render_text(&self, layer: ElevationIndex, cx: &ViewContext<Self>) -> impl IntoElement {
+        let bg = layer.bg(cx);
+
+        let label_with_contrast = |label: &str, fg: Hsla| {
+            let contrast = calculate_contrast_ratio(fg, bg);
+            format!("{} ({:.2})", label, contrast)
+        };
+
+        v_flex()
+            .gap_1()
+            .child(Headline::new("Text").size(HeadlineSize::Small).color(Color::Muted))
+            .child(
+                h_flex()
+                    .items_start()
+                    .gap_4()
+                    .child(
+                        v_flex()
+                            .gap_1()
+                            .child(Headline::new("Headline Sizes").size(HeadlineSize::Small).color(Color::Muted))
+                            .child(Headline::new("XLarge Headline").size(HeadlineSize::XLarge))
+                            .child(Headline::new("Large Headline").size(HeadlineSize::Large))
+                            .child(Headline::new("Medium Headline").size(HeadlineSize::Medium))
+                            .child(Headline::new("Small Headline").size(HeadlineSize::Small))
+                            .child(Headline::new("XSmall Headline").size(HeadlineSize::XSmall)),
+                    )
+                    .child(
+                        v_flex()
+                            .gap_1()
+                            .child(Headline::new("Text Colors").size(HeadlineSize::Small).color(Color::Muted))
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Default Text",
+                                    Color::Default.color(cx),
+                                ))
+                                .color(Color::Default),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Accent Text",
+                                    Color::Accent.color(cx),
+                                ))
+                                .color(Color::Accent),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Conflict Text",
+                                    Color::Conflict.color(cx),
+                                ))
+                                .color(Color::Conflict),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Created Text",
+                                    Color::Created.color(cx),
+                                ))
+                                .color(Color::Created),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Deleted Text",
+                                    Color::Deleted.color(cx),
+                                ))
+                                .color(Color::Deleted),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Disabled Text",
+                                    Color::Disabled.color(cx),
+                                ))
+                                .color(Color::Disabled),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Error Text",
+                                    Color::Error.color(cx),
+                                ))
+                                .color(Color::Error),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Hidden Text",
+                                    Color::Hidden.color(cx),
+                                ))
+                                .color(Color::Hidden),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Hint Text",
+                                    Color::Hint.color(cx),
+                                ))
+                                .color(Color::Hint),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Ignored Text",
+                                    Color::Ignored.color(cx),
+                                ))
+                                .color(Color::Ignored),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Info Text",
+                                    Color::Info.color(cx),
+                                ))
+                                .color(Color::Info),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Modified Text",
+                                    Color::Modified.color(cx),
+                                ))
+                                .color(Color::Modified),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Muted Text",
+                                    Color::Muted.color(cx),
+                                ))
+                                .color(Color::Muted),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Placeholder Text",
+                                    Color::Placeholder.color(cx),
+                                ))
+                                .color(Color::Placeholder),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Selected Text",
+                                    Color::Selected.color(cx),
+                                ))
+                                .color(Color::Selected),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Success Text",
+                                    Color::Success.color(cx),
+                                ))
+                                .color(Color::Success),
+                            )
+                            .child(
+                                Label::new(label_with_contrast(
+                                    "Warning Text",
+                                    Color::Warning.color(cx),
+                                ))
+                                .color(Color::Warning),
+                            )
+                    )
+                    .child(
+                        v_flex()
+                            .gap_1()
+                            .child(Headline::new("Wrapping Text").size(HeadlineSize::Small).color(Color::Muted))
+                            .child(
+                                div().max_w(px(200.)).child(
+                                "This is a longer piece of text that should wrap to multiple lines. It demonstrates how text behaves when it exceeds the width of its container."
+                            ))
+                    )
+            )
+    }
+
+    fn render_colors(&self, layer: ElevationIndex, cx: &ViewContext<Self>) -> impl IntoElement {
+        let bg = layer.bg(cx);
+        let all_colors = all_theme_colors(cx);
+
+        v_flex()
+            .gap_1()
+            .child(
+                Headline::new("Colors")
+                    .size(HeadlineSize::Small)
+                    .color(Color::Muted),
+            )
+            .child(
+                h_flex()
+                    .flex_wrap()
+                    .gap_1()
+                    .children(all_colors.into_iter().map(|(color, name)| {
+                        let id = ElementId::Name(format!("{:?}-preview", color).into());
+                        let name = name.clone();
+                        div().size_8().flex_none().child(
+                            ButtonLike::new(id)
+                                .child(
+                                    div()
+                                        .size_8()
+                                        .bg(color)
+                                        .border_1()
+                                        .border_color(cx.theme().colors().border)
+                                        .overflow_hidden(),
+                                )
+                                .size(ButtonSize::None)
+                                .style(ButtonStyle::Transparent)
+                                .tooltip(move |cx| {
+                                    let name = name.clone();
+                                    Tooltip::with_meta(name, None, format!("{:?}", color), cx)
+                                }),
+                        )
+                    })),
+            )
+    }
+
+    fn render_theme_layer(
+        &self,
+        layer: ElevationIndex,
+        cx: &ViewContext<Self>,
+    ) -> impl IntoElement {
+        v_flex()
+            .p_4()
+            .bg(layer.bg(cx))
+            .text_color(cx.theme().colors().text)
+            .gap_2()
+            .child(Headline::new(layer.clone().to_string()).size(HeadlineSize::Medium))
+            .child(self.render_avatars(cx))
+            .child(self.render_buttons(layer, cx))
+            .child(self.render_text(layer, cx))
+            .child(self.render_colors(layer, cx))
+    }
+}
+
+impl Render for ThemePreview {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl ui::IntoElement {
+        v_flex()
+            .id("theme-preview")
+            .key_context("ThemePreview")
+            .overflow_scroll()
+            .size_full()
+            .max_h_full()
+            .p_4()
+            .track_focus(&self.focus_handle)
+            .bg(Self::preview_bg(cx))
+            .gap_4()
+            .child(self.render_theme_layer(ElevationIndex::Background, cx))
+            .child(self.render_theme_layer(ElevationIndex::Surface, cx))
+            .child(self.render_theme_layer(ElevationIndex::EditorSurface, cx))
+            .child(self.render_theme_layer(ElevationIndex::ElevatedSurface, cx))
+    }
+}

crates/workspace/src/workspace.rs 🔗

@@ -9,6 +9,7 @@ pub mod searchable;
 pub mod shared_screen;
 mod status_bar;
 pub mod tasks;
+mod theme_preview;
 mod toolbar;
 mod workspace_settings;
 
@@ -323,6 +324,7 @@ pub fn init_settings(cx: &mut AppContext) {
 pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
     init_settings(cx);
     notifications::init(cx);
+    theme_preview::init(cx);
 
     cx.on_action(Workspace::close_global);
     cx.on_action(reload);