@@ -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(),
@@ -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))
+ }
+ }
+ }
}