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