diff --git a/zed/Cargo.toml b/zed/Cargo.toml index 7e4d4949d6..25aafaac5f 100644 --- a/zed/Cargo.toml +++ b/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"] } diff --git a/zed/assets/themes/base.toml b/zed/assets/themes/base.toml new file mode 100644 index 0000000000..fd86c98e67 --- /dev/null +++ b/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 }, +] diff --git a/zed/assets/themes/dark.toml b/zed/assets/themes/dark.toml index e4b064ca97..2376293c8a 100644 --- a/zed/assets/themes/dark.toml +++ b/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 diff --git a/zed/src/editor/display_map.rs b/zed/src/editor/display_map.rs index 8efb42f71e..2bebfeaf9e 100644 --- a/zed/src/editor/display_map.rs +++ b/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(), diff --git a/zed/src/lib.rs b/zed/src/lib.rs index b8c84feb1a..b8cfc02c08 100644 --- a/zed/src/lib.rs +++ b/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, pub languages: std::sync::Arc, + pub themes: std::sync::Arc, pub rpc_router: std::sync::Arc, pub rpc: rpc::Client, pub fs: std::sync::Arc, diff --git a/zed/src/main.rs b/zed/src/main.rs index d69818a541..9cbf082b8c 100644 --- a/zed/src/main.rs +++ b/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), diff --git a/zed/src/settings.rs b/zed/src/settings.rs index 54621b905b..1eabc19ef0 100644 --- a/zed/src/settings.rs +++ b/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, } +pub struct ThemeRegistry { + assets: Box, + themes: Mutex>>, + theme_data: Mutex>>, +} + #[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, + #[serde(default)] + variables: HashMap, + #[serde(default)] + ui: HashMap, + #[serde(default)] + editor: HashMap, + #[serde(default)] + syntax: HashMap, } #[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::new_with_theme(font_cache, Arc::new(Theme::default())) + } + + pub fn new_with_theme(font_cache: &FontCache, theme: Arc) -> Result { 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 { - #[derive(Deserialize)] - struct ThemeToml { - #[serde(default)] - ui: UiTheme, - #[serde(default)] - editor: EditorTheme, - #[serde(default)] - syntax: HashMap, +impl ThemeRegistry { + pub fn new(source: impl AssetSource) -> Arc { + Arc::new(Self { + assets: Box::new(source), + themes: Default::default(), + theme_data: Default::default(), + }) + } + + pub fn get(&self, name: &str) -> Result> { + if let Some(theme) = self.themes.lock().get(name) { + return Ok(theme.clone()); } - #[derive(Deserialize)] - #[serde(untagged)] - enum StyleToml { - Color(Color), - Full { - color: Option, - weight: Option, - #[serde(default)] - italic: bool, - }, - } - - 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> { + 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(deserializer: D) -> Result 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) -> Result { - match &weight { +pub fn channel_with_themes( + font_cache: &FontCache, + themes: &ThemeRegistry, +) -> Result<(watch::Sender, watch::Receiver)> { + 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 { + 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) -> Result { Err(anyhow!("Invalid weight {}", weight.unwrap())) } +fn evaluate_variables( + expr: &mut Value, + variables: &HashMap, + stack: &mut Vec, +) -> 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, right: &HashMap) { + 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> { + 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)) + } + } + } } diff --git a/zed/src/test.rs b/zed/src/test.rs index a3e5914b4e..f1367775f2 100644 --- a/zed/src/test.rs +++ b/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 { 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), diff --git a/zed/src/workspace.rs b/zed/src/workspace.rs index 54e5f11f2c..d0d774a817 100644 --- a/zed/src/workspace.rs +++ b/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") }