Text paragraphs, styling, fontconfig

This commit is contained in:
2026-03-20 20:15:27 -04:00
parent 00fe1daa0c
commit f71e03317d
9 changed files with 687 additions and 43 deletions

View File

@@ -8,6 +8,7 @@ cosmic-text = "0.18.2"
ruin_reactivity = { path = "../reactivity" }
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
tracing = { version = "0.1", default-features = false, features = ["std"] }
fontconfig = { version = "0.10", features = ["dlopen"] }
[dev-dependencies]
tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] }

View File

@@ -45,10 +45,10 @@ fn layout_element(
if let Some(text) = element.text_node() {
let content = inset_rect(rect, element.style.padding);
if content.size.width > 0.0 && content.size.height > 0.0 {
scene.push_text(text_system.prepare(
text.text.clone(),
scene.push_text(text_system.prepare_spans(
text.spans.clone(),
content.origin,
text.style.with_bounds(content.size),
text.style.clone().with_bounds(content.size),
));
}
return;
@@ -148,7 +148,12 @@ fn intrinsic_main_size(
FlexDirection::Row => (Some(available_main.max(0.0)), Some(cross_size.max(0.0))),
FlexDirection::Column => (Some(cross_size.max(0.0)), None),
};
let content = text_system.measure(&text.text, text.style, constraints.0, constraints.1);
let content = text_system.measure_spans(
text.spans.clone(),
text.style.clone(),
constraints.0,
constraints.1,
);
let padding = main_axis_padding(child.style.padding, direction);
return main_axis_size(content, direction) + padding;
}
@@ -169,9 +174,9 @@ fn intrinsic_size(
text_system: &mut TextSystem,
) -> UiSize {
if let Some(text) = element.text_node() {
let measured = text_system.measure(
&text.text,
text.style,
let measured = text_system.measure_spans(
text.spans.clone(),
text.style.clone(),
Some(available_size.width.max(0.0)),
Some(available_size.height.max(0.0)),
);

View File

@@ -25,7 +25,10 @@ pub use scene::{
Color, DisplayItem, GlyphInstance, Point, PreparedText, Quad, Rect, SceneSnapshot, Translation,
UiSize,
};
pub use text::{TextAlign, TextStyle, TextSystem, TextWrap};
pub use text::{
TextAlign, TextFontFamily, TextSpan, TextSpanSlant, TextSpanWeight, TextStyle, TextSystem,
TextWrap,
};
pub use tree::{Edges, Element, FlexDirection, Style};
pub use window::{
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,

View File

@@ -93,6 +93,7 @@ pub struct GlyphInstance {
pub glyph: String,
pub position: Point,
pub advance: f32,
pub color: Color,
pub cache_key: Option<CacheKey>,
}
@@ -123,6 +124,7 @@ impl PreparedText {
glyph: ch.to_string(),
position: Point::new(x, origin.y),
advance,
color,
cache_key: None,
});
x += advance;

View File

@@ -1,4 +1,10 @@
use cosmic_text::{Attrs, Buffer, FontSystem, Metrics, Shaping, Wrap};
use std::collections::{BTreeSet, HashMap};
use cosmic_text::{
Attrs, Buffer, Color as CosmicColor, FontSystem, Metrics, Shaping, Style as CosmicStyle,
Weight as CosmicWeight, Wrap, fontdb,
};
use fontconfig::Fontconfig;
use crate::{Color, GlyphInstance, Point, PreparedText, UiSize};
@@ -9,6 +15,78 @@ pub enum TextAlign {
End,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextSpanWeight {
Normal,
Medium,
Semibold,
Bold,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum TextSpanSlant {
Normal,
Italic,
Oblique,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum TextFontFamily {
SansSerif,
Serif,
Monospace,
Cursive,
Fantasy,
Named(String),
}
impl TextFontFamily {
pub fn named(name: impl Into<String>) -> Self {
Self::Named(name.into())
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct TextSpan {
pub text: String,
pub color: Option<Color>,
pub weight: TextSpanWeight,
pub slant: TextSpanSlant,
pub font_family: Option<TextFontFamily>,
}
impl TextSpan {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
color: None,
weight: TextSpanWeight::Normal,
slant: TextSpanSlant::Normal,
font_family: None,
}
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn weight(mut self, weight: TextSpanWeight) -> Self {
self.weight = weight;
self
}
pub fn slant(mut self, slant: TextSpanSlant) -> Self {
self.slant = slant;
self
}
pub fn family(mut self, family: TextFontFamily) -> Self {
self.font_family = Some(family);
self
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextWrap {
None,
@@ -24,11 +102,12 @@ impl TextWrap {
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub struct TextStyle {
pub font_size: f32,
pub line_height: f32,
pub color: Color,
pub font_family: TextFontFamily,
pub bounds: Option<UiSize>,
pub wrap: TextWrap,
pub align: TextAlign,
@@ -41,6 +120,7 @@ impl TextStyle {
font_size,
line_height: font_size * 1.2,
color,
font_family: TextFontFamily::SansSerif,
bounds: None,
wrap: TextWrap::None,
align: TextAlign::Start,
@@ -53,6 +133,11 @@ impl TextStyle {
self
}
pub fn with_font_family(mut self, font_family: TextFontFamily) -> Self {
self.font_family = font_family;
self
}
pub const fn with_bounds(mut self, bounds: UiSize) -> Self {
self.bounds = Some(bounds);
self
@@ -76,6 +161,7 @@ impl TextStyle {
pub struct TextSystem {
font_system: FontSystem,
family_resolver: FontFamilyResolver,
}
#[derive(Clone, Debug, PartialEq)]
@@ -84,6 +170,11 @@ struct TextLayout {
size: UiSize,
}
struct FontFamilyResolver {
fontconfig: Option<Fontconfig>,
cache: HashMap<(TextFontFamily, TextSpanSlant), Option<String>>,
}
impl Default for TextSystem {
fn default() -> Self {
Self::new()
@@ -92,8 +183,12 @@ impl Default for TextSystem {
impl TextSystem {
pub fn new() -> Self {
let mut font_system = FontSystem::new();
let mut family_resolver = FontFamilyResolver::new();
configure_default_font_families(&mut font_system, &mut family_resolver);
Self {
font_system: FontSystem::new(),
font_system,
family_resolver,
}
}
@@ -103,10 +198,20 @@ impl TextSystem {
origin: Point,
style: TextStyle,
) -> PreparedText {
let text = text.into();
self.prepare_spans([TextSpan::new(text)], origin, style)
}
pub fn prepare_spans(
&mut self,
spans: impl IntoIterator<Item = TextSpan>,
origin: Point,
style: TextStyle,
) -> PreparedText {
let spans: Vec<TextSpan> = spans.into_iter().collect();
let text = combined_text(&spans);
let layout = self.layout(
&text,
style,
&spans,
style.clone(),
style.bounds.map(|bounds| bounds.width),
style.bounds.map(|bounds| bounds.height),
);
@@ -117,6 +222,7 @@ impl TextSystem {
glyph: glyph.glyph,
position: Point::new(origin.x + glyph.position.x, origin.y + glyph.position.y),
advance: glyph.advance,
color: glyph.color,
cache_key: glyph.cache_key,
})
.collect();
@@ -139,22 +245,57 @@ impl TextSystem {
width: Option<f32>,
height: Option<f32>,
) -> UiSize {
self.layout(text, style, width, height).size
self.measure_spans([TextSpan::new(text)], style, width, height)
}
pub fn measure_spans(
&mut self,
spans: impl IntoIterator<Item = TextSpan>,
style: TextStyle,
width: Option<f32>,
height: Option<f32>,
) -> UiSize {
let spans: Vec<TextSpan> = spans.into_iter().collect();
self.layout(&spans, style, width, height).size
}
fn layout(
&mut self,
text: &str,
spans: &[TextSpan],
style: TextStyle,
width: Option<f32>,
height: Option<f32>,
) -> TextLayout {
let default_family = self.resolve_font_family(&style.font_family, TextSpanSlant::Normal);
let resolved_span_families: Vec<_> = spans
.iter()
.map(|span| {
self.resolve_font_family(
span.font_family.as_ref().unwrap_or(&style.font_family),
span.slant,
)
})
.collect();
let mut buffer = Buffer::new_empty(Metrics::new(style.font_size, style.line_height));
{
let mut borrowed = buffer.borrow_with(&mut self.font_system);
borrowed.set_wrap(style.wrap.to_cosmic());
borrowed.set_size(width, height);
borrowed.set_text(text, &Attrs::new(), Shaping::Advanced, None);
let default_attrs = default_attrs_for_style(&style, default_family.as_deref());
borrowed.set_rich_text(
spans
.iter()
.zip(resolved_span_families.iter())
.map(|(span, resolved_family)| {
(
span.text.as_str(),
attrs_for_span(span, &style, resolved_family.as_deref()),
)
}),
&default_attrs,
Shaping::Advanced,
None,
);
}
let mut measured_width: f32 = 0.0;
@@ -173,6 +314,7 @@ impl TextSystem {
glyph: run.text[glyph.start..glyph.end].to_string(),
position: Point::new(physical.x as f32, physical.y as f32),
advance: glyph.w,
color: glyph.color_opt.map_or(style.color, color_from_cosmic),
cache_key: Some(physical.cache_key),
}
}));
@@ -186,6 +328,15 @@ impl TextSystem {
size: UiSize::new(measured_width.max(0.0), measured_height.max(0.0)),
}
}
fn resolve_font_family(
&mut self,
family: &TextFontFamily,
slant: TextSpanSlant,
) -> Option<String> {
self.family_resolver
.resolve(self.font_system.db(), family, slant)
}
}
fn aligned_line_offset(align: TextAlign, width: Option<f32>, line_width: f32) -> f32 {
@@ -200,10 +351,263 @@ fn aligned_line_offset(align: TextAlign, width: Option<f32>, line_width: f32) ->
}
}
fn combined_text(spans: &[TextSpan]) -> String {
let total_len = spans.iter().map(|span| span.text.len()).sum();
let mut text = String::with_capacity(total_len);
for span in spans {
text.push_str(&span.text);
}
text
}
fn default_attrs_for_style<'a>(
style: &'a TextStyle,
resolved_family: Option<&'a str>,
) -> Attrs<'a> {
Attrs::new()
.family(font_family_to_cosmic(&style.font_family, resolved_family))
.color(to_cosmic_color(style.color))
.metrics(Metrics::new(style.font_size, style.line_height))
}
fn attrs_for_span<'a>(
span: &'a TextSpan,
style: &'a TextStyle,
resolved_family: Option<&'a str>,
) -> Attrs<'a> {
let font_family = span.font_family.as_ref().unwrap_or(&style.font_family);
default_attrs_for_style(style, resolved_family)
.family(font_family_to_cosmic(font_family, resolved_family))
.color(to_cosmic_color(span.color.unwrap_or(style.color)))
.weight(match span.weight {
TextSpanWeight::Normal => CosmicWeight::NORMAL,
TextSpanWeight::Medium => CosmicWeight::MEDIUM,
TextSpanWeight::Semibold => CosmicWeight::SEMIBOLD,
TextSpanWeight::Bold => CosmicWeight::BOLD,
})
.style(match span.slant {
TextSpanSlant::Normal => CosmicStyle::Normal,
TextSpanSlant::Italic => CosmicStyle::Italic,
TextSpanSlant::Oblique => CosmicStyle::Oblique,
})
}
fn to_cosmic_color(color: Color) -> CosmicColor {
CosmicColor::rgba(color.r, color.g, color.b, color.a)
}
fn color_from_cosmic(color: CosmicColor) -> Color {
Color::rgba(color.r(), color.g(), color.b(), color.a())
}
fn font_family_to_cosmic<'a>(
family: &'a TextFontFamily,
resolved_family: Option<&'a str>,
) -> fontdb::Family<'a> {
if let Some(resolved_family) = resolved_family {
return fontdb::Family::Name(resolved_family);
}
match family {
TextFontFamily::SansSerif => fontdb::Family::SansSerif,
TextFontFamily::Serif => fontdb::Family::Serif,
TextFontFamily::Monospace => fontdb::Family::Monospace,
TextFontFamily::Cursive => fontdb::Family::Cursive,
TextFontFamily::Fantasy => fontdb::Family::Fantasy,
TextFontFamily::Named(name) => fontdb::Family::Name(name.as_str()),
}
}
const SANS_SERIF_FAMILY_CANDIDATES: &[&str] = &[
"Noto Sans",
"DejaVu Sans",
"Liberation Sans",
"Ubuntu",
"Cantarell",
"Arial",
"Open Sans",
];
const SERIF_FAMILY_CANDIDATES: &[&str] = &[
"Noto Serif",
"DejaVu Serif",
"Liberation Serif",
"Times New Roman",
"Open Serif",
];
const MONOSPACE_FAMILY_CANDIDATES: &[&str] = &[
"Noto Sans Mono",
"DejaVu Sans Mono",
"Liberation Mono",
"Ubuntu Mono",
"Courier New",
];
impl FontFamilyResolver {
fn new() -> Self {
Self {
fontconfig: Fontconfig::new(),
cache: HashMap::new(),
}
}
fn resolve(
&mut self,
db: &fontdb::Database,
family: &TextFontFamily,
slant: TextSpanSlant,
) -> Option<String> {
let key = (family.clone(), slant);
if let Some(cached) = self.cache.get(&key) {
return cached.clone();
}
let resolved = self.resolve_uncached(db, family, slant);
self.cache.insert(key, resolved.clone());
resolved
}
fn resolve_uncached(
&self,
db: &fontdb::Database,
family: &TextFontFamily,
slant: TextSpanSlant,
) -> Option<String> {
if let Some(fontconfig) = self.fontconfig.as_ref()
&& let Some(font) =
fontconfig.find(family.fontconfig_query_name(), fontconfig_style_name(slant))
{
if let Some(family_name) = family_name_for_font_match(db, &font) {
return Some(family_name);
}
return Some(font.name);
}
fallback_family_name(db, family)
}
}
fn configure_default_font_families(
font_system: &mut FontSystem,
family_resolver: &mut FontFamilyResolver,
) {
if let Some(sans_family) = family_resolver.resolve(
font_system.db(),
&TextFontFamily::SansSerif,
TextSpanSlant::Normal,
) {
font_system.db_mut().set_sans_serif_family(&sans_family);
}
if let Some(serif_family) = family_resolver.resolve(
font_system.db(),
&TextFontFamily::Serif,
TextSpanSlant::Normal,
) {
font_system.db_mut().set_serif_family(&serif_family);
}
if let Some(monospace_family) = family_resolver.resolve(
font_system.db(),
&TextFontFamily::Monospace,
TextSpanSlant::Normal,
) {
font_system.db_mut().set_monospace_family(&monospace_family);
}
}
fn fallback_family_name(db: &fontdb::Database, family: &TextFontFamily) -> Option<String> {
match family {
TextFontFamily::Named(name) => Some(name.clone()),
TextFontFamily::SansSerif => {
configured_generic_family_name(db, SANS_SERIF_FAMILY_CANDIDATES)
}
TextFontFamily::Serif => configured_generic_family_name(db, SERIF_FAMILY_CANDIDATES),
TextFontFamily::Monospace => {
configured_generic_family_name(db, MONOSPACE_FAMILY_CANDIDATES)
}
TextFontFamily::Cursive | TextFontFamily::Fantasy => db
.faces()
.find(|face| !face.monospaced && !face.post_script_name.contains("Emoji"))
.and_then(|face| face.families.first().map(|(name, _)| name.clone())),
}
}
fn configured_generic_family_name(
db: &fontdb::Database,
candidates: &[&'static str],
) -> Option<String> {
if let Some(preferred) = preferred_family_name(
db.faces()
.flat_map(|face| face.families.iter().map(|(name, _)| name.as_str())),
candidates,
) {
return Some(preferred.to_owned());
}
db.faces()
.find(|face| {
!face.monospaced
&& !face.post_script_name.contains("Emoji")
&& face.style == fontdb::Style::Normal
&& face.weight == fontdb::Weight::NORMAL
})
.and_then(|face| face.families.first().map(|(name, _)| name.clone()))
}
fn family_name_for_font_match(db: &fontdb::Database, font: &fontconfig::Font) -> Option<String> {
db.faces()
.find(|face| match &face.source {
fontdb::Source::File(path) | fontdb::Source::SharedFile(path, _) => {
path == &font.path
&& font
.index
.is_none_or(|index| face.index == index.max(0) as u32)
}
fontdb::Source::Binary(_) => false,
})
.and_then(|face| face.families.first().map(|(name, _)| name.clone()))
}
fn fontconfig_style_name(slant: TextSpanSlant) -> Option<&'static str> {
match slant {
TextSpanSlant::Normal => None,
TextSpanSlant::Italic => Some("Italic"),
TextSpanSlant::Oblique => Some("Oblique"),
}
}
fn preferred_family_name<'a>(
available_families: impl IntoIterator<Item = &'a str>,
candidates: &[&'static str],
) -> Option<&'static str> {
let available: BTreeSet<&str> = available_families.into_iter().collect();
candidates
.iter()
.copied()
.find(|candidate| available.contains(candidate))
}
impl TextFontFamily {
fn fontconfig_query_name(&self) -> &str {
match self {
TextFontFamily::SansSerif => "sans-serif",
TextFontFamily::Serif => "serif",
TextFontFamily::Monospace => "monospace",
TextFontFamily::Cursive => "cursive",
TextFontFamily::Fantasy => "fantasy",
TextFontFamily::Named(name) => name.as_str(),
}
}
}
#[cfg(test)]
mod tests {
use super::{TextAlign, TextStyle, TextSystem, TextWrap};
use super::{
MONOSPACE_FAMILY_CANDIDATES, TextAlign, TextFontFamily, TextSpan, TextSpanSlant,
TextSpanWeight, TextStyle, TextSystem, TextWrap, configured_generic_family_name,
family_name_for_font_match, font_family_to_cosmic, fontdb, preferred_family_name,
};
use crate::{Color, Point, UiSize};
use fontconfig::Font;
use std::path::PathBuf;
#[test]
fn max_lines_limits_measured_height() {
@@ -213,7 +617,7 @@ mod tests {
.with_wrap(TextWrap::Word);
let unclamped = text_system.measure(
"alpha beta gamma delta epsilon zeta eta theta iota kappa",
style,
style.clone(),
Some(120.0),
None,
);
@@ -248,4 +652,106 @@ mod tests {
assert!(!centered.glyphs.is_empty());
assert!(centered.glyphs[0].position.x > start.glyphs[0].position.x);
}
#[test]
fn rich_spans_preserve_per_glyph_colors() {
let mut text_system = TextSystem::new();
let prepared = text_system.prepare_spans(
[
TextSpan::new("Red ").color(Color::rgb(0xFF, 0x33, 0x33)),
TextSpan::new("Blue")
.color(Color::rgb(0x33, 0x66, 0xFF))
.weight(TextSpanWeight::Bold)
.slant(TextSpanSlant::Italic),
],
Point::new(0.0, 0.0),
TextStyle::new(18.0, Color::rgb(0xEE, 0xEE, 0xEE))
.with_wrap(TextWrap::Word)
.with_bounds(UiSize::new(240.0, 80.0)),
);
assert!(
prepared
.glyphs
.iter()
.any(|glyph| glyph.color == Color::rgb(0xFF, 0x33, 0x33))
);
assert!(
prepared
.glyphs
.iter()
.any(|glyph| glyph.color == Color::rgb(0x33, 0x66, 0xFF))
);
}
#[test]
fn preferred_family_name_uses_first_available_candidate() {
let selected = preferred_family_name(
["DejaVu Serif", "Liberation Sans", "Noto Sans Mono"],
&["Noto Sans", "Liberation Sans", "Arial"],
);
assert_eq!(selected, Some("Liberation Sans"));
}
#[test]
fn configured_generic_family_name_falls_back_to_first_available_normal_face() {
let mut db = fontdb::Database::new();
db.push_face_info(fontdb::FaceInfo {
id: fontdb::ID::dummy(),
source: fontdb::Source::Binary(std::sync::Arc::new(Vec::new())),
index: 0,
families: vec![(
String::from("Fallback Sans"),
fontdb::Language::English_UnitedStates,
)],
post_script_name: String::from("FallbackSans-Regular"),
style: fontdb::Style::Normal,
weight: fontdb::Weight::NORMAL,
stretch: fontdb::Stretch::Normal,
monospaced: false,
});
assert_eq!(
configured_generic_family_name(&db, MONOSPACE_FAMILY_CANDIDATES),
Some(String::from("Fallback Sans"))
);
}
#[test]
fn font_family_to_cosmic_prefers_resolved_name() {
match font_family_to_cosmic(&TextFontFamily::SansSerif, Some("Resolved Sans")) {
fontdb::Family::Name(name) => assert_eq!(name, "Resolved Sans"),
other => panic!("expected resolved named family, got {other:?}"),
}
}
#[test]
fn family_name_for_font_match_uses_path_and_index() {
let mut db = fontdb::Database::new();
db.push_face_info(fontdb::FaceInfo {
id: fontdb::ID::dummy(),
source: fontdb::Source::File(PathBuf::from("/tmp/example-font.ttf")),
index: 2,
families: vec![(
String::from("Example Sans"),
fontdb::Language::English_UnitedStates,
)],
post_script_name: String::from("ExampleSans-Italic"),
style: fontdb::Style::Italic,
weight: fontdb::Weight::NORMAL,
stretch: fontdb::Stretch::Normal,
monospaced: false,
});
let font = Font {
name: String::from("Example Sans"),
path: PathBuf::from("/tmp/example-font.ttf"),
index: Some(2),
};
assert_eq!(
family_name_for_font_match(&db, &font),
Some(String::from("Example Sans"))
);
}
}

View File

@@ -1,5 +1,5 @@
use crate::scene::Color;
use crate::text::{TextStyle, TextWrap};
use crate::text::{TextSpan, TextStyle, TextWrap};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FlexDirection {
@@ -75,7 +75,7 @@ enum ElementContent {
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct TextNode {
pub text: String,
pub spans: Vec<TextSpan>,
pub style: TextStyle,
}
@@ -96,11 +96,15 @@ impl Element {
}
pub fn text(text: impl Into<String>, style: TextStyle) -> Self {
Self::spans([TextSpan::new(text)], style)
}
pub fn spans(spans: impl IntoIterator<Item = TextSpan>, style: TextStyle) -> Self {
Self {
style: Style::default(),
children: Vec::new(),
content: ElementContent::Text(TextNode {
text: text.into(),
spans: spans.into_iter().collect(),
style,
}),
}
@@ -110,6 +114,10 @@ impl Element {
Self::text(text, style.with_wrap(TextWrap::Word))
}
pub fn rich_paragraph(spans: impl IntoIterator<Item = TextSpan>, style: TextStyle) -> Self {
Self::spans(spans, style.with_wrap(TextWrap::Word))
}
pub fn row() -> Self {
Self::new().direction(FlexDirection::Row)
}

View File

@@ -22,6 +22,7 @@ struct Vertex {
struct TextVertex {
position: [f32; 2],
uv: [f32; 2],
color: [f32; 4],
}
#[derive(Clone, Copy, Debug)]
@@ -53,6 +54,7 @@ struct GlyphBitmap {
struct PreparedGlyphBitmap {
rect: PixelRect,
cache_key: CacheKey,
color: Color,
}
struct RasterizedText {
@@ -124,13 +126,14 @@ struct TextTextureGlyph {
local_x_bits: u32,
local_y_bits: u32,
advance_bits: u32,
color: (u8, u8, u8, u8),
cache_key: Option<CacheKey>,
}
const VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4];
const TEXT_VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2];
const TEXT_VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 3] =
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4];
impl Vertex {
const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
@@ -258,11 +261,13 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
struct VertexIn {
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
@location(2) color: vec4<f32>,
};
struct VertexOut {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
};
@group(0) @binding(0) var text_texture: texture_2d<f32>;
@@ -273,12 +278,13 @@ fn vs_main(input: VertexIn) -> VertexOut {
var out: VertexOut;
out.position = vec4(input.position, 0.0, 1.0);
out.uv = input.uv;
out.color = input.color;
return out;
}
@fragment
fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
return textureSample(text_texture, text_sampler, input.uv);
return textureSample(text_texture, text_sampler, input.uv) * input.color;
}
"#
.into(),
@@ -561,7 +567,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
else {
continue;
};
blit_cached_image(&mut pixels, clip, glyph.rect, image, text.color);
blit_cached_image(&mut pixels, clip, glyph.rect, image, glyph.color);
}
Some(RasterizedText {
@@ -600,6 +606,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
bottom: local_y - image.placement.top + height,
},
cache_key,
color: glyph.color,
});
}
glyphs
@@ -670,7 +677,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
let Some(cache_key) = glyph.cache_key else {
continue;
};
let Some(atlas_glyph) = self.ensure_atlas_glyph(cache_key, text.color) else {
let Some(atlas_glyph) = self.ensure_atlas_glyph(cache_key, glyph.color) else {
continue;
};
@@ -689,6 +696,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
atlas_glyph.atlas_rect,
clip_rect,
scene.logical_size,
glyph.color,
);
}
}
@@ -1190,31 +1198,38 @@ fn build_text_vertices(origin: Point, size: UiSize, logical_size: UiSize) -> [Te
let right = to_ndc_x(origin.x + size.width, logical_size.width.max(1.0));
let top = to_ndc_y(origin.y, logical_size.height.max(1.0));
let bottom = to_ndc_y(origin.y + size.height, logical_size.height.max(1.0));
let color = [1.0, 1.0, 1.0, 1.0];
[
TextVertex {
position: [left, top],
uv: [0.0, 0.0],
color,
},
TextVertex {
position: [left, bottom],
uv: [0.0, 1.0],
color,
},
TextVertex {
position: [right, top],
uv: [1.0, 0.0],
color,
},
TextVertex {
position: [right, top],
uv: [1.0, 0.0],
color,
},
TextVertex {
position: [left, bottom],
uv: [0.0, 1.0],
color,
},
TextVertex {
position: [right, bottom],
uv: [1.0, 1.0],
color,
},
]
}
@@ -1225,6 +1240,7 @@ fn push_glyph_vertices(
atlas_rect: AtlasRect,
clip_rect: Option<PixelRect>,
logical_size: UiSize,
color: Color,
) {
let Some((dest_rect, uv_rect)) = clipped_glyph_quad(glyph_rect, atlas_rect, clip_rect) else {
return;
@@ -1235,34 +1251,50 @@ fn push_glyph_vertices(
let top = to_ndc_y(dest_rect.top as f32, logical_size.height.max(1.0));
let bottom = to_ndc_y(dest_rect.bottom as f32, logical_size.height.max(1.0));
let color = color_to_f32(color);
vertices.extend_from_slice(&[
TextVertex {
position: [left, top],
uv: [uv_rect.0, uv_rect.1],
color,
},
TextVertex {
position: [left, bottom],
uv: [uv_rect.0, uv_rect.3],
color,
},
TextVertex {
position: [right, top],
uv: [uv_rect.2, uv_rect.1],
color,
},
TextVertex {
position: [right, top],
uv: [uv_rect.2, uv_rect.1],
color,
},
TextVertex {
position: [left, bottom],
uv: [uv_rect.0, uv_rect.3],
color,
},
TextVertex {
position: [right, bottom],
uv: [uv_rect.2, uv_rect.3],
color,
},
]);
}
fn color_to_f32(color: Color) -> [f32; 4] {
[
color.r as f32 / 255.0,
color.g as f32 / 255.0,
color.b as f32 / 255.0,
color.a as f32 / 255.0,
]
}
fn build_vertices(scene: &SceneSnapshot) -> Vec<Vertex> {
let width = scene.logical_size.width.max(1.0);
let height = scene.logical_size.height.max(1.0);
@@ -1385,6 +1417,7 @@ fn text_texture_key(text: &PreparedText) -> TextTextureKey {
local_x_bits: (glyph.position.x - text.origin.x).to_bits(),
local_y_bits: (glyph.position.y - text.origin.y).to_bits(),
advance_bits: glyph.advance.to_bits(),
color: (glyph.color.r, glyph.color.g, glyph.color.b, glyph.color.a),
cache_key: glyph.cache_key,
})
.collect(),
@@ -1443,6 +1476,7 @@ mod tests {
glyph: "c".into(),
position: Point::new(24.0, 44.0),
advance: 8.0,
color: Color::rgb(0xEE, 0xEE, 0xEE),
cache_key: None,
}],
};