Extend `theme_importer` in preparation for importing Zed1 themes (#3791)

Marshall Bowers created

This PR extends the `theme_importer` with the overall structure required
to support importing themes from Zed1.

Release Notes:

- N/A

Change summary

Cargo.lock                                  |   2 
crates/gpui/src/platform/mac.rs             |   2 
crates/gpui2/Cargo.toml                     |   6 
crates/theme2/src/themes/mod.rs             |  16 +-
crates/theme_importer/Cargo.toml            |   6 
crates/theme_importer/src/assets.rs         |  26 +++
crates/theme_importer/src/main.rs           | 118 +++++++++++++++
crates/theme_importer/src/zed1.rs           |   3 
crates/theme_importer/src/zed1/converter.rs | 176 +++++++++++++++++++++++
crates/util/Cargo.toml                      |   6 
crates/util/src/util.rs                     |   7 
11 files changed, 352 insertions(+), 16 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -9834,6 +9834,7 @@ dependencies = [
  "anyhow",
  "clap 4.4.4",
  "convert_case 0.6.0",
+ "gpui",
  "gpui2",
  "indexmap 1.9.3",
  "json_comments",
@@ -9843,6 +9844,7 @@ dependencies = [
  "serde",
  "simplelog",
  "strum",
+ "theme",
  "theme2",
  "uuid 1.4.1",
 ]

crates/gpui/src/platform/mac.rs πŸ”—

@@ -25,7 +25,7 @@ use window::MacWindow;
 
 use crate::executor;
 
-pub(crate) fn platform() -> Arc<dyn super::Platform> {
+pub fn platform() -> Arc<dyn super::Platform> {
     Arc::new(MacPlatform::new())
 }
 

crates/gpui2/Cargo.toml πŸ”—

@@ -9,6 +9,12 @@ publish = false
 [features]
 test-support = ["backtrace", "dhat", "env_logger", "collections/test-support", "util/test-support"]
 
+# Suppress a panic when both GPUI1 and GPUI2 are loaded.
+#
+# This is used in the `theme_importer` where we need to depend on both
+# GPUI1 and GPUI2 in order to convert Zed1 themes to Zed2 themes.
+allow-multiple-gpui-versions = ["util/allow-multiple-gpui-versions"]
+
 [lib]
 path = "src/gpui2.rs"
 doctest = false

crates/theme2/src/themes/mod.rs πŸ”—

@@ -29,16 +29,16 @@ use crate::UserThemeFamily;
 
 pub(crate) fn all_user_themes() -> Vec<UserThemeFamily> {
     vec![
-        rose_pine(),
-        night_owl(),
         andromeda(),
-        synthwave_84(),
-        palenight(),
-        dracula(),
-        solarized(),
-        nord(),
-        noctis(),
         ayu(),
+        dracula(),
         gruvbox(),
+        night_owl(),
+        noctis(),
+        nord(),
+        palenight(),
+        rose_pine(),
+        solarized(),
+        synthwave_84(),
     ]
 }

crates/theme_importer/Cargo.toml πŸ”—

@@ -4,14 +4,13 @@ version = "0.1.0"
 edition = "2021"
 publish = false
 
-# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
-
 [dependencies]
 any_ascii = "0.3.2"
 anyhow.workspace = true
 clap = { version = "4.4", features = ["derive"] }
 convert_case = "0.6.0"
-gpui = { package = "gpui2", path = "../gpui2" }
+gpui = { package = "gpui2", path = "../gpui2", features = ["allow-multiple-gpui-versions"] }
+gpui1 = { package = "gpui", path = "../gpui" }
 indexmap = { version = "1.6.2", features = ["serde"] }
 json_comments = "0.2.2"
 log.workspace = true
@@ -21,4 +20,5 @@ serde.workspace = true
 simplelog = "0.9"
 strum = { version = "0.25.0", features = ["derive"] }
 theme = { package = "theme2", path = "../theme2", features = ["importing-themes"] }
+theme1 = { package = "theme", path = "../theme" }
 uuid.workspace = true

crates/theme_importer/src/assets.rs πŸ”—

@@ -0,0 +1,26 @@
+use std::borrow::Cow;
+
+use anyhow::{anyhow, Result};
+use gpui::{AssetSource, SharedString};
+use rust_embed::RustEmbed;
+
+#[derive(RustEmbed)]
+#[folder = "../../assets"]
+#[include = "fonts/**/*"]
+#[exclude = "*.DS_Store"]
+pub struct Assets;
+
+impl AssetSource for Assets {
+    fn load(&self, path: &str) -> Result<Cow<[u8]>> {
+        Self::get(path)
+            .map(|f| f.data)
+            .ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path))
+    }
+
+    fn list(&self, path: &str) -> Result<Vec<SharedString>> {
+        Ok(Self::iter()
+            .filter(|p| p.starts_with(path))
+            .map(SharedString::from)
+            .collect())
+    }
+}

crates/theme_importer/src/main.rs πŸ”—

@@ -1,29 +1,36 @@
+mod assets;
 mod color;
 mod theme_printer;
 mod util;
 mod vscode;
+mod zed1;
 
+use std::collections::HashMap;
 use std::fs::{self, File};
 use std::io::Write;
 use std::path::PathBuf;
 use std::process::Command;
 use std::str::FromStr;
+use std::sync::Arc;
 
 use any_ascii::any_ascii;
 use anyhow::{anyhow, Context, Result};
 use clap::Parser;
 use convert_case::{Case, Casing};
-use gpui::serde_json;
+use gpui::{serde_json, AssetSource};
 use indexmap::IndexMap;
 use json_comments::StripComments;
 use log::LevelFilter;
 use serde::Deserialize;
 use simplelog::{TermLogger, TerminalMode};
-use theme::{Appearance, UserThemeFamily};
+use theme::{Appearance, UserTheme, UserThemeFamily};
+use theme1::Theme as Zed1Theme;
 
+use crate::assets::Assets;
 use crate::theme_printer::UserThemeFamilyPrinter;
 use crate::vscode::VsCodeTheme;
 use crate::vscode::VsCodeThemeConverter;
+use crate::zed1::Zed1ThemeConverter;
 
 #[derive(Debug, Deserialize)]
 struct FamilyMetadata {
@@ -66,6 +73,10 @@ pub struct ThemeMetadata {
 #[derive(Parser)]
 #[command(author, version, about, long_about = None)]
 struct Args {
+    /// Whether to import Zed1 themes.
+    #[arg(long)]
+    zed1: bool,
+
     /// Whether to warn when values are missing from the theme.
     #[arg(long)]
     warn_on_missing: bool,
@@ -176,6 +187,102 @@ fn main() -> Result<()> {
         theme_families.push(theme_family);
     }
 
+    if args.zed1 {
+        let zed1_themes_path = PathBuf::from_str("assets/themes")?;
+
+        let zed1_theme_familes = [
+            "Andromeda",
+            "Atelier",
+            "Ayu",
+            "Gruvbox",
+            "One",
+            "RosΓ© Pine",
+            "Sandcastle",
+            "Solarized",
+            "Summercamp",
+        ];
+
+        let mut zed1_themes_by_family: HashMap<String, Vec<UserTheme>> = HashMap::from_iter(
+            zed1_theme_familes
+                .into_iter()
+                .map(|family| (family.to_string(), Vec::new())),
+        );
+
+        let platform = gpui1::platform::current::platform();
+        let zed1_font_cache = Arc::new(gpui1::FontCache::new(platform.fonts()));
+
+        let mut embedded_fonts = Vec::new();
+        for font_path in Assets.list("fonts")? {
+            if font_path.ends_with(".ttf") {
+                let font_bytes = Assets.load(&font_path)?.to_vec();
+                embedded_fonts.push(Arc::from(font_bytes));
+            }
+        }
+
+        platform.fonts().add_fonts(&embedded_fonts)?;
+
+        for entry in fs::read_dir(&zed1_themes_path)? {
+            let entry = entry?;
+
+            if entry.file_type()?.is_dir() {
+                continue;
+            }
+
+            match entry.path().extension() {
+                None => continue,
+                Some(extension) => {
+                    if extension != "json" {
+                        continue;
+                    }
+                }
+            }
+
+            let theme_file_path = entry.path();
+
+            let theme_file = match File::open(&theme_file_path) {
+                Ok(file) => file,
+                Err(_) => {
+                    log::info!("Failed to open file at path: {:?}", theme_file_path);
+                    continue;
+                }
+            };
+
+            let theme_without_comments = StripComments::new(theme_file);
+
+            let zed1_theme: Zed1Theme =
+                gpui1::fonts::with_font_cache(zed1_font_cache.clone(), || {
+                    serde_json::from_reader(theme_without_comments)
+                        .context(format!("failed to parse theme {theme_file_path:?}"))
+                })?;
+
+            let theme_name = zed1_theme.meta.name.clone();
+
+            let converter = Zed1ThemeConverter::new(zed1_theme);
+
+            let theme = converter.convert()?;
+
+            let Some((_, themes_for_family)) = zed1_themes_by_family
+                .iter_mut()
+                .find(|(family, _)| theme_name.starts_with(*family))
+            else {
+                log::warn!("No theme family found for '{}'.", theme_name);
+                continue;
+            };
+
+            themes_for_family.push(theme);
+        }
+
+        for (family, themes) in zed1_themes_by_family {
+            let theme_family = UserThemeFamily {
+                name: format!("{family} (Zed1)"),
+                author: "Zed Industries".to_string(),
+                themes,
+            };
+
+            theme_families.push(theme_family);
+        }
+    }
+
     let themes_output_path = PathBuf::from_str(OUT_PATH)?;
 
     if !themes_output_path.exists() {
@@ -188,7 +295,10 @@ fn main() -> Result<()> {
     let mut theme_modules = Vec::new();
 
     for theme_family in theme_families {
-        let theme_family_slug = any_ascii(&theme_family.name).to_case(Case::Snake);
+        let theme_family_slug = any_ascii(&theme_family.name)
+            .replace("(", "")
+            .replace(")", "")
+            .to_case(Case::Snake);
 
         let mut output_file =
             File::create(themes_output_path.join(format!("{theme_family_slug}.rs")))?;
@@ -222,6 +332,8 @@ fn main() -> Result<()> {
         theme_modules.push(theme_family_slug);
     }
 
+    theme_modules.sort();
+
     let themes_vector_contents = format!(
         r#"
         use crate::UserThemeFamily;

crates/theme_importer/src/zed1/converter.rs πŸ”—

@@ -0,0 +1,176 @@
+use anyhow::Result;
+use gpui::{Hsla, Rgba};
+use gpui1::color::Color as Zed1Color;
+use gpui1::fonts::HighlightStyle as Zed1HighlightStyle;
+use theme::{
+    Appearance, StatusColorsRefinement, ThemeColorsRefinement, UserFontStyle, UserFontWeight,
+    UserHighlightStyle, UserSyntaxTheme, UserTheme, UserThemeStylesRefinement,
+};
+use theme1::Theme as Zed1Theme;
+
+fn zed1_color_to_hsla(color: Zed1Color) -> Hsla {
+    let r = color.r as f32 / 255.;
+    let g = color.g as f32 / 255.;
+    let b = color.b as f32 / 255.;
+    let a = color.a as f32 / 255.;
+
+    Hsla::from(Rgba { r, g, b, a })
+}
+
+fn zed1_highlight_style_to_user_highlight_style(
+    highlight: Zed1HighlightStyle,
+) -> UserHighlightStyle {
+    UserHighlightStyle {
+        color: highlight.color.map(zed1_color_to_hsla),
+        font_style: highlight.italic.map(|is_italic| {
+            if is_italic {
+                UserFontStyle::Italic
+            } else {
+                UserFontStyle::Normal
+            }
+        }),
+        font_weight: highlight.weight.map(|weight| UserFontWeight(weight.0)),
+    }
+}
+
+pub struct Zed1ThemeConverter {
+    theme: Zed1Theme,
+}
+
+impl Zed1ThemeConverter {
+    pub fn new(theme: Zed1Theme) -> Self {
+        Self { theme }
+    }
+
+    pub fn convert(self) -> Result<UserTheme> {
+        let appearance = match self.theme.meta.is_light {
+            true => Appearance::Light,
+            false => Appearance::Dark,
+        };
+
+        let status_colors_refinement = self.convert_status_colors()?;
+        let theme_colors_refinement = self.convert_theme_colors()?;
+        let syntax_theme = self.convert_syntax_theme()?;
+
+        Ok(UserTheme {
+            name: format!("{} (Zed1)", self.theme.meta.name),
+            appearance,
+            styles: UserThemeStylesRefinement {
+                colors: theme_colors_refinement,
+                status: status_colors_refinement,
+                syntax: Some(syntax_theme),
+            },
+        })
+    }
+
+    fn convert_status_colors(&self) -> Result<StatusColorsRefinement> {
+        fn convert(color: Zed1Color) -> Option<Hsla> {
+            Some(zed1_color_to_hsla(color))
+        }
+
+        let diff_style = self.theme.editor.diff.clone();
+
+        Ok(StatusColorsRefinement {
+            created: convert(diff_style.inserted),
+            modified: convert(diff_style.modified),
+            deleted: convert(diff_style.deleted),
+            ..Default::default()
+        })
+    }
+
+    fn convert_theme_colors(&self) -> Result<ThemeColorsRefinement> {
+        fn convert(color: Zed1Color) -> Option<Hsla> {
+            Some(zed1_color_to_hsla(color))
+        }
+
+        let tab_bar = self.theme.workspace.tab_bar.clone();
+        let active_tab = self.theme.workspace.tab_bar.tab_style(true, true).clone();
+        let inactive_tab = self.theme.workspace.tab_bar.tab_style(true, false).clone();
+        let toolbar = self.theme.workspace.toolbar.clone();
+        let scrollbar = self.theme.editor.scrollbar.clone();
+
+        let zed1_titlebar_border = convert(self.theme.titlebar.container.border.color);
+
+        Ok(ThemeColorsRefinement {
+            border: zed1_titlebar_border,
+            border_variant: zed1_titlebar_border,
+            background: convert(self.theme.workspace.background),
+            title_bar_background: self
+                .theme
+                .titlebar
+                .container
+                .background_color
+                .map(zed1_color_to_hsla),
+            status_bar_background: self
+                .theme
+                .workspace
+                .status_bar
+                .container
+                .background_color
+                .map(zed1_color_to_hsla),
+            text: convert(self.theme.editor.text_color),
+            tab_bar_background: tab_bar.container.background_color.map(zed1_color_to_hsla),
+            tab_active_background: active_tab
+                .container
+                .background_color
+                .map(zed1_color_to_hsla),
+            tab_inactive_background: inactive_tab
+                .container
+                .background_color
+                .map(zed1_color_to_hsla),
+            toolbar_background: toolbar.container.background_color.map(zed1_color_to_hsla),
+            editor_foreground: convert(self.theme.editor.text_color),
+            editor_background: convert(self.theme.editor.background),
+            editor_gutter_background: convert(self.theme.editor.gutter_background),
+            editor_line_number: convert(self.theme.editor.line_number),
+            editor_active_line_number: convert(self.theme.editor.line_number_active),
+            editor_wrap_guide: convert(self.theme.editor.wrap_guide),
+            editor_active_wrap_guide: convert(self.theme.editor.active_wrap_guide),
+            scrollbar_track_background: scrollbar.track.background_color.map(zed1_color_to_hsla),
+            scrollbar_track_border: convert(scrollbar.track.border.color),
+            scrollbar_thumb_background: scrollbar.thumb.background_color.map(zed1_color_to_hsla),
+            scrollbar_thumb_border: convert(scrollbar.thumb.border.color),
+            scrollbar_thumb_hover_background: scrollbar
+                .thumb
+                .background_color
+                .map(zed1_color_to_hsla),
+            terminal_background: convert(self.theme.terminal.background),
+            terminal_ansi_bright_black: convert(self.theme.terminal.bright_black),
+            terminal_ansi_bright_red: convert(self.theme.terminal.bright_red),
+            terminal_ansi_bright_green: convert(self.theme.terminal.bright_green),
+            terminal_ansi_bright_yellow: convert(self.theme.terminal.bright_yellow),
+            terminal_ansi_bright_blue: convert(self.theme.terminal.bright_blue),
+            terminal_ansi_bright_magenta: convert(self.theme.terminal.bright_magenta),
+            terminal_ansi_bright_cyan: convert(self.theme.terminal.bright_cyan),
+            terminal_ansi_bright_white: convert(self.theme.terminal.bright_white),
+            terminal_ansi_black: convert(self.theme.terminal.black),
+            terminal_ansi_red: convert(self.theme.terminal.red),
+            terminal_ansi_green: convert(self.theme.terminal.green),
+            terminal_ansi_yellow: convert(self.theme.terminal.yellow),
+            terminal_ansi_blue: convert(self.theme.terminal.blue),
+            terminal_ansi_magenta: convert(self.theme.terminal.magenta),
+            terminal_ansi_cyan: convert(self.theme.terminal.cyan),
+            terminal_ansi_white: convert(self.theme.terminal.white),
+            ..Default::default()
+        })
+    }
+
+    fn convert_syntax_theme(&self) -> Result<UserSyntaxTheme> {
+        Ok(UserSyntaxTheme {
+            highlights: self
+                .theme
+                .editor
+                .syntax
+                .highlights
+                .clone()
+                .into_iter()
+                .map(|(name, highlight_style)| {
+                    (
+                        name,
+                        zed1_highlight_style_to_user_highlight_style(highlight_style),
+                    )
+                })
+                .collect(),
+        })
+    }
+}

crates/util/Cargo.toml πŸ”—

@@ -11,6 +11,12 @@ doctest = true
 [features]
 test-support = ["tempdir", "git2"]
 
+# Suppress a panic when both GPUI1 and GPUI2 are loaded.
+#
+# This is used in the `theme_importer` where we need to depend on both
+# GPUI1 and GPUI2 in order to convert Zed1 themes to Zed2 themes.
+allow-multiple-gpui-versions = []
+
 [dependencies]
 anyhow.workspace = true
 backtrace = "0.3"

crates/util/src/util.rs πŸ”—

@@ -13,10 +13,12 @@ use std::{
     ops::{AddAssign, Range, RangeInclusive},
     panic::Location,
     pin::Pin,
-    sync::atomic::AtomicU32,
     task::{Context, Poll},
 };
 
+#[cfg(not(feature = "allow-multiple-gpui-versions"))]
+use std::sync::atomic::AtomicU32;
+
 pub use backtrace::Backtrace;
 use futures::Future;
 use rand::{seq::SliceRandom, Rng};
@@ -434,15 +436,18 @@ impl<T: Ord + Clone> RangeExt<T> for RangeInclusive<T> {
     }
 }
 
+#[cfg(not(feature = "allow-multiple-gpui-versions"))]
 static GPUI_LOADED: AtomicU32 = AtomicU32::new(0);
 
 pub fn gpui2_loaded() {
+    #[cfg(not(feature = "allow-multiple-gpui-versions"))]
     if GPUI_LOADED.fetch_add(2, std::sync::atomic::Ordering::SeqCst) != 0 {
         panic!("=========\nYou are loading both GPUI1 and GPUI2 in the same build!\nFix Your Dependencies with cargo tree!\n=========")
     }
 }
 
 pub fn gpui1_loaded() {
+    #[cfg(not(feature = "allow-multiple-gpui-versions"))]
     if GPUI_LOADED.fetch_add(1, std::sync::atomic::Ordering::SeqCst) != 0 {
         panic!("=========\nYou are loading both GPUI1 and GPUI2 in the same build!\nFix Your Dependencies with cargo tree!\n=========")
     }