main.rs

  1mod theme_printer;
  2
  3use std::borrow::Cow;
  4use std::collections::HashMap;
  5use std::fmt::{self, Debug};
  6use std::fs::File;
  7use std::io::Write;
  8use std::path::PathBuf;
  9use std::str::FromStr;
 10
 11use anyhow::{anyhow, Context, Result};
 12use clap::Parser;
 13use convert_case::{Case, Casing};
 14use gpui2::{hsla, rgb, serde_json, AssetSource, Hsla, SharedString};
 15use log::LevelFilter;
 16use rust_embed::RustEmbed;
 17use serde::de::Visitor;
 18use serde::{Deserialize, Deserializer};
 19use simplelog::SimpleLogger;
 20use theme2::{PlayerTheme, SyntaxTheme};
 21
 22use crate::theme_printer::ThemePrinter;
 23
 24#[derive(Parser)]
 25#[command(author, version, about, long_about = None)]
 26struct Args {
 27    /// The name of the theme to convert.
 28    theme: String,
 29}
 30
 31fn main() -> Result<()> {
 32    SimpleLogger::init(LevelFilter::Info, Default::default()).expect("could not initialize logger");
 33
 34    // let args = Args::parse();
 35
 36    let themes_path = PathBuf::from_str("crates/theme2/src/themes")?;
 37
 38    let mut theme_modules = Vec::new();
 39
 40    for theme_path in Assets.list("themes/")? {
 41        let (_, theme_name) = theme_path.split_once("themes/").unwrap();
 42
 43        if theme_name == ".gitkeep" {
 44            continue;
 45        }
 46
 47        let (json_theme, legacy_theme) = load_theme(&theme_path)?;
 48
 49        let theme = convert_theme(json_theme, legacy_theme)?;
 50
 51        let theme_slug = theme
 52            .metadata
 53            .name
 54            .as_ref()
 55            .replace("é", "e")
 56            .to_case(Case::Snake);
 57
 58        let mut output_file = File::create(themes_path.join(format!("{theme_slug}.rs")))?;
 59
 60        let theme_module = format!(
 61            r#"
 62                use gpui2::rgba;
 63
 64                use crate::{{PlayerTheme, SyntaxTheme, Theme, ThemeMetadata}};
 65
 66                pub fn {theme_slug}() -> Theme {{
 67                    {theme_definition}
 68                }}
 69            "#,
 70            theme_definition = format!("{:#?}", ThemePrinter::new(theme))
 71        );
 72
 73        output_file.write_all(theme_module.as_bytes())?;
 74
 75        theme_modules.push(theme_slug);
 76    }
 77
 78    let mut mod_rs_file = File::create(themes_path.join(format!("mod.rs")))?;
 79
 80    let mod_rs_contents = format!(
 81        r#"
 82        {mod_statements}
 83
 84        {use_statements}
 85        "#,
 86        mod_statements = theme_modules
 87            .iter()
 88            .map(|module| format!("mod {module};"))
 89            .collect::<Vec<_>>()
 90            .join("\n"),
 91        use_statements = theme_modules
 92            .iter()
 93            .map(|module| format!("pub use {module}::*;"))
 94            .collect::<Vec<_>>()
 95            .join("\n")
 96    );
 97
 98    mod_rs_file.write_all(mod_rs_contents.as_bytes())?;
 99
100    Ok(())
101}
102
103#[derive(RustEmbed)]
104#[folder = "../../assets"]
105#[include = "fonts/**/*"]
106#[include = "icons/**/*"]
107#[include = "themes/**/*"]
108#[include = "sounds/**/*"]
109#[include = "*.md"]
110#[exclude = "*.DS_Store"]
111pub struct Assets;
112
113impl AssetSource for Assets {
114    fn load(&self, path: &str) -> Result<Cow<[u8]>> {
115        Self::get(path)
116            .map(|f| f.data)
117            .ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path))
118    }
119
120    fn list(&self, path: &str) -> Result<Vec<SharedString>> {
121        Ok(Self::iter()
122            .filter(|p| p.starts_with(path))
123            .map(SharedString::from)
124            .collect())
125    }
126}
127
128#[derive(Clone, Copy)]
129pub struct PlayerThemeColors {
130    pub cursor: Hsla,
131    pub selection: Hsla,
132}
133
134impl PlayerThemeColors {
135    pub fn new(theme: &LegacyTheme, ix: usize) -> Self {
136        if ix < theme.players.len() {
137            Self {
138                cursor: theme.players[ix].cursor,
139                selection: theme.players[ix].selection,
140            }
141        } else {
142            Self {
143                cursor: rgb::<Hsla>(0xff00ff),
144                selection: rgb::<Hsla>(0xff00ff),
145            }
146        }
147    }
148}
149
150impl From<PlayerThemeColors> for PlayerTheme {
151    fn from(value: PlayerThemeColors) -> Self {
152        Self {
153            cursor: value.cursor,
154            selection: value.selection,
155        }
156    }
157}
158
159fn convert_theme(json_theme: JsonTheme, legacy_theme: LegacyTheme) -> Result<theme2::Theme> {
160    let transparent = hsla(0.0, 0.0, 0.0, 0.0);
161
162    let players: [PlayerTheme; 8] = [
163        PlayerThemeColors::new(&legacy_theme, 0).into(),
164        PlayerThemeColors::new(&legacy_theme, 1).into(),
165        PlayerThemeColors::new(&legacy_theme, 2).into(),
166        PlayerThemeColors::new(&legacy_theme, 3).into(),
167        PlayerThemeColors::new(&legacy_theme, 4).into(),
168        PlayerThemeColors::new(&legacy_theme, 5).into(),
169        PlayerThemeColors::new(&legacy_theme, 6).into(),
170        PlayerThemeColors::new(&legacy_theme, 7).into(),
171    ];
172
173    let theme = theme2::Theme {
174        metadata: theme2::ThemeMetadata {
175            name: legacy_theme.name.clone().into(),
176            is_light: legacy_theme.is_light,
177        },
178        transparent,
179        mac_os_traffic_light_red: rgb::<Hsla>(0xEC695E),
180        mac_os_traffic_light_yellow: rgb::<Hsla>(0xF4BF4F),
181        mac_os_traffic_light_green: rgb::<Hsla>(0x62C554),
182        border: legacy_theme.lowest.base.default.border,
183        border_variant: legacy_theme.lowest.variant.default.border,
184        border_focused: legacy_theme.lowest.accent.default.border,
185        border_transparent: transparent,
186        elevated_surface: legacy_theme.lowest.base.default.background,
187        surface: legacy_theme.middle.base.default.background,
188        background: legacy_theme.lowest.base.default.background,
189        filled_element: legacy_theme.lowest.base.default.background,
190        filled_element_hover: hsla(0.0, 0.0, 100.0, 0.12),
191        filled_element_active: hsla(0.0, 0.0, 100.0, 0.16),
192        filled_element_selected: legacy_theme.lowest.accent.default.background,
193        filled_element_disabled: transparent,
194        ghost_element: transparent,
195        ghost_element_hover: hsla(0.0, 0.0, 100.0, 0.08),
196        ghost_element_active: hsla(0.0, 0.0, 100.0, 0.12),
197        ghost_element_selected: legacy_theme.lowest.accent.default.background,
198        ghost_element_disabled: transparent,
199        text: legacy_theme.lowest.base.default.foreground,
200        text_muted: legacy_theme.lowest.variant.default.foreground,
201        /// TODO: map this to a real value
202        text_placeholder: legacy_theme.lowest.negative.default.foreground,
203        text_disabled: legacy_theme.lowest.base.disabled.foreground,
204        text_accent: legacy_theme.lowest.accent.default.foreground,
205        icon_muted: legacy_theme.lowest.variant.default.foreground,
206        syntax: SyntaxTheme {
207            highlights: json_theme
208                .editor
209                .syntax
210                .iter()
211                .map(|(token, style)| (token.clone(), style.color.clone().into()))
212                .collect(),
213        },
214        status_bar: legacy_theme.lowest.base.default.background,
215        title_bar: legacy_theme.lowest.base.default.background,
216        toolbar: legacy_theme.highest.base.default.background,
217        tab_bar: legacy_theme.middle.base.default.background,
218        editor: legacy_theme.highest.base.default.background,
219        editor_subheader: legacy_theme.middle.base.default.background,
220        terminal: legacy_theme.highest.base.default.background,
221        editor_active_line: legacy_theme.highest.on.default.background,
222        image_fallback_background: legacy_theme.lowest.base.default.background,
223
224        git_created: legacy_theme.lowest.positive.default.foreground,
225        git_modified: legacy_theme.lowest.accent.default.foreground,
226        git_deleted: legacy_theme.lowest.negative.default.foreground,
227        git_conflict: legacy_theme.lowest.warning.default.foreground,
228        git_ignored: legacy_theme.lowest.base.disabled.foreground,
229        git_renamed: legacy_theme.lowest.warning.default.foreground,
230
231        players,
232    };
233
234    Ok(theme)
235}
236
237#[derive(Deserialize)]
238struct JsonTheme {
239    pub editor: JsonEditorTheme,
240    pub base_theme: serde_json::Value,
241}
242
243#[derive(Deserialize)]
244struct JsonEditorTheme {
245    pub syntax: HashMap<String, JsonSyntaxStyle>,
246}
247
248#[derive(Deserialize)]
249struct JsonSyntaxStyle {
250    pub color: Hsla,
251}
252
253/// Loads the [`Theme`] with the given name.
254fn load_theme(theme_path: &str) -> Result<(JsonTheme, LegacyTheme)> {
255    let theme_contents =
256        Assets::get(theme_path).with_context(|| format!("theme file not found: '{theme_path}'"))?;
257
258    let json_theme: JsonTheme = serde_json::from_str(std::str::from_utf8(&theme_contents.data)?)
259        .context("failed to parse legacy theme")?;
260
261    let legacy_theme: LegacyTheme = serde_json::from_value(json_theme.base_theme.clone())
262        .context("failed to parse `base_theme`")?;
263
264    Ok((json_theme, legacy_theme))
265}
266
267#[derive(Deserialize, Clone, Default, Debug)]
268pub struct LegacyTheme {
269    pub name: String,
270    pub is_light: bool,
271    pub lowest: Layer,
272    pub middle: Layer,
273    pub highest: Layer,
274    pub popover_shadow: Shadow,
275    pub modal_shadow: Shadow,
276    #[serde(deserialize_with = "deserialize_player_colors")]
277    pub players: Vec<PlayerColors>,
278    #[serde(deserialize_with = "deserialize_syntax_colors")]
279    pub syntax: HashMap<String, Hsla>,
280}
281
282#[derive(Deserialize, Clone, Default, Debug)]
283pub struct Layer {
284    pub base: StyleSet,
285    pub variant: StyleSet,
286    pub on: StyleSet,
287    pub accent: StyleSet,
288    pub positive: StyleSet,
289    pub warning: StyleSet,
290    pub negative: StyleSet,
291}
292
293#[derive(Deserialize, Clone, Default, Debug)]
294pub struct StyleSet {
295    #[serde(rename = "default")]
296    pub default: ContainerColors,
297    pub hovered: ContainerColors,
298    pub pressed: ContainerColors,
299    pub active: ContainerColors,
300    pub disabled: ContainerColors,
301    pub inverted: ContainerColors,
302}
303
304#[derive(Deserialize, Clone, Default, Debug)]
305pub struct ContainerColors {
306    pub background: Hsla,
307    pub foreground: Hsla,
308    pub border: Hsla,
309}
310
311#[derive(Deserialize, Clone, Default, Debug)]
312pub struct PlayerColors {
313    pub selection: Hsla,
314    pub cursor: Hsla,
315}
316
317#[derive(Deserialize, Clone, Default, Debug)]
318pub struct Shadow {
319    pub blur: u8,
320    pub color: Hsla,
321    pub offset: Vec<u8>,
322}
323
324fn deserialize_player_colors<'de, D>(deserializer: D) -> Result<Vec<PlayerColors>, D::Error>
325where
326    D: Deserializer<'de>,
327{
328    struct PlayerArrayVisitor;
329
330    impl<'de> Visitor<'de> for PlayerArrayVisitor {
331        type Value = Vec<PlayerColors>;
332
333        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
334            formatter.write_str("an object with integer keys")
335        }
336
337        fn visit_map<A: serde::de::MapAccess<'de>>(
338            self,
339            mut map: A,
340        ) -> Result<Self::Value, A::Error> {
341            let mut players = Vec::with_capacity(8);
342            while let Some((key, value)) = map.next_entry::<usize, PlayerColors>()? {
343                if key < 8 {
344                    players.push(value);
345                } else {
346                    return Err(serde::de::Error::invalid_value(
347                        serde::de::Unexpected::Unsigned(key as u64),
348                        &"a key in range 0..7",
349                    ));
350                }
351            }
352            Ok(players)
353        }
354    }
355
356    deserializer.deserialize_map(PlayerArrayVisitor)
357}
358
359fn deserialize_syntax_colors<'de, D>(deserializer: D) -> Result<HashMap<String, Hsla>, D::Error>
360where
361    D: serde::Deserializer<'de>,
362{
363    #[derive(Deserialize)]
364    struct ColorWrapper {
365        color: Hsla,
366    }
367
368    struct SyntaxVisitor;
369
370    impl<'de> Visitor<'de> for SyntaxVisitor {
371        type Value = HashMap<String, Hsla>;
372
373        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
374            formatter.write_str("a map with keys and objects with a single color field as values")
375        }
376
377        fn visit_map<M>(self, mut map: M) -> Result<HashMap<String, Hsla>, M::Error>
378        where
379            M: serde::de::MapAccess<'de>,
380        {
381            let mut result = HashMap::new();
382            while let Some(key) = map.next_key()? {
383                let wrapper: ColorWrapper = map.next_value()?; // Deserialize values as Hsla
384                result.insert(key, wrapper.color);
385            }
386            Ok(result)
387        }
388    }
389    deserializer.deserialize_map(SyntaxVisitor)
390}