use vello::peniko::{Brush, Color};
pub use parley;
pub use vello;
pub use vello::peniko;
pub struct Typesetter {
font_cx: parley::FontContext,
layout_cx: parley::LayoutContext<()>,
runs_cx: parley::LayoutContext<RunBrush>,
cache: ShapeCache,
cache_hits: u64,
cache_misses: u64,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct CacheStats {
pub hits: u64,
pub misses: u64,
pub entries: usize,
}
#[derive(Clone, PartialEq, Eq, Hash)]
struct ShapeKey {
text: String,
size_bits: u32,
max_width_bits: Option<u32>,
align: u8,
line_height_bits: u32,
italic: bool,
font_family: Option<String>,
weight_bits: u32,
underline: bool,
strikethrough: bool,
letter_bits: u32,
word_bits: u32,
overflow_wrap: bool,
}
fn align_tag(a: Alignment) -> u8 {
match a {
Alignment::Start => 0,
Alignment::Center => 1,
Alignment::End => 2,
Alignment::Justify => 3,
}
}
struct ShapeCache {
hot: std::collections::HashMap<ShapeKey, parley::Layout<()>>,
cold: std::collections::HashMap<ShapeKey, parley::Layout<()>>,
cap: usize,
}
impl ShapeCache {
fn new(cap: usize) -> Self {
Self {
hot: std::collections::HashMap::new(),
cold: std::collections::HashMap::new(),
cap,
}
}
fn get(&mut self, key: &ShapeKey) -> Option<parley::Layout<()>> {
if let Some(v) = self.hot.get(key) {
return Some(v.clone());
}
if let Some(v) = self.cold.remove(key) {
self.hot.insert(key.clone(), v.clone());
return Some(v);
}
None
}
fn put(&mut self, key: ShapeKey, layout: parley::Layout<()>) {
if self.hot.len() >= self.cap {
self.cold = std::mem::take(&mut self.hot);
}
self.hot.insert(key, layout);
}
fn clear(&mut self) {
self.hot.clear();
self.cold.clear();
}
fn entries(&self) -> usize {
self.hot.len() + self.cold.len()
}
}
const SHAPE_CACHE_CAP: usize = 512;
impl Default for Typesetter {
fn default() -> Self {
Self::new()
}
}
const DEJAVU_SANS: &[u8] = include_bytes!("../assets/DejaVuSans.ttf");
const INTER_SANS: &[u8] = include_bytes!("../assets/Inter-Regular.ttf");
const LIBERATION_MONO: &[u8] = include_bytes!("../assets/LiberationMono.ttf");
pub const MONO_FONT_BYTES: &[u8] = LIBERATION_MONO;
pub const MONOSPACE: &str = "Liberation Mono";
pub const UI_SANS: &str = "Inter";
impl Typesetter {
pub fn new() -> Self {
let mut font_cx = parley::FontContext::new();
Self::install_ui_font(&mut font_cx);
Self::install_symbol_fallback(&mut font_cx);
Self::install_monospace(&mut font_cx);
Self {
font_cx,
layout_cx: parley::LayoutContext::new(),
runs_cx: parley::LayoutContext::new(),
cache: ShapeCache::new(SHAPE_CACHE_CAP),
cache_hits: 0,
cache_misses: 0,
}
}
fn install_ui_font(font_cx: &mut parley::FontContext) {
use parley::fontique::{Blob, GenericFamily};
let blob = Blob::new(std::sync::Arc::new(INTER_SANS));
let registered = font_cx.collection.register_fonts(blob, None);
if let Some((family_id, _)) = registered.first() {
let existing: Vec<_> = font_cx
.collection
.generic_families(GenericFamily::SansSerif)
.collect();
font_cx.collection.set_generic_families(
GenericFamily::SansSerif,
std::iter::once(*family_id).chain(existing),
);
}
}
fn install_symbol_fallback(font_cx: &mut parley::FontContext) {
use parley::fontique::Blob;
let blob = Blob::new(std::sync::Arc::new(DEJAVU_SANS));
let registered = font_cx.collection.register_fonts(blob, None);
if let Some((family_id, _)) = registered.first() {
font_cx
.collection
.append_fallbacks("Zyyy", std::iter::once(*family_id));
}
}
fn install_monospace(font_cx: &mut parley::FontContext) {
use parley::fontique::Blob;
let blob = Blob::new(std::sync::Arc::new(LIBERATION_MONO));
font_cx.collection.register_fonts(blob, None);
}
pub fn font_context_mut(&mut self) -> &mut parley::FontContext {
self.cache.clear();
&mut self.font_cx
}
pub fn cache_stats(&self) -> CacheStats {
CacheStats {
hits: self.cache_hits,
misses: self.cache_misses,
entries: self.cache.entries(),
}
}
#[allow(clippy::too_many_arguments)]
pub fn layout(
&mut self,
text: &str,
size_px: f32,
max_width: Option<f32>,
alignment: Alignment,
line_height: f32,
italic: bool,
font_family: Option<&str>,
weight: f32,
underline: bool,
strikethrough: bool,
letter_spacing: f32,
word_spacing: f32,
) -> parley::Layout<()> {
self.layout_inner(
text, size_px, max_width, alignment, line_height, italic, font_family, weight,
underline, strikethrough, letter_spacing, word_spacing, false,
)
}
#[allow(clippy::too_many_arguments)]
fn layout_inner(
&mut self,
text: &str,
size_px: f32,
max_width: Option<f32>,
alignment: Alignment,
line_height: f32,
italic: bool,
font_family: Option<&str>,
weight: f32,
underline: bool,
strikethrough: bool,
letter_spacing: f32,
word_spacing: f32,
overflow_wrap: bool,
) -> parley::Layout<()> {
let key = ShapeKey {
text: text.to_string(),
size_bits: size_px.to_bits(),
max_width_bits: max_width.map(f32::to_bits),
align: align_tag(alignment),
line_height_bits: line_height.to_bits(),
italic,
font_family: font_family.map(str::to_string),
weight_bits: weight.to_bits(),
underline,
strikethrough,
letter_bits: letter_spacing.to_bits(),
word_bits: word_spacing.to_bits(),
overflow_wrap,
};
if let Some(hit) = self.cache.get(&key) {
self.cache_hits += 1;
return hit;
}
self.cache_misses += 1;
let mut builder =
self.layout_cx
.ranged_builder(&mut self.font_cx, text, 1.0, true);
builder.push_default(parley::StyleProperty::FontSize(size_px));
builder.push_default(parley::StyleProperty::LineHeight(
parley::LineHeight::FontSizeRelative(line_height),
));
if weight != 400.0 {
builder.push_default(parley::StyleProperty::FontWeight(
parley::FontWeight::new(weight),
));
}
if italic {
builder.push_default(parley::StyleProperty::FontStyle(
parley::FontStyle::Italic,
));
}
if let Some(ff) = font_family {
builder.push_default(parley::StyleProperty::FontStack(
parley::FontStack::Source(std::borrow::Cow::Borrowed(ff)),
));
}
if underline {
builder.push_default(parley::StyleProperty::Underline(true));
}
if strikethrough {
builder.push_default(parley::StyleProperty::Strikethrough(true));
}
if letter_spacing != 0.0 {
builder.push_default(parley::StyleProperty::LetterSpacing(letter_spacing));
}
if word_spacing != 0.0 {
builder.push_default(parley::StyleProperty::WordSpacing(word_spacing));
}
if overflow_wrap {
builder.push_default(parley::StyleProperty::OverflowWrap(
parley::OverflowWrap::Anywhere,
));
}
let mut layout = builder.build(text);
layout.break_all_lines(max_width);
layout.align(
max_width,
alignment.into(),
parley::AlignmentOptions::default(),
);
self.cache.put(key, layout.clone());
layout
}
#[allow(clippy::too_many_arguments)]
pub fn layout_clamped(
&mut self,
text: &str,
size_px: f32,
max_width: Option<f32>,
alignment: Alignment,
line_height: f32,
italic: bool,
font_family: Option<&str>,
weight: f32,
max_lines: Option<usize>,
ellipsis: bool,
underline: bool,
strikethrough: bool,
letter_spacing: f32,
word_spacing: f32,
overflow_wrap: bool,
) -> parley::Layout<()> {
let full = self.layout_inner(
text, size_px, max_width, alignment, line_height, italic, font_family, weight,
underline, strikethrough, letter_spacing, word_spacing, overflow_wrap,
);
let limit = match max_lines {
Some(n) if n >= 1 => n,
_ => return full,
};
if full.lines().count() <= limit {
return full;
}
let mut cutoff = full
.lines()
.nth(limit - 1)
.map(|l| l.text_range().end)
.unwrap_or(text.len())
.min(text.len());
while cutoff > 0 && !text.is_char_boundary(cutoff) {
cutoff -= 1;
}
let base = text[..cutoff].trim_end();
if !ellipsis {
return self.layout_inner(
base, size_px, max_width, alignment, line_height, italic, font_family, weight,
underline, strikethrough, letter_spacing, word_spacing, overflow_wrap,
);
}
let mut s = base.to_string();
loop {
let candidate = format!("{s}…");
let lay = self.layout_inner(
&candidate, size_px, max_width, alignment, line_height, italic, font_family,
weight, underline, strikethrough, letter_spacing, word_spacing, overflow_wrap,
);
if s.is_empty() || lay.lines().count() <= limit {
return lay;
}
s.pop();
while s.ends_with(char::is_whitespace) {
s.pop();
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn layout_runs(
&mut self,
text: &str,
size_px: f32,
default_color: Color,
runs: &[(usize, usize, Color)],
alignment: Alignment,
line_height: f32,
weight: f32,
underline: bool,
strikethrough: bool,
) -> parley::Layout<RunBrush> {
let mut builder = self
.runs_cx
.ranged_builder(&mut self.font_cx, text, 1.0, true);
builder.push_default(parley::StyleProperty::FontSize(size_px));
builder.push_default(parley::StyleProperty::LineHeight(
parley::LineHeight::FontSizeRelative(line_height),
));
if weight != 400.0 {
builder.push_default(parley::StyleProperty::FontWeight(
parley::FontWeight::new(weight),
));
}
builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color)));
if underline {
builder.push_default(parley::StyleProperty::Underline(true));
}
if strikethrough {
builder.push_default(parley::StyleProperty::Strikethrough(true));
}
let len = text.len();
for &(start, end, color) in runs {
if start < end && end <= len {
builder.push(parley::StyleProperty::Brush(RunBrush(color)), start..end);
}
}
let mut layout = builder.build(text);
layout.break_all_lines(None);
layout.align(None, alignment.into(), parley::AlignmentOptions::default());
layout
}
#[allow(clippy::too_many_arguments)]
pub fn layout_spans(
&mut self,
text: &str,
size_px: f32,
default_color: Color,
weight: f32,
line_height: f32,
italic: bool,
font_family: Option<&str>,
underline: bool,
strikethrough: bool,
spans: &[TextSpan],
max_width: Option<f32>,
alignment: Alignment,
) -> parley::Layout<RunBrush> {
let mut builder = self
.runs_cx
.ranged_builder(&mut self.font_cx, text, 1.0, true);
builder.push_default(parley::StyleProperty::FontSize(size_px));
builder.push_default(parley::StyleProperty::LineHeight(
parley::LineHeight::FontSizeRelative(line_height),
));
if weight != 400.0 {
builder.push_default(parley::StyleProperty::FontWeight(
parley::FontWeight::new(weight),
));
}
if italic {
builder.push_default(parley::StyleProperty::FontStyle(
parley::FontStyle::Italic,
));
}
if let Some(ff) = font_family {
builder.push_default(parley::StyleProperty::FontStack(
parley::FontStack::Source(std::borrow::Cow::Borrowed(ff)),
));
}
builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color)));
if underline {
builder.push_default(parley::StyleProperty::Underline(true));
}
if strikethrough {
builder.push_default(parley::StyleProperty::Strikethrough(true));
}
let len = text.len();
for span in spans {
if span.start >= span.end || span.end > len {
continue;
}
let range = span.start..span.end;
let s = &span.style;
if let Some(v) = s.size_px {
builder.push(parley::StyleProperty::FontSize(v), range.clone());
}
if let Some(v) = s.weight {
builder.push(
parley::StyleProperty::FontWeight(parley::FontWeight::new(v)),
range.clone(),
);
}
if let Some(v) = s.italic {
let style = if v {
parley::FontStyle::Italic
} else {
parley::FontStyle::Normal
};
builder.push(parley::StyleProperty::FontStyle(style), range.clone());
}
if let Some(ff) = s.font_family.as_deref() {
builder.push(
parley::StyleProperty::FontStack(parley::FontStack::Source(
std::borrow::Cow::Owned(ff.to_string()),
)),
range.clone(),
);
}
if let Some(c) = s.color {
builder.push(parley::StyleProperty::Brush(RunBrush(c)), range.clone());
}
if let Some(v) = s.underline {
builder.push(parley::StyleProperty::Underline(v), range.clone());
}
if let Some(v) = s.strikethrough {
builder.push(parley::StyleProperty::Strikethrough(v), range.clone());
}
}
let mut layout = builder.build(text);
layout.break_all_lines(max_width);
layout.align(
max_width,
alignment.into(),
parley::AlignmentOptions::default(),
);
layout
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct RunBrush(pub Color);
impl Default for RunBrush {
fn default() -> Self {
RunBrush(Color::from_rgba8(0, 0, 0, 255))
}
}
#[derive(Default, Clone, Debug, PartialEq)]
pub struct TextSpanStyle {
pub size_px: Option<f32>,
pub weight: Option<f32>,
pub italic: Option<bool>,
pub font_family: Option<String>,
pub color: Option<Color>,
pub underline: Option<bool>,
pub strikethrough: Option<bool>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TextSpan {
pub start: usize,
pub end: usize,
pub style: TextSpanStyle,
}
impl TextSpan {
pub fn new(start: usize, end: usize, style: TextSpanStyle) -> Self {
Self { start, end, style }
}
}
#[derive(Debug, Clone, Copy)]
pub enum Alignment {
Start,
Center,
End,
Justify,
}
impl From<Alignment> for parley::Alignment {
fn from(a: Alignment) -> Self {
match a {
Alignment::Start => parley::Alignment::Start,
Alignment::Center => parley::Alignment::Center,
Alignment::End => parley::Alignment::End,
Alignment::Justify => parley::Alignment::Justify,
}
}
}
pub struct TextBlock<'a> {
pub text: &'a str,
pub size_px: f32,
pub color: Color,
pub origin: (f64, f64),
pub max_width: Option<f32>,
pub alignment: Alignment,
pub line_height: f32,
pub italic: bool,
pub font_family: Option<String>,
}
impl<'a> TextBlock<'a> {
pub fn simple(text: &'a str, size_px: f32, color: Color, origin: (f64, f64)) -> Self {
Self {
text,
size_px,
color,
origin,
max_width: None,
alignment: Alignment::Start,
line_height: 1.0,
italic: false,
font_family: None,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Measurement {
pub width: f32,
pub height: f32,
}
pub fn layout_block(ts: &mut Typesetter, block: &TextBlock<'_>) -> parley::Layout<()> {
ts.layout(
block.text,
block.size_px,
block.max_width,
block.alignment,
block.line_height,
block.italic,
block.font_family.as_deref(),
400.0,
false,
false,
0.0,
0.0,
)
}
pub fn measurement(layout: &parley::Layout<()>) -> Measurement {
Measurement {
width: layout.width(),
height: layout.height(),
}
}
pub fn draw_layout(
scene: &mut vello::Scene,
layout: &parley::Layout<()>,
color: Color,
origin: (f64, f64),
) {
draw_layout_xf(scene, layout, color, vello::kurbo::Affine::translate(origin));
}
pub fn draw_layout_xf(
scene: &mut vello::Scene,
layout: &parley::Layout<()>,
color: Color,
transform: vello::kurbo::Affine,
) {
draw_layout_brush_xf(scene, layout, &Brush::Solid(color), transform);
}
pub fn draw_layout_brush_xf(
scene: &mut vello::Scene,
layout: &parley::Layout<()>,
brush: &Brush,
transform: vello::kurbo::Affine,
) {
for line in layout.lines() {
for item in line.items() {
if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item {
let run = glyph_run.run();
let font = run.font().clone();
let font_size = run.font_size();
scene
.draw_glyphs(&font)
.font_size(font_size)
.brush(brush)
.transform(transform)
.draw(
peniko::Fill::NonZero,
glyph_run.positioned_glyphs().map(|g| vello::Glyph {
id: g.id as u32,
x: g.x,
y: g.y,
}),
);
paint_decoration(scene, &glyph_run, brush, transform);
}
}
}
}
fn paint_decoration<B: parley::Brush>(
scene: &mut vello::Scene,
glyph_run: &parley::GlyphRun<'_, B>,
brush: &Brush,
transform: vello::kurbo::Affine,
) {
let style = glyph_run.style();
let run = glyph_run.run();
let metrics = run.metrics();
let x = glyph_run.offset() as f64;
let baseline = glyph_run.baseline() as f64;
let advance = glyph_run.advance() as f64;
if let Some(dec) = &style.underline {
let offset = dec.offset.unwrap_or(metrics.underline_offset) as f64;
let size = dec.size.unwrap_or(metrics.underline_size) as f64;
let y0 = baseline - offset;
let rect = vello::kurbo::Rect::new(x, y0, x + advance, y0 + size);
scene.fill(peniko::Fill::NonZero, transform, brush, None, &rect);
}
if let Some(dec) = &style.strikethrough {
let offset = dec.offset.unwrap_or(metrics.strikethrough_offset) as f64;
let size = dec.size.unwrap_or(metrics.strikethrough_size) as f64;
let y0 = baseline - offset;
let rect = vello::kurbo::Rect::new(x, y0, x + advance, y0 + size);
scene.fill(peniko::Fill::NonZero, transform, brush, None, &rect);
}
}
pub fn draw_layout_runs(
scene: &mut vello::Scene,
layout: &parley::Layout<RunBrush>,
origin: (f64, f64),
) {
draw_layout_runs_xf(scene, layout, vello::kurbo::Affine::translate(origin));
}
pub fn draw_layout_runs_xf(
scene: &mut vello::Scene,
layout: &parley::Layout<RunBrush>,
transform: vello::kurbo::Affine,
) {
for line in layout.lines() {
for item in line.items() {
if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item {
let brush = Brush::Solid(glyph_run.style().brush.0);
let run = glyph_run.run();
let font = run.font().clone();
let font_size = run.font_size();
scene
.draw_glyphs(&font)
.font_size(font_size)
.brush(&brush)
.transform(transform)
.draw(
peniko::Fill::NonZero,
glyph_run.positioned_glyphs().map(|g| vello::Glyph {
id: g.id as u32,
x: g.x,
y: g.y,
}),
);
paint_decoration(scene, &glyph_run, &brush, transform);
}
}
}
}
pub fn measure(ts: &mut Typesetter, block: &TextBlock<'_>) -> Measurement {
measurement(&layout_block(ts, block))
}
pub fn draw_block(scene: &mut vello::Scene, ts: &mut Typesetter, block: &TextBlock<'_>) {
let layout = layout_block(ts, block);
draw_layout(scene, &layout, block.color, block.origin);
}
#[cfg(test)]
mod tests {
use super::*;
const LARGO: &str =
"palabras varias que envuelven en bastantes renglones cuando el ancho \
disponible es realmente angosto y no caben de un solo tirón";
fn n_lineas(ts: &mut Typesetter, max_lines: Option<usize>, ellipsis: bool) -> usize {
ts.layout_clamped(
LARGO,
14.0,
Some(120.0),
Alignment::Start,
1.2,
false,
None,
400.0,
max_lines,
ellipsis,
false,
false,
0.0,
0.0,
false,
)
.lines()
.count()
}
#[test]
fn clamp_limita_el_numero_de_lineas() {
let mut ts = Typesetter::new();
let libre = n_lineas(&mut ts, None, false);
assert!(libre > 2, "el fixture debe envolver a >2 líneas (dio {libre})");
assert_eq!(n_lineas(&mut ts, Some(1), false), 1);
assert_eq!(n_lineas(&mut ts, Some(1), true), 1);
assert!(n_lineas(&mut ts, Some(2), true) <= 2);
assert_eq!(n_lineas(&mut ts, None, true), libre);
}
#[test]
fn letter_y_word_spacing_ensanchan_la_medida() {
let mut ts = Typesetter::new();
let w = |ts: &mut Typesetter, ls: f32, ws: f32| {
measurement(&ts.layout(
"hola mundo cruel", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false,
false, ls, ws,
))
.width
};
let base = w(&mut ts, 0.0, 0.0);
let con_letter = w(&mut ts, 4.0, 0.0);
let con_word = w(&mut ts, 0.0, 10.0);
assert!(con_letter > base, "letter-spacing ensancha ({con_letter} > {base})");
assert!(con_word > base, "word-spacing ensancha ({con_word} > {base})");
}
#[test]
fn clamp_no_trunca_si_ya_cabe() {
let mut ts = Typesetter::new();
let lay = ts.layout_clamped(
"Hola", 14.0, Some(200.0), Alignment::Start, 1.2, false, None, 400.0, Some(3), true,
false, false, 0.0, 0.0, false,
);
assert_eq!(lay.lines().count(), 1);
}
#[test]
fn cache_es_transparente_y_pega() {
let mut ts = Typesetter::new();
let m1 = {
let l = ts.layout(LARGO, 14.0, Some(120.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
(l.width(), l.height(), l.lines().count())
};
let s1 = ts.cache_stats();
assert_eq!(s1.misses, 1, "primera vez = miss");
assert_eq!(s1.hits, 0);
let m2 = {
let l = ts.layout(LARGO, 14.0, Some(120.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
(l.width(), l.height(), l.lines().count())
};
let s2 = ts.cache_stats();
assert_eq!(s2.hits, 1, "segunda vez idéntica = hit");
assert_eq!(s2.misses, 1, "no hubo nuevo miss");
assert_eq!(m1, m2, "el layout cacheado es idéntico al fresco");
let _ = ts.layout(LARGO, 14.0, Some(80.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
assert_eq!(ts.cache_stats().misses, 2, "otro ancho = otra clave");
}
#[test]
fn font_context_mut_invalida_el_cache() {
let mut ts = Typesetter::new();
let _ = ts.layout("hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
assert_eq!(ts.cache_stats().entries, 1);
let _ = ts.font_context_mut();
assert_eq!(ts.cache_stats().entries, 0, "el caché quedó vacío");
let _ = ts.layout("hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
assert_eq!(ts.cache_stats().misses, 2, "post-invalidación = miss");
}
#[test]
fn underline_y_strikethrough_se_propagan_al_layout() {
let mut ts = Typesetter::new();
let with_dec = ts.layout(
"Hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, true, true, 0.0, 0.0,
);
let mut visto_u = false;
let mut visto_s = false;
for line in with_dec.lines() {
for item in line.items() {
if let parley::PositionedLayoutItem::GlyphRun(gr) = item {
if gr.style().underline.is_some() {
visto_u = true;
}
if gr.style().strikethrough.is_some() {
visto_s = true;
}
}
}
}
assert!(visto_u, "underline=true ⇒ Decoration en al menos un run");
assert!(visto_s, "strikethrough=true ⇒ Decoration en al menos un run");
let plain = ts.layout(
"Hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0,
);
for line in plain.lines() {
for item in line.items() {
if let parley::PositionedLayoutItem::GlyphRun(gr) = item {
assert!(gr.style().underline.is_none(), "sin underline=true ⇒ None");
assert!(gr.style().strikethrough.is_none(), "sin strikethrough=true ⇒ None");
}
}
}
let s = ts.cache_stats();
assert!(s.misses >= 2, "claves distintas por decoración ⇒ misses separados");
}
#[test]
fn cache_generacional_promueve_y_rota() {
let mut c = ShapeCache::new(2);
let mk = |s: &str| ShapeKey {
text: s.to_string(),
size_bits: 0,
max_width_bits: None,
align: 0,
line_height_bits: 0,
italic: false,
font_family: None,
weight_bits: 0,
underline: false,
strikethrough: false,
letter_bits: 0,
word_bits: 0,
overflow_wrap: false,
};
let dummy = parley::Layout::<()>::default;
c.put(mk("a"), dummy());
c.put(mk("b"), dummy());
assert!(c.get(&mk("a")).is_some());
c.put(mk("c"), dummy());
assert!(c.get(&mk("a")).is_some(), "ítem reaccedido sobrevive la rotación");
c.put(mk("d"), dummy()); c.put(mk("e"), dummy());
c.put(mk("f"), dummy());
assert!(c.get(&mk("b")).is_none(), "ítem nunca reaccedido se libera");
}
}