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 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::text_measurer::LineFit;
use super::types::TextEntry;
fn text_buffer_key(entry: &TextEntry, scale_factor: f32) -> u64 {
let mut hasher = std::hash::DefaultHasher::new();
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.font_family.hash(&mut hasher);
entry.align.hash(&mut hasher);
let (width, height) = shaping_buffer(entry.rect, scale_factor, entry.align);
width.to_bits().hash(&mut hasher);
height.to_bits().hash(&mut hasher);
entry.fit.map(|fit| fit.key()).hash(&mut hasher);
hasher.finish()
}
pub(super) fn shaping_buffer(rect: Rect, scale: f32, align: TextAlign) -> (f32, f32) {
let width = match align {
TextAlign::Start => rect.width.max(200.0),
_ => rect.width,
};
(width * scale, rect.height.max(50.0) * scale)
}
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.align,
{
let (width, height) = shaping_buffer(entry.rect, scale_factor, entry.align);
(Some(width), Some(height))
},
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,
align: TextAlign,
size: (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.map(|fit| fit.max_lines.max(1) as usize);
match fit {
None => buffer.set_size(size.0, size.1),
Some(fit) => {
let width = match align {
TextAlign::Start => fit.width.map(|w| w * scale),
_ => size.0,
};
let cut = lines.map(|n| (n as f32 - 0.5) * line_height);
buffer.set_size(width, cut);
if !fit.wrap {
buffer.set_wrap(Wrap::None);
}
}
}
buffer.set_text(
text,
&Attrs::new()
.family(font.family.to_cosmic())
.weight(weight.to_cosmic()),
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 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);
let drawing_family = 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));
let nearest = drawing_family.and_then(|drawing| {
let db = font_system.db();
let id = db.query(&glyphon::fontdb::Query {
families: &[drawing.to_cosmic()],
weight: weight.to_cosmic(),
..Default::default()
})?;
Some((id, FontWeight(db.face(id)?.weight.0)))
});
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 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, Vec<Buffer>>,
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)) {
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();
}
pub fn prepare_layer(
&mut self,
slot: usize,
device: &Device,
queue: &Queue,
texts: &[TextEntry],
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.world_aabb()))
{
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
.is_some_and(|clip| !clip.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;
let scaled_top = screen_y * scale_factor;
let bounds = if let Some(clip_rect) = &entry.clip.map(|c| c.world_aabb()) {
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,
align: Default::default(),
fit: None,
opacity: 1.0,
clip: None,
transform,
}
}
#[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, (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, (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 shaping_buffer_tests {
use super::shaping_buffer;
use crate::widgets::{Rect, TextAlign};
#[test]
fn the_floor_is_logical_and_the_scale_comes_after_it() {
assert_eq!(
shaping_buffer(Rect::new(0.0, 0.0, 50.0, 10.0), 1.0, TextAlign::Start),
(200.0, 50.0)
);
assert_eq!(
shaping_buffer(Rect::new(0.0, 0.0, 50.0, 10.0), 2.0, TextAlign::Start),
(400.0, 100.0)
);
}
#[test]
fn a_box_above_the_floor_is_carried_by_the_scale_alone() {
assert_eq!(
shaping_buffer(Rect::new(0.0, 0.0, 300.0, 80.0), 1.0, TextAlign::Start),
(300.0, 80.0)
);
assert_eq!(
shaping_buffer(Rect::new(0.0, 0.0, 300.0, 80.0), 2.0, TextAlign::Start),
(600.0, 160.0)
);
assert_eq!(
shaping_buffer(Rect::new(0.0, 0.0, 300.0, 80.0), 0.5, TextAlign::Start),
(150.0, 40.0)
);
}
#[test]
fn the_transformed_shaper_asks_for_something_else() {
let rect = Rect::new(0.0, 0.0, 72.0, 80.0);
assert_ne!(
shaping_buffer(rect, 4.0, TextAlign::Start),
super::super::text_quad::shaping_buffer(rect, 4.0, TextAlign::Start),
"a 72-point box: 400 texels through this one and 317 through the other"
);
}
}
#[cfg(test)]
mod a_line_is_aligned_in_its_own_box {
use super::{shape_entry, test_entry, text_buffer_key};
use crate::transform::Transform;
use crate::widgets::{FontFamily, Rect, TextAlign};
use glyphon::FontSystem;
const FONT: &[u8] = include_bytes!("../../tests/assets/DejaVuSansMono.ttf");
const BOX: f32 = 120.0;
fn entry(align: TextAlign) -> super::TextEntry {
let mut entry = test_entry(Rect::new(0.0, 0.0, BOX, 20.0), Transform::default());
entry.text = "hi".into();
entry.font_family = FontFamily::name("DejaVu Sans Mono");
entry.align = align;
entry
}
fn first_line(align: TextAlign, scale: f32) -> (f32, f32) {
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 buffer = shape_entry(&mut font_system, &entry(align), scale);
let run = buffer.layout_runs().next().expect("a line");
let start = run.glyphs.iter().map(|g| g.x).fold(f32::INFINITY, f32::min);
(start, run.line_w)
}
#[test]
fn a_centred_line_is_centred_in_its_box_and_not_in_the_floor() {
for scale in [1.0, 2.0] {
let (start, width) = first_line(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(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(TextAlign::Start, 1.0);
assert!(start.abs() < 0.5, "the line starts 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(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 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)]
mod an_aligned_cut_is_the_measured_cut {
use super::shape;
use crate::renderer::LineFit;
use crate::widgets::{FontFamily, FontWeight, LineHeight, TextAlign, TextOverflow};
use glyphon::FontSystem;
const FONT: &[u8] = include_bytes!("../../tests/assets/DejaVuSansMono.ttf");
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 font_system() -> FontSystem {
let mut db = glyphon::fontdb::Database::new();
db.load_font_data(FONT.to_vec());
FontSystem::new_with_locale_and_db("en-US".into(), db)
}
fn fit(overflow: TextOverflow) -> LineFit {
LineFit {
width: Some(OFFERED),
max_lines: 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 font_system(),
WORDS,
SIZE * scale,
family,
FontWeight::NORMAL,
LineHeight::Normal,
align,
(width, Some(1000.0)),
Some(fit),
scale,
);
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,
run.line_w,
)
})
.collect()
}
fn ellipsis_glyph() -> u16 {
let buffer = shape(
&mut font_system(),
"\u{2026}",
SIZE,
FontFamily::name("DejaVu Sans Mono"),
FontWeight::NORMAL,
LineHeight::Normal,
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() {
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 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),
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,
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,
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"
);
}