Start work on allowing variables in themes

Max Brunsfeld created

Change summary

zed/Cargo.toml                |   6 
zed/assets/themes/base.toml   |  28 +++
zed/assets/themes/dark.toml   |  35 +--
zed/src/editor/display_map.rs |  46 +++-
zed/src/lib.rs                |   2 
zed/src/main.rs               |   4 
zed/src/settings.rs           | 323 ++++++++++++++++++++++++++++++------
zed/src/test.rs               |  11 +
zed/src/workspace.rs          |   2 
9 files changed, 348 insertions(+), 109 deletions(-)

Detailed changes

zed/Cargo.toml 🔗

@@ -14,7 +14,7 @@ name = "Zed"
 path = "src/main.rs"
 
 [features]
-test-support = ["tempdir", "serde_json", "zrpc/test-support"]
+test-support = ["tempdir", "zrpc/test-support"]
 
 [dependencies]
 anyhow = "1.0.38"
@@ -41,9 +41,7 @@ rsa = "0.4"
 rust-embed = "5.9.0"
 seahash = "4.1"
 serde = { version = "1", features = ["derive"] }
-serde_json = { version = "1.0.64", features = [
-  "preserve_order",
-], optional = true }
+serde_json = { version = "1.0.64", features = ["preserve_order"] }
 similar = "1.3"
 simplelog = "0.9"
 smallvec = { version = "1.6", features = ["union"] }

zed/assets/themes/base.toml 🔗

@@ -0,0 +1,28 @@
+[ui]
+background = "$elevation_1"
+tab_background = "$elevation_2"
+tab_background_active = "$elevation_3"
+tab_text = "$text_dull"
+tab_text_active = "$text_bright"
+tab_border = 0x000000
+tab_icon_close = 0x383839
+tab_icon_dirty = 0x556de8
+tab_icon_conflict = 0xe45349
+modal_background = "$elevation_4"
+modal_match_background = 0x424344
+modal_match_background_active = 0x094771
+modal_match_border = 0x000000
+modal_match_text = 0xcccccc
+modal_match_text_highlight = 0x18a3ff
+
+[editor]
+background = "$elevation_3"
+gutter_background = "$elevation_3"
+active_line_background = "$elevation_4"
+line_number = "$text_dull"
+line_number_active = "$text_bright"
+default_text = "$text_normal"
+replicas = [
+    { selection = 0x264f78, cursor = "$text_bright" },
+    { selection = 0x504f31, cursor = 0xfcf154 },
+]

zed/assets/themes/dark.toml 🔗

@@ -1,30 +1,13 @@
-[ui]
-tab_background = 0x131415
-tab_background_active = 0x1c1d1e
-tab_text = 0x5a5a5b
-tab_text_active = 0xffffff
-tab_border = 0x000000
-tab_icon_close = 0x383839
-tab_icon_dirty = 0x556de8
-tab_icon_conflict = 0xe45349
-modal_background = 0x3a3b3c
-modal_match_background = 0x424344
-modal_match_background_active = 0x094771
-modal_match_border = 0x000000
-modal_match_text = 0xcccccc
-modal_match_text_highlight = 0x18a3ff
+extends = "base"
 
-[editor]
-background = 0x131415
-gutter_background = 0x131415
-active_line_background = 0x1c1d1e
-line_number = 0x5a5a5b
-line_number_active = 0xffffff
-default_text = 0xd4d4d4
-replicas = [
-    { selection = 0x264f78, cursor = 0xffffff },
-    { selection = 0x504f31, cursor = 0xfcf154 },
-]
+[variables]
+elevation_1 = 0x050101
+elevation_2 = 0x131415
+elevation_3 = 0x1c1d1e
+elevation_4 = 0x3a3b3c
+text_dull = 0x5a5a5b
+text_bright = 0xffffff
+text_normal = 0xd4d4d4
 
 [syntax]
 keyword = 0xc586c0

zed/src/editor/display_map.rs 🔗

