use super::*;
use core::hash::{Hash, Hasher};
use core::num::NonZeroUsize;
use core::ops::Range;
use lru::LruCache;
use rustc_hash::FxHasher;
use std::sync::{Arc, Mutex};
const TEXT_LAYOUT_CACHE_CAPACITY: usize = 4096;
pub(crate) struct TextMeasureService {
fonts: parley::FontContext,
cache: Mutex<LruCache<TextLayoutCacheKey, Arc<parley::Layout<[u8; 4]>>>>,
scratch: Mutex<Option<TextShapingScratch>>,
scene_cache: Mutex<LruCache<TextSceneCacheKey, Arc<vello::Scene>>>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct TextSceneCacheKey {
layout: TextLayoutCacheKey,
max_lines: Option<usize>,
}
struct TextShapingScratch {
font_cx: parley::FontContext,
layout_cx: parley::LayoutContext<[u8; 4]>,
}
impl TextMeasureService {
pub(crate) fn new() -> Self {
Self {
fonts: parley::FontContext::new(),
cache: Mutex::new(LruCache::new(
NonZeroUsize::new(TEXT_LAYOUT_CACHE_CAPACITY)
.expect("text layout cache capacity must be non-zero"),
)),
scratch: Mutex::new(None),
scene_cache: Mutex::new(LruCache::new(
NonZeroUsize::new(TEXT_LAYOUT_CACHE_CAPACITY)
.expect("text scene cache capacity must be non-zero"),
)),
}
}
pub(crate) fn fonts_mut(&mut self) -> &mut parley::FontContext {
self.cache
.get_mut()
.expect("text layout cache mutex must not be poisoned")
.clear();
self.scene_cache
.get_mut()
.expect("text scene cache mutex must not be poisoned")
.clear();
*self
.scratch
.get_mut()
.expect("text shaping scratch mutex must not be poisoned") = None;
&mut self.fonts
}
pub(crate) fn shape(
&self,
input: &ResolvedTextLayoutInput,
max_width: Option<f32>,
) -> Arc<parley::Layout<[u8; 4]>> {
if input.plain.is_empty() {
return Arc::new(parley::Layout::new());
}
let cache_key = input.cache_key(max_width);
if let Some(layout) = self
.cache
.lock()
.expect("text layout cache mutex must not be poisoned")
.get(&cache_key)
{
return Arc::clone(layout);
}
let mut scratch = self.checkout_scratch();
let layout = Arc::new(build_parley_layout(&mut scratch, input, max_width));
self.return_scratch(scratch);
self.cache
.lock()
.expect("text layout cache mutex must not be poisoned")
.put(cache_key, Arc::clone(&layout));
layout
}
pub(crate) fn glyph_scene_with(
&self,
input: &ResolvedTextLayoutInput,
max_width: Option<f32>,
max_lines: Option<usize>,
encode: impl FnOnce(&parley::Layout<[u8; 4]>, &mut vello::Scene),
) -> Arc<vello::Scene> {
let key = TextSceneCacheKey {
layout: input.cache_key(max_width),
max_lines,
};
if let Some(scene) = self
.scene_cache
.lock()
.expect("text scene cache mutex must not be poisoned")
.get(&key)
{
return Arc::clone(scene);
}
let layout = self.shape(input, max_width);
let mut scene = vello::Scene::new();
encode(&layout, &mut scene);
let scene = Arc::new(scene);
self.scene_cache
.lock()
.expect("text scene cache mutex must not be poisoned")
.put(key, Arc::clone(&scene));
scene
}
fn checkout_scratch(&self) -> TextShapingScratch {
self.scratch
.lock()
.expect("text shaping scratch mutex must not be poisoned")
.take()
.unwrap_or_else(|| TextShapingScratch {
font_cx: self.fonts.clone(),
layout_cx: parley::LayoutContext::new(),
})
}
fn return_scratch(&self, scratch: TextShapingScratch) {
*self
.scratch
.lock()
.expect("text shaping scratch mutex must not be poisoned") = Some(scratch);
}
}
impl core::fmt::Debug for TextMeasureService {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("TextMeasureService").finish_non_exhaustive()
}
}
pub(crate) struct ResolvedTextLayoutInput {
plain: String,
spans: Vec<(Range<usize>, ResolvedTextStyleSpec)>,
default_font: ResolvedFontSpec,
default_brush: [u8; 4],
locale: String,
alignment: HorizontalAlignment,
right_to_left: bool,
identity: Arc<TextLayoutIdentity>,
}
impl ResolvedTextLayoutInput {
fn cache_key(&self, max_width: Option<f32>) -> TextLayoutCacheKey {
TextLayoutCacheKey {
identity: Arc::clone(&self.identity),
max_width: max_width.map(f32::to_bits),
}
}
}
struct ResolvedFontSpec {
size: f32,
weight: TextFontWeight,
line_height: Option<f32>,
letter_spacing: f32,
family: Option<String>,
}
struct ResolvedTextStyleSpec {
font: ResolvedFontSpec,
foreground: Option<[u8; 4]>,
italic: bool,
underline: bool,
strikethrough: bool,
}
pub(crate) fn resolve_text_layout_input(
styled: &StyledStr,
alignment: HorizontalAlignment,
env: &Environment,
) -> ResolvedTextLayoutInput {
let mut plain = String::new();
let mut spans = Vec::with_capacity(styled.chunks().len());
for (chunk, style) in styled.chunks() {
let start = plain.len();
plain.push_str(chunk.as_str());
let end = plain.len();
spans.push((start..end, resolve_text_style(style, env)));
}
let default_font = font_spec(&waterui_text::font::Font::default().resolve(env).get());
let default_brush = default_text_brush(env);
let locale = text_layout_locale(env);
let right_to_left = waterui_core::layout::layout_direction(env)
.get()
.is_right_to_left();
let alignment_id = alignment.stable_id();
let identity = Arc::new(TextLayoutIdentity::new(
plain.clone(),
spans
.iter()
.map(|(range, style)| TextLayoutSpanCacheKey {
start: range.start,
end: range.end,
font: text_layout_font_cache_key(&style.font),
foreground: style.foreground,
italic: style.italic,
underline: style.underline,
strikethrough: style.strikethrough,
})
.collect(),
text_layout_font_cache_key(&default_font),
default_brush,
locale.clone(),
TextLayoutAlignmentCacheKey {
low: alignment_id.low(),
high: alignment_id.high(),
right_to_left,
},
));
ResolvedTextLayoutInput {
plain,
spans,
default_font,
default_brush,
locale,
alignment,
right_to_left,
identity,
}
}
fn font_spec(font: &waterui_text::font::ResolvedFont) -> ResolvedFontSpec {
ResolvedFontSpec {
size: font.size,
weight: font.weight,
line_height: font.line_height,
letter_spacing: font.letter_spacing,
family: font.family.as_deref().map(String::from),
}
}
fn resolve_text_style(style: &TextStyle, env: &Environment) -> ResolvedTextStyleSpec {
ResolvedTextStyleSpec {
font: font_spec(&style.font.resolve(env).get()),
foreground: style
.foreground
.clone()
.map(|color| resolved_color_to_rgba8(color.resolve(env).get())),
italic: style.italic,
underline: style.underline,
strikethrough: style.strikethrough,
}
}
fn default_text_brush(env: &Environment) -> [u8; 4] {
let color = theme::installed_color_signal::<theme::color::Foreground>(env).map_or_else(
|| Color::srgb(0, 0, 0).resolve(env).get(),
|signal| signal.get(),
);
resolved_color_to_rgba8(color)
}
fn build_parley_layout(
scratch: &mut TextShapingScratch,
input: &ResolvedTextLayoutInput,
max_width: Option<f32>,
) -> parley::Layout<[u8; 4]> {
let mut builder =
scratch
.layout_cx
.ranged_builder(&mut scratch.font_cx, &input.plain, 1.0, true);
builder.push_default(parley::StyleProperty::Brush(input.default_brush));
builder.push_default(parley::StyleProperty::FontSize(input.default_font.size));
builder.push_default(parley::StyleProperty::FontWeight(parley_font_weight(
input.default_font.weight,
)));
if let Some(line_height) = input.default_font.line_height {
builder.push_default(parley::StyleProperty::LineHeight(
parley::LineHeight::Absolute(line_height),
));
}
builder.push_default(parley::StyleProperty::LetterSpacing(
input.default_font.letter_spacing,
));
let locale = input
.locale
.parse::<parley::Language>()
.unwrap_or_else(|error| {
panic!(
"WaterUI locale `{}` is not a valid BCP 47 language tag: {error}",
input.locale
)
});
builder.push_default(parley::StyleProperty::Locale(Some(locale)));
builder.push_default(parley::StyleProperty::FontFamily(font_family(
input.default_font.family.as_deref(),
)));
for (range, style) in &input.spans {
push_text_style(&mut builder, style, range.clone());
}
let mut layout = builder.build(&input.plain);
layout.break_all_lines(max_width);
layout.align(
parley_alignment(input.alignment, input.right_to_left),
parley::AlignmentOptions::default(),
);
layout
}
fn push_text_style(
builder: &mut parley::RangedBuilder<'_, [u8; 4]>,
style: &ResolvedTextStyleSpec,
range: Range<usize>,
) {
builder.push(
parley::StyleProperty::FontSize(style.font.size),
range.clone(),
);
builder.push(
parley::StyleProperty::FontWeight(parley_font_weight(style.font.weight)),
range.clone(),
);
if let Some(line_height) = style.font.line_height {
builder.push(
parley::StyleProperty::LineHeight(parley::LineHeight::Absolute(line_height)),
range.clone(),
);
}
builder.push(
parley::StyleProperty::LetterSpacing(style.font.letter_spacing),
range.clone(),
);
if let Some(family) = &style.font.family {
builder.push(
parley::StyleProperty::FontFamily(font_family(Some(family.as_str()))),
range.clone(),
);
}
builder.push(
parley::StyleProperty::FontStyle(if style.italic {
parley::FontStyle::Italic
} else {
parley::FontStyle::Normal
}),
range.clone(),
);
builder.push(
parley::StyleProperty::Underline(style.underline),
range.clone(),
);
builder.push(
parley::StyleProperty::Strikethrough(style.strikethrough),
range.clone(),
);
if let Some(color) = style.foreground {
builder.push(parley::StyleProperty::Brush(color), range);
}
}
fn font_family(family: Option<&str>) -> parley::FontFamily<'static> {
family.map_or_else(
|| parley::style::GenericFamily::SansSerif.into(),
|family| parley::FontFamily::Source(std::borrow::Cow::Owned(family.to_string())),
)
}
fn text_layout_locale(env: &Environment) -> String {
waterui_locale::locale_binding(env).get().canonical_tag()
}
pub(crate) fn text_dimensions_from_layout(
layout: &parley::Layout<[u8; 4]>,
max_lines: Option<usize>,
) -> ViewDimensions {
if layout.is_empty() {
return ViewDimensions::new(LayoutSize::zero());
}
let mut width = 0.0_f32;
let mut height = 0.0_f32;
let mut first_baseline = None;
let mut last_baseline = None;
for (index, line) in layout.lines().enumerate() {
if max_lines.is_some_and(|limit| index >= limit) {
break;
}
let metrics = line.metrics();
width = width.max(metrics.advance);
height += metrics.line_height;
if first_baseline.is_none() {
first_baseline = Some(metrics.baseline);
}
last_baseline = Some(metrics.baseline);
}
let mut dimensions = ViewDimensions::new(LayoutSize::new(width, height));
if let Some(first_baseline) = first_baseline {
dimensions.set_vertical(VerticalAlignment::FirstBaseline, first_baseline);
}
if let Some(last_baseline) = last_baseline {
dimensions.set_vertical(VerticalAlignment::LastBaseline, last_baseline);
}
dimensions
}
fn text_layout_font_cache_key(font: &ResolvedFontSpec) -> TextLayoutFontCacheKey {
TextLayoutFontCacheKey {
size: font.size.to_bits(),
weight: text_font_weight_cache_key(font.weight),
line_height: font.line_height.map(f32::to_bits),
letter_spacing: font.letter_spacing.to_bits(),
family: font.family.clone(),
}
}
const fn text_font_weight_cache_key(weight: TextFontWeight) -> u16 {
match weight {
TextFontWeight::Thin => 100,
TextFontWeight::UltraLight => 200,
TextFontWeight::Light => 300,
TextFontWeight::Normal => 400,
TextFontWeight::Medium => 500,
TextFontWeight::SemiBold => 600,
TextFontWeight::Bold => 700,
TextFontWeight::UltraBold => 800,
TextFontWeight::Black => 900,
}
}
#[derive(Debug, Eq)]
struct TextLayoutIdentity {
text: String,
spans: Vec<TextLayoutSpanCacheKey>,
default_font: TextLayoutFontCacheKey,
default_brush: [u8; 4],
locale: String,
alignment: TextLayoutAlignmentCacheKey,
hash: u64,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
struct TextLayoutAlignmentCacheKey {
low: u64,
high: u64,
right_to_left: bool,
}
impl TextLayoutIdentity {
fn new(
text: String,
spans: Vec<TextLayoutSpanCacheKey>,
default_font: TextLayoutFontCacheKey,
default_brush: [u8; 4],
locale: String,
alignment: TextLayoutAlignmentCacheKey,
) -> Self {
let mut hasher = FxHasher::default();
text.hash(&mut hasher);
spans.hash(&mut hasher);
default_font.hash(&mut hasher);
default_brush.hash(&mut hasher);
locale.hash(&mut hasher);
alignment.hash(&mut hasher);
Self {
text,
spans,
default_font,
default_brush,
locale,
alignment,
hash: hasher.finish(),
}
}
}
impl PartialEq for TextLayoutIdentity {
fn eq(&self, other: &Self) -> bool {
self.hash == other.hash
&& self.text == other.text
&& self.spans == other.spans
&& self.default_font == other.default_font
&& self.default_brush == other.default_brush
&& self.locale == other.locale
&& self.alignment == other.alignment
}
}
impl Hash for TextLayoutIdentity {
fn hash<H: Hasher>(&self, state: &mut H) {
state.write_u64(self.hash);
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct TextLayoutCacheKey {
identity: Arc<TextLayoutIdentity>,
max_width: Option<u32>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub(crate) struct TextLayoutSpanCacheKey {
pub(crate) start: usize,
pub(crate) end: usize,
pub(crate) font: TextLayoutFontCacheKey,
pub(crate) foreground: Option<[u8; 4]>,
pub(crate) italic: bool,
pub(crate) underline: bool,
pub(crate) strikethrough: bool,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub(crate) struct TextLayoutFontCacheKey {
pub(crate) size: u32,
pub(crate) weight: u16,
pub(crate) line_height: Option<u32>,
pub(crate) letter_spacing: u32,
pub(crate) family: Option<String>,
}
#[cfg(test)]
mod font_family_tests {
use super::{font_family, text_layout_locale};
use parley::style::GenericFamily;
use waterui_core::Environment;
use waterui_locale::locales;
#[test]
fn explicit_family_list_is_preserved_for_parley_css_parsing() {
let family = font_family(Some("Roboto, Noto Sans CJK SC, sans-serif"));
assert_eq!(
family,
parley::FontFamily::Source("Roboto, Noto Sans CJK SC, sans-serif".into())
);
}
#[test]
fn missing_family_uses_sans_serif_generic() {
assert_eq!(
font_family(None),
parley::FontFamily::from(GenericFamily::SansSerif)
);
}
#[test]
fn text_layout_locale_uses_environment_locale() {
let mut env = Environment::new();
env.insert(locales::ZH_TW);
assert_eq!(text_layout_locale(&env), "zh-TW");
}
}