Overhaul handling of font families

Max Brunsfeld , Antonio Scandurra , and Nathan Sobo created

* Specify font families in the theme.
* Load fonts eagerly when loading themes, instead of loading
  them lazily when rendering.

Co-Authored-By: Antonio Scandurra <me@as-cii.com>
Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

gpui/src/elements/label.rs             | 151 +++++++++++-------------
gpui/src/elements/text.rs              |  34 +----
gpui/src/fonts.rs                      | 169 ++++++++++++++++++++-------
server/src/rpc.rs                      |   2 
zed/assets/themes/_base.toml           |  22 +-
zed/assets/themes/dark.toml            |   7 
zed/assets/themes/light.toml           |   7 
zed/src/chat_panel.rs                  |  18 --
zed/src/editor.rs                      |  57 ++++-----
zed/src/editor/display_map.rs          |  51 +++++--
zed/src/editor/display_map/wrap_map.rs |   2 
zed/src/editor/movement.rs             |   4 
zed/src/file_finder.rs                 |  40 +----
zed/src/language.rs                    |   6 
zed/src/main.rs                        |   7 
zed/src/settings.rs                    |  35 +++-
zed/src/test.rs                        |  15 -
zed/src/theme.rs                       | 107 +++++++++--------
zed/src/theme/highlight_map.rs         |  15 +-
zed/src/theme/theme_registry.rs        |  24 ++-
zed/src/theme_selector.rs              |  20 --
zed/src/workspace.rs                   |  16 +-
zed/src/workspace/pane.rs              |  12 -
23 files changed, 431 insertions(+), 390 deletions(-)

Detailed changes

gpui/src/elements/label.rs πŸ”—

