use crate::app_state::with_app_state;
use crate::layout::Size;
use crate::widgets::font::{FontFamily, FontWeight, LineHeight};
use crate::widgets::{TextAlign, TextOverflow};
use cosmic_text::{Buffer, FontSystem};
use rustc_hash::FxHashMap;
const SMALLEST_SHAPEABLE: f32 = 0.01;
pub(crate) fn shapeable_size(font_size: f32) -> f32 {
if font_size.is_finite() && font_size >= SMALLEST_SHAPEABLE {
font_size
} else {
SMALLEST_SHAPEABLE
}
}
use std::hash::{Hash, Hasher};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LineFit {
pub width: Option<f32>,
pub max_lines: Option<u32>,
pub overflow: TextOverflow,
pub wrap: bool,
}
pub(crate) type LineFitKey = (Option<u32>, Option<u32>, TextOverflow, bool);
impl LineFit {
pub fn wrapping(width: Option<f32>) -> Self {
Self {
width,
max_lines: None,
overflow: TextOverflow::Clip,
wrap: true,
}
}
pub(crate) fn key(&self) -> LineFitKey {
(
self.width.map(f32::to_bits),
self.max_lines,
self.overflow,
self.wrap,
)
}
}
#[derive(Hash, Eq, PartialEq, Clone, Copy)]
struct MeasureCacheKey {
content_hash: u64,
text_len: u32,
font_size_bits: u32,
font_weight: FontWeight,
line_height: (u8, u32),
letter_spacing_bits: u32,
fit: LineFitKey,
}
impl MeasureCacheKey {
#[allow(clippy::too_many_arguments)]
fn new(
text: &str,
font_size: f32,
font_family: FontFamily,
font_weight: FontWeight,
line_height: LineHeight,
letter_spacing: f32,
fit: LineFit,
) -> Self {
let mut hasher = rustc_hash::FxHasher::default();
text.hash(&mut hasher);
font_family.hash(&mut hasher);
Self {
content_hash: hasher.finish(),
text_len: text.len() as u32,
font_size_bits: font_size.to_bits(),
font_weight,
line_height: line_height.key(),
letter_spacing_bits: letter_spacing.to_bits(),
fit: fit.key(),
}
}
}
const MEASURE_CACHE_CAP: usize = 8192;
#[derive(Clone, Copy, Debug)]
pub struct Measured {
pub size: Size,
pub baseline: f32,
pub wraps: bool,
pub longest_word: f32,
}
pub struct TextMeasurer {
font_system: FontSystem,
measure_cache: FxHashMap<MeasureCacheKey, Measured>,
}
impl TextMeasurer {
pub fn new() -> Self {
let mut font_system = FontSystem::new();
for data in crate::get_registered_fonts() {
font_system
.db_mut()
.load_font_source(cosmic_text::fontdb::Source::Binary(data));
}
Self {
font_system,
measure_cache: FxHashMap::default(),
}
}
pub fn measure(&mut self, text: &str, font_size: f32, max_width: Option<f32>) -> Size {
self.measure_styled(
text,
font_size,
max_width,
FontFamily::default(),
FontWeight::NORMAL,
LineHeight::Normal,
0.0,
)
}
#[allow(clippy::too_many_arguments)]
pub fn measure_styled(
&mut self,
text: &str,
font_size: f32,
max_width: Option<f32>,
font_family: FontFamily,
font_weight: FontWeight,
line_height: LineHeight,
letter_spacing: f32,
) -> Size {
self.measure_full(
text,
font_size,
max_width,
font_family,
font_weight,
line_height,
letter_spacing,
None,
)
.size
}
#[allow(clippy::too_many_arguments)]
pub fn measure_full(
&mut self,
text: &str,
font_size: f32,
max_width: Option<f32>,
font_family: FontFamily,
font_weight: FontWeight,
line_height: LineHeight,
letter_spacing: f32,
fit: Option<LineFit>,
) -> Measured {
let fit = fit.unwrap_or(LineFit::wrapping(max_width));
let cache_key = MeasureCacheKey::new(
text,
font_size,
font_family,
font_weight,
line_height,
letter_spacing,
fit,
);
if let Some(&cached) = self.measure_cache.get(&cache_key) {
return cached;
}
let measured = {
let buffer = self.shape(
text,
font_size,
None,
font_family,
font_weight,
line_height,
letter_spacing,
Some(fit),
);
let mut width = 0.0f32;
let mut height = 0.0f32;
let mut baseline = None;
for run in buffer.layout_runs() {
width = width.max(run.line_w);
height += run.line_height;
baseline.get_or_insert(run.line_y);
}
if height == 0.0 {
height = buffer.metrics().line_height;
}
let longest_word = if fit.wrap {
let font_size = buffer.metrics().font_size;
buffer
.lines
.iter()
.filter_map(|line| line.shape_opt())
.flat_map(|shape| &shape.spans)
.flat_map(|span| &span.words)
.filter(|word| !word.blank)
.map(|word| word.width(font_size))
.fold(0.0, f32::max)
} else {
0.0
};
Measured {
size: Size::new(width, height),
longest_word,
baseline: baseline.unwrap_or(font_size),
wraps: buffer
.lines
.iter()
.any(|line| line.layout_opt().is_some_and(|lines| lines.len() > 1)),
}
};
if self.measure_cache.len() >= MEASURE_CACHE_CAP {
self.measure_cache.clear();
}
self.measure_cache.insert(cache_key, measured);
measured
}
#[allow(clippy::too_many_arguments)]
fn shape(
&mut self,
text: &str,
font_size: f32,
max_width: Option<f32>,
font_family: FontFamily,
font_weight: FontWeight,
line_height: LineHeight,
letter_spacing: f32,
fit: Option<LineFit>,
) -> Buffer {
let fit = fit.unwrap_or(LineFit::wrapping(max_width));
super::text::shape(
&mut self.font_system,
text,
font_size,
font_family,
font_weight,
line_height,
letter_spacing,
TextAlign::Start,
(None, None),
Some(fit),
1.0,
)
}
pub fn char_positions_styled(
&mut self,
text: &str,
font_size: f32,
font_family: FontFamily,
font_weight: FontWeight,
letter_spacing: f32,
) -> Vec<f32> {
if text.is_empty() {
return vec![0.0];
}
let buffer = self.shape(
text,
font_size,
None,
font_family,
font_weight,
LineHeight::Normal,
letter_spacing,
None,
);
let mut starts = vec![None; text.len()];
let mut total_width = 0.0f32;
for run in buffer.layout_runs() {
for glyph in run.glyphs {
if let Some(start) = starts.get_mut(glyph.start) {
start.get_or_insert(glyph.x);
}
}
total_width = total_width.max(run.line_w);
}
let mut x = 0.0;
let mut positions: Vec<f32> = text
.char_indices()
.map(|(byte, _)| {
x = starts[byte].unwrap_or(x);
x
})
.collect();
positions.push(total_width);
positions
}
pub fn measure_to_char(&mut self, text: &str, font_size: f32, char_index: usize) -> f32 {
self.measure_to_char_styled(
text,
font_size,
char_index,
FontFamily::default(),
FontWeight::NORMAL,
0.0,
)
}
pub fn measure_to_char_styled(
&mut self,
text: &str,
font_size: f32,
char_index: usize,
font_family: FontFamily,
font_weight: FontWeight,
letter_spacing: f32,
) -> f32 {
if char_index == 0 || text.is_empty() {
return 0.0;
}
let byte_pos = text
.char_indices()
.nth(char_index)
.map(|(i, _)| i)
.unwrap_or(text.len());
let prefix = &text[..byte_pos];
self.measure_styled(
prefix,
font_size,
None,
font_family,
font_weight,
LineHeight::Normal,
letter_spacing,
)
.width
}
pub fn char_from_x(&mut self, text: &str, font_size: f32, x: f32) -> usize {
self.char_from_x_styled(
text,
font_size,
x,
FontFamily::default(),
FontWeight::NORMAL,
0.0,
)
}
pub fn char_from_x_styled(
&mut self,
text: &str,
font_size: f32,
x: f32,
font_family: FontFamily,
font_weight: FontWeight,
letter_spacing: f32,
) -> usize {
if text.is_empty() || x <= 0.0 {
return 0;
}
let char_count = text.chars().count();
let total_width = self
.measure_styled(
text,
font_size,
None,
font_family,
font_weight,
LineHeight::Normal,
letter_spacing,
)
.width;
if x >= total_width {
return char_count;
}
let mut left = 0;
let mut right = char_count;
while left < right {
let mid = (left + right) / 2;
let width = self.measure_to_char_styled(
text,
font_size,
mid,
font_family,
font_weight,
letter_spacing,
);
if width < x {
left = mid + 1;
} else {
right = mid;
}
}
if left > 0 {
let prev_width = self.measure_to_char_styled(
text,
font_size,
left - 1,
font_family,
font_weight,
letter_spacing,
);
let curr_width = self.measure_to_char_styled(
text,
font_size,
left,
font_family,
font_weight,
letter_spacing,
);
if (x - prev_width) < (curr_width - x) {
return left - 1;
}
}
left.min(char_count)
}
}
fn with_measurer<R>(f: impl FnOnce(&mut TextMeasurer) -> R) -> R {
with_app_state(|app| {
let mut measurer = app.text_measurer.borrow_mut();
f(measurer.get_or_insert_with(TextMeasurer::new))
})
}
pub fn measure_text(text: &str, font_size: f32, max_width: Option<f32>) -> Size {
with_measurer(|m| m.measure(text, font_size, max_width))
}
pub fn measure_text_styled(
text: &str,
font_size: f32,
max_width: Option<f32>,
font_family: FontFamily,
font_weight: FontWeight,
line_height: LineHeight,
letter_spacing: f32,
) -> Size {
with_measurer(|m| {
m.measure_styled(
text,
font_size,
max_width,
font_family,
font_weight,
line_height,
letter_spacing,
)
})
}
#[allow(clippy::too_many_arguments)]
pub fn measure_text_full(
text: &str,
font_size: f32,
max_width: Option<f32>,
font_family: FontFamily,
font_weight: FontWeight,
line_height: LineHeight,
letter_spacing: f32,
fit: Option<LineFit>,
) -> Measured {
with_measurer(|m| {
m.measure_full(
text,
font_size,
max_width,
font_family,
font_weight,
line_height,
letter_spacing,
fit,
)
})
}
pub(crate) fn measure_line_height(
font_size: f32,
font_family: FontFamily,
font_weight: FontWeight,
line_height: LineHeight,
) -> f32 {
with_measurer(|m| {
m.measure_full(
"",
font_size,
None,
font_family,
font_weight,
line_height,
0.0,
None,
)
.size
.height
})
}
pub fn measure_text_to_char(text: &str, font_size: f32, char_index: usize) -> f32 {
with_measurer(|m| m.measure_to_char(text, font_size, char_index))
}
pub fn measure_text_to_char_styled(
text: &str,
font_size: f32,
char_index: usize,
font_family: FontFamily,
font_weight: FontWeight,
letter_spacing: f32,
) -> f32 {
with_measurer(|m| {
m.measure_to_char_styled(
text,
font_size,
char_index,
font_family,
font_weight,
letter_spacing,
)
})
}
pub fn measure_char_positions_styled(
text: &str,
font_size: f32,
font_family: FontFamily,
font_weight: FontWeight,
letter_spacing: f32,
) -> Vec<f32> {
with_measurer(|m| {
m.char_positions_styled(text, font_size, font_family, font_weight, letter_spacing)
})
}
pub fn char_index_from_x(text: &str, font_size: f32, x: f32) -> usize {
with_measurer(|m| m.char_from_x(text, font_size, x))
}
pub fn char_index_from_x_styled(
text: &str,
font_size: f32,
x: f32,
font_family: FontFamily,
font_weight: FontWeight,
letter_spacing: f32,
) -> usize {
with_measurer(|m| {
m.char_from_x_styled(text, font_size, x, font_family, font_weight, letter_spacing)
})
}
#[cfg(test)]
mod baseline_tests {
use super::*;
#[test]
fn a_size_that_cannot_be_shaped_still_answers() {
let (done, waiting) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for font_size in [-1.0f32, 0.0, -0.0, f32::NAN, f32::NEG_INFINITY] {
let m = measure_text_full(
"a line of words",
font_size,
Some(100.0),
FontFamily::default(),
FontWeight::NORMAL,
LineHeight::Normal,
0.0,
None,
);
assert!(
m.size.width.is_finite() && m.size.height.is_finite(),
"a {font_size} size measured as {:?}",
m.size
);
}
let _ = done.send(());
});
waiting
.recv_timeout(std::time::Duration::from_secs(10))
.expect("a font size that cannot be shaped has to come back, not spin");
}
#[test]
fn a_baseline_sits_inside_the_line_box() {
for font_size in [12.0f32, 16.0, 24.0, 48.0] {
let m = measure_text_full(
"Hxgjp",
font_size,
None,
FontFamily::default(),
FontWeight::NORMAL,
LineHeight::Normal,
0.0,
None,
);
assert!(
m.baseline > m.size.height * 0.5 && m.baseline < m.size.height,
"at {font_size}px the baseline is {} of a {} line — \
that is not a baseline",
m.baseline,
m.size.height
);
}
}
}
#[cfg(test)]
mod fit_tests {
use super::*;
const OVERLONG: &str = "a considerable quantity of words, far more than any two \
lines of a narrow box could hold, and then some more";
fn fit(width: f32, max_lines: u32, overflow: TextOverflow, wrap: bool) -> LineFit {
LineFit {
width: Some(width),
max_lines: Some(max_lines),
overflow,
wrap,
}
}
fn drawn(text: &str, fit: Option<LineFit>) -> (Vec<Vec<u16>>, u16) {
let mut measurer = TextMeasurer::new();
let family = FontFamily::default();
let lines = measurer
.shape(
text,
20.0,
None,
family,
FontWeight::NORMAL,
LineHeight::Normal,
0.0,
fit,
)
.layout_runs()
.map(|run| run.glyphs.iter().map(|g| g.glyph_id).collect())
.collect();
let mark = measurer
.shape(
"\u{2026}",
20.0,
None,
family,
FontWeight::NORMAL,
LineHeight::Normal,
0.0,
None,
)
.layout_runs()
.next()
.and_then(|run| run.glyphs.first().map(|g| g.glyph_id))
.expect("the ellipsis shapes as a glyph");
(lines, mark)
}
#[test]
fn one_overlong_line_ends_in_an_ellipsis() {
let (lines, mark) = drawn(OVERLONG, Some(fit(120.0, 1, TextOverflow::Ellipsis, true)));
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].last(), Some(&mark), "{lines:?}");
}
#[test]
fn the_last_of_two_wrapped_lines_ends_in_an_ellipsis() {
let (lines, mark) = drawn(OVERLONG, Some(fit(120.0, 2, TextOverflow::Ellipsis, true)));
assert_eq!(lines.len(), 2);
assert!(!lines[0].contains(&mark), "{lines:?}");
assert_eq!(lines[1].last(), Some(&mark), "{lines:?}");
}
#[test]
fn a_marked_line_starts_where_an_unmarked_one_does() {
const WORDS: &str = "one two three four five six seven eight nine ten eleven";
let (marked, _) = drawn(WORDS, Some(fit(150.0, 2, TextOverflow::Ellipsis, true)));
let (clipped, _) = drawn(WORDS, Some(fit(150.0, 2, TextOverflow::Clip, true)));
assert_eq!(
marked[1].first(),
clipped[1].first(),
"{marked:?} against {clipped:?}"
);
}
#[test]
fn an_unwrapped_line_in_a_narrow_box_ends_in_an_ellipsis() {
let mut measurer = TextMeasurer::new();
let cut = measurer.measure_full(
OVERLONG,
20.0,
None,
FontFamily::default(),
FontWeight::NORMAL,
LineHeight::Normal,
0.0,
Some(fit(90.0, 1, TextOverflow::Ellipsis, false)),
);
assert!(cut.size.width <= 90.0, "{:?}", cut.size);
let (lines, mark) = drawn(OVERLONG, Some(fit(90.0, 1, TextOverflow::Ellipsis, false)));
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].last(), Some(&mark), "{lines:?}");
}
#[test]
fn a_cut_at_a_line_break_ends_in_an_ellipsis() {
let (lines, mark) = drawn(
"one\ntwo\nthree",
Some(fit(400.0, 2, TextOverflow::Ellipsis, true)),
);
assert_eq!(lines.len(), 2, "{lines:?}");
assert_eq!(lines[1].last(), Some(&mark), "{lines:?}");
}
#[test]
fn a_later_paragraph_is_cut_at_what_is_left_of_the_limit() {
let text = format!("first\n{OVERLONG}");
let (lines, mark) = drawn(&text, Some(fit(120.0, 2, TextOverflow::Ellipsis, true)));
assert_eq!(lines.len(), 2, "{lines:?}");
assert_eq!(lines[1].last(), Some(&mark), "{lines:?}");
}
#[test]
fn a_clipped_cut_keeps_its_lines_and_marks_nothing() {
let (lines, mark) = drawn(OVERLONG, Some(fit(120.0, 2, TextOverflow::Clip, true)));
assert_eq!(lines.len(), 2, "{lines:?}");
assert!(lines.iter().all(|l| !l.contains(&mark)), "{lines:?}");
}
#[test]
fn the_mark_can_open_the_line_or_stand_in_its_middle() {
let (lines, mark) = drawn(
OVERLONG,
Some(fit(120.0, 1, TextOverflow::EllipsisStart, true)),
);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].first(), Some(&mark), "{lines:?}");
let (lines, mark) = drawn(
OVERLONG,
Some(fit(120.0, 1, TextOverflow::EllipsisMiddle, true)),
);
assert_eq!(lines.len(), 1);
let at = lines[0].iter().position(|g| *g == mark);
assert!(
at.is_some_and(|at| at > 0 && at < lines[0].len() - 1),
"{lines:?}"
);
}
#[test]
fn content_that_fits_is_drawn_unchanged() {
let (free, mark) = drawn("short", None);
let (limited, _) = drawn("short", Some(fit(400.0, 1, TextOverflow::Ellipsis, true)));
assert_eq!(free, limited);
assert!(!limited[0].contains(&mark));
}
}
#[cfg(test)]
mod line_height_tests {
use super::*;
const FONT: &[u8] = include_bytes!("../../tests/assets/DejaVuSansMono.ttf");
pub(super) const DEJAVU_RATIO: f32 = (1901.0 + 483.0) / 2048.0;
pub(super) fn measurer() -> TextMeasurer {
let mut db = cosmic_text::fontdb::Database::new();
db.load_font_data(FONT.to_vec());
TextMeasurer {
font_system: FontSystem::new_with_locale_and_db("en-US".into(), db),
measure_cache: FxHashMap::default(),
}
}
fn height(measurer: &mut TextMeasurer, text: &str, line_height: LineHeight) -> f32 {
measurer
.measure_full(
text,
14.0,
None,
FontFamily::name("DejaVu Sans Mono"),
FontWeight::NORMAL,
line_height,
0.0,
None,
)
.size
.height
}
pub(super) fn assert_near(actual: f32, expected: f32, what: &str) {
assert!(
(actual - expected).abs() < 0.01,
"{what}: measured {actual}, expected {expected}"
);
}
#[test]
fn two_lines_are_two_of_the_line_height_asked_for() {
let mut m = measurer();
assert_near(
height(&mut m, "a\nb", LineHeight::Normal),
2.0 * DEJAVU_RATIO * 14.0,
"by default a line is as tall as the font says",
);
assert_near(
height(&mut m, "a\nb", LineHeight::Relative(1.5)),
2.0 * 21.0,
"a factor is a multiple of the font size",
);
assert_near(
height(&mut m, "a\nb", LineHeight::Absolute(20.0)),
40.0,
"an absolute height is logical pixels",
);
}
#[test]
fn a_line_height_is_part_of_the_measurement_s_key() {
let mut m = measurer();
let normal = height(&mut m, "a\nb", LineHeight::Normal);
let tall = height(&mut m, "a\nb", LineHeight::Absolute(30.0));
assert_near(
tall,
60.0,
"the second text was handed the first one's lines",
);
assert!(normal < tall);
}
#[test]
fn empty_text_is_one_line_of_the_resolved_height() {
let mut m = measurer();
assert_near(
height(&mut m, "", LineHeight::Normal),
DEJAVU_RATIO * 14.0,
"an empty text by default",
);
assert_near(
height(&mut m, "", LineHeight::Absolute(20.0)),
20.0,
"an empty text at an absolute height",
);
}
#[test]
fn a_line_height_that_is_not_a_height_is_the_font_s() {
let mut m = measurer();
for bad in [
LineHeight::Relative(0.0),
LineHeight::Relative(-1.0),
LineHeight::Absolute(f32::NAN),
LineHeight::Absolute(f32::INFINITY),
] {
assert_near(
height(&mut m, "a", bad),
DEJAVU_RATIO * 14.0,
&format!("{bad:?}"),
);
}
}
}
#[cfg(test)]
mod letter_spacing_tests {
use super::line_height_tests::{DEJAVU_RATIO as LINE, assert_near, measurer};
use super::*;
const ADVANCE: f32 = 1233.0 / 2048.0;
fn measure(
m: &mut TextMeasurer,
text: &str,
size: f32,
max_width: Option<f32>,
spacing: f32,
) -> Size {
m.measure_styled(
text,
size,
max_width,
FontFamily::name("DejaVu Sans Mono"),
FontWeight::NORMAL,
LineHeight::Normal,
spacing,
)
}
#[test]
fn a_spacing_is_added_after_every_glyph_at_every_size() {
let mut m = measurer();
for (size, spacing) in [
(20.0, 0.0),
(20.0, 3.0),
(20.0, -2.0),
(10.0, 1.5),
(14.0, 1.5),
(40.0, 1.5),
] {
assert_near(
measure(&mut m, "abcd", size, None, spacing).width,
4.0 * (ADVANCE * size + spacing),
&format!("{spacing} px at {size} px"),
);
}
}
#[test]
fn a_spacing_moves_where_a_line_wraps() {
let mut m = measurer();
let width = 117.0;
assert!(9.0 * ADVANCE * 20.0 < width && width < 9.0 * (ADVANCE * 20.0 + 2.0));
let unspaced = measure(&mut m, "aaaa bbbb", 20.0, Some(width), 0.0);
assert_near(unspaced.height, LINE * 20.0, "unspaced, one line");
let spaced = measure(&mut m, "aaaa bbbb", 20.0, Some(width), 2.0);
assert_near(spaced.height, 2.0 * LINE * 20.0, "spaced, two lines");
}
#[test]
fn a_spacing_is_part_of_the_measurement_s_key() {
let mut m = measurer();
let plain = measure(&mut m, "abcd", 20.0, None, 0.0).width;
let spaced = measure(&mut m, "abcd", 20.0, None, 5.0).width;
assert_near(
spaced - plain,
20.0,
"the second measurement was the first one's",
);
}
#[test]
fn a_spacing_that_is_not_a_number_is_none() {
let mut m = measurer();
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert_near(
measure(&mut m, "abcd", 20.0, None, bad).width,
4.0 * ADVANCE * 20.0,
&format!("{bad}"),
);
}
}
#[test]
fn a_caret_stands_after_the_spacing_of_every_glyph_before_it() {
let mut m = measurer();
let family = FontFamily::name("DejaVu Sans Mono");
let cell = ADVANCE * 20.0 + 4.0;
let positions = m.char_positions_styled("abcde", 20.0, family, FontWeight::NORMAL, 4.0);
assert_eq!(positions.len(), 6);
for (i, x) in positions.iter().enumerate() {
assert_near(*x, i as f32 * cell, &format!("boundary {i}"));
let prefix =
m.measure_to_char_styled("abcde", 20.0, i, family, FontWeight::NORMAL, 4.0);
assert_near(prefix, i as f32 * cell, &format!("prefix of {i}"));
}
}
#[test]
fn a_click_finds_the_boundary_of_the_spaced_line() {
let mut m = measurer();
let family = FontFamily::name("DejaVu Sans Mono");
let x = 3.0 * (ADVANCE * 20.0 + 10.0);
assert_eq!(
m.char_from_x_styled("abcdefgh", 20.0, x, family, FontWeight::NORMAL, 10.0),
3
);
assert_eq!(
m.char_from_x_styled("abcdefgh", 20.0, x, family, FontWeight::NORMAL, 0.0),
5
);
}
#[test]
fn a_spacing_is_added_per_glyph_and_not_per_character() {
let mut m = measurer();
let cell = ADVANCE * 20.0;
for (text, advances, glyphs, what) in [
(
"x\u{301}",
1.0,
2.0,
"a mark after a base it cannot compose with",
),
("e\u{301}", 1.0, 1.0, "a mark the shaper composes"),
("\u{644}\u{627}", 1.0, 1.0, "the lam-alef ligature"),
(
"\u{633}\u{644}\u{645}",
3.0,
3.0,
"three joined Arabic letters",
),
] {
assert_near(
measure(&mut m, text, 20.0, None, 0.0).width,
advances * cell,
&format!("{what}, unspaced"),
);
assert_near(
measure(&mut m, text, 20.0, None, 4.0).width,
advances * cell + glyphs * 4.0,
what,
);
}
}
#[test]
fn a_combining_mark_lands_one_spacing_past_its_base() {
let mut m = measurer();
let mark_from_base = |m: &mut TextMeasurer, spacing| {
let buffer = m.shape(
"x\u{301}",
20.0,
None,
FontFamily::name("DejaVu Sans Mono"),
FontWeight::NORMAL,
LineHeight::Normal,
spacing,
None,
);
let run = buffer.layout_runs().next().expect("one line");
assert_eq!(run.glyphs.len(), 2, "a base and its mark");
let at = |g: &cosmic_text::LayoutGlyph| g.x + g.x_offset;
at(&run.glyphs[1]) - at(&run.glyphs[0])
};
let attached = mark_from_base(&mut m, 0.0);
assert_near(
mark_from_base(&mut m, 4.0),
attached + 4.0,
"the mark's place",
);
}
#[test]
fn a_boundary_takes_the_first_glyph_of_its_cluster() {
let mut m = measurer();
let family = FontFamily::name("DejaVu Sans Mono");
let cell = ADVANCE * 20.0;
for (text, spacing, expected) in [
("x\u{301}", 0.0, vec![0.0, 0.0, cell]),
("x\u{301}", 4.0, vec![0.0, 0.0, cell + 8.0]),
(
"ax\u{301}b",
0.0,
vec![0.0, cell, cell, 2.0 * cell, 3.0 * cell],
),
] {
let got = m.char_positions_styled(text, 20.0, family, FontWeight::NORMAL, spacing);
assert_eq!(got.len(), expected.len(), "{text:?}: {got:?}");
for (i, (x, want)) in got.iter().zip(&expected).enumerate() {
assert_near(
*x,
*want,
&format!("{text:?} at {spacing} px, boundary {i}"),
);
}
}
}
}
#[cfg(test)]
mod longest_word_tests {
use super::line_height_tests::measurer;
use super::*;
#[test]
fn the_longest_word_is_measured_whole() {
let mut m = measurer();
let family = FontFamily::name("DejaVu Sans Mono");
let mut measure = |text: &str, width: Option<f32>| {
m.measure_full(
text,
14.0,
None,
family,
FontWeight::NORMAL,
LineHeight::Normal,
0.0,
Some(LineFit::wrapping(width)),
)
};
let glyph = measure("a", None).size.width;
for width in [None, Some(30.0), Some(500.0)] {
let widest = measure("abc defghij kl", width).longest_word;
assert!(
(widest - 7.0 * glyph).abs() < 0.01,
"seven glyphs at {width:?}, got {widest} for a glyph of {glyph}"
);
}
}
}