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

commit0dea9002fa5a7d828fac3e373167e6b620765479
parentdda10ccc2c
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 21:45
fix(text): a named family renders in its nearest face, never a fallback

cosmic-text 0.12 takes a face of the requested family only at the exact
style, stretch AND weight asked for; anything else fell through to the
fallback families. A family with no face at the asked weight rendered in
some other font entirely: a Thin-only cut at weight 280 asked for at
400, a pixel font that only ships Medium, a Condensed-only family. The
backend now snaps the request to the family's nearest face first (CSS
order: stretch, style, weight), so a named family always draws as itself.

TextBox also gains `font_attrs` (italic / weight), drawn through a new
defaulted `Paint::text_attrs` hook and measured with the same attrs, so a
box can show one particular face of its family -- the font picker's
preview of the selected style, which until now always drew the default.

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

 src/backend/text.rs          | 69 ++++++++++++++++++++++++++++++++++++++++++++
 src/widget/input/text_box.rs | 15 ++++++++--
 src/widget/model.rs          | 24 +++++++++++----
 3 files changed, 101 insertions(+), 7 deletions(-)

diff --git a/src/backend/text.rs b/src/backend/text.rs
index 0a1fb66..1e81145 100644
--- a/src/backend/text.rs
+++ b/src/backend/text.rs
@@ -155,6 +155,74 @@ fn family_is_monospaced(fs: &FontSystem, name: &str) -> bool {
     })
 }
 
+thread_local! {
+    /// Family name → the (style, stretch, weight) of every face fontdb holds
+    /// under that name, resolved once per family for [`snap_to_family_face`].
+    static FAMILY_FACES_CACHE: std::cell::RefCell<
+        std::collections::HashMap<String, Vec<(cosmic_text::Style, cosmic_text::Stretch, u16)>>,
+    > = std::cell::RefCell::new(std::collections::HashMap::new());
+}
+
+/// `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
+/// and stretch equal the request (`Attrs::matches`) AND its weight does too
+/// (`font_weight_diff == 0` in `FontFallbackIter`); anything else falls
+/// through to the fallback families. So a family with no face at the asked
+/// weight rendered in some other font entirely: a Thin-only cut at weight
+/// 280 asked for at 400, a pixel font that only ships Medium, a
+/// Condensed-only family asked for at normal width — the font picker's
+/// preview showed the fallback sans for each. Matching here instead follows
+/// CSS font matching's order — stretch, then style, then weight — so a named
+/// family always renders as itself, in its closest face. A request the
+/// family can meet exactly, a generic family, or a name fontdb does not know
+/// passes through untouched.
+fn snap_to_family_face<'a>(fs: &FontSystem, attrs: Attrs<'a>) -> Attrs<'a> {
+    use cosmic_text::{Stretch, Style};
+    let cosmic_text::Family::Name(name) = attrs.family else { return attrs };
+    let faces = FAMILY_FACES_CACHE.with(|cache| {
+        cache
+            .borrow_mut()
+            .entry(name.to_string())
+            .or_insert_with(|| {
+                fs.db()
+                    .faces()
+                    .filter(|face| face.families.iter().any(|(f, _)| f == name))
+                    .map(|face| (face.style, face.stretch, face.weight.0))
+                    .collect()
+            })
+            .clone()
+    });
+    let want = (attrs.style, attrs.stretch, attrs.weight.0);
+    if faces.is_empty() || faces.contains(&want) {
+        return attrs;
+    }
+    let stretch_rank = |s: Stretch| {
+        let (w, f) = (attrs.stretch.to_number() as i32, s.to_number() as i32);
+        // Narrower-first below normal width, wider-first above it (CSS).
+        let toward = if w <= 5 { f < w } else { f > w };
+        ((w - f).abs() * 2 + if toward || f == w { 0 } else { 1 }) as u32
+    };
+    let style_rank = |s: Style| match (attrs.style, s) {
+        (a, b) if a == b => 0u32,
+        (Style::Italic, Style::Oblique) | (Style::Oblique, Style::Italic) => 1,
+        _ => 2,
+    };
+    let weight_rank = |w: u16| {
+        let want = attrs.weight.0;
+        // Ties go lighter for a light-to-regular request, heavier above it.
+        let off_side = if want <= 450 { w > want } else { w < want };
+        (want.abs_diff(w) as u32) * 2 + off_side as u32
+    };
+    let Some(&(style, stretch, weight)) = faces
+        .iter()
+        .min_by_key(|(st, sr, w)| (stretch_rank(*sr), style_rank(*st), weight_rank(*w)))
+    else {
+        return attrs;
+    };
+    attrs.style(style).stretch(stretch).weight(cosmic_text::Weight(weight))
+}
+
 /// The shaping mode for one text run: ASCII-only text in a MONOSPACED face
 /// shapes `Basic`, everything else `Advanced`.
 ///
