GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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);
}
}