Repository: pop-os/cosmic-text
Pull request #371: Add support for kerning and ligature font features
Author: login: StewartCanva
Created: 2025-03-21T07:04:33Z | Merged: 2025-03-31T14:41:11Z

Right now cosmic text is not passing any font feature information into rustybuzz. This is fine for most cases, but in our use case we want to be able to control whether kerning and ligature is applied or not on text (whether specified in the font file or using the fallback mechanism in rustbuzz) . Rustybuzz will without intervention, indiscriminately apply kerning and ligatures for all text.

This would also open up the pathway for more font feature support within the future.

On a side note. We're trying to use this crate at Canva and there's a few other additions we would love to add to cosmic text in order to increase its functionality. Would love to get the conversation rolling in terms of that to see if we can invest our development time into it.


Diff:
diff --git a/examples/multiview/src/main.rs b/examples/multiview/src/main.rs
index 5e14298651..12713cc7b7 100644
--- a/examples/multiview/src/main.rs
+++ b/examples/multiview/src/main.rs
@@ -31,7 +31,7 @@ fn main() {
 
     let attrs = Attrs::new().family(Family::Monospace);
     match fs::read_to_string(&path) {
-        Ok(text) => buffer.set_text(&text, attrs, Shaping::Advanced),
+        Ok(text) => buffer.set_text(&text, &attrs, Shaping::Advanced),
         Err(err) => {
             log::error!("failed to load {:?}: {}", path, err);
         }
diff --git a/examples/rich-text/src/main.rs b/examples/rich-text/src/main.rs
index 285cea9a7a..167486d368 100644
--- a/examples/rich-text/src/main.rs
+++ b/examples/rich-text/src/main.rs
@@ -21,89 +21,112 @@ use winit::{
 
 fn set_buffer_text(buffer: &mut BorrowedWithFontSystem<'_, Buffer>) {
     let attrs = Attrs::new();
-    let serif_attrs = attrs.family(Family::Serif);
-    let mono_attrs = attrs.family(Family::Monospace);
-    let comic_attrs = attrs.family(Family::Name("Comic Neue"));
+    let serif_attrs = attrs.clone().family(Family::Serif);
+    let mono_attrs = attrs.clone().family(Family::Monospace);
+    let comic_attrs = attrs.clone().family(Family::Name("Comic Neue"));
 
     let spans: &[(&str, Attrs)] = &[
-        ("Font size 64 ", attrs.metrics(Metrics::relative(64.0, 1.2))),
-        ("Font size 8 ", attrs.metrics(Metrics::relative(8.0, 1.2))),
-        ("Font size 20 ", attrs.metrics(Metrics::relative(20.0, 1.2))),
-        ("Font size 14 ", attrs.metrics(Metrics::relative(14.0, 1.2))),
+        (
+            "Font size 64 ",
+            attrs.clone().metrics(Metrics::relative(64.0, 1.2)),
+        ),
+        (
+            "Font size 8 ",
+            attrs.clone().metrics(Metrics::relative(8.0, 1.2)),
+        ),
+        (
+            "Font size 20 ",
+            attrs.clone().metrics(Metrics::relative(20.0, 1.2)),
+        ),
+        (
+            "Font size 14 ",
+            attrs.clone().metrics(Metrics::relative(14.0, 1.2)),
+        ),
         (
             "Font size 48\n",
-            attrs.metrics(Metrics::relative(48.0, 1.2)),
+            attrs.clone().metrics(Metrics::relative(48.0, 1.2)),
         ),
-        ("B", attrs.weight(Weight::BOLD)),
-        ("old ", attrs),
-        ("I", attrs.style(Style::Italic)),
-        ("talic ", attrs),
-        ("f", attrs),
-        ("i ", attrs),
-        ("f", attrs.weight(Weight::BOLD)),
-        ("i ", attrs),
-        ("f", attrs.style(Style::Italic)),
-        ("i \n", attrs),
-        ("Sans-Serif Normal ", attrs),
-        ("Sans-Serif Bold ", attrs.weight(Weight::BOLD)),
-        ("Sans-Serif Italic ", attrs.style(Style::Italic)),
+        ("B", attrs.clone().weight(Weight::BOLD)),
+        ("old ", attrs.clone()),
+        ("I", attrs.clone().style(Style::Italic)),
+        ("talic ", attrs.clone()),
+        ("f", attrs.clone()),
+        ("i ", attrs.clone()),
+        ("f", attrs.clone().weight(Weight::BOLD)),
+        ("i ", attrs.clone()),
+        ("f", attrs.clone().style(Style::Italic)),
+        ("i \n", attrs.clone()),
+        ("Sans-Serif Normal ", attrs.clone()),
+        ("Sans-Serif Bold ", attrs.clone().weight(Weight::BOLD)),
+        ("Sans-Serif Italic ", attrs.clone().style(Style::Italic)),
         (
             "Sans-Serif Fake Italic ",
-            attrs.cache_key_flags(CacheKeyFlags::FAKE_ITALIC),
+            attrs.clone().cache_key_flags(CacheKeyFlags::FAKE_ITALIC),
         ),
         (
             "Sans-Serif Bold Italic\n",
-            attrs.weight(Weight::BOLD).style(Style::Italic),
+            attrs.clone().weight(Weight::BOLD).style(Style::Italic),
         ),
-        ("Serif Normal ", serif_attrs),
-        ("Serif Bold ", serif_attrs.weight(Weight::BOLD)),
-        ("Serif Italic ", serif_attrs.style(Style::Italic)),
+        ("Serif Normal ", serif_attrs.clone()),
+        ("Serif Bold ", serif_attrs.clone().weight(Weight::BOLD)),
+        ("Serif Italic ", serif_attrs.clone().style(Style::Italic)),
         (
             "Serif Bold Italic\n",
-            serif_attrs.weight(Weight::BOLD).style(Style::Italic),
+            serif_attrs
+                .clone()
+                .weight(Weight::BOLD)
+                .style(Style::Italic),
         ),
-        ("Mono Normal ", mono_attrs),
-        ("Mono Bold ", mono_attrs.weight(Weight::BOLD)),
-        ("Mono Italic ", mono_attrs.style(Style::Italic)),
+        ("Mono Normal ", mono_attrs.clone()),
+        ("Mono Bold ", mono_attrs.clone().weight(Weight::BOLD)),
+        ("Mono Italic ", mono_attrs.clone().style(Style::Italic)),
         (
             "Mono Bold Italic\n",
-            mono_attrs.weight(Weight::BOLD).style(Style::Italic),
+            mono_attrs.clone().weight(Weight::BOLD).style(Style::Italic),
         ),
-        ("Comic Normal ", comic_attrs),
-        ("Comic Bold ", comic_attrs.weight(Weight::BOLD)),
-        ("Comic Italic ", comic_attrs.style(Style::Italic)),
+        ("Comic Normal ", comic_attrs.clone()),
+        ("Comic Bold ", comic_attrs.clone().weight(Weight::BOLD)),
+        ("Comic Italic ", comic_attrs.clone().style(Style::Italic)),
         (
             "Comic Bold Italic\n",
-            comic_attrs.weight(Weight::BOLD).style(Style::Italic),
+            comic_attrs
+                .clone()
+                .weight(Weight::BOLD)
+                .style(Style::Italic),
         ),
-        ("R", attrs.color(Color::rgb(0xFF, 0x00, 0x00))),
-        ("A", attrs.color(Color::rgb(0xFF, 0x7F, 0x00))),
-        ("I", attrs.color(Color::rgb(0xFF, 0xFF, 0x00))),
-        ("N", attrs.color(Color::rgb(0x00, 0xFF, 0x00))),
-        ("B", attrs.color(Color::rgb(0x00, 0x00, 0xFF))),
-        ("O", attrs.color(Color::rgb(0x4B, 0x00, 0x82))),
-        ("W ", attrs.color(Color::rgb(0x94, 0x00, 0xD3))),
-        ("Red ", attrs.color(Color::rgb(0xFF, 0x00, 0x00))),
-        ("Orange ", attrs.color(Color::rgb(0xFF, 0x7F, 0x00))),
-        ("Yellow ", attrs.color(Color::rgb(0xFF, 0xFF, 0x00))),
-        ("Green ", attrs.color(Color::rgb(0x00, 0xFF, 0x00))),
-        ("Blue ", attrs.color(Color::rgb(0x00, 0x00, 0xFF))),
-        ("Indigo ", attrs.color(Color::rgb(0x4B, 0x00, 0x82))),
-        ("Violet ", attrs.color(Color::rgb(0x94, 0x00, 0xD3))),
-        ("U", attrs.color(Color::rgb(0x94, 0x00, 0xD3))),
-        ("N", attrs.color(Color::rgb(0x4B, 0x00, 0x82))),
-        ("I", attrs.color(Color::rgb(0x00, 0x00, 0xFF))),
-        ("C", attrs.color(Color::rgb(0x00, 0xFF, 0x00))),
-        ("O", attrs.color(Color::rgb(0xFF, 0xFF, 0x00))),
-        ("R", attrs.color(Color::rgb(0xFF, 0x7F, 0x00))),
-        ("N\n", attrs.color(Color::rgb(0xFF, 0x00, 0x00))),
+        ("R", attrs.clone().color(Color::rgb(0xFF, 0x00, 0x00))),
+        ("A", attrs.clone().color(Color::rgb(0xFF, 0x7F, 0x00))),
+        ("I", attrs.clone().color(Color::rgb(0xFF, 0xFF, 0x00))),
+        ("N", attrs.clone().color(Color::rgb(0x00, 0xFF, 0x00))),
+        ("B", attrs.clone().color(Color::rgb(0x00, 0x00, 0xFF))),
+        ("O", attrs.clone().color(Color::rgb(0x4B, 0x00, 0x82))),
+        ("W ", attrs.clone().color(Color::rgb(0x94, 0x00, 0xD3))),
+        ("Red ", attrs.clone().color(Color::rgb(0xFF, 0x00, 0x00))),
+        ("Orange ", attrs.clone().color(Color::rgb(0xFF, 0x7F, 0x00))),
+        ("Yellow ", attrs.clone().color(Color::rgb(0xFF, 0xFF, 0x00))),
+        ("Green ", attrs.clone().color(Color::rgb(0x00, 0xFF, 0x00))),
+        ("Blue ", attrs.clone().color(Color::rgb(0x00, 0x00, 0xFF))),
+        ("Indigo ", attrs.clone().color(Color::rgb(0x4B, 0x00, 0x82))),
+        ("Violet ", attrs.clone().color(Color::rgb(0x94, 0x00, 0xD3))),
+        ("U", attrs.clone().color(Color::rgb(0x94, 0x00, 0xD3))),
+        ("N", attrs.clone().color(Color::rgb(0x4B, 0x00, 0x82))),
+        ("I", attrs.clone().color(Color::rgb(0x00, 0x00, 0xFF))),
+        ("C", attrs.clone().color(Color::rgb(0x00, 0xFF, 0x00))),
+        ("O", attrs.clone().color(Color::rgb(0xFF, 0xFF, 0x00))),
+        ("R", attrs.clone().color(Color::rgb(0xFF, 0x7F, 0x00))),
+        ("N\n", attrs.clone().color(Color::rgb(0xFF, 0x00, 0x00))),
         (
             "生活,삶,जिंदगी 😀 FPS\n",
-            attrs.color(Color::rgb(0xFF, 0x00, 0x00)),
+            attrs.clone().color(Color::rgb(0xFF, 0x00, 0x00)),
         ),
     ];
 
-    buffer.set_rich_text(spans.iter().copied(), attrs, Shaping::Advanced, None);
+    buffer.set_rich_text(
+        spans.iter().map(|(text, attrs)| (*text, attrs.clone())),
+        &attrs,
+        Shaping::Advanced,
+        None,
+    );
 }
 
 fn main() {
diff --git a/examples/terminal/src/main.rs b/examples/terminal/src/main.rs
index 7e4bfbb8f2..286dc33b89 100644
--- a/examples/terminal/src/main.rs
+++ b/examples/terminal/src/main.rs
@@ -36,7 +36,7 @@ fn main() {
     let text = std::env::args()
         .nth(1)
         .unwrap_or(" Hi, Rust! 🦀 ".to_string());
-    buffer.set_text(&text, attrs, Shaping::Advanced);
+    buffer.set_text(&text, &attrs, Shaping::Advanced);
 
     // Perform shaping as desired
     buffer.shape_until_scroll(true);
diff --git a/src/attrs.rs b/src/attrs.rs
index 2ad8f91b0c..43f17898f4 100644
--- a/src/attrs.rs
+++ b/src/attrs.rs
@@ -125,6 +125,72 @@ impl From<CacheMetrics> for Metrics {
         }
     }
 }
+/// A 4-byte `OpenType` feature tag identifier
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct FeatureTag([u8; 4]);
+
+impl FeatureTag {
+    pub const fn new(tag: &[u8; 4]) -> Self {
+        Self(*tag)
+    }
+
+    /// Kerning adjusts spacing between specific character pairs
+    pub const KERNING: Self = Self::new(b"kern");
+    /// Standard ligatures (fi, fl, etc.)
+    pub const STANDARD_LIGATURES: Self = Self::new(b"liga");
+    /// Contextual ligatures (context-dependent ligatures)
+    pub const CONTEXTUAL_LIGATURES: Self = Self::new(b"clig");
+    /// Contextual alternates (glyph substitutions based on context)
+    pub const CONTEXTUAL_ALTERNATES: Self = Self::new(b"calt");
+    /// Discretionary ligatures (optional stylistic ligatures)
+    pub const DISCRETIONARY_LIGATURES: Self = Self::new(b"dlig");
+    /// Small caps (lowercase to small capitals)
+    pub const SMALL_CAPS: Self = Self::new(b"smcp");
+    /// All small caps (uppercase and lowercase to small capitals)
+    pub const ALL_SMALL_CAPS: Self = Self::new(b"c2sc");
+    /// Stylistic Set 1 (font-specific alternate glyphs)
+    pub const STYLISTIC_SET_1: Self = Self::new(b"ss01");
+    /// Stylistic Set 2 (font-specific alternate glyphs)
+    pub const STYLISTIC_SET_2: Self = Self::new(b"ss02");
+
+    pub fn as_bytes(&self) -> &[u8; 4] {
+        &self.0
+    }
+}
+
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct Feature {
+    pub tag: FeatureTag,
+    pub value: u32,
+}
+
+#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
+pub struct FontFeatures {
+    pub features: Vec<Feature>,
+}
+
+impl FontFeatures {
+    pub fn new() -> Self {
+        Self {
+            features: Vec::new(),
+        }
+    }
+
+    pub fn set(&mut self, tag: FeatureTag, value: u32) -> &mut Self {
+        self.features.push(Feature { tag, value });
+        self
+    }
+
+    /// Enable a feature (set to 1)
+    pub fn enable(&mut self, tag: FeatureTag) -> &mut Self {
+        self.set(tag, 1)
+    }
+
+    /// Disable a feature (set to 0)
+    pub fn disable(&mut self, tag: FeatureTag) -> &mut Self {
+        self.set(tag, 0)
+    }
+}
 
 /// A wrapper for letter spacing to get around that f32 doesn't implement Eq and Hash
 #[derive(Clone, Copy, Debug)]
@@ -158,7 +224,7 @@ impl Hash for LetterSpacing {
 }
 
 /// Text attributes
-#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
 pub struct Attrs<'a> {
     //TODO: should this be an option?
     pub color_opt: Option<Color>,
@@ -171,6 +237,7 @@ pub struct Attrs<'a> {
     pub metrics_opt: Option<CacheMetrics>,
     /// Letter spacing (tracking) in EM
     pub letter_spacing_opt: Option<LetterSpacing>,
+    pub font_features: FontFeatures,
 }
 
 impl<'a> Attrs<'a> {
@@ -188,6 +255,7 @@ impl<'a> Attrs<'a> {
             cache_key_flags: CacheKeyFlags::empty(),
             metrics_opt: None,
             letter_spacing_opt: None,
+            font_features: FontFeatures::new(),
         }
     }
 
@@ -245,6 +313,12 @@ impl<'a> Attrs<'a> {
         self
     }
 
+    /// Set [`FontFeatures`]
+    pub fn font_features(mut self, font_features: FontFeatures) -> Self {
+        self.font_features = font_features;
+        self
+    }
+
     /// Check if font matches
     pub fn matches(&self, face: &fontdb::FaceInfo) -> bool {
         //TODO: smarter way of including emoji
@@ -270,8 +344,8 @@ pub struct FontMatchAttrs {
     weight: Weight,
 }
 
-impl<'a> From<Attrs<'a>> for FontMatchAttrs {
-    fn from(attrs: Attrs<'a>) -> Self {
+impl<'a> From<&Attrs<'a>> for FontMatchAttrs {
+    fn from(attrs: &Attrs<'a>) -> Self {
         Self {
             family: FamilyOwned::new(attrs.family),
             stretch: attrs.stretch,
@@ -295,10 +369,11 @@ pub struct AttrsOwned {
     pub metrics_opt: Option<CacheMetrics>,
     /// Letter spacing (tracking) in EM
     pub letter_spacing_opt: Option<LetterSpacing>,
+    pub font_features: FontFeatures,
 }
 
 impl AttrsOwned {
-    pub fn new(attrs: Attrs) -> Self {
+    pub fn new(attrs: &Attrs) -> Self {
         Self {
             color_opt: attrs.color_opt,
             family_owned: FamilyOwned::new(attrs.family),
@@ -309,6 +384,7 @@ impl AttrsOwned {
             cache_key_flags: attrs.cache_key_flags,
             metrics_opt: attrs.metrics_opt,
             letter_spacing_opt: attrs.letter_spacing_opt,
+            font_features: attrs.font_features.clone(),
         }
     }
 
@@ -323,6 +399,7 @@ impl AttrsOwned {
             cache_key_flags: self.cache_key_flags,
             metrics_opt: self.metrics_opt,
             letter_spacing_opt: self.letter_spacing_opt,
+            font_features: self.font_features.clone(),
         }
     }
 }
@@ -337,7 +414,7 @@ pub struct AttrsList {
 
 impl AttrsList {
     /// Create a new attributes list with a set of default [Attrs]
-    pub fn new(defaults: Attrs) -> Self {
+    pub fn new(defaults: &Attrs) -> Self {
         Self {
             defaults: AttrsOwned::new(defaults),
             spans: RangeMap::new(),
@@ -365,7 +442,7 @@ impl AttrsList {
     }
 
     /// Add an attribute span, removes any previous matching parts of spans
-    pub fn add_span(&mut self, range: Range<usize>, attrs: Attrs) {
+    pub fn add_span(&mut self, range: Range<usize>, attrs: &Attrs) {
         //do not support 1..1 or 2..1 even if by accident.
         if range.is_empty() {
             return;
@@ -387,7 +464,7 @@ impl AttrsList {
     /// Split attributes list at an offset
     #[allow(clippy::missing_panics_doc)]
     pub fn split_off(&mut self, index: usize) -> Self {
-        let mut new = Self::new(self.defaults.as_attrs());
+        let mut new = Self::new(&self.defaults.as_attrs());
         let mut removes = Vec::new();
 
         //get the keys we need to remove or fix.
@@ -421,7 +498,7 @@ impl AttrsList {
     }
 
     /// Resets the attributes with new defaults.
-    pub(crate) fn reset(mut self, default: Attrs) -> Self {
+    pub(crate) fn reset(mut self, default: &Attrs) -> Self {
         self.defaults = AttrsOwned::new(default);
         self.spans.clear();
         self
diff --git a/src/buffer.rs b/src/buffer.rs
index f9c9792c60..0292e6bd3a 100644
--- a/src/buffer.rs
+++ b/src/buffer.rs
@@ -266,7 +266,7 @@ impl Buffer {
     /// Will panic if `metrics.line_height` is zero.
     pub fn new(font_system: &mut FontSystem, metrics: Metrics) -> Self {
         let mut buffer = Self::new_empty(metrics);
-        buffer.set_text(font_system, "", Attrs::new(), Shaping::Advanced);
+        buffer.set_text(font_system, "", &Attrs::new(), Shaping::Advanced);
         buffer
     }
 
@@ -676,7 +676,7 @@ impl Buffer {
         &mut self,
         font_system: &mut FontSystem,
         text: &str,
-        attrs: Attrs,
+        attrs: &Attrs,
         shaping: Shaping,
     ) {
         self.lines.clear();
@@ -710,10 +710,10 @@ impl Buffer {
     /// buffer.set_rich_text(
     ///     &mut font_system,
     ///     [
-    ///         ("hello, ", attrs),
-    ///         ("cosmic\ntext", attrs.family(Family::Monospace)),
+    ///         ("hello, ", attrs.clone()),
+    ///         ("cosmic\ntext", attrs.clone().family(Family::Monospace)),
     ///     ],
-    ///     attrs,
+    ///     &attrs,
     ///     Shaping::Advanced,
     ///     None,
     /// );
@@ -722,7 +722,7 @@ impl Buffer {
         &mut self,
         font_system: &mut FontSystem,
         spans: I,
-        default_attrs: Attrs,
+        default_attrs: &Attrs,
         shaping: Shaping,
         alignment: Option<Align>,
     ) where
@@ -758,7 +758,7 @@ impl Buffer {
             .lines
             .get_mut(line_count)
             .map(BufferLine::reclaim_attrs)
-            .unwrap_or_else(|| AttrsList::new(Attrs::new()))
+            .unwrap_or_else(|| AttrsList::new(&Attrs::new()))
             .reset(default_attrs);
         let mut line_string = self
             .lines
@@ -792,7 +792,7 @@ impl Buffer {
                 let text_end = line_string.len();
                 // Only add attrs if they don't match the defaults
                 if *attrs != attrs_list.defaults() {
-                    attrs_list.add_span(text_start..text_end, *attrs);
+                    attrs_list.add_span(text_start..text_end, attrs);
                 }
             }
 
@@ -811,7 +811,7 @@ impl Buffer {
                         .lines
                         .get_mut(line_count + 1)
                         .map(BufferLine::reclaim_attrs)
-                        .unwrap_or_else(|| AttrsList::new(Attrs::new()))
+                        .unwrap_or_else(|| AttrsList::new(&Attrs::new()))
                         .reset(default_attrs);
                     let next_line_string = self
                         .lines
@@ -1428,7 +1428,7 @@ impl BorrowedWithFontSystem<'_, Buffer> {
     }
 
     /// Set text of buffer, using provided attributes for each line by default
-    pub fn set_text(&mut self, text: &str, attrs: Attrs, shaping: Shaping) {
+    pub fn set_text(&mut self, text: &str, attrs: &Attrs, shaping: Shaping) {
         self.inner.set_text(self.font_system, text, attrs, shaping);
     }
 
@@ -1438,14 +1438,14 @@ impl BorrowedWithFontSystem<'_, Buffer> {
     /// # use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping};
     /// # let mut font_system = FontSystem::new();
     /// let mut buffer = Buffer::new_empty(Metrics::new(32.0, 44.0));
-    /// let mut buffer = buffer.borrow_with(&mut font_system);
     /// let attrs = Attrs::new().family(Family::Serif);
     /// buffer.set_rich_text(
+    ///     &mut font_system,
     ///     [
-    ///         ("hello, ", attrs),
-    ///         ("cosmic\ntext", attrs.family(Family::Monospace)),
+    ///         ("hello, ", attrs.clone()),
+    ///         ("cosmic\ntext", attrs.clone().family(Family::Monospace)),
     ///     ],
-    ///     attrs,
+    ///     &attrs,
     ///     Shaping::Advanced,
     ///     None,
     /// );
@@ -1453,7 +1453,7 @@ impl BorrowedWithFontSystem<'_, Buffer> {
     pub fn set_rich_text<'r, 's, I>(
         &mut self,
         spans: I,
-        default_attrs: Attrs,
+        default_attrs: &Attrs,
         shaping: Shaping,
         alignment: Option<Align>,
     ) where
diff --git a/src/buffer_line.rs b/src/buffer_line.rs
index 36e719582f..6929e03a55 100644
--- a/src/buffer_line.rs
+++ b/src/buffer_line.rs
@@ -162,13 +162,13 @@ impl BufferLine {
         if other.attrs_list.defaults() != self.attrs_list.defaults() {
             // If default formatting does not match, make a new span for it
             self.attrs_list
-                .add_span(len..len + other.text().len(), other.attrs_list.defaults());
+                .add_span(len..len + other.text().len(), &other.attrs_list.defaults());
         }
 
         for (other_range, attrs) in other.attrs_list.spans_iter() {
             // Add previous attrs spans
             let range = other_range.start + len..other_range.end + len;
-            self.attrs_list.add_span(range, attrs.as_attrs());
+            self.attrs_list.add_span(range, &attrs.as_attrs());
         }
 
         self.reset();
@@ -283,7 +283,7 @@ impl BufferLine {
         Self {
             text: String::default(),
             ending: LineEnding::default(),
-            attrs_list: AttrsList::new(Attrs::new()),
+            attrs_list: AttrsList::new(&Attrs::new()),
             align: None,
             shape_opt: Cached::Empty,
             layout_opt: Cached::Empty,
@@ -296,7 +296,7 @@ impl BufferLine {
     ///
     /// The buffer line is in an invalid state after this is called. See [`Self::reset_new`].
     pub(crate) fn reclaim_attrs(&mut self) -> AttrsList {
-        mem::replace(&mut self.attrs_list, AttrsList::new(Attrs::new()))
+        mem::replace(&mut self.attrs_list, AttrsList::new(&Attrs::new()))
     }
 
     /// Reclaim text memory that isn't needed any longer.
diff --git a/src/edit/editor.rs b/src/edit/editor.rs
index a3e85da699..c1b63d8c8f 100644
--- a/src/edit/editor.rs
+++ b/src/edit/editor.rs
@@ -380,7 +380,7 @@ impl<'buffer> Edit<'buffer> for Editor<'buffer> {
                 let line = BufferLine::new(
                     String::new(),
                     ending,
-                    AttrsList::new(attrs_list.as_ref().map_or_else(
+                    AttrsList::new(&attrs_list.as_ref().map_or_else(
                         || {
                             buffer
                                 .lines
@@ -404,7 +404,7 @@ impl<'buffer> Edit<'buffer> for Editor<'buffer> {
 
             // Collect attributes
             let mut final_attrs = attrs_list.unwrap_or_else(|| {
-                AttrsList::new(line.attrs_list().get_span(cursor.index.saturating_sub(1)))
+                AttrsList::new(&line.attrs_list().get_span(cursor.index.saturating_sub(1)))
             });
 
             // Append the inserted text, line by line
diff --git a/src/edit/syntect.rs b/src/edit/syntect.rs
index b532c96712..57eb9c74db 100644
--- a/src/edit/syntect.rs
+++ b/src/edit/syntect.rs
@@ -88,7 +88,7 @@ impl<'syntax_system, 'buffer> SyntaxEditor<'syntax_system, 'buffer> {
                                 foreground.a,
                             ));
                         }
-                        line.set_attrs_list(AttrsList::new(attrs));
+                        line.set_attrs_list(AttrsList::new(&attrs));
                     }
                 });
             }
@@ -125,7 +125,7 @@ impl<'syntax_system, 'buffer> SyntaxEditor<'syntax_system, 'buffer> {
 
         let text = fs::read_to_string(path)?;
         self.editor.with_buffer_mut(|buffer| {
-            buffer.set_text(font_system, &text, attrs, Shaping::Advanced);
+            buffer.set_text(font_system, &text, &attrs, Shaping::Advanced);
         });
 
         //TODO: re-use text
@@ -332,9 +332,11 @@ impl<'buffer> Edit<'buffer> for SyntaxEditor<'_, 'buffer> {
                 );
 
                 let attrs = line.attrs_list().defaults();
-                let mut attrs_list = AttrsList::new(attrs);
+                let mut attrs_list = AttrsList::new(&attrs);
+                let original_attrs = attrs.clone(); // Store a clone for comparison
                 for (style, _, range) in ranges {
                     let span_attrs = attrs
+                        .clone() // Clone attrs for modification
                         .color(Color::rgba(
                             style.foreground.r,
                             style.foreground.g,
@@ -352,8 +354,8 @@ impl<'buffer> Edit<'buffer> for SyntaxEditor<'_, 'buffer> {
                         } else {
                             Weight::NORMAL
                         }); //TODO: underline
-                    if span_attrs != attrs {
-                        attrs_list.add_span(range, span_attrs);
+                    if span_attrs != original_attrs {
+                        attrs_list.add_span(range, &span_attrs);
                     }
                 }
 
diff --git a/src/font/fallback/macos.rs b/src/font/fallback/macos.rs
index acfb0ac512..7eddd48060 100644
--- a/src/font/fallback/macos.rs
+++ b/src/font/fallback/macos.rs
@@ -4,7 +4,7 @@ use unicode_script::Script;
 
 use super::Fallback;
 
-/// A platform-specific font fallback list, for MacOS.
+/// A platform-specific font fallback list, for `MacOS`.
 #[derive(Debug)]
 pub struct PlatformFallback;
 
diff --git a/src/font/system.rs b/src/font/system.rs
index d52c1add20..d3a91d94ff 100644
--- a/src/font/system.rs
+++ b/src/font/system.rs
@@ -306,7 +306,7 @@ impl FontSystem {
         })
     }
 
-    pub fn get_font_matches(&mut self, attrs: Attrs<'_>) -> Arc<Vec<FontMatchKey>> {
+    pub fn get_font_matches(&mut self, attrs: &Attrs<'_>) -> Arc<Vec<FontMatchKey>> {
         // Clear the cache first if it reached the size limit
         if self.font_matches_cache.len() >= Self::FONT_MATCHES_CACHE_SIZE_LIMIT {
             log::trace!("clear font mache cache");
diff --git a/src/lib.rs b/src/lib.rs
index 5482e05f87..53c5ea5a72 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -36,7 +36,7 @@
 //! let attrs = Attrs::new();
 //!
 //! // Add some text!
-//! buffer.set_text("Hello, Rust! 🦀\n", attrs, Shaping::Advanced);
+//! buffer.set_text("Hello, Rust! 🦀\n", &attrs, Shaping::Advanced);
 //!
 //! // Perform shaping as desired
 //! buffer.shape_until_scroll(true);
diff --git a/src/shape.rs b/src/shape.rs
index 2b499a8039..98eb730bb9 100644
--- a/src/shape.rs
+++ b/src/shape.rs
@@ -140,12 +140,24 @@ fn shape_fallback(
     let rtl = matches!(buffer.direction(), rustybuzz::Direction::RightToLeft);
     assert_eq!(rtl, span_rtl);
 
+    let attrs = attrs_list.get_span(start_run);
+    let mut rb_font_features = Vec::new();
+
+    // Convert attrs::Feature to rustybuzz::Feature
+    for feature in attrs.font_features.features {
+        rb_font_features.push(rustybuzz::Feature::new(
+            rustybuzz::ttf_parser::Tag::from_bytes(feature.tag.as_bytes()),
+            feature.value,
+            0..usize::MAX,
+        ));
+    }
+
     let shape_plan = rustybuzz::ShapePlan::new(
         font.rustybuzz(),
         buffer.direction(),
         Some(buffer.script()),
         buffer.language().as_ref(),
-        &[],
+        &rb_font_features,
     );
     let glyph_buffer = rustybuzz::shape_with_plan(font.rustybuzz(), &shape_plan, buffer);
     let glyph_infos = glyph_buffer.glyph_infos();
@@ -249,7 +261,7 @@ fn shape_run(
 
     let attrs = attrs_list.get_span(start_run);
 
-    let fonts = font_system.get_font_matches(attrs);
+    let fonts = font_system.get_font_matches(&attrs);
 
     let default_families = [&attrs.family];
     let mut font_iter = FontFallbackIter::new(
@@ -377,7 +389,7 @@ fn shape_run_cached(
     let run_range = start_run..end_run;
     let mut key = ShapeRunKey {
         text: line[run_range.clone()].to_string(),
-        default_attrs: AttrsOwned::new(attrs_list.defaults()),
+        default_attrs: AttrsOwned::new(&attrs_list.defaults()),
         attrs_spans: Vec::new(),
     };
     for (attrs_range, attrs) in attrs_list.spans.overlapping(&run_range) {
@@ -432,7 +444,7 @@ fn shape_skip(
     end_run: usize,
 ) {
     let attrs = attrs_list.get_span(start_run);
-    let fonts = font_system.get_font_matches(attrs);
+    let fonts = font_system.get_font_matches(&attrs);
 
     let default_families = [&attrs.family];
     let mut font_iter = FontFallbackIter::new(font_system, &fonts, &default_families, &[], "");
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index c290f2b37a..8440070e0e 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -36,7 +36,7 @@ impl Default for DrawTestCfg {
         let font = Attrs::new().family(Family::Serif);
         Self {
             name: "default".into(),
-            font: AttrsOwned::new(font),
+            font: AttrsOwned::new(&font),
             text: "".into(),
             font_size: 16.0,
             line_height: 20.0,
@@ -60,7 +60,7 @@ impl DrawTestCfg {
     }
 
     pub fn font_attrs(mut self, attrs: Attrs) -> Self {
-        self.font = AttrsOwned::new(attrs);
+        self.font = AttrsOwned::new(&attrs);
         self
     }
 
@@ -92,7 +92,7 @@ impl DrawTestCfg {
             Some((self.canvas_width - margins * 2) as f32),
             Some((self.canvas_height - margins * 2) as f32),
         );
-        buffer.set_text(&self.text, self.font.as_attrs(), Shaping::Advanced);
+        buffer.set_text(&self.text, &self.font.as_attrs(), Shaping::Advanced);
         buffer.shape_until_scroll(true);
 
         // Black
diff --git a/tests/wrap_stability.rs b/tests/wrap_stability.rs
index 11188fc8aa..3bcdf29f5d 100644
--- a/tests/wrap_stability.rs
+++ b/tests/wrap_stability.rs
@@ -11,7 +11,7 @@ use cosmic_text::{
 fn stable_wrap() {
     let font_size = 18.0;
     let attrs = AttrsList::new(
-        Attrs::new()
+        &Attrs::new()
             .family(Family::Name("FiraMono"))
             .weight(Weight::MEDIUM),
     );
@@ -105,7 +105,7 @@ fn wrap_extra_line() {
 
     // Add some text!
     buffer.set_wrap(Wrap::Word);
-    buffer.set_text("Lorem ipsum dolor sit amet, qui minim labore adipisicing\n\nweeewoooo minim sint cillum sint consectetur cupidatat.", Attrs::new().family(cosmic_text::Family::Name("Inter")), Shaping::Advanced);
+    buffer.set_text("Lorem ipsum dolor sit amet, qui minim labore adipisicing\n\nweeewoooo minim sint cillum sint consectetur cupidatat.", &Attrs::new().family(cosmic_text::Family::Name("Inter")), Shaping::Advanced);
 
     // Set a size for the text buffer, in pixels
     buffer.set_size(Some(50.0), Some(1000.0));
diff --git a/tests/wrap_word_fallback.rs b/tests/wrap_word_fallback.rs
index 3a2b474bf5..0b3c66447d 100644
--- a/tests/wrap_word_fallback.rs
+++ b/tests/wrap_word_fallback.rs
@@ -15,7 +15,7 @@ fn wrap_word_fallback() {
     let mut buffer = buffer.borrow_with(&mut font_system);
 
     buffer.set_wrap(Wrap::WordOrGlyph);
-    buffer.set_text("Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint consectetur cupidatat.", Attrs::new().family(cosmic_text::Family::Name("Inter")), Shaping::Advanced);
+    buffer.set_text("Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint consectetur cupidatat.", &Attrs::new().family(cosmic_text::Family::Name("Inter")), Shaping::Advanced);
     buffer.set_size(Some(50.0), Some(1000.0));
 
     buffer.shape_until_scroll(false);


Reviews:
user: login: jackpot51
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T13:34:28Z
user: login: jackpot51
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T13:36:16Z
user: login: StewartCanva
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T06:57:22Z
user: login: StewartCanva
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T07:12:24Z
user: login: jackpot51
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T20:05:35Z
user: login: jackpot51
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-31T14:39:40Z

Review comments:
diff_hunk: @@ -125,6 +125,34 @@ impl From<CacheMetrics> for Metrics {
     }
 }
 
+/// Font features for controlling typographic behavior
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct FontFeatures {
path: src/attrs.rs
user: login: jackpot51
body: This looks very good! I think it would be nice to document the font feature name for each of these, like `liga`
created_at: 2025-03-21T13:34:28Z
author_association: MEMBER
diff_hunk: @@ -204,6 +234,12 @@ impl<'a> Attrs<'a> {
         self
     }
 
+    /// Set [FontFeatures]
+    pub fn font_features(mut self, font_features: FontFeatures) -> Self {
+        self.font_features = font_features;
+        self
+    }
+
     /// Check if font matches
     pub fn matches(&self, face: &fontdb::FaceInfo) -> bool {
path: src/attrs.rs
user: login: jackpot51
body: Should the matches function be checking font features? This is used to determine what is grouped into a run for shaping, so it seems important.
created_at: 2025-03-21T13:36:16Z
author_association: MEMBER
diff_hunk: @@ -204,6 +234,12 @@ impl<'a> Attrs<'a> {
         self
     }
 
+    /// Set [FontFeatures]
+    pub fn font_features(mut self, font_features: FontFeatures) -> Self {
+        self.font_features = font_features;
+        self
+    }
+
     /// Check if font matches
     pub fn matches(&self, face: &fontdb::FaceInfo) -> bool {
path: src/attrs.rs
user: login: StewartCanva
body: Realising I might be missing a bit of context here. It does make sense that runs should also be grouped/split based on font features. I was under the impression that `matches` was used for font matching and I guess font fallback behaviour and that runs before this stage are already split up based on attributes (among other things)? If that's the case, it should be expected. Rustybuzz has fallback behaviour for some of the font features, and if a font doesn't support it then the feature is ignored. If that's not the case then yep, runs should be split further on font features and I'd need to add logic in for that.
created_at: 2025-03-26T06:57:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,6 +201,7 @@ pub struct Attrs<'a> {
     pub metadata: usize,
     pub cache_key_flags: CacheKeyFlags,
     pub metrics_opt: Option<CacheMetrics>,
+    pub font_features: FontFeatures,
path: src/attrs.rs
user: login: StewartCanva
body: @jackpot51 I realised my first proposal, while it would work for kerning and ligature support, would not scale further for future font feature support. I was adding a `bool` or `u32` per font feature with the previous one, and at over `120` different font features that will add a massive memory overhead. 😅

This version I guess is more similar to the rustybuzz interface and scales better. The tradeoff is that `Copy` cannot be applied on `Attrs` which does have slight implications for consumers of the crate (namely the use of `.clone()` depending on how a consumer is associating a span with an attr). There's also some other places throughout the crate that need to be updated because of this. Which I'll do if you think this approach is fine. I tried to avoid it, but there doesn't seem to be a better way. 
created_at: 2025-03-26T07:12:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,6 +201,7 @@ pub struct Attrs<'a> {
     pub metadata: usize,
     pub cache_key_flags: CacheKeyFlags,
     pub metrics_opt: Option<CacheMetrics>,
+    pub font_features: FontFeatures,
path: src/attrs.rs
user: login: jackpot51
body: That seems fine to me
created_at: 2025-03-27T20:05:35Z
author_association: MEMBER

PR comments:
user: login: jackpot51
created_at: 2025-03-21T13:38:32Z
body: @StewartCanva I'm interested in improving cosmic-text for all use cases, your contributions are welcome.
author_association: MEMBER
user: login: jackpot51
created_at: 2025-03-28T15:52:40Z
body: CI is having issues, can you take a look? It looks like some more places need to clone Attrs instead of trying to copy
author_association: MEMBER
user: login: StewartCanva
created_at: 2025-03-31T06:52:31Z
body: @jackpot51 `Rust / build` is passing now after a couple of changes. Is cargo-deny expected to pass though? I'm running it locally on this branch and `main` and neither of them are passing. 
author_association: CONTRIBUTOR
user: login: jackpot51
created_at: 2025-03-31T14:39:24Z
body: Cargo deny has not been passing. We are blocked on fixing it due to some complicated downstream dependency reasons.
author_association: MEMBER