@@ -307,6 +375,7 @@ pub(crate) fn shared_text_buffer(
     if let Some(w) = text_attrs.weight {
         attrs = attrs.weight(cosmic_text::Weight(w));
     }
+    let attrs = snap_to_family_face(fs, attrs);
     let shaping = shaping_for(fs, text, &family);
     buf.set_text(fs, text, attrs, shaping);
     buf.shape_until_scroll(fs, true);
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 2156107..c846c37 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -50,6 +50,7 @@ struct PrepKey {
     password: bool,
     font_size_bits: u32,
     font: Option<String>,
+    attrs: crate::scene::paint::TextAttrs,
     scale_bits: u32,
     vertical: bool,
     /// `Some(max chars per line)` for a multiline box.
@@ -123,6 +124,10 @@ pub struct TextBox {
     pub text_color: Option<[u8; 3]>,
     pub font_size: f32,
     pub font_family: String,
+    /// Italic / weight the value text is drawn AND measured with (see
+    /// `value_font`) — default for every box but one showing a particular
+    /// face of `font_family`.
+    pub font_attrs: crate::scene::paint::TextAttrs,
     pub placeholder: Option<String>,
     pub editor_state: TextEditorState,
     /// Edit history for the current editing session (cleared by
@@ -216,6 +221,7 @@ impl TextBox {
             text_color: None,
             font_size: style_size,
             font_family: style_family.clone(),
+            font_attrs: crate::scene::paint::TextAttrs::default(),
             placeholder: None,
             editor_state,
             history: History::new(),
@@ -323,7 +329,7 @@ impl TextBox {
     fn shape_columns(&mut self, fs: &mut cosmic_text::FontSystem, key: &PrepKey) {
         let scale = f32::from_bits(key.scale_bits);
         let font_fam = key.font.as_deref();
-        let attrs = crate::scene::paint::TextAttrs::default();
+        let attrs = key.attrs;
         let shared = crate::backend::text::shared_text_buffer;
 
         let render_text = if key.password {
@@ -1613,6 +1619,10 @@ impl Paint for TextBox {
         }
     }
 
+    fn text_attrs(&self) -> crate::scene::paint::TextAttrs {
+        self.font_attrs
+    }
+
     fn sync_label(&mut self, label: &str) {
         self.label = Some(label.to_string());
     }
@@ -1660,7 +1670,7 @@ impl Paint for TextBox {
             "MMMMMMMM",
             self.font_size,
             font_fam.as_deref(),
-            crate::scene::paint::TextAttrs::default(),
+            self.font_attrs,
         );
         self.shaped_char_advance = probe
             .layout_runs()
@@ -1685,6 +1695,7 @@ impl Paint for TextBox {
             password: self.is_password,
             font_size_bits: self.font_size.to_bits(),
             font: font_fam.clone(),
+            attrs: self.font_attrs,
             scale_bits: scale.to_bits(),
             vertical: crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed),
             wrap,
diff --git a/src/widget/model.rs b/src/widget/model.rs
index c843e50..15b0d85 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -219,6 +219,14 @@ pub trait Paint {
         self.widget_font()
     }
 
+    /// Shaping attributes (italic / weight) for the same content text
+    /// [`text_font`](Paint::text_font) names — a TextBox showing a specific
+    /// face of its family (the font picker's preview of a Bold or Thin cut).
+    /// The detached base label never takes them.
+    fn text_attrs(&self) -> crate::scene::paint::TextAttrs {
+        crate::scene::paint::TextAttrs::default()
+    }
+
     /// Receive the control label set on the wrapper via [`Adapted::with_label`] (and legacy
     /// `Control::set_label` paths). Widgets that paint their label themselves (inline-label
     /// widgets) store it here; the default discards it, leaving label drawing to the adapter's
@@ -1374,13 +1382,19 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
             }
             return;
         }
-        let labels = if Paint::serves_legacy_labels(&self.inner) {
-            Paint::legacy_labels_with_font_and_bounds(&self.inner, self.content_rect(), ui)
+        // `own_labels_from_painted` lists the content labels first (all of them
+        // while visible), then the base label: the first `content` take the
+        // widget's `text_attrs`.
+        let (labels, content) = if Paint::serves_legacy_labels(&self.inner) {
+            (Paint::legacy_labels_with_font_and_bounds(&self.inner, self.content_rect(), ui), 0)
         } else {
-            self.own_labels_from_painted(ui, painted_text, Paint::text_font(&self.inner))
+            let content = if self.visible() { painted_text.len() } else { 0 };
+            (self.own_labels_from_painted(ui, painted_text, Paint::text_font(&self.inner)), content)
         };
-        for (tl, font, bounds) in labels {
-            ctx.text_with(tl.text, tl.x, tl.y, tl.font_size, tl.color, font, bounds);
+        let attrs = Paint::text_attrs(&self.inner);
+        for (i, (tl, font, bounds)) in labels.into_iter().enumerate() {
+            let attrs = if i < content { attrs } else { crate::scene::paint::TextAttrs::default() };
+            ctx.text_attrs(tl.text, tl.x, tl.y, tl.font_size, tl.color, font, bounds, attrs);
         }
     }