use std::hash::{Hash, Hasher};
use glyphon::cosmic_text::{Align, Ellipsize, EllipsizeHeightLimit, Wrap};
use glyphon::{
Attrs, Buffer, Cache, Color as GlyphonColor, ColorMode, FontSystem, Metrics, Resolution,
Shaping, SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Viewport,
};
use rustc_hash::FxHashMap;
use smallvec::SmallVec;
use wgpu::{Device, MultisampleState, Queue};
use crate::app_state::with_app_state;
use crate::render_stats::{self, Pipeline};
use crate::widgets::font::{FontFamily, FontWeight, LineHeight};
use crate::widgets::{Rect, TextAlign, TextOverflow};
use super::clip::ClipChains;
use super::text_measurer::LineFit;
use super::types::TextEntry;
fn text_buffer_key(entry: &TextEntry, scale_factor: f32) -> u64 {
let mut hasher = rustc_hash::FxHasher::default();
entry.text.hash(&mut hasher);
(entry.font_size * scale_factor).to_bits().hash(&mut hasher);
entry.font_weight.hash(&mut hasher);
entry.line_height.key().hash(&mut hasher);
entry.letter_spacing.to_bits().hash(&mut hasher);
entry.font_family.hash(&mut hasher);
entry.align.hash(&mut hasher);
let (width, _) = line_width(
entry.fit,
Some(entry.rect.width * scale_factor),
scale_factor,
);
width.map(f32::to_bits).hash(&mut hasher);
buffer_height(entry.rect, scale_factor)
.to_bits()
.hash(&mut hasher);
entry.fit.map(|fit| fit.key()).hash(&mut hasher);
hasher.finish()
}
pub(super) fn line_width(
fit: Option<LineFit>,
box_width: Option<f32>,
scale: f32,
) -> (Option<f32>, bool) {
match fit {
None => (box_width, true),
Some(LineFit {
width: Some(width),
wrap,
..
}) => (Some(width * scale), wrap),
Some(_) => (box_width, false),
}
}
pub(super) fn buffer_height(rect: Rect, scale: f32) -> f32 {
rect.height.max(50.0) * scale
}
pub(super) fn paint_offset(buffer: &Buffer, box_width: f32, align: TextAlign) -> f32 {
let Some(shaped_in) = buffer.size().0 else {
return 0.0;
};
let rtl = buffer.layout_runs().next().is_some_and(|run| run.rtl);
let fraction = match (align, rtl) {
(TextAlign::Center, _) => 0.5,
(TextAlign::Start | TextAlign::Justified, false) | (TextAlign::End, true) => 0.0,
(TextAlign::Start | TextAlign::Justified, true) | (TextAlign::End, false) => 1.0,
};
fraction * (box_width - shaped_in)
}
fn shape_entry(font_system: &mut FontSystem, entry: &TextEntry, scale_factor: f32) -> Buffer {
shape(
font_system,
&entry.text,
entry.font_size * scale_factor,
entry.font_family,
entry.font_weight,
entry.line_height,
entry.letter_spacing,
entry.align,
(
Some(entry.rect.width * scale_factor),
Some(buffer_height(entry.rect, scale_factor)),
),
entry.fit,
scale_factor,
)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn shape(
font_system: &mut FontSystem,
text: &str,
font_size: f32,
font_family: FontFamily,
font_weight: FontWeight,
line_height: LineHeight,
letter_spacing: f32,
align: TextAlign,
frame: (Option<f32>, Option<f32>),
fit: Option<LineFit>,
scale: f32,
) -> Buffer {
let px = crate::renderer::text_measurer::shapeable_size(font_size);
let weight = if font_weight == FontWeight::default() {
FontWeight::NORMAL
} else {
font_weight
};
let declared = match line_height {
LineHeight::Normal => None,
LineHeight::Relative(factor) => Some(factor * px),
LineHeight::Absolute(height) => Some(height * scale),
};
let font = resolve_font(font_system, font_family, weight);
let line_height = declared
.filter(|h| h.is_finite() && *h > 0.0)
.unwrap_or(font.line_ratio * px);
let mut buffer = Buffer::new(font_system, Metrics::new(px, line_height));
let lines = fit.and_then(|fit| fit.max_lines).map(|n| n.max(1) as usize);
let (width, wraps) = line_width(fit, frame.0, scale);
let height = match lines {
Some(n) => Some((n as f32 - 0.5) * line_height),
None => frame.1,
};
buffer.set_size(width, height);
if !wraps {
buffer.set_wrap(Wrap::None);
}
let mut attrs = Attrs::new()
.family(font.family.to_cosmic())
.weight(weight.to_cosmic());
let spacing = letter_spacing * scale;
if spacing.is_finite() && spacing != 0.0 {
attrs = attrs.letter_spacing(spacing / px);
}
buffer.set_text(text, &attrs, Shaping::Advanced, cosmic_align(align));
buffer.shape_until_scroll(font_system, true);
if let (Some(lines), Some(mark)) = (lines, fit.and_then(|fit| ellipsize(fit.overflow))) {
cut_to_lines(font_system, &mut buffer, lines, mark);
}
buffer
}
#[derive(Clone, Copy)]
pub(crate) struct ResolvedFont {
family: FontFamily,
line_ratio: f32,
}
fn resolve_font(
font_system: &mut FontSystem,
family: FontFamily,
weight: FontWeight,
) -> ResolvedFont {
let known = with_app_state(|app| app.resolved_fonts.borrow().get(&(family, weight)).copied());
if let Some(resolved) = known {
return resolved;
}
let nearest_face = |font_system: &FontSystem, of: FontFamily| {
let db = font_system.db();
let id = db.query(&glyphon::fontdb::Query {
families: &[of.to_cosmic()],
weight: weight.to_cosmic(),
..Default::default()
})?;
Some((id, FontWeight(db.face(id)?.weight.0)))
};
let drawing_family = match family {
FontFamily::Name(_) if nearest_face(font_system, family).is_some() => Some(family),
_ => drawing_family_of_a_space(font_system, family),
};
let nearest = drawing_family.and_then(|drawing| nearest_face(font_system, drawing));
let line_ratio = nearest
.and_then(|(id, weight)| font_system.get_font(id, weight.to_cosmic()))
.and_then(|font| {
let m = font.metrics();
line_ratio(m.ascent, m.descent, m.leading, m.units_per_em)
})
.unwrap_or(RATIO_WITHOUT_A_FACE);
let resolved = ResolvedFont {
family: drawing_family.unwrap_or(family),
line_ratio,
};
with_app_state(|app| {
app.resolved_fonts
.borrow_mut()
.insert((family, weight), resolved)
});
resolved
}
fn drawing_family_of_a_space(
font_system: &mut FontSystem,
family: FontFamily,
) -> Option<FontFamily> {
let mut probe = Buffer::new(font_system, Metrics::new(16.0, 16.0));
probe.set_text(
" ",
&Attrs::new()
.family(family.to_cosmic())
.weight(FontWeight::NORMAL.to_cosmic()),
Shaping::Advanced,
None,
);
probe.shape_until_scroll(font_system, false);
probe
.layout_runs()
.find_map(|run| run.glyphs.first().map(|glyph| glyph.font_id))
.and_then(|id| font_system.db().face(id))
.and_then(|face| face.families.first())
.map(|(name, _)| FontFamily::name(name))
}
fn line_ratio(ascent: f32, descent: f32, line_gap: f32, units_per_em: u16) -> Option<f32> {
let ratio = (ascent - descent + line_gap) / f32::from(units_per_em);
(ratio.is_finite() && ratio > 0.0).then_some(ratio)
}
const RATIO_WITHOUT_A_FACE: f32 = 1.2;
fn cosmic_align(align: TextAlign) -> Option<Align> {
match align {
TextAlign::Start => None,
TextAlign::Center => Some(Align::Center),
TextAlign::End => Some(Align::End),
TextAlign::Justified => Some(Align::Justified),
}
}
fn ellipsize(overflow: TextOverflow) -> Option<Ellipsize> {
let limit = EllipsizeHeightLimit::Lines(1);
match overflow {
TextOverflow::Clip => None,
TextOverflow::Ellipsis => Some(Ellipsize::End(limit)),
TextOverflow::EllipsisStart => Some(Ellipsize::Start(limit)),
TextOverflow::EllipsisMiddle => Some(Ellipsize::Middle(limit)),
}
}
fn cut_to_lines(
font_system: &mut FontSystem,
buffer: &mut Buffer,
max_lines: usize,
mark: Ellipsize,
) {
let width = buffer.size().0.unwrap_or(f32::INFINITY);
let mut remaining = max_lines;
let mut i = 0;
while i < buffer.lines.len() {
let layout = buffer.line_layout(font_system, i).unwrap_or_default();
let count = layout.len();
let kept = count.min(remaining);
let last = kept.checked_sub(1).and_then(|at| layout.get(at));
let too_wide = last.is_some_and(|line| line.w > width);
let begins_at = last.map_or(0, |line| {
line.glyphs.iter().map(|g| g.start).min().unwrap_or(0)
});
let ends_here = count >= remaining;
let more_inside = count > remaining;
let more_after = ends_here && i + 1 < buffer.lines.len();
if !(more_inside || more_after || too_wide) {
if ends_here {
return;
}
remaining -= count;
i += 1;
continue;
}
if ends_here {
buffer.lines.truncate(i + 1);
}
if kept > 1 {
let rest = buffer.lines[i].split_off(begins_at);
buffer.lines.insert(i + 1, rest);
buffer.line_layout(font_system, i);
i += 1;
}
let mark = if more_inside || too_wide {
mark
} else {
let line = &mut buffer.lines[i];
let marked = format!("{}\u{2026}", line.text());
let (ending, attrs) = (line.ending(), line.attrs_list().clone());
line.set_text(marked, ending, attrs);
Ellipsize::End(EllipsizeHeightLimit::Lines(1))
};
let (fits_in, wrap, font_size) =
(buffer.size().0, buffer.wrap(), buffer.metrics().font_size);
let (mono, tab, hinting) = (
buffer.monospace_width(),
buffer.tab_width(),
buffer.hinting(),
);
let line = &mut buffer.lines[i];
line.reset_layout();
line.layout(
font_system,
font_size,
fits_in,
wrap,
mark,
mono,
tab,
hinting,
);
if ends_here {
return;
}
remaining -= kept;
i += 1;
}
}
pub struct TextRenderState {
font_system: FontSystem,
swash_cache: SwashCache,
#[allow(dead_code)] cache: Cache,
atlas: TextAtlas,
text_renderers: Vec<TextRenderer>,
buffers: Vec<Buffer>,
viewport: Viewport,
buffer_cache: FxHashMap<u64, SmallVec<[Buffer; 1]>>,
frame_keys: Vec<u64>,
transformed_indices: Vec<usize>,
kept: Vec<usize>,
}
fn falls_outside(text: Rect, clip: Rect) -> bool {
const EPSILON: f32 = 0.1;
text.x + text.width < clip.x - EPSILON
|| text.x > clip.x + clip.width + EPSILON
|| text.y + text.height < clip.y - EPSILON
|| text.y > clip.y + clip.height + EPSILON
}
fn laid_out_in(entry: &TextEntry) -> Rect {
let (p1x, p1y) = entry.transform.transform_point(entry.rect.x, entry.rect.y);
let (p2x, p2y) = entry.transform.transform_point(
entry.rect.x + entry.rect.width,
entry.rect.y + entry.rect.height,
);
Rect::new(
p1x.min(p2x),
p1y.min(p2y),
(p2x - p1x).abs(),
(p2y - p1y).abs(),
)
}
impl TextRenderState {
pub fn new(device: &Device, queue: &Queue, format: wgpu::TextureFormat) -> Self {
let mut font_system = FontSystem::new();
for data in crate::get_registered_fonts() {
font_system
.db_mut()
.load_font_source(glyphon::fontdb::Source::Binary(data));
}
let swash_cache = SwashCache::new();
let cache = Cache::new(device);
let mut atlas = TextAtlas::with_color_mode(device, queue, &cache, format, ColorMode::Web);
let text_renderer =
TextRenderer::new(&mut atlas, device, MultisampleState::default(), None);
let viewport = Viewport::new(device, &cache);
Self {
font_system,
swash_cache,
cache,
atlas,
text_renderers: vec![text_renderer],
buffers: Vec::new(),
viewport,
buffer_cache: FxHashMap::default(),
frame_keys: Vec::new(),
transformed_indices: Vec::new(),
kept: Vec::new(),
}
}
pub fn begin_frame(&mut self, queue: &Queue, screen: (u32, u32)) {
self.atlas.trim();
for (key, buffer) in self.frame_keys.drain(..).zip(self.buffers.drain(..)) {
self.buffer_cache.entry(key).or_default().push(buffer);
}
self.viewport.update(
queue,
Resolution {
width: screen.0,
height: screen.1,
},
);
}
pub fn end_frame(&mut self) {
self.buffer_cache.clear();
}
#[allow(clippy::too_many_arguments)]
pub fn prepare_layer(
&mut self,
slot: usize,
device: &Device,
queue: &Queue,
texts: &[TextEntry],
chains: &ClipChains,
screen: (u32, u32),
scale_factor: f32,
) -> &[usize] {
let (screen_width, screen_height) = screen;
while self.text_renderers.len() <= slot {
self.text_renderers.push(TextRenderer::new(
&mut self.atlas,
device,
MultisampleState::default(),
None,
));
}
let buffers_start = self.buffers.len();
self.transformed_indices.clear();
self.kept.clear();
for (idx, entry) in texts.iter().enumerate() {
if !entry.transform.is_identity() && !entry.transform.is_translation_only() {
let (sx, sy) = entry.transform.extract_scale_components();
if sx.abs() < 1e-3 || sy.abs() < 1e-3 {
continue;
}
}
let laid_out = laid_out_in(entry);
if entry
.clip
.is_some_and(|clip| falls_outside(laid_out, clip.open))
{
continue;
}
let moved = !entry.transform.is_identity() && !entry.transform.is_translation_only();
let ink = laid_out.outset(entry.font_size * 0.5);
let box_will_not_do = entry.clip.as_ref().is_some_and(|clip| {
chains
.shapes(clip)
.any(|shape| !shape.box_cuts_like_the_shape(ink))
});
if moved || box_will_not_do {
self.transformed_indices.push(idx);
continue; }
let key = text_buffer_key(entry, scale_factor);
let cached = self
.buffer_cache
.get_mut(&key)
.and_then(|buffers| buffers.pop());
let buffer =
cached.unwrap_or_else(|| shape_entry(&mut self.font_system, entry, scale_factor));
self.kept.push(idx);
self.frame_keys.push(key);
self.buffers.push(buffer);
}
let Self {
font_system,
swash_cache,
atlas,
viewport,
buffers,
text_renderers,
kept,
..
} = self;
let text_areas = kept
.iter()
.map(|&idx| &texts[idx])
.zip(buffers[buffers_start..].iter())
.map(|(entry, buffer)| {
let (screen_x, screen_y) =
entry.transform.transform_point(entry.rect.x, entry.rect.y);
let scaled_left = screen_x * scale_factor
+ paint_offset(buffer, entry.rect.width * scale_factor, entry.align);
let scaled_top = screen_y * scale_factor;
let bounds = if let Some(clip_rect) = &entry.clip.map(|c| c.open) {
TextBounds {
left: (clip_rect.x * scale_factor) as i32,
top: (clip_rect.y * scale_factor) as i32,
right: ((clip_rect.x + clip_rect.width) * scale_factor) as i32,
bottom: ((clip_rect.y + clip_rect.height) * scale_factor) as i32,
}
} else {
TextBounds {
left: 0,
top: 0,
right: screen_width as i32,
bottom: screen_height as i32,
}
};
TextArea {
buffer,
left: scaled_left,
top: scaled_top,
scale: 1.0, bounds,
default_color: {
let color = entry.color.scale_alpha(entry.opacity);
GlyphonColor::rgba(
(color.r * 255.0) as u8,
(color.g * 255.0) as u8,
(color.b * 255.0) as u8,
(color.a * 255.0) as u8,
)
},
custom_glyphs: &[],
}
});
let result = text_renderers[slot].prepare(
device,
queue,
font_system,
atlas,
viewport,
text_areas,
swash_cache,
);
if let Err(e) = result {
log::error!("Text prepare failed: {:?}", e);
}
&self.transformed_indices
}
pub fn render_slot<'a>(&'a self, slot: usize, pass: &mut wgpu::RenderPass<'a>) {
if let Some(renderer) = self.text_renderers.get(slot) {
renderer
.render(&self.atlas, &self.viewport, pass)
.expect("Failed to render text");
render_stats::record_draw_calls(Pipeline::Text, 1);
}
}
}
#[cfg(test)]
pub(super) fn test_entry(rect: Rect, transform: crate::transform::Transform) -> TextEntry {
TextEntry {
text: "x".into(),
rect,
color: crate::widgets::Color::WHITE,
font_size: 16.0,
font_family: crate::widgets::FontFamily::default(),
font_weight: FontWeight::default(),
line_height: LineHeight::Normal,
letter_spacing: 0.0,
align: Default::default(),
fit: None,
opacity: 1.0,
clip: None,
transform,
}
}
#[cfg(test)]
pub(super) fn vendored_font_system() -> FontSystem {
const FONT: &[u8] = include_bytes!("../../tests/assets/DejaVuSansMono.ttf");
let mut db = glyphon::fontdb::Database::new();
db.load_font_data(FONT.to_vec());
FontSystem::new_with_locale_and_db("en-US".into(), db)
}
#[cfg(test)]
mod scratch_survives_the_frame {
use super::{TextRenderState, test_entry};
use crate::renderer::GpuContext;
use crate::renderer::types::TextEntry;
use crate::transform::Transform;
use crate::widgets::Rect;
#[test]
fn a_smaller_second_frame_still_has_the_first_frame_s_room() {
let Some(gpu) = crate::or_skip(GpuContext::try_new()) else {
return;
};
let mut state =
TextRenderState::new(&gpu.device, &gpu.queue, wgpu::TextureFormat::Rgba8UnormSrgb);
const MIN_CAP: usize = 4;
const UPRIGHT: usize = 5;
let box_ = Rect::new(0.0, 0.0, 80.0, 20.0);
let mut texts: Vec<TextEntry> = (0..UPRIGHT)
.map(|_| test_entry(box_, Transform::default()))
.collect();
texts.push(test_entry(box_, Transform::scale(2.0)));
let frame = |state: &mut TextRenderState, texts: &[TextEntry]| {
state.begin_frame(&gpu.queue, (200, 100));
let transformed = state.prepare_layer(
0,
&gpu.device,
&gpu.queue,
texts,
&Default::default(),
(200, 100),
1.0,
);
let quads = transformed.to_vec();
state.end_frame();
quads
};
assert_eq!(
frame(&mut state, &texts),
[UPRIGHT],
"the scaled text goes to the quad path, and only it"
);
let first = (state.transformed_indices.capacity(), state.kept.capacity());
assert!(
first.0 > 0 && first.1 > MIN_CAP,
"five upright and one scaled should fill one list and grow the \
other past a fresh `Vec`'s floor: {first:?}"
);
assert_eq!(frame(&mut state, &texts[..1]), []);
assert_eq!(
(state.transformed_indices.capacity(), state.kept.capacity()),
first,
"the second frame reused the first frame's scratch"
);
}
}
#[cfg(test)]
mod a_text_is_culled_only_when_an_axis_has_collapsed {
use super::{TextRenderState, test_entry};
use crate::renderer::GpuContext;
use crate::transform::Transform;
use crate::widgets::Rect;
const COLLAPSED: f32 = 1e-3;
#[test]
fn one_flattened_axis_is_enough_and_the_threshold_itself_is_not() {
let Some(gpu) = crate::or_skip(GpuContext::try_new()) else {
return;
};
let mut state =
TextRenderState::new(&gpu.device, &gpu.queue, wgpu::TextureFormat::Rgba8UnormSrgb);
let box_ = Rect::new(0.0, 0.0, 80.0, 20.0);
let cases = [
(Transform::scale_xy(0.0, 1.0), true, "flat in x, whole in y"),
(Transform::scale_xy(1.0, 0.0), true, "whole in x, flat in y"),
(Transform::scale_xy(0.0, 0.0), true, "flat in both"),
(
Transform::scale_xy(COLLAPSED, 1.0),
false,
"x exactly at the threshold, which is not below it",
),
(
Transform::scale_xy(1.0, COLLAPSED),
false,
"y exactly at the threshold",
),
(
Transform::rotate(std::f32::consts::FRAC_PI_2),
false,
"a quarter turn, where the diagonal is zero and the scale is not",
),
];
for (transform, culled, what) in cases {
let texts = [test_entry(box_, transform)];
state.begin_frame(&gpu.queue, (200, 100));
let transformed = state.prepare_layer(
0,
&gpu.device,
&gpu.queue,
&texts,
&Default::default(),
(200, 100),
1.0,
);
let quads = transformed.to_vec();
let kept = state.kept.len();
state.end_frame();
assert_eq!(
quads.is_empty(),
culled,
"{what}: {quads:?} went to the quad path"
);
assert_eq!(kept, 0, "{what}: a transformed text never reaches glyphon");
}
}
}
#[cfg(test)]
mod laid_out_tests {
use super::{laid_out_in, test_entry as entry};
use crate::transform::Transform;
use crate::widgets::Rect;
#[test]
fn a_text_is_dropped_only_when_it_is_wholly_outside_its_clip() {
use super::falls_outside;
let clip = Rect::new(100.0, 100.0, 200.0, 100.0);
assert!(
!falls_outside(Rect::new(150.0, 120.0, 40.0, 20.0), clip),
"inside"
);
assert!(
!falls_outside(Rect::new(0.0, 0.0, 400.0, 400.0), clip),
"over all of it"
);
assert!(
falls_outside(Rect::new(10.0, 120.0, 80.0, 20.0), clip),
"left of it"
);
assert!(
falls_outside(Rect::new(310.0, 120.0, 80.0, 20.0), clip),
"right of it"
);
assert!(
falls_outside(Rect::new(150.0, 10.0, 40.0, 80.0), clip),
"above it"
);
assert!(
falls_outside(Rect::new(150.0, 210.0, 40.0, 80.0), clip),
"below it"
);
assert!(
!falls_outside(Rect::new(20.0, 120.0, 81.0, 20.0), clip),
"reaching in from the left"
);
assert!(
!falls_outside(Rect::new(299.0, 120.0, 80.0, 20.0), clip),
"reaching in from the right"
);
assert!(
!falls_outside(Rect::new(150.0, 20.0, 40.0, 81.0), clip),
"reaching in from above"
);
assert!(
!falls_outside(Rect::new(150.0, 199.0, 40.0, 80.0), clip),
"reaching in from below"
);
assert!(
falls_outside(Rect::new(0.0, 0.0, 50.0, 50.0), clip),
"off the top-left corner"
);
for (label, text) in [
(
"ending exactly at the left edge",
Rect::new(20.0, 120.0, 80.0, 20.0),
),
(
"a hundredth short of the left edge",
Rect::new(20.0, 120.0, 79.99, 20.0),
),
(
"starting exactly at the right edge",
Rect::new(300.0, 120.0, 80.0, 20.0),
),
(
"a hundredth past the right edge",
Rect::new(300.01, 120.0, 80.0, 20.0),
),
(
"ending exactly at the top edge",
Rect::new(150.0, 20.0, 40.0, 80.0),
),
(
"starting exactly at the bottom edge",
Rect::new(150.0, 200.0, 40.0, 80.0),
),
] {
assert!(
!falls_outside(text, clip),
"{label}: a text touching its clip is still drawn"
);
}
for (label, text) in [
(
"half a pixel short of the left edge",
Rect::new(20.0, 120.0, 79.5, 20.0),
),
(
"half a pixel past the right edge",
Rect::new(300.5, 120.0, 80.0, 20.0),
),
(
"half a pixel short of the top edge",
Rect::new(150.0, 20.0, 40.0, 79.5),
),
(
"half a pixel past the bottom edge",
Rect::new(150.0, 200.5, 40.0, 80.0),
),
] {
assert!(
falls_outside(text, clip),
"{label}: past the tolerance is past the clip"
);
}
}
#[test]
fn a_text_is_laid_out_where_its_placement_puts_it() {
let r = Rect::new(10.0, 20.0, 100.0, 40.0);
let plain = laid_out_in(&entry(r, Transform::IDENTITY));
assert_eq!((plain.x, plain.y), (10.0, 20.0));
assert_eq!((plain.width, plain.height), (100.0, 40.0));
let moved = laid_out_in(&entry(r, Transform::translate(5.0, -7.0)));
assert_eq!((moved.x, moved.y), (15.0, 13.0));
assert_eq!((moved.width, moved.height), (100.0, 40.0));
let scaled = laid_out_in(&entry(r, Transform::scale_xy(2.0, 3.0)));
assert_eq!((scaled.x, scaled.y), (20.0, 60.0));
assert_eq!((scaled.width, scaled.height), (200.0, 120.0));
let mirrored = laid_out_in(&entry(r, Transform::scale_xy(-1.0, 1.0)));
assert_eq!((mirrored.x, mirrored.y), (-110.0, 20.0));
assert_eq!((mirrored.width, mirrored.height), (100.0, 40.0));
}
}
#[cfg(test)]
mod line_width_tests {
use super::{buffer_height, line_width};
use crate::renderer::LineFit;
use crate::widgets::{Rect, TextOverflow};
fn laid_out_in(width: Option<f32>, wrap: bool) -> Option<LineFit> {
Some(LineFit {
width,
max_lines: None,
overflow: TextOverflow::Clip,
wrap,
})
}
#[test]
fn the_width_laid_out_in_is_the_width_shaped_in() {
let box_width = Some(48.0);
assert_eq!(
line_width(laid_out_in(Some(50.0), true), box_width, 1.0),
(Some(50.0), true)
);
assert_eq!(
line_width(laid_out_in(Some(50.0), true), box_width, 1.5),
(Some(75.0), true),
"scaled, and nothing floors it"
);
assert_eq!(
line_width(laid_out_in(Some(90.0), false), box_width, 1.0),
(Some(90.0), false)
);
}
#[test]
fn a_text_laid_out_with_no_width_is_shaped_in_its_box_unwrapped() {
for fit in [laid_out_in(None, true), laid_out_in(None, false)] {
assert_eq!(line_width(fit, Some(72.0), 2.0), (Some(72.0), false));
assert_eq!(line_width(fit, None, 2.0), (None, false));
}
}
#[test]
fn no_fit_wraps_at_the_box() {
assert_eq!(line_width(None, Some(72.0), 2.0), (Some(72.0), true));
}
#[test]
fn the_height_floor_is_logical_and_the_scale_comes_after_it() {
assert_eq!(buffer_height(Rect::new(0.0, 0.0, 50.0, 10.0), 1.0), 50.0);
assert_eq!(buffer_height(Rect::new(0.0, 0.0, 50.0, 10.0), 2.0), 100.0);
assert_eq!(buffer_height(Rect::new(0.0, 0.0, 50.0, 80.0), 0.5), 40.0);
}
#[test]
fn the_transformed_shaper_asks_for_another_height() {
let rect = Rect::new(0.0, 0.0, 72.0, 30.0);
assert_ne!(
buffer_height(rect, 4.0),
super::super::text_quad::buffer_height(rect, 4.0),
"a 30-point box: 200 texels through this one and 132 through the other"
);
}
}
#[cfg(test)]
mod a_line_is_placed_in_its_own_box {
use super::{paint_offset, shape_entry, test_entry, text_buffer_key, vendored_font_system};
use crate::renderer::LineFit;
use crate::transform::Transform;
use crate::widgets::{FontFamily, Rect, TextAlign, TextOverflow};
const BOX: f32 = 120.0;
const OFFERED: f32 = 300.0;
const ARABIC: &str = "\u{633}\u{644}\u{627}\u{645}";
fn entry(text: &str, align: TextAlign) -> super::TextEntry {
let mut entry = test_entry(Rect::new(0.0, 0.0, BOX, 20.0), Transform::default());
entry.text = text.into();
entry.font_family = FontFamily::name("DejaVu Sans Mono");
entry.align = align;
entry.fit = Some(LineFit {
width: Some(OFFERED),
max_lines: None,
overflow: TextOverflow::Clip,
wrap: true,
});
entry
}
fn first_line(entry: &super::TextEntry, scale: f32) -> (f32, f32) {
let buffer = shape_entry(&mut vendored_font_system(), entry, scale);
let offset = paint_offset(&buffer, entry.rect.width * scale, entry.align);
let run = buffer.layout_runs().next().expect("a line");
let start = run.glyphs.iter().map(|g| g.x).fold(f32::INFINITY, f32::min);
(start + offset, run.line_w)
}
#[test]
fn a_centred_line_is_centred_in_its_box_and_not_in_the_width_it_was_laid_out_in() {
for scale in [1.0, 1.5, 2.0] {
let (start, width) = first_line(&entry("hi", TextAlign::Center), scale);
let expected = (BOX * scale - width) / 2.0;
assert!(
(start - expected).abs() < 0.5,
"at scale {scale} a {width}-pixel line in a {}-pixel box starts at \
{start}, not at {expected}",
BOX * scale
);
}
}
#[test]
fn an_end_aligned_line_ends_where_its_box_does() {
let (start, width) = first_line(&entry("hi", TextAlign::End), 1.0);
assert!(
(start + width - BOX).abs() < 0.5,
"the line ends at {} in a box that ends at {BOX}",
start + width
);
}
#[test]
fn a_start_aligned_line_starts_at_the_box() {
let (start, _) = first_line(&entry("hi", TextAlign::Start), 1.0);
assert!(start.abs() < 0.5, "the line starts at {start}");
}
#[test]
fn a_right_to_left_line_starts_at_its_box_s_right_edge() {
for scale in [1.0, 1.5] {
let (start, width) = first_line(&entry(ARABIC, TextAlign::Start), scale);
assert!(
(start + width - BOX * scale).abs() < 0.5,
"at scale {scale} the line ends at {} in a box that ends at {}",
start + width,
BOX * scale
);
let (start, _) = first_line(&entry(ARABIC, TextAlign::End), scale);
assert!(start.abs() < 0.5, "at scale {scale} its end is at {start}");
}
}
#[test]
fn alignment_is_part_of_the_cache_key() {
let keys: Vec<u64> = [
TextAlign::Start,
TextAlign::Center,
TextAlign::End,
TextAlign::Justified,
]
.map(|align| text_buffer_key(&entry("hi", align), 1.0))
.into();
for (i, a) in keys.iter().enumerate() {
for b in &keys[i + 1..] {
assert_ne!(a, b, "two alignments share a cached buffer");
}
}
}
}
#[cfg(test)]
#[test]
fn a_text_drawn_without_a_fit_wraps_at_its_box() {
let mut entry = test_entry(
Rect::new(0.0, 0.0, 80.0, 200.0),
crate::transform::Transform::default(),
);
entry.text = "one two three four five six seven".into();
entry.font_family = FontFamily::name("DejaVu Sans Mono");
for scale in [1.0, 1.5] {
let buffer = shape_entry(&mut vendored_font_system(), &entry, scale);
assert!(
buffer.layout_runs().count() > 1,
"at {scale}x a long text in an 80-pixel box ran on one line"
);
}
}
#[cfg(test)]
mod a_wrapped_text_keeps_its_measured_lines {
use super::{shape_entry, test_entry, vendored_font_system};
use crate::renderer::LineFit;
use crate::transform::Transform;
use crate::widgets::{FontFamily, FontWeight, LineHeight, Rect, TextAlign, TextOverflow};
const OFFERED: f32 = 120.0;
fn lines(buffer: &glyphon::Buffer) -> Vec<Vec<u16>> {
buffer
.layout_runs()
.map(|run| run.glyphs.iter().map(|g| g.glyph_id).collect())
.collect()
}
#[test]
fn the_lines_drawn_are_the_lines_measured() {
let family = FontFamily::name("DejaVu Sans Mono");
let fit = LineFit {
width: Some(OFFERED),
max_lines: None,
overflow: TextOverflow::Clip,
wrap: true,
};
for text in [
"one two three four five six seven",
"\u{633}\u{644}\u{627}\u{645} \u{633}\u{644}\u{627}\u{645} \
\u{633}\u{644}\u{627}\u{645} \u{633}\u{644}\u{627}\u{645}",
] {
let measured = super::shape(
&mut vendored_font_system(),
text,
16.0,
family,
FontWeight::NORMAL,
LineHeight::Normal,
0.0,
TextAlign::Start,
(None, None),
Some(fit),
1.0,
);
let widest = measured.layout_runs().map(|r| r.line_w).fold(0.0, f32::max);
let height: f32 = measured.layout_runs().map(|r| r.line_height).sum();
assert!(
measured.layout_runs().count() > 1,
"the text has to wrap at {OFFERED}, or this proves nothing"
);
let mut entry = test_entry(Rect::new(0.0, 0.0, widest, height), Transform::default());
entry.text = text.into();
entry.font_family = family;
entry.fit = Some(fit);
for scale in [1.0, 1.5] {
let drawn = shape_entry(&mut vendored_font_system(), &entry, scale);
assert_eq!(
lines(&drawn),
lines(&measured),
"{text:?} at {scale}x is drawn on other lines than it was measured on"
);
}
}
}
}
#[cfg(test)]
#[test]
fn line_height_is_part_of_the_buffer_key() {
use crate::transform::Transform;
let entry = |line_height| TextEntry {
line_height,
..test_entry(Rect::new(0.0, 0.0, 120.0, 40.0), Transform::default())
};
assert_ne!(
text_buffer_key(&entry(LineHeight::Normal), 1.0),
text_buffer_key(&entry(LineHeight::Absolute(30.0)), 1.0),
);
assert_ne!(
text_buffer_key(&entry(LineHeight::Relative(2.0)), 1.0),
text_buffer_key(&entry(LineHeight::Absolute(2.0)), 1.0),
"a factor and a height of the same number are not the same line",
);
}
#[cfg(test)]
#[test]
fn letter_spacing_is_part_of_the_buffer_key() {
use crate::transform::Transform;
let entry = |letter_spacing| TextEntry {
letter_spacing,
..test_entry(Rect::new(0.0, 0.0, 120.0, 40.0), Transform::default())
};
assert_ne!(
text_buffer_key(&entry(0.0), 1.0),
text_buffer_key(&entry(2.0), 1.0),
);
}
#[cfg(test)]
mod an_aligned_cut_is_the_measured_cut {
use super::{paint_offset, shape, vendored_font_system};
use crate::renderer::LineFit;
use crate::widgets::{FontFamily, FontWeight, LineHeight, TextAlign, TextOverflow};
const WORDS: &str = "one two three four five six seven eight nine ten eleven twelve";
const OFFERED: f32 = 150.0;
const SIZE: f32 = 16.0;
fn fit(overflow: TextOverflow) -> LineFit {
LineFit {
width: Some(OFFERED),
max_lines: Some(2),
overflow,
wrap: true,
}
}
fn lines(
align: TextAlign,
width: Option<f32>,
fit: LineFit,
scale: f32,
) -> Vec<(Vec<u16>, f32, f32)> {
let family = FontFamily::name("DejaVu Sans Mono");
let buffer = shape(
&mut vendored_font_system(),
WORDS,
SIZE * scale,
family,
FontWeight::NORMAL,
LineHeight::Normal,
0.0,
align,
(width, Some(1000.0)),
Some(fit),
scale,
);
let offset = width.map_or(0.0, |width| paint_offset(&buffer, width, align));
buffer
.layout_runs()
.map(|run| {
let start = run.glyphs.iter().map(|g| g.x).fold(f32::INFINITY, f32::min);
(
run.glyphs.iter().map(|g| g.glyph_id).collect(),
start + offset,
run.line_w,
)
})
.collect()
}
fn ellipsis_glyph() -> u16 {
let buffer = shape(
&mut vendored_font_system(),
"\u{2026}",
SIZE,
FontFamily::name("DejaVu Sans Mono"),
FontWeight::NORMAL,
LineHeight::Normal,
0.0,
TextAlign::Start,
(None, None),
None,
1.0,
);
buffer.layout_runs().next().unwrap().glyphs[0].glyph_id
}
#[test]
fn a_centred_ellipsis_keeps_the_measured_lines_and_centres_them_in_the_box() {
for overflow in [TextOverflow::Ellipsis, TextOverflow::Clip] {
let measured = lines(TextAlign::Start, None, fit(overflow), 1.0);
assert_eq!(measured.len(), 2, "{measured:?}");
let widest = measured.iter().map(|l| l.2).fold(0.0, f32::max);
for scale in [1.0, 2.0] {
let drawn = lines(
TextAlign::Center,
Some(widest * scale),
fit(overflow),
scale,
);
let glyphs = |ls: &[(Vec<u16>, f32, f32)]| {
ls.iter().map(|l| l.0.clone()).collect::<Vec<_>>()
};
assert_eq!(
glyphs(&drawn),
glyphs(&measured),
"{overflow:?} at {scale}x cut somewhere else than it was measured"
);
for (_, start, width) in &drawn {
let expected = (widest * scale - width) / 2.0;
assert!(
(start - expected).abs() < 0.5,
"{overflow:?} at {scale}x: a {width}-pixel line starts at \
{start}, not centred at {expected}"
);
}
}
if overflow == TextOverflow::Ellipsis {
assert_eq!(measured[1].0.last(), Some(&ellipsis_glyph()));
}
}
}
}
#[cfg(test)]
#[test]
fn normal_is_the_line_height_of_the_face_that_draws_the_text() {
let mut font_system = vendored_font_system();
let ratio =
resolve_font(&mut font_system, FontFamily::SansSerif, FontWeight::NORMAL).line_ratio;
assert!(
(ratio - 1.1640625).abs() < 1e-4,
"{ratio} is not DejaVu Sans Mono's own 1.164"
);
}
#[cfg(test)]
#[test]
fn a_face_s_line_counts_its_ascent_descent_and_gap() {
assert_eq!(line_ratio(800.0, -200.0, 200.0, 1000), Some(1.2));
assert_eq!(line_ratio(800.0, -200.0, 0.0, 2000), Some(0.5));
}
#[cfg(test)]
#[test]
fn metrics_that_make_no_height_are_no_ratio() {
assert_eq!(line_ratio(800.0, -200.0, 0.0, 0), None, "an em of zero");
assert_eq!(
line_ratio(0.0, 0.0, 0.0, 1000),
None,
"a face that says nothing"
);
assert_eq!(line_ratio(f32::NAN, -200.0, 0.0, 1000), None);
}
#[cfg(test)]
#[test]
fn an_absolute_line_height_is_scaled_with_the_text() {
const FONT: &[u8] = include_bytes!("../../tests/assets/DejaVuSansMono.ttf");
let mut db = glyphon::fontdb::Database::new();
db.load_font_data(FONT.to_vec());
let mut font_system = FontSystem::new_with_locale_and_db("en-US".into(), db);
let line = |font_system: &mut FontSystem, scale: f32| {
shape(
font_system,
"a",
14.0 * scale,
FontFamily::name("DejaVu Sans Mono"),
FontWeight::NORMAL,
LineHeight::Absolute(20.0),
0.0,
TextAlign::Start,
(None, None),
None,
scale,
)
.metrics()
.line_height
};
assert_eq!(line(&mut font_system, 1.0), 20.0);
assert_eq!(line(&mut font_system, 2.0), 40.0);
}
#[cfg(test)]
#[test]
fn a_missing_weight_is_the_nearest_one_of_the_same_family() {
const REGULAR: &[u8] = include_bytes!("../../tests/assets/DejaVuSansMono.ttf");
const BOLD: &[u8] = include_bytes!("../../tests/assets/DejaVuSansMono-Bold-digits.ttf");
const DECOY: &[u8] = include_bytes!("../../tests/assets/WeightDecoy-SemiBold-digits.ttf");
let mut db = glyphon::fontdb::Database::new();
for font in [REGULAR, BOLD, DECOY] {
db.load_font_data(font.to_vec());
}
let mut font_system = FontSystem::new_with_locale_and_db("en-US".into(), db);
let mut drawn_in = |weight: FontWeight| {
let buffer = shape(
&mut font_system,
"2356",
14.0,
FontFamily::SansSerif,
weight,
LineHeight::Normal,
0.0,
TextAlign::Start,
(None, None),
None,
1.0,
);
buffer
.layout_runs()
.flat_map(|run| run.glyphs.iter().map(|glyph| glyph.font_id))
.map(|id| {
let face = font_system.db().face(id).expect("a face that drew a glyph");
(face.families[0].0.clone(), face.weight.0)
})
.collect::<Vec<_>>()
};
let semibold = drawn_in(FontWeight(600));
assert_eq!(semibold.len(), 4, "four digits drawn");
assert!(
semibold
.iter()
.all(|face| *face == ("DejaVu Sans Mono".into(), 700)),
"600 was drawn in {semibold:?}, not the family's nearest, 700"
);
let light = drawn_in(FontWeight(300));
assert!(
light
.iter()
.all(|face| *face == ("DejaVu Sans Mono".into(), 400)),
"300 was drawn in {light:?}, not the family's nearest, 400"
);
}
#[cfg(test)]
#[test]
fn a_variable_face_is_shaped_at_the_weight_asked_of_it() {
const VARIABLE: &[u8] = include_bytes!("../../tests/assets/GuidoVariable.ttf");
let mut db = glyphon::fontdb::Database::new();
db.load_font_data(VARIABLE.to_vec());
let mut font_system = FontSystem::new_with_locale_and_db("en-US".into(), db);
let buffer = shape(
&mut font_system,
"2356",
14.0,
FontFamily::name("Guido Variable"),
FontWeight::BOLD,
LineHeight::Normal,
0.0,
TextAlign::Start,
(None, None),
None,
1.0,
);
let weights: Vec<u16> = buffer
.layout_runs()
.flat_map(|run| run.glyphs.iter().map(|glyph| glyph.font_weight.0))
.collect();
assert_eq!(weights, [700; 4], "the axis was set to {weights:?}");
}
#[cfg(test)]
#[test]
fn a_weight_s_line_height_is_its_own_face_s() {
const REGULAR: &[u8] = include_bytes!("../../tests/assets/DejaVuSansMono.ttf");
const BOLD: &[u8] = include_bytes!("../../tests/assets/DejaVuSansMono-Bold-digits.ttf");
let mut db = glyphon::fontdb::Database::new();
for font in [REGULAR, BOLD] {
db.load_font_data(font.to_vec());
}
let mut font_system = FontSystem::new_with_locale_and_db("en-US".into(), db);
let ratio = |font_system: &mut FontSystem, weight| {
resolve_font(font_system, FontFamily::SansSerif, weight).line_ratio
};
assert!((ratio(&mut font_system, FontWeight::NORMAL) - 1.1640625).abs() < 1e-4);
let bold = ratio(&mut font_system, FontWeight::BOLD);
assert!(
(bold - 1.3642578).abs() < 1e-4,
"{bold} is the regular face's line, not the bold one's 1.364"
);
}
#[cfg(test)]
#[test]
fn a_named_family_without_a_space_is_measured_in_itself() {
const FALLBACK: &[u8] = include_bytes!("../../tests/assets/DejaVuSansMono.ttf");
const NO_SPACE: &[u8] = include_bytes!("../../tests/assets/GuidoNoSpace.ttf");
let mut db = glyphon::fontdb::Database::new();
for font in [FALLBACK, NO_SPACE] {
db.load_font_data(font.to_vec());
}
let mut font_system = FontSystem::new_with_locale_and_db("en-US".into(), db);
let buffer = shape(
&mut font_system,
"2356",
14.0,
FontFamily::name("Guido No Space"),
FontWeight::NORMAL,
LineHeight::Normal,
0.0,
TextAlign::Start,
(None, None),
None,
1.0,
);
let width: f32 = buffer.layout_runs().map(|run| run.line_w).sum();
assert!(
(width - 4.0 * 1.5 * 14.0).abs() < 0.01,
"four digits 1.5 em wide at 14 px were laid out {width} px wide"
);
let line = buffer.metrics().line_height;
assert_eq!(
line, 14.0,
"a line of the family's own 1.0, not DejaVu's 1.164"
);
}
#[cfg(test)]
#[test]
fn a_named_family_that_is_not_installed_is_measured_in_its_fallback() {
const FALLBACK: &[u8] = include_bytes!("../../tests/assets/DejaVuSansMono.ttf");
let mut db = glyphon::fontdb::Database::new();
db.load_font_data(FALLBACK.to_vec());
let mut font_system = FontSystem::new_with_locale_and_db("en-US".into(), db);
let ratio = resolve_font(
&mut font_system,
FontFamily::name("Not Installed Anywhere"),
FontWeight::NORMAL,
)
.line_ratio;
assert!(
(ratio - 1.1640625).abs() < 1e-4,
"{ratio} is not DejaVu Sans Mono's 1.164"
);
}
#[cfg(test)]
pub(super) mod the_glyph_atlas_holds_a_frame_not_a_history {
use super::{TextRenderState, test_entry, vendored_font_system};
use crate::renderer::GpuContext;
use crate::renderer::types::TextEntry;
use crate::transform::Transform;
use crate::widgets::{FontFamily, Rect};
pub(in crate::renderer) fn pages() -> Vec<String> {
let characters: Vec<char> = (0x100..0x180)
.chain(0x391..0x3CA)
.chain(0x400..0x460)
.filter_map(char::from_u32)
.collect();
characters
.chunks(32)
.map(|page| page.iter().collect())
.collect()
}
pub(in crate::renderer) fn page(text: &str, transform: Transform) -> TextEntry {
let mut entry = test_entry(Rect::new(0.0, 0.0, 1200.0, 60.0), transform);
entry.text = text.into();
entry.font_family = FontFamily::name("DejaVu Sans Mono");
entry.font_size = 32.0;
entry
}
pub(in crate::renderer) fn holds_one_page(
atlas_after: impl Fn(&GpuContext, &[String]) -> isize,
) {
let Some(gpu) = crate::or_skip(GpuContext::try_new()) else {
return;
};
let pages = pages();
let one = atlas_after(&gpu, &pages[..1]);
assert!(one > 0, "the device counted no texture memory at all");
let every = atlas_after(&gpu, &pages);
assert!(
every <= one,
"one page's glyphs need {one} bytes of atlas; {} new pages left {every}",
pages.len()
);
}
fn atlas_after(gpu: &GpuContext, frames: &[String]) -> isize {
let before = gpu.texture_bytes();
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let mut state = TextRenderState::new(&gpu.device, &gpu.queue, format);
state.font_system = vendored_font_system();
let screen = (1200, 60);
for text in frames {
state.begin_frame(&gpu.queue, screen);
let texts = [page(text, Transform::IDENTITY)];
state.prepare_layer(
0,
&gpu.device,
&gpu.queue,
&texts,
&Default::default(),
screen,
1.0,
);
state.end_frame();
gpu.queue.submit([]);
}
gpu.texture_bytes() - before
}
#[test]
fn a_text_that_changes_every_frame_needs_no_more_atlas_than_one_frame() {
holds_one_page(atlas_after);
}
}