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

21
Cargo.lock generated
View File

@@ -273,6 +273,15 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "fontconfig"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b19c4bca8c705ea23bfb3e3403a9e699344d1ee3205b631f03fe4dbf1e52429f"
dependencies = [
"yeslogic-fontconfig-sys",
]
[[package]] [[package]]
name = "fontconfig-parser" name = "fontconfig-parser"
version = "0.5.8" version = "0.5.8"
@@ -987,6 +996,7 @@ name = "ruin_ui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cosmic-text", "cosmic-text",
"fontconfig",
"ruin-runtime", "ruin-runtime",
"ruin_reactivity", "ruin_reactivity",
"tracing", "tracing",
@@ -1849,6 +1859,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
[[package]]
name = "yeslogic-fontconfig-sys"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd"
dependencies = [
"dlib",
"once_cell",
"pkg-config",
]
[[package]] [[package]]
name = "zeno" name = "zeno"
version = "0.3.3" version = "0.3.3"

View File

@@ -1,17 +1,14 @@
use std::error::Error; use std::error::Error;
use ruin_ui::{ use ruin_ui::{
Color, Edges, Element, SceneSnapshot, TextAlign, TextStyle, TextSystem, UiSize, WindowSpec, Color, Edges, Element, SceneSnapshot, TextAlign, TextFontFamily, TextSpan, TextSpanSlant,
layout_scene_with_text_system, TextSpanWeight, TextStyle, TextSystem, UiSize, WindowSpec, layout_scene_with_text_system,
}; };
use ruin_ui_platform_wayland::WaylandWindow; use ruin_ui_platform_wayland::WaylandWindow;
use ruin_ui_renderer_wgpu::{RenderError, WgpuSceneRenderer}; use ruin_ui_renderer_wgpu::{RenderError, WgpuSceneRenderer};
const INTRO: &str = "RUIN is exploring a retained layout tree backed by explicit scene building, a dedicated platform thread, and a renderer that can stay simple while the higher-level UI model evolves. This example is intentionally calm: no animated panels, no pulsing widths, just a document-like surface that makes paragraph behavior easier to inspect.";
const BODY_ONE: &str = "Paragraph widgets are the next useful layer above raw text leaves. They should be able to wrap naturally inside containers, respect alignment, clamp to a maximum number of lines when appropriate, and participate in layout without forcing every example to become a custom text experiment. That gets us closer to real application surfaces instead of just proving that glyphs can reach the screen."; const BODY_ONE: &str = "Paragraph widgets are the next useful layer above raw text leaves. They should be able to wrap naturally inside containers, respect alignment, clamp to a maximum number of lines when appropriate, and participate in layout without forcing every example to become a custom text experiment. That gets us closer to real application surfaces instead of just proving that glyphs can reach the screen.";
const BODY_TWO: &str = "This demo keeps the overall layout mostly static while still responding to window resizing. The centered title, the body copy, and the clamped sidebar notes all use the same retained layout pipeline, but they exercise different paragraph semantics. It should be a much less exhausting place to look at text than the reactive dashboard stress test."; const BODY_TWO: &str = "This demo keeps the overall layout mostly static while still responding to window resizing. The centered title, the body copy, and the clamped sidebar notes all use the same retained layout pipeline, but they exercise different paragraph semantics. It should be a much less exhausting place to look at text than the reactive dashboard stress test.";
const SIDEBAR_NOTE: &str = "Clamped note: the renderer now uses a shared glyph atlas for prepared text, so the remaining debug-build cost mostly comes from paragraph layout and scene building rather than per-frame CPU text compositing.";
const FOOTER: &str = "Next up after this slice: richer paragraph/block rules, then inline style runs, then interactive text editing and selection.";
#[ruin_runtime::main] #[ruin_runtime::main]
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
@@ -86,8 +83,26 @@ fn build_document_tree(viewport: UiSize) -> Element {
.with_line_height(40.0) .with_line_height(40.0)
.with_align(TextAlign::Center), .with_align(TextAlign::Center),
), ),
Element::paragraph( Element::rich_paragraph(
INTRO, [
TextSpan::new("RUIN is exploring a "),
TextSpan::new("retained")
.weight(TextSpanWeight::Semibold)
.color(Color::rgb(0xF5, 0xD0, 0x74)),
TextSpan::new(" layout tree backed by explicit scene building, a dedicated "),
TextSpan::new("platform")
.weight(TextSpanWeight::Semibold)
.color(Color::rgb(0x7D, 0xD3, 0xFC)),
TextSpan::new(" thread, and a "),
TextSpan::new("renderer")
.weight(TextSpanWeight::Bold)
.slant(TextSpanSlant::Oblique)
.family(TextFontFamily::Monospace)
.color(Color::rgb(0xA7, 0xF3, 0xD0)),
TextSpan::new(
" that can stay simple while the higher-level UI model evolves. This example is intentionally calm: no animated panels, no pulsing widths, just a document-like surface that makes paragraph behavior easier to inspect.",
),
],
TextStyle::new(18.0, Color::rgb(0xC9, 0xD2, 0xE3)) TextStyle::new(18.0, Color::rgb(0xC9, 0xD2, 0xE3))
.with_line_height(28.0) .with_line_height(28.0)
.with_align(TextAlign::Center), .with_align(TextAlign::Center),
@@ -110,8 +125,22 @@ fn build_document_tree(viewport: UiSize) -> Element {
TextStyle::new(20.0, Color::rgb(0xF5, 0xF7, 0xFB)) TextStyle::new(20.0, Color::rgb(0xF5, 0xF7, 0xFB))
.with_line_height(26.0), .with_line_height(26.0),
), ),
Element::paragraph( Element::rich_paragraph(
FOOTER, [
TextSpan::new("Next up after this slice: "),
TextSpan::new("paragraph/block")
.weight(TextSpanWeight::Semibold)
.color(Color::rgb(0xF5, 0xD0, 0x74)),
TextSpan::new(" rules, then "),
TextSpan::new("inline")
.weight(TextSpanWeight::Semibold)
.color(Color::rgb(0xA7, 0xF3, 0xD0)),
TextSpan::new(" style runs, then "),
TextSpan::new("interactive")
.weight(TextSpanWeight::Bold)
.color(Color::rgb(0x7D, 0xD3, 0xFC)),
TextSpan::new(" text editing and selection."),
],
TextStyle::new(17.0, Color::rgb(0xD8, 0xDF, 0xED)) TextStyle::new(17.0, Color::rgb(0xD8, 0xDF, 0xED))
.with_line_height(26.0), .with_line_height(26.0),
), ),
@@ -126,21 +155,17 @@ fn build_document_tree(viewport: UiSize) -> Element {
"“A retained layout tree is only really convincing once text can participate in it naturally.”", "“A retained layout tree is only really convincing once text can participate in it naturally.”",
gutter, gutter,
Some(TextAlign::Center), Some(TextAlign::Center),
Some(TextFontFamily::Serif),
None, None,
), ),
sidebar_card( rich_sidebar_card(gutter),
"Clamped note",
SIDEBAR_NOTE,
gutter,
None,
Some(4),
),
sidebar_card( sidebar_card(
"Status", "Status",
"Static layout, responsive resize, paragraph wrapping, centered headings, and line clamping all share the same UI pipeline now.", "Static layout, responsive resize, paragraph wrapping, centered headings, and line clamping all share the same UI pipeline now.",
gutter, gutter,
Some(TextAlign::End), Some(TextAlign::End),
None, None,
None,
), ),
]), ]),
]), ]),
@@ -169,12 +194,16 @@ fn sidebar_card(
body: &str, body: &str,
gutter: f32, gutter: f32,
align: Option<TextAlign>, align: Option<TextAlign>,
font_family: Option<TextFontFamily>,
max_lines: Option<usize>, max_lines: Option<usize>,
) -> Element { ) -> Element {
let mut body_style = TextStyle::new(16.0, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(25.0); let mut body_style = TextStyle::new(16.0, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(25.0);
if let Some(align) = align { if let Some(align) = align {
body_style = body_style.with_align(align); body_style = body_style.with_align(align);
} }
if let Some(font_family) = font_family {
body_style = body_style.with_font_family(font_family);
}
if let Some(max_lines) = max_lines { if let Some(max_lines) = max_lines {
body_style = body_style.with_max_lines(max_lines); body_style = body_style.with_max_lines(max_lines);
} }
@@ -191,3 +220,38 @@ fn sidebar_card(
Element::paragraph(body, body_style), Element::paragraph(body, body_style),
]) ])
} }
fn rich_sidebar_card(gutter: f32) -> Element {
Element::column()
.padding(Edges::all(gutter * 0.9))
.gap(gutter * 0.35)
.background(Color::rgb(0x1C, 0x24, 0x34))
.children([
Element::paragraph(
"Clamped note",
TextStyle::new(18.0, Color::rgb(0xF4, 0xF7, 0xFF)).with_line_height(24.0),
),
Element::rich_paragraph(
[
TextSpan::new("Clamped note: the renderer now uses a "),
TextSpan::new("shared")
.weight(TextSpanWeight::Bold)
.color(Color::rgb(0xA7, 0xF3, 0xD0)),
TextSpan::new(" glyph atlas for prepared text, so the remaining "),
TextSpan::new("debug-build")
.weight(TextSpanWeight::Semibold)
.color(Color::rgb(0xF5, 0xD0, 0x74)),
TextSpan::new(" cost mostly comes from "),
TextSpan::new("paragraph")
.weight(TextSpanWeight::Semibold)
.color(Color::rgb(0x7D, 0xD3, 0xFC)),
TextSpan::new(
" layout and scene building rather than per-frame CPU text compositing.",
),
],
TextStyle::new(16.0, Color::rgb(0xD4, 0xDB, 0xEA))
.with_line_height(25.0)
.with_max_lines(4),
),
])
}