@@ -340,7 +340,7 @@ mod tests {
         util::RandomCharIter,
     };
     use buffer::{History, SelectionGoal};
-    use gpui::MutableAppContext;
+    use gpui::{color::ColorU, MutableAppContext};
     use rand::{prelude::StdRng, Rng};
     use std::{env, sync::Arc};
     use Bias::*;
@@ -652,13 +652,21 @@ mod tests {
             (function_item name: (identifier) @fn.name)"#,
         )
         .unwrap();
-        let theme = Theme::parse(
-            r#"
-            [syntax]
-            "mod.body" = 0xff0000
-            "fn.name" = 0x00ff00"#,
-        )
-        .unwrap();
+        let theme = Theme {
+            syntax: vec![
+                (
+                    "mod.body".to_string(),
+                    ColorU::from_u32(0xff0000ff),
+                    Default::default(),
+                ),
+                (
+                    "fn.name".to_string(),
+                    ColorU::from_u32(0x00ff00ff),
+                    Default::default(),
+                ),
+            ],
+            ..Default::default()
+        };
         let lang = Arc::new(Language {
             config: LanguageConfig {
                 name: "Test".to_string(),
@@ -742,13 +750,21 @@ mod tests {
             (function_item name: (identifier) @fn.name)"#,
         )
         .unwrap();
-        let theme = Theme::parse(
-            r#"
-            [syntax]
-            "mod.body" = 0xff0000
-            "fn.name" = 0x00ff00"#,
-        )
-        .unwrap();
+        let theme = Theme {
+            syntax: vec![
+                (
+                    "mod.body".to_string(),
+                    ColorU::from_u32(0xff0000ff),
+                    Default::default(),
+                ),
+                (
+                    "fn.name".to_string(),
+                    ColorU::from_u32(0x00ff00ff),
+                    Default::default(),
+                ),
+            ],
+            ..Default::default()
+        };
         let lang = Arc::new(Language {
             config: LanguageConfig {
                 name: "Test".to_string(),

zed/src/lib.rs 🔗

@@ -18,9 +18,11 @@ pub mod workspace;
 pub mod worktree;
 
 pub use settings::Settings;
+
 pub struct AppState {
     pub settings: postage::watch::Receiver<Settings>,
     pub languages: std::sync::Arc<language::LanguageRegistry>,
+    pub themes: std::sync::Arc<settings::ThemeRegistry>,
     pub rpc_router: std::sync::Arc<ForegroundRouter>,
     pub rpc: rpc::Client,
     pub fs: std::sync::Arc<dyn fs::Fs>,

zed/src/main.rs 🔗

@@ -20,13 +20,15 @@ fn main() {
 
     let app = gpui::App::new(assets::Assets).unwrap();
 
-    let (_, settings) = settings::channel(&app.font_cache()).unwrap();
+    let themes = settings::ThemeRegistry::new(assets::Assets);
+    let (_, settings) = settings::channel_with_themes(&app.font_cache(), &themes).unwrap();
     let languages = Arc::new(language::LanguageRegistry::new());
     languages.set_theme(&settings.borrow().theme);
 
     let mut app_state = AppState {
         languages: languages.clone(),
         settings,
+        themes,
         rpc_router: Arc::new(ForegroundRouter::new()),
         rpc: rpc::Client::new(languages),
         fs: Arc::new(RealFs),

zed/src/settings.rs 🔗

@@ -1,12 +1,14 @@
-use super::assets::Assets;
 use anyhow::{anyhow, Context, Result};
 use gpui::{
     color::ColorU,
     font_cache::{FamilyId, FontCache},
     fonts::{Properties as FontProperties, Style as FontStyle, Weight as FontWeight},
+    AssetSource,
 };
+use parking_lot::Mutex;
 use postage::watch;
-use serde::Deserialize;
+use serde::{de::value::MapDeserializer, Deserialize};
+use serde_json::Value;
 use std::{
     collections::HashMap,
     fmt,
@@ -26,16 +28,37 @@ pub struct Settings {
     pub theme: Arc<Theme>,
 }
 
+pub struct ThemeRegistry {
+    assets: Box<dyn AssetSource>,
+    themes: Mutex<HashMap<String, Arc<Theme>>>,
+    theme_data: Mutex<HashMap<String, Arc<ThemeToml>>>,
+}
+
 #[derive(Clone, Default)]
 pub struct Theme {
     pub ui: UiTheme,
     pub editor: EditorTheme,
-    syntax: Vec<(String, ColorU, FontProperties)>,
+    pub syntax: Vec<(String, ColorU, FontProperties)>,
+}
+
+#[derive(Deserialize)]
+struct ThemeToml {
+    #[serde(default)]
+    extends: Option<String>,
+    #[serde(default)]
+    variables: HashMap<String, Value>,
+    #[serde(default)]
+    ui: HashMap<String, Value>,
+    #[serde(default)]
+    editor: HashMap<String, Value>,
+    #[serde(default)]
+    syntax: HashMap<String, Value>,
 }
 
 #[derive(Clone, Default, Deserialize)]
 #[serde(default)]
 pub struct UiTheme {
+    pub background: Color,
     pub tab_background: Color,
     pub tab_background_active: Color,
     pub tab_text: Color,
@@ -81,16 +104,17 @@ pub struct StyleId(u32);
 
 impl Settings {
     pub fn new(font_cache: &FontCache) -> Result<Self> {
+        Self::new_with_theme(font_cache, Arc::new(Theme::default()))
+    }
+
+    pub fn new_with_theme(font_cache: &FontCache, theme: Arc<Theme>) -> Result<Self> {
         Ok(Self {
             buffer_font_family: font_cache.load_family(&["Fira Code", "Monaco"])?,
             buffer_font_size: 14.0,
             tab_size: 4,
             ui_font_family: font_cache.load_family(&["SF Pro", "Helvetica"])?,
             ui_font_size: 12.0,
-            theme: Arc::new(
-                Theme::parse(Assets::get("themes/dark.toml").unwrap())
-                    .expect("Failed to parse built-in theme"),
-            ),
+            theme,
         })
     }
 
@@ -100,62 +124,104 @@ impl Settings {
     }
 }
 
-impl Theme {
-    pub fn parse(source: impl AsRef<[u8]>) -> Result<Self> {
-        #[derive(Deserialize)]
-        struct ThemeToml {
-            #[serde(default)]
-            ui: UiTheme,
-            #[serde(default)]
-            editor: EditorTheme,
-            #[serde(default)]
-            syntax: HashMap<String, StyleToml>,
-        }
+impl ThemeRegistry {
+    pub fn new(source: impl AssetSource) -> Arc<Self> {
+        Arc::new(Self {
+            assets: Box::new(source),
+            themes: Default::default(),
+            theme_data: Default::default(),
+        })
+    }
 
-        #[derive(Deserialize)]
-        #[serde(untagged)]
-        enum StyleToml {
-            Color(Color),
-            Full {
-                color: Option<Color>,
-                weight: Option<toml::Value>,
-                #[serde(default)]
-                italic: bool,
-            },
+    pub fn get(&self, name: &str) -> Result<Arc<Theme>> {
+        if let Some(theme) = self.themes.lock().get(name) {
+            return Ok(theme.clone());
         }
 
-        let theme_toml: ThemeToml =
-            toml::from_slice(source.as_ref()).context("failed to parse theme TOML")?;
-
+        let theme_toml = self.load(name)?;
         let mut syntax = Vec::<(String, ColorU, FontProperties)>::new();
-        for (key, style) in theme_toml.syntax {
-            let (color, weight, italic) = match style {
-                StyleToml::Color(color) => (color, None, false),
-                StyleToml::Full {
-                    color,
-                    weight,
-                    italic,
-                } => (color.unwrap_or(Color::default()), weight, italic),
-            };
-            match syntax.binary_search_by_key(&&key, |e| &e.0) {
-                Ok(i) | Err(i) => {
-                    let mut properties = FontProperties::new();
-                    properties.weight = deserialize_weight(weight)?;
-                    if italic {
+        for (key, style) in theme_toml.syntax.iter() {
+            let mut color = Color::default();
+            let mut properties = FontProperties::new();
+            match style {
+                Value::Object(object) => {
+                    if let Some(value) = object.get("color") {
+                        color = serde_json::from_value(value.clone())?;
+                    }
+                    if let Some(Value::Bool(true)) = object.get("italic") {
                         properties.style = FontStyle::Italic;
                     }
-                    syntax.insert(i, (key, color.0, properties));
+                    properties.weight = deserialize_weight(object.get("weight"))?;
+                }
+                _ => {
+                    color = serde_json::from_value(style.clone())?;
+                }
+            }
+            match syntax.binary_search_by_key(&key, |e| &e.0) {
+                Ok(i) | Err(i) => {
+                    syntax.insert(i, (key.to_string(), color.0, properties));
                 }
             }
         }
 
-        Ok(Theme {
-            ui: theme_toml.ui,
-            editor: theme_toml.editor,
+        let theme = Arc::new(Theme {
+            ui: UiTheme::deserialize(MapDeserializer::new(theme_toml.ui.clone().into_iter()))?,
+            editor: EditorTheme::deserialize(MapDeserializer::new(
+                theme_toml.editor.clone().into_iter(),
+            ))?,
             syntax,
-        })
+        });
+
+        self.themes.lock().insert(name.to_string(), theme.clone());
+        Ok(theme)
+    }
+
+    fn load(&self, name: &str) -> Result<Arc<ThemeToml>> {
+        if let Some(data) = self.theme_data.lock().get(name) {
+            return Ok(data.clone());
+        }
+
+        let asset_path = format!("themes/{}.toml", name);
+        let source_code = self
+            .assets
+            .load(&asset_path)
+            .with_context(|| format!("failed to load theme file {}", asset_path))?;
+
+        let mut theme_toml: ThemeToml = toml::from_slice(source_code.as_ref())
+            .with_context(|| format!("failed to parse {}.toml", name))?;
+
+        // If this theme extends another base theme, merge in the raw data from the base theme.
+        if let Some(base_name) = theme_toml.extends.as_ref() {
+            let base_theme_toml = self
+                .load(base_name)
+                .with_context(|| format!("failed to load base theme {}", base_name))?;
+            merge_map(&mut theme_toml.ui, &base_theme_toml.ui);
+            merge_map(&mut theme_toml.editor, &base_theme_toml.editor);
+            merge_map(&mut theme_toml.syntax, &base_theme_toml.syntax);
+            merge_map(&mut theme_toml.variables, &base_theme_toml.variables);
+        }
+
+        // Substitute any variable references for their definitions.
+        let values = theme_toml
+            .ui
+            .values_mut()
+            .chain(theme_toml.editor.values_mut())
+            .chain(theme_toml.syntax.values_mut());
+        let mut name_stack = Vec::new();
+        for value in values {
+            name_stack.clear();
+            evaluate_variables(value, &theme_toml.variables, &mut name_stack)?;
+        }
+
+        let result = Arc::new(theme_toml);
+        self.theme_data
+            .lock()
+            .insert(name.to_string(), result.clone());
+        Ok(result)
     }
+}
 
+impl Theme {
     pub fn syntax_style(&self, id: StyleId) -> (ColorU, FontProperties) {
         self.syntax.get(id.0 as usize).map_or(
             (self.editor.default_text.0, FontProperties::new()),
@@ -221,13 +287,19 @@ impl Default for StyleId {
     }
 }
 
+impl Color {
+    fn from_u32(rgba: u32) -> Self {
+        Self(ColorU::from_u32(rgba))
+    }
+}
+
 impl<'de> Deserialize<'de> for Color {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     where
         D: serde::Deserializer<'de>,
     {
-        let rgba_value = u32::deserialize(deserializer)?;
-        Ok(Self(ColorU::from_u32((rgba_value << 8) + 0xFF)))
+        let rgb = u32::deserialize(deserializer)?;
+        Ok(Self::from_u32((rgb << 8) + 0xFF))
     }
 }
 
@@ -268,11 +340,25 @@ pub fn channel(
     Ok(watch::channel_with(Settings::new(font_cache)?))
 }
 
-fn deserialize_weight(weight: Option<toml::Value>) -> Result<FontWeight> {
-    match &weight {
+pub fn channel_with_themes(
+    font_cache: &FontCache,
+    themes: &ThemeRegistry,
+) -> Result<(watch::Sender<Settings>, watch::Receiver<Settings>)> {
+    Ok(watch::channel_with(Settings::new_with_theme(
+        font_cache,
+        themes.get("dark").expect("failed to load default theme"),
+    )?))
+}
+
+fn deserialize_weight(weight: Option<&Value>) -> Result<FontWeight> {
+    match weight {
         None => return Ok(FontWeight::NORMAL),
-        Some(toml::Value::Integer(i)) => return Ok(FontWeight(*i as f32)),
-        Some(toml::Value::String(s)) => match s.as_str() {
+        Some(Value::Number(number)) => {
+            if let Some(weight) = number.as_f64() {
+                return Ok(FontWeight(weight as f32));
+            }
+        }
+        Some(Value::String(s)) => match s.as_str() {
             "normal" => return Ok(FontWeight::NORMAL),
             "bold" => return Ok(FontWeight::BOLD),
             "light" => return Ok(FontWeight::LIGHT),
@@ -284,13 +370,70 @@ fn deserialize_weight(weight: Option<toml::Value>) -> Result<FontWeight> {
     Err(anyhow!("Invalid weight {}", weight.unwrap()))
 }
 
+fn evaluate_variables(
+    expr: &mut Value,
+    variables: &HashMap<String, Value>,
+    stack: &mut Vec<String>,
+) -> Result<()> {
+    match expr {
+        Value::String(s) => {
+            if let Some(name) = s.strip_prefix("$") {
+                if stack.iter().any(|e| e == name) {
+                    Err(anyhow!("variable {} is defined recursively", name))?;
+                }
+                if validate_variable_name(name) {
+                    stack.push(name.to_string());
+                    if let Some(definition) = variables.get(name).cloned() {
+                        *expr = definition;
+                        evaluate_variables(expr, variables, stack)?;
+                    }
+                    stack.pop();
+                }
+            }
+        }
+        Value::Array(a) => {
+            for value in a.iter_mut() {
+                evaluate_variables(value, variables, stack)?;
+            }
+        }
+        Value::Object(object) => {
+            for value in object.values_mut() {
+                evaluate_variables(value, variables, stack)?;
+            }
+        }
+        _ => {}
+    }
+    Ok(())
+}
+
+fn validate_variable_name(name: &str) -> bool {
+    let mut chars = name.chars();
+    if let Some(first) = chars.next() {
+        if first.is_alphabetic() || first == '_' {
+            if chars.all(|c| c.is_alphanumeric() || c == '_') {
+                return true;
+            }
+        }
+    }
+    false
+}
+
+fn merge_map(left: &mut HashMap<String, Value>, right: &HashMap<String, Value>) {
+    for (name, value) in right {
+        if !left.contains_key(name) {
+            left.insert(name.clone(), value.clone());
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
 
     #[test]
-    fn test_parse_theme() {
-        let theme = Theme::parse(
+    fn test_parse_simple_theme() {
+        let assets = TestAssets(&[(
+            "themes/my-theme.toml",
             r#"
             [ui]
             tab_background_active = 0x100000
@@ -304,8 +447,10 @@ mod tests {
             "alpha.one" = {color = 0x112233, weight = "bold"}
             "gamma.three" = {weight = "light", italic = true}
             "#,
-        )
-        .unwrap();
+        )]);
+
+        let registry = ThemeRegistry::new(assets);
+        let theme = registry.get("my-theme").unwrap();
 
         assert_eq!(theme.ui.tab_background_active, ColorU::from_u32(0x100000ff));
         assert_eq!(theme.editor.background, ColorU::from_u32(0x00ed00ff));
@@ -334,9 +479,53 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_parse_extended_theme() {
+        let assets = TestAssets(&[
+            (
+                "themes/base.toml",
+                r#"
+                [ui]
+                tab_background = 0x111111
+                tab_text = "$variable_1"
+
+                [editor]
+                background = 0x222222
+                default_text = "$variable_2"
+                "#,
+            ),
+            (
+                "themes/light.toml",
+                r#"
+                extends = "base"
+
+                [variables]
+                variable_1 = 0x333333
+                variable_2 = 0x444444
+
+                [ui]
+                tab_background = 0x555555
+
+                [editor]
+                background = 0x666666
+                "#,
+            ),
+        ]);
+
+        let registry = ThemeRegistry::new(assets);
+        let theme = registry.get("light").unwrap();
+
+        assert_eq!(theme.ui.tab_background, ColorU::from_u32(0x555555ff));
+        assert_eq!(theme.ui.tab_text, ColorU::from_u32(0x333333ff));
+        assert_eq!(theme.editor.background, ColorU::from_u32(0x666666ff));
+        assert_eq!(theme.editor.default_text, ColorU::from_u32(0x444444ff));
+    }
+
     #[test]
     fn test_parse_empty_theme() {
-        Theme::parse("").unwrap();
+        let assets = TestAssets(&[("themes/my-theme.toml", "")]);
+        let registry = ThemeRegistry::new(assets);
+        registry.get("my-theme").unwrap();
     }
 
     #[test]
@@ -371,4 +560,16 @@ mod tests {
             Some("variable.builtin")
         );
     }
+
+    struct TestAssets(&'static [(&'static str, &'static str)]);
+
+    impl AssetSource for TestAssets {
+        fn load(&self, path: &str) -> Result<std::borrow::Cow<[u8]>> {
+            if let Some(row) = self.0.iter().find(|e| e.0 == path) {
+                Ok(row.1.as_bytes().into())
+            } else {
+                Err(anyhow!("no such path {}", path))
+            }
+        }
+    }
 }

zed/src/test.rs 🔗

@@ -1,4 +1,11 @@
-use crate::{fs::RealFs, language::LanguageRegistry, rpc, settings, time::ReplicaId, AppState};
+use crate::{
+    fs::RealFs,
+    language::LanguageRegistry,
+    rpc,
+    settings::{self, ThemeRegistry},
+    time::ReplicaId,
+    AppState,
+};
 use gpui::{AppContext, Entity, ModelHandle};
 use smol::channel;
 use std::{
@@ -149,8 +156,10 @@ fn write_tree(path: &Path, tree: serde_json::Value) {
 pub fn build_app_state(cx: &AppContext) -> Arc<AppState> {
     let settings = settings::channel(&cx.font_cache()).unwrap().1;
     let languages = Arc::new(LanguageRegistry::new());
+    let themes = ThemeRegistry::new(());
     Arc::new(AppState {
         settings,
+        themes,
         languages: languages.clone(),
         rpc_router: Arc::new(ForegroundRouter::new()),
         rpc: rpc::Client::new(languages),

zed/src/workspace.rs 🔗

@@ -887,7 +887,7 @@ impl View for Workspace {
                 .with_children(self.modal.as_ref().map(|m| ChildView::new(m.id()).boxed()))
                 .boxed(),
         )
-        .with_background_color(settings.theme.editor.background)
+        .with_background_color(settings.theme.ui.background)
         .named("workspace")
     }