Start work on allowing variables in themes

This commit is contained in:
Max Brunsfeld 2021-07-30 17:22:59 -07:00
parent 5ac0a1985e
commit 92353b6967
9 changed files with 349 additions and 110 deletions

View File

@ -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"] }

View File

@ -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 },
]

View File

@ -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

View File

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

View File

@ -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>,

View File

@ -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),

View File

@ -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(),
})
}
pub fn get(&self, name: &str) -> Result<Arc<Theme>> {
if let Some(theme) = self.themes.lock().get(name) {
return Ok(theme.clone());
}
#[derive(Deserialize)]
#[serde(untagged)]
enum StyleToml {
Color(Color),
Full {
color: Option<Color>,
weight: Option<toml::Value>,
#[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<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))
}
}
}
}

View File

@ -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),

View File

@ -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")
}