View File

@@ -8,6 +8,7 @@ cosmic-text = "0.18.2"
ruin_reactivity = { path = "../reactivity" } ruin_reactivity = { path = "../reactivity" }
ruin_runtime = { package = "ruin-runtime", path = "../runtime" } ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
tracing = { version = "0.1", default-features = false, features = ["std"] } tracing = { version = "0.1", default-features = false, features = ["std"] }
fontconfig = { version = "0.10", features = ["dlopen"] }
[dev-dependencies] [dev-dependencies]
tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] } 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() { if let Some(text) = element.text_node() {
let content = inset_rect(rect, element.style.padding); let content = inset_rect(rect, element.style.padding);
if content.size.width > 0.0 && content.size.height > 0.0 { if content.size.width > 0.0 && content.size.height > 0.0 {
scene.push_text(text_system.prepare( scene.push_text(text_system.prepare_spans(
text.text.clone(), text.spans.clone(),
content.origin, content.origin,
text.style.with_bounds(content.size), text.style.clone().with_bounds(content.size),
)); ));
} }
return; return;
@@ -148,7 +148,12 @@ fn intrinsic_main_size(
FlexDirection::Row => (Some(available_main.max(0.0)), Some(cross_size.max(0.0))), FlexDirection::Row => (Some(available_main.max(0.0)), Some(cross_size.max(0.0))),
FlexDirection::Column => (Some(cross_size.max(0.0)), None), 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); let padding = main_axis_padding(child.style.padding, direction);
return main_axis_size(content, direction) + padding; return main_axis_size(content, direction) + padding;
} }
@@ -169,9 +174,9 @@ fn intrinsic_size(
text_system: &mut TextSystem, text_system: &mut TextSystem,
) -> UiSize { ) -> UiSize {
if let Some(text) = element.text_node() { if let Some(text) = element.text_node() {
let measured = text_system.measure( let measured = text_system.measure_spans(
&text.text, text.spans.clone(),
text.style, text.style.clone(),
Some(available_size.width.max(0.0)), Some(available_size.width.max(0.0)),
Some(available_size.height.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, Color, DisplayItem, GlyphInstance, Point, PreparedText, Quad, Rect, SceneSnapshot, Translation,
UiSize, 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 tree::{Edges, Element, FlexDirection, Style};
pub use window::{ pub use window::{
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate, DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,

View File

@@ -93,6 +93,7 @@ pub struct GlyphInstance {
pub glyph: String, pub glyph: String,
pub position: Point, pub position: Point,
pub advance: f32, pub advance: f32,
pub color: Color,
pub cache_key: Option<CacheKey>, pub cache_key: Option<CacheKey>,
} }
@@ -123,6 +124,7 @@ impl PreparedText {
glyph: ch.to_string(), glyph: ch.to_string(),
position: Point::new(x, origin.y), position: Point::new(x, origin.y),
advance, advance,
color,
cache_key: None, cache_key: None,
}); });
x += advance; 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}; use crate::{Color, GlyphInstance, Point, PreparedText, UiSize};
@@ -9,6 +15,78 @@ pub enum TextAlign {
End, 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextWrap { pub enum TextWrap {
None, None,
@@ -24,11 +102,12 @@ impl TextWrap {
} }
} }
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct TextStyle { pub struct TextStyle {
pub font_size: f32, pub font_size: f32,
pub line_height: f32, pub line_height: f32,
pub color: Color, pub color: Color,
pub font_family: TextFontFamily,
pub bounds: Option<UiSize>, pub bounds: Option<UiSize>,
pub wrap: TextWrap, pub wrap: TextWrap,
pub align: TextAlign, pub align: TextAlign,
@@ -41,6 +120,7 @@ impl TextStyle {
font_size, font_size,
line_height: font_size * 1.2, line_height: font_size * 1.2,
color, color,
font_family: TextFontFamily::SansSerif,
bounds: None, bounds: None,
wrap: TextWrap::None, wrap: TextWrap::None,
align: TextAlign::Start, align: TextAlign::Start,
@@ -53,6 +133,11 @@ impl TextStyle {
self 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 { pub const fn with_bounds(mut self, bounds: UiSize) -> Self {
self.bounds = Some(bounds); self.bounds = Some(bounds);
self self
@@ -76,6 +161,7 @@ impl TextStyle {
pub struct TextSystem { pub struct TextSystem {
font_system: FontSystem, font_system: FontSystem,
family_resolver: FontFamilyResolver,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -84,6 +170,11 @@ struct TextLayout {
size: UiSize, size: UiSize,
} }
struct FontFamilyResolver {
fontconfig: Option<Fontconfig>,
cache: HashMap<(TextFontFamily, TextSpanSlant), Option<String>>,
}
impl Default for TextSystem { impl Default for TextSystem {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
@@ -92,8 +183,12 @@ impl Default for TextSystem {
impl TextSystem { impl TextSystem {
pub fn new() -> Self { 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 { Self {
font_system: FontSystem::new(), font_system,
family_resolver,
} }
} }
@@ -103,10 +198,20 @@ impl TextSystem {
origin: Point, origin: Point,
style: TextStyle, style: TextStyle,
) -> PreparedText { ) -> 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( let layout = self.layout(
&text, &spans,
style, style.clone(),
style.bounds.map(|bounds| bounds.width), style.bounds.map(|bounds| bounds.width),
style.bounds.map(|bounds| bounds.height), style.bounds.map(|bounds| bounds.height),
); );
@@ -117,6 +222,7 @@ impl TextSystem {
glyph: glyph.glyph, glyph: glyph.glyph,
position: Point::new(origin.x + glyph.position.x, origin.y + glyph.position.y), position: Point::new(origin.x + glyph.position.x, origin.y + glyph.position.y),
advance: glyph.advance, advance: glyph.advance,
color: glyph.color,
cache_key: glyph.cache_key, cache_key: glyph.cache_key,
}) })
.collect(); .collect();
@@ -139,22 +245,57 @@ impl TextSystem {
width: Option<f32>, width: Option<f32>,
height: Option<f32>, height: Option<f32>,
) -> UiSize { ) -> 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( fn layout(
&mut self, &mut self,
text: &str, spans: &[TextSpan],
style: TextStyle, style: TextStyle,
width: Option<f32>, width: Option<f32>,
height: Option<f32>, height: Option<f32>,
) -> TextLayout { ) -> 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 buffer = Buffer::new_empty(Metrics::new(style.font_size, style.line_height));
{ {
let mut borrowed = buffer.borrow_with(&mut self.font_system); let mut borrowed = buffer.borrow_with(&mut self.font_system);
borrowed.set_wrap(style.wrap.to_cosmic()); borrowed.set_wrap(style.wrap.to_cosmic());
borrowed.set_size(width, height); 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; let mut measured_width: f32 = 0.0;
@@ -173,6 +314,7 @@ impl TextSystem {
glyph: run.text[glyph.start..glyph.end].to_string(), glyph: run.text[glyph.start..glyph.end].to_string(),
position: Point::new(physical.x as f32, physical.y as f32), position: Point::new(physical.x as f32, physical.y as f32),
advance: glyph.w, advance: glyph.w,
color: glyph.color_opt.map_or(style.color, color_from_cosmic),
cache_key: Some(physical.cache_key), 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)), 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 { 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)] #[cfg(test)]
mod tests { 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 crate::{Color, Point, UiSize};
use fontconfig::Font;
use std::path::PathBuf;
#[test] #[test]
fn max_lines_limits_measured_height() { fn max_lines_limits_measured_height() {
@@ -213,7 +617,7 @@ mod tests {
.with_wrap(TextWrap::Word); .with_wrap(TextWrap::Word);
let unclamped = text_system.measure( let unclamped = text_system.measure(
"alpha beta gamma delta epsilon zeta eta theta iota kappa", "alpha beta gamma delta epsilon zeta eta theta iota kappa",
style, style.clone(),
Some(120.0), Some(120.0),
None, None,
); );
@@ -248,4 +652,106 @@ mod tests {
assert!(!centered.glyphs.is_empty()); assert!(!centered.glyphs.is_empty());
assert!(centered.glyphs[0].position.x > start.glyphs[0].position.x); 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::scene::Color;
use crate::text::{TextStyle, TextWrap}; use crate::text::{TextSpan, TextStyle, TextWrap};
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FlexDirection { pub enum FlexDirection {
@@ -75,7 +75,7 @@ enum ElementContent {
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub(crate) struct TextNode { pub(crate) struct TextNode {
pub text: String, pub spans: Vec<TextSpan>,
pub style: TextStyle, pub style: TextStyle,
} }
@@ -96,11 +96,15 @@ impl Element {
} }
pub fn text(text: impl Into<String>, style: TextStyle) -> Self { 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 { Self {
style: Style::default(), style: Style::default(),
children: Vec::new(), children: Vec::new(),
content: ElementContent::Text(TextNode { content: ElementContent::Text(TextNode {
text: text.into(), spans: spans.into_iter().collect(),
style, style,
}), }),
} }
@@ -110,6 +114,10 @@ impl Element {
Self::text(text, style.with_wrap(TextWrap::Word)) 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 { pub fn row() -> Self {
Self::new().direction(FlexDirection::Row) Self::new().direction(FlexDirection::Row)
} }

View File

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