@@ -1,6 +1,5 @@
 use crate::{
     color::Color,
-    font_cache::FamilyId,
     fonts::{FontId, TextStyle},
     geometry::{
         rect::RectF,
@@ -8,8 +7,7 @@ use crate::{
     },
     json::{ToJson, Value},
     text_layout::Line,
-    DebugContext, Element, Event, EventContext, FontCache, LayoutContext, PaintContext,
-    SizeConstraint,
+    DebugContext, Element, Event, EventContext, LayoutContext, PaintContext, SizeConstraint,
 };
 use serde::Deserialize;
 use serde_json::json;
@@ -17,49 +15,41 @@ use smallvec::{smallvec, SmallVec};
 
 pub struct Label {
     text: String,
-    family_id: FamilyId,
-    font_size: f32,
     style: LabelStyle,
     highlight_indices: Vec<usize>,
 }
 
-#[derive(Clone, Debug, Default, Deserialize)]
+#[derive(Clone, Debug, Deserialize)]
 pub struct LabelStyle {
     pub text: TextStyle,
     pub highlight_text: Option<TextStyle>,
 }
 
+impl From<TextStyle> for LabelStyle {
+    fn from(text: TextStyle) -> Self {
+        LabelStyle {
+            text,
+            highlight_text: None,
+        }
+    }
+}
+
 impl Label {
-    pub fn new(text: String, family_id: FamilyId, font_size: f32) -> Self {
+    pub fn new(text: String, style: impl Into<LabelStyle>) -> Self {
         Self {
             text,
-            family_id,
-            font_size,
             highlight_indices: Default::default(),
-            style: Default::default(),
+            style: style.into(),
         }
     }
 
-    pub fn with_style(mut self, style: &LabelStyle) -> Self {
-        self.style = style.clone();
-        self
-    }
-
-    pub fn with_default_color(mut self, color: Color) -> Self {
-        self.style.text.color = color;
-        self
-    }
-
     pub fn with_highlights(mut self, indices: Vec<usize>) -> Self {
         self.highlight_indices = indices;
         self
     }
 
-    fn compute_runs(
-        &self,
-        font_cache: &FontCache,
-        font_id: FontId,
-    ) -> SmallVec<[(usize, FontId, Color); 8]> {
+    fn compute_runs(&self) -> SmallVec<[(usize, FontId, Color); 8]> {
+        let font_id = self.style.text.font_id;
         if self.highlight_indices.is_empty() {
             return smallvec![(self.text.len(), font_id, self.style.text.color)];
         }
@@ -68,12 +58,7 @@ impl Label {
             .style
             .highlight_text
             .as_ref()
-            .and_then(|style| {
-                font_cache
-                    .select_font(self.family_id, &style.font_properties)
-                    .ok()
-            })
-            .unwrap_or(font_id);
+            .map_or(font_id, |style| style.font_id);
 
         let mut highlight_indices = self.highlight_indices.iter().copied().peekable();
         let mut runs = SmallVec::new();
@@ -123,18 +108,18 @@ impl Element for Label {
         constraint: SizeConstraint,
         cx: &mut LayoutContext,
     ) -> (Vector2F, Self::LayoutState) {
-        let font_id = cx
-            .font_cache
-            .select_font(self.family_id, &self.style.text.font_properties)
-            .unwrap();
-        let runs = self.compute_runs(&cx.font_cache, font_id);
-        let line =
-            cx.text_layout_cache
-                .layout_str(self.text.as_str(), self.font_size, runs.as_slice());
+        let runs = self.compute_runs();
+        let line = cx.text_layout_cache.layout_str(
+            self.text.as_str(),
+            self.style.text.font_size,
+            runs.as_slice(),
+        );
 
         let size = vec2f(
             line.width().max(constraint.min.x()).min(constraint.max.x()),
-            cx.font_cache.line_height(font_id, self.font_size).ceil(),
+            cx.font_cache
+                .line_height(self.style.text.font_id, self.style.text.font_size)
+                .ceil(),
         );
 
         (size, line)
@@ -169,15 +154,13 @@ impl Element for Label {
         bounds: RectF,
         _: &Self::LayoutState,
         _: &Self::PaintState,
-        cx: &DebugContext,
+        _: &DebugContext,
     ) -> Value {
         json!({
             "type": "Label",
             "bounds": bounds.to_json(),
             "text": &self.text,
             "highlight_indices": self.highlight_indices,
-            "font_family": cx.font_cache.family_name(self.family_id).unwrap(),
-            "font_size": self.font_size,
             "style": self.style.to_json(),
         })
     }
@@ -201,48 +184,52 @@ mod tests {
 
     #[crate::test(self)]
     fn test_layout_label_with_highlights(cx: &mut crate::MutableAppContext) {
-        let menlo = cx.font_cache().load_family(&["Menlo"]).unwrap();
-        let menlo_regular = cx
-            .font_cache()
-            .select_font(menlo, &FontProperties::new())
-            .unwrap();
-        let menlo_bold = cx
-            .font_cache()
-            .select_font(menlo, FontProperties::new().weight(Weight::BOLD))
-            .unwrap();
-        let black = Color::black();
-        let red = Color::new(255, 0, 0, 255);
-
-        let label = Label::new(".αβγδΡ.ⓐⓑⓒⓓⓔ.abcde.".to_string(), menlo, 12.0)
-            .with_style(&LabelStyle {
-                text: TextStyle {
-                    color: black,
-                    font_properties: Default::default(),
-                },
-                highlight_text: Some(TextStyle {
-                    color: red,
-                    font_properties: *FontProperties::new().weight(Weight::BOLD),
-                }),
-            })
-            .with_highlights(vec![
-                ".Ξ±".len(),
-                ".Ξ±Ξ²".len(),
-                ".Ξ±Ξ²Ξ³Ξ΄".len(),
-                ".αβγδΡ.ⓐ".len(),
-                ".αβγδΡ.ⓐⓑ".len(),
-            ]);
-
-        let runs = label.compute_runs(cx.font_cache().as_ref(), menlo_regular);
+        let default_style = TextStyle::new(
+            "Menlo",
+            12.,
+            Default::default(),
+            Color::black(),
+            cx.font_cache(),
+        )
+        .unwrap();
+        let highlight_style = TextStyle::new(
+            "Menlo",
+            12.,
+            *FontProperties::new().weight(Weight::BOLD),
+            Color::new(255, 0, 0, 255),
+            cx.font_cache(),
+        )
+        .unwrap();
+        let label = Label::new(
+            ".αβγδΡ.ⓐⓑⓒⓓⓔ.abcde.".to_string(),
+            LabelStyle {
+                text: default_style.clone(),
+                highlight_text: Some(highlight_style.clone()),
+            },
+        )
+        .with_highlights(vec![
+            ".Ξ±".len(),
+            ".Ξ±Ξ²".len(),
+            ".Ξ±Ξ²Ξ³Ξ΄".len(),
+            ".αβγδΡ.ⓐ".len(),
+            ".αβγδΡ.ⓐⓑ".len(),
+        ]);
+
+        let runs = label.compute_runs();
         assert_eq!(
             runs.as_slice(),
             &[
-                (".Ξ±".len(), menlo_regular, black),
-                ("Ξ²Ξ³".len(), menlo_bold, red),
-                ("Ξ΄".len(), menlo_regular, black),
-                ("Ξ΅".len(), menlo_bold, red),
-                (".ⓐ".len(), menlo_regular, black),
-                ("β“‘β“’".len(), menlo_bold, red),
-                ("β““β“”.abcde.".len(), menlo_regular, black),
+                (".Ξ±".len(), default_style.font_id, default_style.color),
+                ("Ξ²Ξ³".len(), highlight_style.font_id, highlight_style.color),
+                ("Ξ΄".len(), default_style.font_id, default_style.color),
+                ("Ξ΅".len(), highlight_style.font_id, highlight_style.color),
+                (".ⓐ".len(), default_style.font_id, default_style.color),
+                ("β“‘β“’".len(), highlight_style.font_id, highlight_style.color),
+                (
+                    "β““β“”.abcde.".len(),
+                    default_style.font_id,
+                    default_style.color
+                ),
             ]
         );
     }

gpui/src/elements/text.rs πŸ”—

@@ -1,6 +1,5 @@
 use crate::{
     color::Color,
-    font_cache::FamilyId,
     fonts::TextStyle,
     geometry::{
         rect::RectF,
@@ -14,8 +13,6 @@ use serde_json::json;
 
 pub struct Text {
     text: String,
-    family_id: FamilyId,
-    font_size: f32,
     style: TextStyle,
 }
 
@@ -25,18 +22,8 @@ pub struct LayoutState {
 }
 
 impl Text {
-    pub fn new(text: String, family_id: FamilyId, font_size: f32) -> Self {
-        Self {
-            text,
-            family_id,
-            font_size,
-            style: Default::default(),
-        }
-    }
-
-    pub fn with_style(mut self, style: &TextStyle) -> Self {
-        self.style = style.clone();
-        self
+    pub fn new(text: String, style: TextStyle) -> Self {
+        Self { text, style }
     }
 
     pub fn with_default_color(mut self, color: Color) -> Self {
@@ -54,20 +41,17 @@ impl Element for Text {
         constraint: SizeConstraint,
         cx: &mut LayoutContext,
     ) -> (Vector2F, Self::LayoutState) {
-        let font_id = cx
-            .font_cache
-            .select_font(self.family_id, &self.style.font_properties)
-            .unwrap();
-        let line_height = cx.font_cache.line_height(font_id, self.font_size);
+        let font_id = self.style.font_id;
+        let line_height = cx.font_cache.line_height(font_id, self.style.font_size);
 
-        let mut wrapper = cx.font_cache.line_wrapper(font_id, self.font_size);
+        let mut wrapper = cx.font_cache.line_wrapper(font_id, self.style.font_size);
         let mut lines = Vec::new();
         let mut line_count = 0;
         let mut max_line_width = 0_f32;
         for line in self.text.lines() {
             let shaped_line = cx.text_layout_cache.layout_str(
                 line,
-                self.font_size,
+                self.style.font_size,
                 &[(line.len(), font_id, self.style.color)],
             );
             let wrap_boundaries = wrapper
@@ -123,14 +107,12 @@ impl Element for Text {
         bounds: RectF,
         _: &Self::LayoutState,
         _: &Self::PaintState,
-        cx: &DebugContext,
+        _: &DebugContext,
     ) -> Value {
         json!({
-            "type": "Label",
+            "type": "Text",
             "bounds": bounds.to_json(),
             "text": &self.text,
-            "font_family": cx.font_cache.family_name(self.family_id).unwrap(),
-            "font_size": self.font_size,
             "style": self.style.to_json(),
         })
     }

gpui/src/fonts.rs πŸ”—

@@ -1,32 +1,35 @@
 use crate::{
     color::Color,
     json::{json, ToJson},
+    FontCache,
 };
+use anyhow::anyhow;
 pub use font_kit::{
     metrics::Metrics,
     properties::{Properties, Stretch, Style, Weight},
 };
 use serde::{de, Deserialize};
 use serde_json::Value;
+use std::{cell::RefCell, sync::Arc};
 
 #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
 pub struct FontId(pub usize);
 
 pub type GlyphId = u32;
 
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug)]
 pub struct TextStyle {
     pub color: Color,
+    pub font_family_name: Arc<str>,
+    pub font_id: FontId,
+    pub font_size: f32,
     pub font_properties: Properties,
 }
 
-impl Default for TextStyle {
-    fn default() -> Self {
-        Self {
-            color: Color::from_u32(0xff0000ff),
-            font_properties: Default::default(),
-        }
-    }
+#[derive(Clone, Debug, Default)]
+pub struct HighlightStyle {
+    pub color: Color,
+    pub font_properties: Properties,
 }
 
 #[allow(non_camel_case_types)]
@@ -43,34 +46,79 @@ enum WeightJson {
     black,
 }
 
+thread_local! {
+    static FONT_CACHE: RefCell<Option<Arc<FontCache>>> = Default::default();
+}
+
 #[derive(Deserialize)]
 struct TextStyleJson {
     color: Color,
+    family: String,
     weight: Option<WeightJson>,
     #[serde(default)]
     italic: bool,
+    size: f32,
 }
 
-impl<'de> Deserialize<'de> for TextStyle {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: serde::Deserializer<'de>,
-    {
-        let json = Value::deserialize(deserializer)?;
-        if json.is_object() {
-            let style_json: TextStyleJson =
-                serde_json::from_value(json).map_err(de::Error::custom)?;
-            Ok(style_json.into())
-        } else {
-            Ok(Self {
-                color: serde_json::from_value(json).map_err(de::Error::custom)?,
-                font_properties: Properties::new(),
-            })
+#[derive(Deserialize)]
+struct HighlightStyleJson {
+    color: Color,
+    weight: Option<WeightJson>,
+    #[serde(default)]
+    italic: bool,
+}
+
+impl TextStyle {
+    pub fn new(
+        font_family_name: impl Into<Arc<str>>,
+        font_size: f32,
+        font_properties: Properties,
+        color: Color,
+        font_cache: &FontCache,
+    ) -> anyhow::Result<Self> {
+        let font_family_name = font_family_name.into();
+        let family_id = font_cache.load_family(&[&font_family_name])?;
+        let font_id = font_cache.select_font(family_id, &font_properties)?;
+        Ok(Self {
+            color,
+            font_family_name,
+            font_id,
+            font_size,
+            font_properties,
+        })
+    }
+
+    fn from_json(json: TextStyleJson) -> anyhow::Result<Self> {
+        FONT_CACHE.with(|font_cache| {
+            if let Some(font_cache) = font_cache.borrow().as_ref() {
+                let font_properties = properties_from_json(json.weight, json.italic);
+                Self::new(
+                    json.family,
+                    json.size,
+                    font_properties,
+                    json.color,
+                    font_cache,
+                )
+            } else {
+                Err(anyhow!(
+                    "TextStyle can only be deserialized within a call to with_font_cache"
+                ))
+            }
+        })
+    }
+}
+
+impl HighlightStyle {
+    fn from_json(json: HighlightStyleJson) -> Self {
+        let font_properties = properties_from_json(json.weight, json.italic);
+        Self {
+            color: json.color,
+            font_properties,
         }
     }
 }
 
-impl From<Color> for TextStyle {
+impl From<Color> for HighlightStyle {
     fn from(color: Color) -> Self {
         Self {
             color,
@@ -79,40 +127,61 @@ impl From<Color> for TextStyle {
     }
 }
 
+impl<'de> Deserialize<'de> for TextStyle {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        Ok(Self::from_json(TextStyleJson::deserialize(deserializer)?)
+            .map_err(|e| de::Error::custom(e))?)
+    }
+}
+
 impl ToJson for TextStyle {
     fn to_json(&self) -> Value {
         json!({
             "color": self.color.to_json(),
+            "font_family": self.font_family_name.as_ref(),
             "font_properties": self.font_properties.to_json(),
         })
     }
 }
 
-impl Into<TextStyle> for TextStyleJson {
-    fn into(self) -> TextStyle {
-        let weight = match self.weight.unwrap_or(WeightJson::normal) {
-            WeightJson::thin => Weight::THIN,
-            WeightJson::extra_light => Weight::EXTRA_LIGHT,
-            WeightJson::light => Weight::LIGHT,
-            WeightJson::normal => Weight::NORMAL,
-            WeightJson::medium => Weight::MEDIUM,
-            WeightJson::semibold => Weight::SEMIBOLD,
-            WeightJson::bold => Weight::BOLD,
-            WeightJson::extra_bold => Weight::EXTRA_BOLD,
-            WeightJson::black => Weight::BLACK,
-        };
-        let style = if self.italic {
-            Style::Italic
+impl<'de> Deserialize<'de> for HighlightStyle {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let json = serde_json::Value::deserialize(deserializer)?;
+        if json.is_object() {
+            Ok(Self::from_json(
+                serde_json::from_value(json).map_err(de::Error::custom)?,
+            ))
         } else {
-            Style::Normal
-        };
-        TextStyle {
-            color: self.color,
-            font_properties: *Properties::new().weight(weight).style(style),
+            Ok(Self {
+                color: serde_json::from_value(json).map_err(de::Error::custom)?,
+                font_properties: Properties::new(),
+            })
         }
     }
 }
 
+fn properties_from_json(weight: Option<WeightJson>, italic: bool) -> Properties {
+    let weight = match weight.unwrap_or(WeightJson::normal) {
+        WeightJson::thin => Weight::THIN,
+        WeightJson::extra_light => Weight::EXTRA_LIGHT,
+        WeightJson::light => Weight::LIGHT,
+        WeightJson::normal => Weight::NORMAL,
+        WeightJson::medium => Weight::MEDIUM,
+        WeightJson::semibold => Weight::SEMIBOLD,
+        WeightJson::bold => Weight::BOLD,
+        WeightJson::extra_bold => Weight::EXTRA_BOLD,
+        WeightJson::black => Weight::BLACK,
+    };
+    let style = if italic { Style::Italic } else { Style::Normal };
+    *Properties::new().weight(weight).style(style)
+}
+
 impl ToJson for Properties {
     fn to_json(&self) -> crate::json::Value {
         json!({
@@ -164,3 +233,15 @@ impl ToJson for Stretch {
         json!(self.0)
     }
 }
+
+pub fn with_font_cache<F, T>(font_cache: Arc<FontCache>, callback: F) -> T
+where
+    F: FnOnce() -> T,
+{
+    FONT_CACHE.with(|cache| {
+        *cache.borrow_mut() = Some(font_cache);
+        let result = callback();
+        cache.borrow_mut().take();
+        result
+    })
+}

server/src/rpc.rs πŸ”—

@@ -943,7 +943,7 @@ mod tests {
     #[gpui::test]
     async fn test_share_worktree(mut cx_a: TestAppContext, mut cx_b: TestAppContext) {
         let (window_b, _) = cx_b.add_window(|_| EmptyView);
-        let settings = settings::channel(&cx_b.font_cache()).unwrap().1;
+        let settings = cx_b.read(settings::test).1;
         let lang_registry = Arc::new(LanguageRegistry::new());
 
         // Connect to a server as 2 clients.

zed/assets/themes/_base.toml πŸ”—

@@ -4,7 +4,7 @@ background = "$surface.0"
 [workspace.tab]
 text = "$text.2"
 padding = { left = 10, right = 10 }
-icon_close = "$text.0"
+icon_close = "$text.0.color"
 icon_dirty = "$status.info"
 icon_conflict = "$status.warn"
 
@@ -17,10 +17,10 @@ text = "$text.0"
 padding = { left = 10, right = 10 }
 
 [workspace.sidebar_icon]
-color = "$text.2"
+color = "$text.2.color"
 
 [workspace.active_sidebar_icon]
-color = "$text.0"
+color = "$text.0.color"
 
 [chat_panel]
 padding = { top = 10.0, bottom = 10.0, left = 10.0, right = 10.0 }
@@ -28,7 +28,7 @@ padding = { top = 10.0, bottom = 10.0, left = 10.0, right = 10.0 }
 [chat_panel.message]
 body = "$text.0"
 sender.margin.right = 10.0
-sender.text = { color = "$text.0", weight = "bold" }
+sender.text = { extends = "$text.0", weight = "bold" }
 timestamp.text = "$text.2"
 
 [selector]
@@ -41,8 +41,8 @@ shadow = { offset = [0.0, 0.0], blur = 12.0, color = "#00000088" }
 
 [selector.item]
 background = "#424344"
-text = "#cccccc"
-highlight_text = { color = "#18a3ff", weight = "bold" }
+text = "$text.1"
+highlight_text = { extends = "$text.base", color = "#18a3ff", weight = "bold" }
 border = { color = "#000000", width = 1.0 }
 padding = { top = 6.0, bottom = 6.0, left = 6.0, right = 6.0 }
 
@@ -54,10 +54,12 @@ background = "#094771"
 background = "$surface.1"
 gutter_background = "$surface.1"
 active_line_background = "$surface.2"
-line_number = "$text.2"
-line_number_active = "$text.0"
-text = "$text.1"
+line_number = "$text.2.color"
+line_number_active = "$text.0.color"
 replicas = [
-    { selection = "#264f78", cursor = "$text.0" },
+    { selection = "#264f78", cursor = "$text.0.color" },
     { selection = "#504f31", cursor = "#fcf154" },
 ]
+
+[syntax]
+default = "$text.1.color"

zed/assets/themes/dark.toml πŸ”—

@@ -6,9 +6,10 @@ extends = "_base"
 2 = "#131415"
 
 [text]
-0 = "#ffffff"
-1 = "#b3b3b3"
-2 = "#7b7d80"
+base = { family = "Helvetica", size = 12.0 }
+0 = { extends = "$text.base", color = "#ffffff" }
+1 = { extends = "$text.base", color = "#b3b3b3" }
+2 = { extends = "$text.base", color = "#7b7d80" }
 
 [status]
 good = "#4fac63"

zed/assets/themes/light.toml πŸ”—

@@ -7,9 +7,10 @@ extends = "_base"
 3 = "#3a3b3c"
 
 [text]
-0 = "#acacac"
-1 = "#111111"
-2 = "#333333"
+base = { family = "Inconsolata" }
+0 = { extends = "$text.base", color = "#acacac" }
+1 = { extends = "$text.base", color = "#111111" }
+2 = { extends = "$text.base", color = "#333333" }
 
 [status]
 good = "#4fac63"

zed/src/chat_panel.rs πŸ”—

@@ -126,10 +126,8 @@ impl ChatPanel {
                         Container::new(
                             Label::new(
                                 message.sender.github_login.clone(),
-                                settings.ui_font_family,
-                                settings.ui_font_size,
+                                theme.sender.label.clone(),
                             )
-                            .with_style(&theme.sender.label)
                             .boxed(),
                         )
                         .with_style(&theme.sender.container)
@@ -139,10 +137,8 @@ impl ChatPanel {
                         Container::new(
                             Label::new(
                                 format_timestamp(message.timestamp, now),
-                                settings.ui_font_family,
-                                settings.ui_font_size,
+                                theme.timestamp.label.clone(),
                             )
-                            .with_style(&theme.timestamp.label)
                             .boxed(),
                         )
                         .with_style(&theme.timestamp.container)
@@ -150,15 +146,7 @@ impl ChatPanel {
                     )
                     .boxed(),
             )
-            .with_child(
-                Text::new(
-                    message.body.clone(),
-                    settings.ui_font_family,
-                    settings.ui_font_size,
-                )
-                .with_style(&theme.body)
-                .boxed(),
-            )
+            .with_child(Text::new(message.body.clone(), theme.body.clone()).boxed())
             .boxed()
     }
 

zed/src/editor.rs πŸ”—

@@ -2418,7 +2418,7 @@ impl Snapshot {
                 }
 
                 if !line_chunk.is_empty() && !line_exceeded_max_len {
-                    let style = self.theme.highlight_style(style_ix);
+                    let style = self.theme.syntax.highlight_style(style_ix);
                     // Avoid a lookup if the font properties match the previous ones.
                     let font_id = if style.font_properties == prev_font_properties {
                         prev_font_id
@@ -2632,19 +2632,14 @@ impl workspace::ItemView for Editor {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::{
-        editor::Point,
-        language::LanguageRegistry,
-        settings,
-        test::{build_settings, sample_text},
-    };
+    use crate::{editor::Point, language::LanguageRegistry, settings, test::sample_text};
     use buffer::History;
     use unindent::Unindent;
 
     #[gpui::test]
     fn test_selection_with_mouse(cx: &mut gpui::MutableAppContext) {
         let buffer = cx.add_model(|cx| Buffer::new(0, "aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx));
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, editor) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
         });
@@ -2712,7 +2707,7 @@ mod tests {
     #[gpui::test]
     fn test_canceling_pending_selection(cx: &mut gpui::MutableAppContext) {
         let buffer = cx.add_model(|cx| Buffer::new(0, "aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx));
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
         });
@@ -2746,7 +2741,7 @@ mod tests {
     #[gpui::test]
     fn test_cancel(cx: &mut gpui::MutableAppContext) {
         let buffer = cx.add_model(|cx| Buffer::new(0, "aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx));
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
         });
@@ -2792,7 +2787,7 @@ mod tests {
 
         let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(6, 6), cx));
 
-        let settings = settings::channel(&font_cache).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, editor) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer.clone(), settings.clone(), cx)
         });
@@ -2838,7 +2833,7 @@ mod tests {
                 cx,
             )
         });
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer.clone(), settings, cx)
         });
@@ -2906,7 +2901,7 @@ mod tests {
     #[gpui::test]
     fn test_move_cursor(cx: &mut gpui::MutableAppContext) {
         let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(6, 6), cx));
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer.clone(), settings, cx)
         });
@@ -2983,7 +2978,7 @@ mod tests {
     #[gpui::test]
     fn test_move_cursor_multibyte(cx: &mut gpui::MutableAppContext) {
         let buffer = cx.add_model(|cx| Buffer::new(0, "ⓐⓑⓒⓓⓔ\nabcde\nαβγδΡ\n", cx));
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer.clone(), settings, cx)
         });
@@ -3041,7 +3036,7 @@ mod tests {
     #[gpui::test]
     fn test_move_cursor_different_line_lengths(cx: &mut gpui::MutableAppContext) {
         let buffer = cx.add_model(|cx| Buffer::new(0, "ⓐⓑⓒⓓⓔ\nabcd\nΞ±Ξ²Ξ³\nabcd\nⓐⓑⓒⓓⓔ\n", cx));
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer.clone(), settings, cx)
         });
@@ -3072,7 +3067,7 @@ mod tests {
     #[gpui::test]
     fn test_beginning_end_of_line(cx: &mut gpui::MutableAppContext) {
         let buffer = cx.add_model(|cx| Buffer::new(0, "abc\n  def", cx));
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
         });
@@ -3217,7 +3212,7 @@ mod tests {
     fn test_prev_next_word_boundary(cx: &mut gpui::MutableAppContext) {
         let buffer =
             cx.add_model(|cx| Buffer::new(0, "use std::str::{foo, bar}\n\n  {baz.qux()}", cx));
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
         });
@@ -3405,7 +3400,7 @@ mod tests {
     fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut gpui::MutableAppContext) {
         let buffer =
             cx.add_model(|cx| Buffer::new(0, "use one::{\n    two::three::four::five\n};", cx));
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
         });
@@ -3467,7 +3462,7 @@ mod tests {
                 cx,
             )
         });
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer.clone(), settings, cx)
         });
@@ -3503,7 +3498,7 @@ mod tests {
                 cx,
             )
         });
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer.clone(), settings, cx)
         });
@@ -3532,7 +3527,7 @@ mod tests {
 
     #[gpui::test]
     fn test_delete_line(cx: &mut gpui::MutableAppContext) {
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let buffer = cx.add_model(|cx| Buffer::new(0, "abc\ndef\nghi\n", cx));
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
@@ -3558,7 +3553,7 @@ mod tests {
             );
         });
 
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let buffer = cx.add_model(|cx| Buffer::new(0, "abc\ndef\nghi\n", cx));
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
@@ -3577,7 +3572,7 @@ mod tests {
 
     #[gpui::test]
     fn test_duplicate_line(cx: &mut gpui::MutableAppContext) {
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let buffer = cx.add_model(|cx| Buffer::new(0, "abc\ndef\nghi\n", cx));
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
@@ -3606,7 +3601,7 @@ mod tests {
             );
         });
 
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let buffer = cx.add_model(|cx| Buffer::new(0, "abc\ndef\nghi\n", cx));
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
@@ -3634,7 +3629,7 @@ mod tests {
 
     #[gpui::test]
     fn test_move_line_up_down(cx: &mut gpui::MutableAppContext) {
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(10, 5), cx));
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
@@ -3719,7 +3714,7 @@ mod tests {
     #[gpui::test]
     fn test_clipboard(cx: &mut gpui::MutableAppContext) {
         let buffer = cx.add_model(|cx| Buffer::new(0, "one two three four five six ", cx));
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let view = cx
             .add_window(Default::default(), |cx| {
                 Editor::for_buffer(buffer.clone(), settings, cx)
@@ -3854,7 +3849,7 @@ mod tests {
     #[gpui::test]
     fn test_select_all(cx: &mut gpui::MutableAppContext) {
         let buffer = cx.add_model(|cx| Buffer::new(0, "abc\nde\nfgh", cx));
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
         });
@@ -3869,7 +3864,7 @@ mod tests {
 
     #[gpui::test]
     fn test_select_line(cx: &mut gpui::MutableAppContext) {
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(6, 5), cx));
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
@@ -3917,7 +3912,7 @@ mod tests {
 
     #[gpui::test]
     fn test_split_selection_into_lines(cx: &mut gpui::MutableAppContext) {
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(9, 5), cx));
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
@@ -3984,7 +3979,7 @@ mod tests {
 
     #[gpui::test]
     fn test_add_selection_above_below(cx: &mut gpui::MutableAppContext) {
-        let settings = settings::channel(&cx.font_cache()).unwrap().1;
+        let settings = settings::test(&cx).1;
         let buffer = cx.add_model(|cx| Buffer::new(0, "abc\ndefghi\n\njk\nlmno\n", cx));
         let (_, view) = cx.add_window(Default::default(), |cx| {
             Editor::for_buffer(buffer, settings, cx)
@@ -4159,7 +4154,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_select_larger_smaller_syntax_node(mut cx: gpui::TestAppContext) {
-        let settings = cx.read(build_settings);
+        let settings = cx.read(settings::test).1;
         let languages = LanguageRegistry::new();
         let lang = languages.select_language("z.rs");
         let text = r#"

zed/src/editor/display_map.rs πŸ”—

@@ -343,8 +343,8 @@ mod tests {
     use crate::{
         editor::movement,
         language::{Language, LanguageConfig},
-        settings::Theme,
         test::*,
+        theme::SyntaxTheme,
         util::RandomCharIter,
     };
     use buffer::{History, SelectionGoal};
@@ -366,7 +366,7 @@ mod tests {
             tab_size: rng.gen_range(1..=4),
             buffer_font_family: font_cache.load_family(&["Helvetica"]).unwrap(),
             buffer_font_size: 14.0,
-            ..Settings::new(&font_cache).unwrap()
+            ..cx.read(Settings::test)
         };
         let max_wrap_width = 300.0;
         let mut wrap_width = if rng.gen_bool(0.1) {
@@ -535,7 +535,7 @@ mod tests {
             buffer_font_size: 12.0,
             ui_font_size: 12.0,
             tab_size: 4,
-            theme: Arc::new(Theme::default()),
+            ..cx.read(Settings::test)
         };
         let wrap_width = Some(64.);
 
@@ -606,7 +606,10 @@ mod tests {
         let map = cx.add_model(|cx| {
             DisplayMap::new(
                 buffer.clone(),
-                Settings::new(cx.font_cache()).unwrap().with_tab_size(4),
+                Settings {
+                    tab_size: 4,
+                    ..Settings::test(cx)
+                },
                 None,
                 cx,
             )
@@ -660,13 +663,13 @@ mod tests {
             (function_item name: (identifier) @fn.name)"#,
         )
         .unwrap();
-        let theme = Theme {
-            syntax: vec![
+        let theme = SyntaxTheme::new(
+            Default::default(),
+            vec![
                 ("mod.body".to_string(), Color::from_u32(0xff0000ff).into()),
                 ("fn.name".to_string(), Color::from_u32(0x00ff00ff).into()),
             ],
-            ..Default::default()
-        };
+        );
         let lang = Arc::new(Language {
             config: LanguageConfig {
                 name: "Test".to_string(),
@@ -688,7 +691,10 @@ mod tests {
         let map = cx.add_model(|cx| {
             DisplayMap::new(
                 buffer,
-                Settings::new(cx.font_cache()).unwrap().with_tab_size(2),
+                Settings {
+                    tab_size: 2,
+                    ..Settings::test(cx)
+                },
                 None,
                 cx,
             )
@@ -750,13 +756,13 @@ mod tests {
             (function_item name: (identifier) @fn.name)"#,
         )
         .unwrap();
-        let theme = Theme {
-            syntax: vec![
+        let theme = SyntaxTheme::new(
+            Default::default(),
+            vec![
                 ("mod.body".to_string(), Color::from_u32(0xff0000ff).into()),
                 ("fn.name".to_string(), Color::from_u32(0x00ff00ff).into()),
             ],
-            ..Default::default()
-        };
+        );
         let lang = Arc::new(Language {
             config: LanguageConfig {
                 name: "Test".to_string(),
@@ -780,7 +786,7 @@ mod tests {
             tab_size: 4,
             buffer_font_family: font_cache.load_family(&["Courier"]).unwrap(),
             buffer_font_size: 16.0,
-            ..Settings::new(&font_cache).unwrap()
+            ..cx.read(Settings::test)
         };
         let map = cx.add_model(|cx| DisplayMap::new(buffer, settings, Some(40.0), cx));
         assert_eq!(
@@ -820,7 +826,10 @@ mod tests {
         let map = cx.add_model(|cx| {
             DisplayMap::new(
                 buffer.clone(),
-                Settings::new(cx.font_cache()).unwrap().with_tab_size(4),
+                Settings {
+                    tab_size: 4,
+                    ..Settings::test(cx)
+                },
                 None,
                 cx,
             )
@@ -861,7 +870,10 @@ mod tests {
         let map = cx.add_model(|cx| {
             DisplayMap::new(
                 buffer.clone(),
-                Settings::new(cx.font_cache()).unwrap().with_tab_size(4),
+                Settings {
+                    tab_size: 4,
+                    ..Settings::test(cx)
+                },
                 None,
                 cx,
             )
@@ -925,7 +937,10 @@ mod tests {
         let map = cx.add_model(|cx| {
             DisplayMap::new(
                 buffer.clone(),
-                Settings::new(cx.font_cache()).unwrap().with_tab_size(4),
+                Settings {
+                    tab_size: 4,
+                    ..Settings::test(cx)
+                },
                 None,
                 cx,
             )
@@ -939,7 +954,7 @@ mod tests {
     fn highlighted_chunks<'a>(
         rows: Range<u32>,
         map: &ModelHandle<DisplayMap>,
-        theme: &'a Theme,
+        theme: &'a SyntaxTheme,
         cx: &mut MutableAppContext,
     ) -> Vec<(String, Option<&'a str>)> {
         let mut snapshot = map.update(cx, |map, cx| map.snapshot(cx));

zed/src/editor/display_map/wrap_map.rs πŸ”—

@@ -921,7 +921,7 @@ mod tests {
             tab_size: rng.gen_range(1..=4),
             buffer_font_family: font_cache.load_family(&["Helvetica"]).unwrap(),
             buffer_font_size: 14.0,
-            ..Settings::new(&font_cache).unwrap()
+            ..cx.read(Settings::test)
         };
         log::info!("Tab size: {}", settings.tab_size);
         log::info!("Wrap width: {:?}", wrap_width);

zed/src/editor/movement.rs πŸ”—

@@ -182,12 +182,12 @@ mod tests {
     use super::*;
     use crate::{
         editor::{display_map::DisplayMap, Buffer},
-        test::build_app_state,
+        test::test_app_state,
     };
 
     #[gpui::test]
     fn test_prev_next_word_boundary_multibyte(cx: &mut gpui::MutableAppContext) {
-        let settings = build_app_state(cx).settings.borrow().clone();
+        let settings = test_app_state(cx).settings.borrow().clone();
         let buffer = cx.add_model(|cx| Buffer::new(0, "a bcΞ” defΞ³", cx));
         let display_map = cx.add_model(|cx| DisplayMap::new(buffer, settings, None, cx));
         let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx));

zed/src/file_finder.rs πŸ”—

@@ -117,13 +117,7 @@ impl FileFinder {
         if self.matches.is_empty() {
             let settings = self.settings.borrow();
             return Container::new(
-                Label::new(
-                    "No matches".into(),
-                    settings.ui_font_family,
-                    settings.ui_font_size,
-                )
-                .with_style(&settings.theme.selector.label)
-                .boxed(),
+                Label::new("No matches".into(), settings.theme.selector.label.clone()).boxed(),
             )
             .with_margin_top(6.0)
             .named("empty matches");
@@ -184,24 +178,14 @@ impl FileFinder {
                         1.0,
                         Flex::column()
                             .with_child(
-                                Label::new(
-                                    file_name.to_string(),
-                                    settings.ui_font_family,
-                                    settings.ui_font_size,
-                                )
-                                .with_style(&style.label)
-                                .with_highlights(file_name_positions)
-                                .boxed(),
+                                Label::new(file_name.to_string(), style.label.clone())
+                                    .with_highlights(file_name_positions)
+                                    .boxed(),
                             )
                             .with_child(
-                                Label::new(
-                                    full_path,
-                                    settings.ui_font_family,
-                                    settings.ui_font_size,
-                                )
-                                .with_style(&style.label)
-                                .with_highlights(full_path_positions)
-                                .boxed(),
+                                Label::new(full_path, style.label.clone())
+                                    .with_highlights(full_path_positions)
+                                    .boxed(),
                             )
                             .boxed(),
                     )
@@ -438,7 +422,7 @@ mod tests {
     use crate::{
         editor::{self, Insert},
         fs::FakeFs,
-        test::{build_app_state, temp_tree},
+        test::{temp_tree, test_app_state},
         workspace::Workspace,
     };
     use serde_json::json;
@@ -456,7 +440,7 @@ mod tests {
             editor::init(cx);
         });
 
-        let app_state = cx.update(build_app_state);
+        let app_state = cx.update(test_app_state);
         let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
         workspace
             .update(&mut cx, |workspace, cx| {
@@ -516,7 +500,7 @@ mod tests {
         )
         .await;
 
-        let mut app_state = cx.update(build_app_state);
+        let mut app_state = cx.update(test_app_state);
         Arc::get_mut(&mut app_state).unwrap().fs = fs;
 
         let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
@@ -578,7 +562,7 @@ mod tests {
         fs::create_dir(&dir_path).unwrap();
         fs::write(&file_path, "").unwrap();
 
-        let app_state = cx.update(build_app_state);
+        let app_state = cx.update(test_app_state);
         let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
         workspace
             .update(&mut cx, |workspace, cx| {
@@ -625,7 +609,7 @@ mod tests {
             "dir2": { "a.txt": "" }
         }));
 
-        let app_state = cx.update(build_app_state);
+        let app_state = cx.update(test_app_state);
         let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
 
         workspace

zed/src/language.rs πŸ”—

@@ -1,4 +1,4 @@
-use crate::settings::{HighlightMap, Theme};
+use crate::{settings::HighlightMap, theme::SyntaxTheme};
 use parking_lot::Mutex;
 use rust_embed::RustEmbed;
 use serde::Deserialize;
@@ -39,7 +39,7 @@ impl Language {
         self.highlight_map.lock().clone()
     }
 
-    pub fn set_theme(&self, theme: &Theme) {
+    pub fn set_theme(&self, theme: &SyntaxTheme) {
         *self.highlight_map.lock() = HighlightMap::new(self.highlight_query.capture_names(), theme);
     }
 }
@@ -61,7 +61,7 @@ impl LanguageRegistry {
         }
     }
 
-    pub fn set_theme(&self, theme: &Theme) {
+    pub fn set_theme(&self, theme: &SyntaxTheme) {
         for language in &self.languages {
             language.set_theme(theme);
         }

zed/src/main.rs πŸ”—

@@ -22,11 +22,10 @@ fn main() {
 
     let app = gpui::App::new(assets::Assets).unwrap();
 
-    let themes = settings::ThemeRegistry::new(assets::Assets);
-    let (settings_tx, settings) =
-        settings::channel_with_themes(&app.font_cache(), &themes).unwrap();
+    let themes = settings::ThemeRegistry::new(assets::Assets, app.font_cache());
+    let (settings_tx, settings) = settings::channel(&app.font_cache(), &themes).unwrap();
     let languages = Arc::new(language::LanguageRegistry::new());
-    languages.set_theme(&settings.borrow().theme);
+    languages.set_theme(&settings.borrow().theme.syntax);
 
     app.run(move |cx| {
         let rpc = rpc::Client::new();

zed/src/settings.rs πŸ”—

@@ -17,11 +17,27 @@ pub struct Settings {
 }
 
 impl Settings {
-    pub fn new(font_cache: &FontCache) -> Result<Self> {
-        Self::new_with_theme(font_cache, Arc::new(Theme::default()))
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn test(cx: &gpui::AppContext) -> Self {
+        lazy_static::lazy_static! {
+            static ref DEFAULT_THEME: parking_lot::Mutex<Option<Arc<Theme>>> = Default::default();
+        }
+
+        let mut theme_guard = DEFAULT_THEME.lock();
+        let theme = if let Some(theme) = theme_guard.as_ref() {
+            theme.clone()
+        } else {
+            let theme = ThemeRegistry::new(crate::assets::Assets, cx.font_cache().clone())
+                .get(DEFAULT_THEME_NAME)
+                .expect("failed to load default theme in tests");
+            *theme_guard = Some(theme.clone());
+            theme
+        };
+
+        Self::new(cx.font_cache(), theme).unwrap()
     }
 
-    pub fn new_with_theme(font_cache: &FontCache, theme: Arc<Theme>) -> Result<Self> {
+    pub fn new(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,
@@ -38,13 +54,12 @@ impl Settings {
     }
 }
 
-pub fn channel(
-    font_cache: &FontCache,
-) -> Result<(watch::Sender<Settings>, watch::Receiver<Settings>)> {
-    Ok(watch::channel_with(Settings::new(font_cache)?))
+#[cfg(any(test, feature = "test-support"))]
+pub fn test(cx: &gpui::AppContext) -> (watch::Sender<Settings>, watch::Receiver<Settings>) {
+    watch::channel_with(Settings::test(cx))
 }
 
-pub fn channel_with_themes(
+pub fn channel(
     font_cache: &FontCache,
     themes: &ThemeRegistry,
 ) -> Result<(watch::Sender<Settings>, watch::Receiver<Settings>)> {
@@ -54,7 +69,5 @@ pub fn channel_with_themes(
             panic!("failed to deserialize default theme: {:?}", err)
         }
     };
-    Ok(watch::channel_with(Settings::new_with_theme(
-        font_cache, theme,
-    )?))
+    Ok(watch::channel_with(Settings::new(font_cache, theme)?))
 }

zed/src/test.rs πŸ”—

@@ -6,11 +6,10 @@ use crate::{
     settings::{self, ThemeRegistry},
     time::ReplicaId,
     user::UserStore,
-    AppState, Settings,
+    AppState,
 };
-use gpui::{AppContext, Entity, ModelHandle, MutableAppContext};
+use gpui::{Entity, ModelHandle, MutableAppContext};
 use parking_lot::Mutex;
-use postage::watch;
 use smol::channel;
 use std::{
     marker::PhantomData,
@@ -156,14 +155,10 @@ fn write_tree(path: &Path, tree: serde_json::Value) {
     }
 }
 
-pub fn build_settings(cx: &AppContext) -> watch::Receiver<Settings> {
-    settings::channel(&cx.font_cache()).unwrap().1
-}
-
-pub fn build_app_state(cx: &mut MutableAppContext) -> Arc<AppState> {
-    let (settings_tx, settings) = settings::channel(&cx.font_cache()).unwrap();
+pub fn test_app_state(cx: &mut MutableAppContext) -> Arc<AppState> {
+    let (settings_tx, settings) = settings::test(cx);
     let languages = Arc::new(LanguageRegistry::new());
-    let themes = ThemeRegistry::new(());
+    let themes = ThemeRegistry::new((), cx.font_cache().clone());
     let rpc = rpc::Client::new();
     let user_store = Arc::new(UserStore::new(rpc.clone()));
     Arc::new(AppState {

zed/src/theme.rs πŸ”—

@@ -5,9 +5,9 @@ use anyhow::Result;
 use gpui::{
     color::Color,
     elements::{ContainerStyle, LabelStyle},
-    fonts::TextStyle,
+    fonts::{HighlightStyle, TextStyle},
 };
-use serde::{Deserialize, Deserializer};
+use serde::{de, Deserialize};
 use std::collections::HashMap;
 
 pub use highlight_map::*;
@@ -15,7 +15,7 @@ pub use theme_registry::*;
 
 pub const DEFAULT_THEME_NAME: &'static str = "dark";
 
-#[derive(Debug, Default, Deserialize)]
+#[derive(Deserialize)]
 pub struct Theme {
     #[serde(default)]
     pub name: String,
@@ -23,11 +23,15 @@ pub struct Theme {
     pub chat_panel: ChatPanel,
     pub selector: Selector,
     pub editor: Editor,
-    #[serde(deserialize_with = "deserialize_syntax_theme")]
-    pub syntax: Vec<(String, TextStyle)>,
+    pub syntax: SyntaxTheme,
 }
 
-#[derive(Debug, Default, Deserialize)]
+pub struct SyntaxTheme {
+    highlights: Vec<(String, HighlightStyle)>,
+    default_style: HighlightStyle,
+}
+
+#[derive(Deserialize)]
 pub struct Workspace {
     pub background: Color,
     pub tab: Tab,
@@ -37,7 +41,7 @@ pub struct Workspace {
     pub active_sidebar_icon: SidebarIcon,
 }
 
-#[derive(Debug, Default, Deserialize)]
+#[derive(Deserialize)]
 pub struct Tab {
     #[serde(flatten)]
     pub container: ContainerStyle,
@@ -48,26 +52,26 @@ pub struct Tab {
     pub icon_conflict: Color,
 }
 
-#[derive(Debug, Default, Deserialize)]
+#[derive(Deserialize)]
 pub struct SidebarIcon {
     pub color: Color,
 }
 
-#[derive(Debug, Default, Deserialize)]
+#[derive(Deserialize)]
 pub struct ChatPanel {
     #[serde(flatten)]
     pub container: ContainerStyle,
     pub message: ChatMessage,
 }
 
-#[derive(Debug, Default, Deserialize)]
+#[derive(Deserialize)]
 pub struct ChatMessage {
     pub body: TextStyle,
     pub sender: ContainedLabel,
     pub timestamp: ContainedLabel,
 }
 
-#[derive(Debug, Default, Deserialize)]
+#[derive(Deserialize)]
 pub struct Selector {
     #[serde(flatten)]
     pub container: ContainerStyle,
@@ -78,7 +82,7 @@ pub struct Selector {
     pub active_item: ContainedLabel,
 }
 
-#[derive(Debug, Default, Deserialize)]
+#[derive(Deserialize)]
 pub struct ContainedLabel {
     #[serde(flatten)]
     pub container: ContainerStyle,
@@ -86,70 +90,69 @@ pub struct ContainedLabel {
     pub label: LabelStyle,
 }
 
-#[derive(Debug, Deserialize)]
+#[derive(Deserialize)]
 pub struct Editor {
     pub background: Color,
     pub gutter_background: Color,
     pub active_line_background: Color,
     pub line_number: Color,
     pub line_number_active: Color,
-    pub text: Color,
     pub replicas: Vec<Replica>,
 }
 
-#[derive(Clone, Copy, Debug, Default, Deserialize)]
+#[derive(Clone, Copy, Deserialize)]
 pub struct Replica {
     pub cursor: Color,
     pub selection: Color,
 }
 
-impl Theme {
-    pub fn highlight_style(&self, id: HighlightId) -> TextStyle {
-        self.syntax
+impl SyntaxTheme {
+    pub fn new(default_style: HighlightStyle, highlights: Vec<(String, HighlightStyle)>) -> Self {
+        Self {
+            default_style,
+            highlights,
+        }
+    }
+
+    pub fn highlight_style(&self, id: HighlightId) -> HighlightStyle {
+        self.highlights
             .get(id.0 as usize)
             .map(|entry| entry.1.clone())
-            .unwrap_or_else(|| TextStyle {
-                color: self.editor.text,
-                font_properties: Default::default(),
-            })
+            .unwrap_or_else(|| self.default_style.clone())
     }
 
     #[cfg(test)]
     pub fn highlight_name(&self, id: HighlightId) -> Option<&str> {
-        self.syntax.get(id.0 as usize).map(|e| e.0.as_str())
+        self.highlights.get(id.0 as usize).map(|e| e.0.as_str())
     }
 }
 
-impl Default for Editor {
-    fn default() -> Self {
-        Self {
-            background: Default::default(),
-            gutter_background: Default::default(),
-            active_line_background: Default::default(),
-            line_number: Default::default(),
-            line_number_active: Default::default(),
-            text: Default::default(),
-            replicas: vec![Replica::default()],
-        }
-    }
-}
-
-pub fn deserialize_syntax_theme<'de, D>(
-    deserializer: D,
-) -> Result<Vec<(String, TextStyle)>, D::Error>
-where
-    D: Deserializer<'de>,
-{
-    let mut result = Vec::<(String, TextStyle)>::new();
-
-    let syntax_data: HashMap<String, TextStyle> = Deserialize::deserialize(deserializer)?;
-    for (key, style) in syntax_data {
-        match result.binary_search_by(|(needle, _)| needle.cmp(&key)) {
-            Ok(i) | Err(i) => {
-                result.insert(i, (key, style));
+impl<'de> Deserialize<'de> for SyntaxTheme {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let mut syntax_data: HashMap<String, HighlightStyle> =
+            Deserialize::deserialize(deserializer)?;
+
+        let mut result = Self {
+            highlights: Vec::<(String, HighlightStyle)>::new(),
+            default_style: syntax_data
+                .remove("default")
+                .ok_or_else(|| de::Error::custom("must specify a default color in syntax theme"))?,
+        };
+
+        for (key, style) in syntax_data {
+            match result
+                .highlights
+                .binary_search_by(|(needle, _)| needle.cmp(&key))
+            {
+                Ok(i) | Err(i) => {
+                    result.highlights.insert(i, (key, style));
+                }
             }
         }
-    }
 
-    Ok(result)
+        Ok(result)
+    }
 }

zed/src/theme/highlight_map.rs πŸ”—

@@ -1,4 +1,4 @@
-use super::Theme;
+use super::SyntaxTheme;
 use std::sync::Arc;
 
 #[derive(Clone, Debug)]
@@ -10,7 +10,7 @@ pub struct HighlightId(pub u32);
 const DEFAULT_HIGHLIGHT_ID: HighlightId = HighlightId(u32::MAX);
 
 impl HighlightMap {
-    pub fn new(capture_names: &[String], theme: &Theme) -> Self {
+    pub fn new(capture_names: &[String], theme: &SyntaxTheme) -> Self {
         // For each capture name in the highlight query, find the longest
         // key in the theme's syntax styles that matches all of the
         // dot-separated components of the capture name.
@@ -19,7 +19,7 @@ impl HighlightMap {
                 .iter()
                 .map(|capture_name| {
                     theme
-                        .syntax
+                        .highlights
                         .iter()
                         .enumerate()
                         .filter_map(|(i, (key, _))| {
@@ -68,9 +68,9 @@ mod tests {
 
     #[test]
     fn test_highlight_map() {
-        let theme = Theme {
-            name: "test".into(),
-            syntax: [
+        let theme = SyntaxTheme::new(
+            Default::default(),
+            [
                 ("function", Color::from_u32(0x100000ff)),
                 ("function.method", Color::from_u32(0x200000ff)),
                 ("function.async", Color::from_u32(0x300000ff)),
@@ -81,8 +81,7 @@ mod tests {
             .iter()
             .map(|(name, color)| (name.to_string(), (*color).into()))
             .collect(),
-            ..Default::default()
-        };
+        );
 
         let capture_names = &[
             "function.special".to_string(),

zed/src/theme/theme_registry.rs πŸ”—

@@ -1,5 +1,5 @@
 use anyhow::{anyhow, Context, Result};
-use gpui::AssetSource;
+use gpui::{fonts, AssetSource, FontCache};
 use json::{Map, Value};
 use parking_lot::Mutex;
 use serde_json as json;
@@ -11,6 +11,7 @@ pub struct ThemeRegistry {
     assets: Box<dyn AssetSource>,
     themes: Mutex<HashMap<String, Arc<Theme>>>,
     theme_data: Mutex<HashMap<String, Arc<Value>>>,
+    font_cache: Arc<FontCache>,
 }
 
 #[derive(Default)]
@@ -38,11 +39,12 @@ enum Key {
 }
 
 impl ThemeRegistry {
-    pub fn new(source: impl AssetSource) -> Arc<Self> {
+    pub fn new(source: impl AssetSource, font_cache: Arc<FontCache>) -> Arc<Self> {
         Arc::new(Self {
             assets: Box::new(source),
             themes: Default::default(),
             theme_data: Default::default(),
+            font_cache,
         })
     }
 
@@ -69,7 +71,10 @@ impl ThemeRegistry {
         }
 
         let theme_data = self.load(name, true)?;
-        let mut theme = serde_json::from_value::<Theme>(theme_data.as_ref().clone())?;
+        let mut theme = fonts::with_font_cache(self.font_cache.clone(), || {
+            serde_json::from_value::<Theme>(theme_data.as_ref().clone())
+        })?;
+
         theme.name = name.into();
         let theme = Arc::new(theme);
         self.themes.lock().insert(name.to_string(), theme.clone());
@@ -512,11 +517,12 @@ fn value_at<'a>(object: &'a mut Map<String, Value>, key_path: &KeyPath) -> Optio
 mod tests {
     use super::*;
     use crate::{assets::Assets, theme::DEFAULT_THEME_NAME};
+    use gpui::MutableAppContext;
     use rand::{prelude::StdRng, Rng};
 
-    #[test]
-    fn test_bundled_themes() {
-        let registry = ThemeRegistry::new(Assets);
+    #[gpui::test]
+    fn test_bundled_themes(cx: &mut MutableAppContext) {
+        let registry = ThemeRegistry::new(Assets, cx.font_cache().clone());
         let mut has_default_theme = false;
         for theme_name in registry.list() {
             let theme = registry.get(&theme_name).unwrap();
@@ -528,8 +534,8 @@ mod tests {
         assert!(has_default_theme);
     }
 
-    #[test]
-    fn test_theme_extension() {
+    #[gpui::test]
+    fn test_theme_extension(cx: &mut MutableAppContext) {
         let assets = TestAssets(&[
             (
                 "themes/_base.toml",
@@ -568,7 +574,7 @@ mod tests {
             ),
         ]);
 
-        let registry = ThemeRegistry::new(assets);
+        let registry = ThemeRegistry::new(assets, cx.font_cache().clone());
         let theme_data = registry.load("light", true).unwrap();
         assert_eq!(
             theme_data.as_ref(),

zed/src/theme_selector.rs πŸ”—

@@ -204,13 +204,7 @@ impl ThemeSelector {
         if self.matches.is_empty() {
             let settings = self.settings.borrow();
             return Container::new(
-                Label::new(
-                    "No matches".into(),
-                    settings.ui_font_family,
-                    settings.ui_font_size,
-                )
-                .with_style(&settings.theme.selector.label)
-                .boxed(),
+                Label::new("No matches".into(), settings.theme.selector.label.clone()).boxed(),
             )
             .with_margin_top(6.0)
             .named("empty matches");
@@ -247,14 +241,12 @@ impl ThemeSelector {
         let container = Container::new(
             Label::new(
                 theme_match.string.clone(),
-                settings.ui_font_family,
-                settings.ui_font_size,
+                if index == self.selected_index {
+                    theme.selector.active_item.label.clone()
+                } else {
+                    theme.selector.item.label.clone()
+                },
             )
-            .with_style(if index == self.selected_index {
-                &theme.selector.active_item.label
-            } else {
-                &theme.selector.item.label
-            })
             .with_highlights(theme_match.positions.clone())
             .boxed(),
         )

zed/src/workspace.rs πŸ”—

@@ -1018,7 +1018,7 @@ mod tests {
     use crate::{
         editor::{Editor, Insert},
         fs::FakeFs,
-        test::{build_app_state, temp_tree},
+        test::{temp_tree, test_app_state},
         worktree::WorktreeHandle,
     };
     use serde_json::json;
@@ -1027,7 +1027,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_open_paths_action(mut cx: gpui::TestAppContext) {
-        let app_state = cx.update(build_app_state);
+        let app_state = cx.update(test_app_state);
         let dir = temp_tree(json!({
             "a": {
                 "aa": null,
@@ -1100,7 +1100,7 @@ mod tests {
             },
         }));
 
-        let app_state = cx.update(build_app_state);
+        let app_state = cx.update(test_app_state);
 
         let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
         workspace
@@ -1204,7 +1204,7 @@ mod tests {
         fs.insert_file("/dir1/a.txt", "".into()).await.unwrap();
         fs.insert_file("/dir2/b.txt", "".into()).await.unwrap();
 
-        let mut app_state = cx.update(build_app_state);
+        let mut app_state = cx.update(test_app_state);
         Arc::get_mut(&mut app_state).unwrap().fs = Arc::new(fs);
 
         let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
@@ -1273,7 +1273,7 @@ mod tests {
             "a.txt": "",
         }));
 
-        let app_state = cx.update(build_app_state);
+        let app_state = cx.update(test_app_state);
         let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
         workspace
             .update(&mut cx, |workspace, cx| {
@@ -1318,7 +1318,7 @@ mod tests {
     #[gpui::test]
     async fn test_open_and_save_new_file(mut cx: gpui::TestAppContext) {
         let dir = TempDir::new("test-new-file").unwrap();
-        let app_state = cx.update(build_app_state);
+        let app_state = cx.update(test_app_state);
         let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
         workspace
             .update(&mut cx, |workspace, cx| {
@@ -1417,7 +1417,7 @@ mod tests {
     async fn test_new_empty_workspace(mut cx: gpui::TestAppContext) {
         cx.update(init);
 
-        let app_state = cx.update(build_app_state);
+        let app_state = cx.update(test_app_state);
         cx.dispatch_global_action(OpenNew(app_state));
         let window_id = *cx.window_ids().first().unwrap();
         let workspace = cx.root_view::<Workspace>(window_id).unwrap();
@@ -1463,7 +1463,7 @@ mod tests {
             },
         }));
 
-        let app_state = cx.update(build_app_state);
+        let app_state = cx.update(test_app_state);
         let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
         workspace
             .update(&mut cx, |workspace, cx| {

zed/src/workspace/pane.rs πŸ”—

@@ -208,14 +208,12 @@ impl Pane {
                                     Align::new(
                                         Label::new(
                                             title,
-                                            settings.ui_font_family,
-                                            settings.ui_font_size,
+                                            if is_active {
+                                                theme.workspace.active_tab.label.clone()
+                                            } else {
+                                                theme.workspace.tab.label.clone()
+                                            },
                                         )
-                                        .with_style(if is_active {
-                                            &theme.workspace.active_tab.label
-                                        } else {
-                                            &theme.workspace.tab.label
-                                        })
                                         .boxed(),
                                     )
                                     .boxed(),