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: u32,
pub overflow: TextOverflow,
pub wrap: bool,
}
pub(crate) type LineFitKey = (Option<u32>, u32, TextOverflow, bool);
impl LineFit {
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),
max_width_bits: Option<u32>,
fit: Option<LineFitKey>,
}
impl MeasureCacheKey {
fn new(
text: &str,
font_size: f32,
max_width: Option<f32>,
font_family: FontFamily,
font_weight: FontWeight,
line_height: LineHeight,
fit: Option<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(),
max_width_bits: max_width.map(|w| w.to_bits()),
fit: fit.map(|f| f.key()),
}
}
}
const MEASURE_CACHE_CAP: usize = 8192;
#[derive(Clone, Copy, Debug)]
pub struct Measured {
pub size: Size,
pub baseline: 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,
)
}
pub fn measure_styled(
&mut self,
text: &str,
font_size: f32,
max_width: Option<f32>,
font_family: FontFamily,
font_weight: FontWeight,
line_height: LineHeight,
) -> Size {
self.measure_full(
text,
font_size,
max_width,
font_family,
font_weight,
line_height,
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,
fit: Option<LineFit>,
) -> Measured {
let max_width = fit.map_or(max_width, |f| f.width);
let cache_key = MeasureCacheKey::new(
text,
font_size,
max_width,
font_family,
font_weight,
line_height,
fit,
);
if let Some(&cached) = self.measure_cache.get(&cache_key) {
return cached;
}
let measured = {
let buffer = self.shape(
text,
font_size,
max_width,
font_family,
font_weight,
line_height,
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;
}
Measured {
size: Size::new(width, height),
baseline: baseline.unwrap_or(font_size),
}
};
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,
fit: Option<LineFit>,
) -> Buffer {
super::text::shape(
&mut self.font_system,
text,
font_size,
font_family,
font_weight,
line_height,
TextAlign::Start,
(max_width, None),
fit,
1.0,
)
}
pub fn char_positions_styled(
&mut self,
text: &str,
font_size: f32,
font_family: FontFamily,
font_weight: FontWeight,
) -> Vec<f32> {
let char_count = text.chars().count();
let mut positions = vec![0.0f32; char_count + 1];
if text.is_empty() {
return positions;
}
let mut char_index_at_byte = vec![usize::MAX; text.len() + 1];
for (char_idx, (byte_idx, _)) in text.char_indices().enumerate() {
char_index_at_byte[byte_idx] = char_idx;
}
char_index_at_byte[text.len()] = char_count;
let buffer = self.shape(
text,
font_size,
None,
font_family,
font_weight,
LineHeight::Normal,
None,
);
let mut total_width = 0.0f32;
let mut any_glyphs = false;
for run in buffer.layout_runs() {
for glyph in run.glyphs {
if let Some(&char_idx) = char_index_at_byte.get(glyph.start)
&& char_idx != usize::MAX
{
positions[char_idx] = glyph.x;
any_glyphs = true;
}
}
total_width = total_width.max(run.line_w);
}
positions[char_count] = total_width;
if any_glyphs {
for i in 1..char_count {
if positions[i] == 0.0 && positions[i - 1] > 0.0 {
positions[i] = positions[i - 1];
}
}
}
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,
)
}
pub fn measure_to_char_styled(
&mut self,
text: &str,
font_size: f32,
char_index: usize,
font_family: FontFamily,
font_weight: FontWeight,
) -> 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,
)
.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,
)
}
pub fn char_from_x_styled(
&mut self,
text: &str,
font_size: f32,
x: f32,
font_family: FontFamily,
font_weight: FontWeight,
) -> 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,
)
.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);
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);
let curr_width =
self.measure_to_char_styled(text, font_size, left, font_family, font_weight);
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,
) -> Size {
with_measurer(|m| {
m.measure_styled(
text,
font_size,
max_width,
font_family,
font_weight,
line_height,
)
})
}
pub fn measure_text_full(
text: &str,
font_size: f32,
max_width: Option<f32>,
font_family: FontFamily,
font_weight: FontWeight,
line_height: LineHeight,
fit: Option<LineFit>,
) -> Measured {
with_measurer(|m| {
m.measure_full(
text,
font_size,
max_width,
font_family,
font_weight,
line_height,
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,
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,
) -> f32 {
with_measurer(|m| {
m.measure_to_char_styled(text, font_size, char_index, font_family, font_weight)
})
}
pub fn measure_char_positions_styled(
text: &str,
font_size: f32,
font_family: FontFamily,
font_weight: FontWeight,
) -> Vec<f32> {
with_measurer(|m| m.char_positions_styled(text, font_size, font_family, font_weight))
}
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,
) -> usize {
with_measurer(|m| m.char_from_x_styled(text, font_size, x, font_family, font_weight))
}
#[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,
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,
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,
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,
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,
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,
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");
const DEJAVU_RATIO: f32 = (1901.0 + 483.0) / 2048.0;
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,
None,
)
.size
.height
}
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:?}"),
);
}
}
}