git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitf9b0fa85de32afd26cd2b3d8ae0e4d08d492b59c
parent0dea9002fa
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 21:54
feat(text): TextAttrs carries a width class (stretch)

`TextAttrs::stretch` is the OpenType usWidthClass (1-9, None = normal),
mapped onto cosmic_text::Stretch at shape time. cosmic-text matches a
face's stretch exactly, so without it a family's Narrow / Condensed cut
was unreachable whenever a normal-width sibling sat at the same weight:
the font picker previewed Circe Slab A Narrow as plain Circe Slab A.

Adding a field breaks every struct literal, so the in-crate ones now end
in `..Default::default()` and the doc says to build one that way; the
sibling crates' literals were moved to that form in their own repos
first (it compiles against the old struct too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/backend/text.rs             | 20 ++++++++++++++++++++
 src/scene/paint.rs              | 11 +++++++++--
 src/widget/doc_editor/layout.rs |  2 +-
 src/widget/markdown.rs          |  4 ++--
 4 files changed, 32 insertions(+), 5 deletions(-)

diff --git a/src/backend/text.rs b/src/backend/text.rs
index 1e81145..d73c876 100644
--- a/src/backend/text.rs
+++ b/src/backend/text.rs
@@ -163,6 +163,23 @@ thread_local! {
     > = std::cell::RefCell::new(std::collections::HashMap::new());
 }
 
+/// The `cosmic_text::Stretch` for an OpenType width class (1–9, clamped; see
+/// [`TextAttrs::stretch`](crate::scene::paint::TextAttrs::stretch)).
+fn stretch_from_width_class(class: u16) -> cosmic_text::Stretch {
+    use cosmic_text::Stretch::*;
+    match class {
+        0 | 1 => UltraCondensed,
+        2 => ExtraCondensed,
+        3 => Condensed,
+        4 => SemiCondensed,
+        5 => Normal,
+        6 => SemiExpanded,
+        7 => Expanded,
+        8 => ExtraExpanded,
+        _ => UltraExpanded,
+    }
+}
+
 /// `attrs` moved onto the nearest face its named family actually has.
 ///
 /// cosmic-text 0.12 takes a face of the requested family only when its style
@@ -375,6 +392,9 @@ pub(crate) fn shared_text_buffer(
     if let Some(w) = text_attrs.weight {
         attrs = attrs.weight(cosmic_text::Weight(w));
     }
+    if let Some(s) = text_attrs.stretch {
+        attrs = attrs.stretch(stretch_from_width_class(s));
+    }
     let attrs = snap_to_family_face(fs, attrs);
     let shaping = shaping_for(fs, text, &family);
     buf.set_text(fs, text, attrs, shaping);
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 2e3089c..e64aafb 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -957,12 +957,19 @@ pub struct TextLayout {
 
 /// Optional shaping attributes for a [`Prim::Text`] — the subset a widget can request beyond
 /// family + size. `weight` is the OpenType weight (400 regular, 700 bold); `None` leaves the
-/// family default. Kept toolkit-plain (no cosmic-text types) like the rest of the scene layer;
-/// the backend maps them onto `cosmic_text::Style`/`Weight` at shape time.
+/// family default. `stretch` is the OpenType width class (`usWidthClass`, 1 ultra-condensed
+/// … 5 normal … 9 ultra-expanded); `None` is normal width — what picks a family's Narrow or
+/// Condensed cut over its normal-width sibling at the same weight. Kept toolkit-plain (no
+/// cosmic-text types) like the rest of the scene layer; the backend maps them onto
+/// `cosmic_text::Style`/`Weight`/`Stretch` at shape time.
+///
+/// Build one with `..Default::default()` after the fields you set, so a field added here
+/// does not break every literal in the sibling crates.
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
 pub struct TextAttrs {
     pub italic: bool,
     pub weight: Option<u16>,
+    pub stretch: Option<u16>,
 }
 
 /// A primitive plus the scissor rect it must be clipped to (`None` = unclipped), an
diff --git a/src/widget/doc_editor/layout.rs b/src/widget/doc_editor/layout.rs
index eef780a..fef69f2 100644
--- a/src/widget/doc_editor/layout.rs
+++ b/src/widget/doc_editor/layout.rs
@@ -162,7 +162,7 @@ pub fn layout_line(text: &str, line: &preview::Line, active: bool, width: f32, t
         let mono = look.mono || look.code;
         let size = if look.code && !look.mono { (base * 0.92).round() } else { base };
         let font = if mono { th.mono_font.clone() } else { th.body_font.clone() };
-        (font, size, TextAttrs { italic: look.italic, weight: (look.bold || heading).then_some(700) })
+        (font, size, TextAttrs { italic: look.italic, weight: (look.bold || heading).then_some(700), ..Default::default() })
     };
 
     // Where content starts, and the decorations of a hidden prefix.
diff --git a/src/widget/markdown.rs b/src/widget/markdown.rs
index 60f4d2e..ffa1856 100644
--- a/src/widget/markdown.rs
+++ b/src/widget/markdown.rs
@@ -506,7 +506,7 @@ impl<'a> Layouter<'a> {
 
     #[allow(clippy::too_many_arguments)]
     fn text(&mut self, text: &str, x: f32, y: f32, size: f32, color: [f32; 4], bold: bool, italic: bool) {
-        let attrs = TextAttrs { italic, weight: bold.then_some(700) };
+        let attrs = TextAttrs { italic, weight: bold.then_some(700), ..Default::default() };
         let font = self.theme.body_font.clone();
         self.out.draws.push(Draw::Text { text: text.to_string(), x, y, size, color, font, attrs });
     }
@@ -669,7 +669,7 @@ impl<'a> Layouter<'a> {
         Look {
             size,
             font: if code { self.theme.mono_font.clone() } else { self.theme.body_font.clone() },
-            attrs: TextAttrs { italic: s.italic, weight: (s.bold || base.bold).then_some(700) },
+            attrs: TextAttrs { italic: s.italic, weight: (s.bold || base.bold).then_some(700), ..Default::default() },
             color,
             bg: if code {
                 Some(CODE_BG)