use std::sync::Mutex;
use parley::fontique::{Blob, GenericFamily};
use parley::style::StyleProperty;
use peniko::Brush;
use crate::layout::TextLayout;
use crate::shape_cache::{DEFAULT_CAPACITY, ShapeCache, ShapeCacheStats, ShapeKey};
use crate::style::{
GenericSlot, TextOverflow, TextStyle, to_parley_align, to_parley_family, to_parley_line_height,
to_parley_style, to_parley_weight,
};
const ELLIPSIS: char = '\u{2026}';
const MAX_TRUNCATION_MEASUREMENTS: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegisteredFamily {
pub name: String,
pub face_count: usize,
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum FontError {
#[error("font data contained no parseable faces")]
NoFacesFound,
}
static APP_FONTS: Mutex<Vec<Blob<u8>>> = Mutex::new(Vec::new());
struct PendingGenericFallback {
blob: Blob<u8>,
generics: Vec<GenericSlot>,
}
static GENERIC_FALLBACKS: Mutex<Vec<PendingGenericFallback>> = Mutex::new(Vec::new());
pub fn register_generic_fallback(data: Vec<u8>, generics: &[GenericSlot]) {
if generics.is_empty() {
return;
}
let mut slot = GENERIC_FALLBACKS.lock().unwrap_or_else(|e| e.into_inner());
slot.push(PendingGenericFallback {
blob: Blob::from(data),
generics: generics.to_vec(),
});
}
fn generic_slot_to_parley(slot: GenericSlot) -> GenericFamily {
match slot {
GenericSlot::Monospace => GenericFamily::Monospace,
GenericSlot::SansSerif => GenericFamily::SansSerif,
GenericSlot::Serif => GenericFamily::Serif,
GenericSlot::SystemUi => GenericFamily::SystemUi,
GenericSlot::Emoji => GenericFamily::Emoji,
}
}
fn apply_generic_fallbacks(
font_ctx: &mut parley::FontContext,
entries: &[PendingGenericFallback],
watermark: &mut usize,
) -> bool {
if *watermark == entries.len() {
return false;
}
let mut applied = false;
for entry in &entries[*watermark..] {
let registered = font_ctx.collection.register_fonts(entry.blob.clone(), None);
for (family_id, _faces) in registered {
applied = true;
for &generic in &entry.generics {
font_ctx.collection.append_generic_families(
generic_slot_to_parley(generic),
std::iter::once(family_id),
);
}
}
}
*watermark = entries.len();
applied
}
pub struct TextContext {
font_ctx: parley::FontContext,
layout_ctx: parley::LayoutContext<Brush>,
shape_cache: ShapeCache,
app_fonts_applied: usize,
generic_fallbacks_applied: usize,
#[cfg(test)]
measurements: usize,
}
fn push_style_defaults(builder: &mut parley::RangedBuilder<'_, Brush>, style: &TextStyle) {
builder.push_default(to_parley_family(&style.family));
builder.push_default(StyleProperty::FontSize(style.size));
builder.push_default(StyleProperty::FontWeight(to_parley_weight(style.weight)));
builder.push_default(StyleProperty::FontStyle(to_parley_style(style.style)));
builder.push_default(StyleProperty::LetterSpacing(style.letter_spacing));
builder.push_default(StyleProperty::LineHeight(to_parley_line_height(
style.line_height,
)));
builder.push_default(StyleProperty::Brush(Brush::Solid(style.color)));
}
fn visible_line_count(layout: &TextLayout, text_len: usize) -> usize {
let count = layout.line_count();
if count <= 1 {
return count;
}
match layout.line_info(count - 1) {
Some(last) if last.range.is_empty() && last.range.end >= text_len => count - 1,
_ => count,
}
}
fn ellipsized(line_text: &str, end: usize) -> String {
format!("{}{ELLIPSIS}", line_text.get(..end).unwrap_or_default())
}
fn proportional_estimate(span: usize, target: f32, measured: f32) -> usize {
if measured <= 0.0 || !measured.is_finite() || !target.is_finite() {
return span;
}
if target <= 0.0 {
return 0;
}
let span_f = span as f64;
let estimate = (span_f * f64::from(target) / f64::from(measured)).floor();
if estimate <= 0.0 {
0
} else if estimate >= span_f {
span
} else {
estimate as usize
}
}
impl TextContext {
pub fn new() -> Self {
let mut cx = Self {
font_ctx: parley::FontContext::new(),
layout_ctx: parley::LayoutContext::new(),
shape_cache: ShapeCache::new(DEFAULT_CAPACITY),
app_fonts_applied: 0,
generic_fallbacks_applied: 0,
#[cfg(test)]
measurements: 0,
};
cx.sync_app_fonts();
cx
}
pub fn layout(&mut self, text: &str, style: &TextStyle, max_width: Option<f32>) -> TextLayout {
let key = ShapeKey::new(text, style);
if let Some(layout) = self.shape_cache.get(&key, max_width) {
return TextLayout::new(layout);
}
let mut builder = self
.layout_ctx
.ranged_builder(&mut self.font_ctx, text, 1.0, true);
push_style_defaults(&mut builder, style);
let mut layout = builder.build(text);
layout.break_all_lines(max_width);
layout.align(
to_parley_align(style.align),
parley::layout::AlignmentOptions::default(),
);
self.shape_cache.insert(key, layout.clone(), max_width);
TextLayout::new(layout)
}
fn measure_uncached(&mut self, text: &str, style: &TextStyle) -> f32 {
#[cfg(test)]
{
self.measurements += 1;
}
let mut builder = self
.layout_ctx
.ranged_builder(&mut self.font_ctx, text, 1.0, true);
push_style_defaults(&mut builder, style);
let mut layout = builder.build(text);
layout.break_all_lines(None);
layout.width()
}
#[cfg(test)]
fn measurement_count(&self) -> usize {
self.measurements
}
pub fn layout_bounded(
&mut self,
text: &str,
style: &TextStyle,
max_width: Option<f32>,
max_lines: Option<usize>,
overflow: TextOverflow,
) -> TextLayout {
let Some(max_lines) = max_lines else {
return self.layout(text, style, max_width);
};
if max_lines == 0 {
return self.layout("", style, max_width);
}
let full = self.layout(text, style, max_width);
let last_visible = max_lines - 1;
let visible_lines = visible_line_count(&full, text.len());
let has_extra_lines = visible_lines > max_lines;
let Some(last_line) = full.line_info(last_visible) else {
return full;
};
let width_overflows = matches!(max_width, Some(w) if last_line.width > w);
if !has_extra_lines && !width_overflows {
if full.line_count() == visible_lines {
return full;
}
let Some(cut) = text.get(..last_line.range.end) else {
return full;
};
return self.layout(cut.trim_end(), style, max_width);
}
match overflow {
TextOverflow::Clip => {
if !has_extra_lines {
return full;
}
let Some(cut) = text.get(..last_line.range.end) else {
return full;
};
self.layout(cut.trim_end(), style, max_width)
}
TextOverflow::Ellipsis => {
let (Some(before), Some(line_text)) = (
text.get(..last_line.range.start),
text.get(last_line.range.start..last_line.range.end),
) else {
return full;
};
let line_text = line_text.trim_end();
let truncated_last = match max_width {
Some(w) => self.truncate_last_line(line_text, style, w, last_line.width),
None => format!("{line_text}{ELLIPSIS}"),
};
let final_text = format!("{before}{truncated_last}");
self.layout(&final_text, style, max_width)
}
}
}
fn truncate_last_line(
&mut self,
line_text: &str,
style: &TextStyle,
max_width: f32,
line_width: f32,
) -> String {
let mut budget = MAX_TRUNCATION_MEASUREMENTS;
let ellipsis_width = self.measure_uncached(&ellipsized(line_text, 0), style);
budget -= 1;
if ellipsis_width > max_width {
return ELLIPSIS.to_string();
}
let available = max_width - ellipsis_width;
let mut ends: Vec<usize> = line_text.char_indices().map(|(i, _)| i).collect();
ends.push(line_text.len());
let longest = ends.len() - 1;
let mut cursor = proportional_estimate(longest, available, line_width);
let mut width = self.measure_uncached(&ellipsized(line_text, ends[cursor]), style);
budget -= 1;
if budget > 0 && cursor > 0 {
let refined = proportional_estimate(cursor, available, width - ellipsis_width);
if refined != cursor {
cursor = refined;
width = self.measure_uncached(&ellipsized(line_text, ends[cursor]), style);
budget -= 1;
}
}
let mut best = 0;
if width <= max_width {
best = cursor;
while best < longest && budget > 0 {
budget -= 1;
let next = best + 1;
if self.measure_uncached(&ellipsized(line_text, ends[next]), style) > max_width {
break;
}
best = next;
}
} else {
while cursor > 0 && budget > 0 {
budget -= 1;
cursor -= 1;
if self.measure_uncached(&ellipsized(line_text, ends[cursor]), style) <= max_width {
best = cursor;
break;
}
}
}
ellipsized(line_text, ends[best])
}
pub fn shape_cache_stats(&self) -> ShapeCacheStats {
self.shape_cache.stats()
}
pub fn register_fonts(&mut self, data: Vec<u8>) -> Result<Vec<RegisteredFamily>, FontError> {
let blob = Blob::from(data);
let mut slot = APP_FONTS.lock().unwrap_or_else(|e| e.into_inner());
for missed in &slot[self.app_fonts_applied..] {
let _ = self
.font_ctx
.collection
.register_fonts(missed.clone(), None);
}
self.app_fonts_applied = slot.len();
let registered = self.font_ctx.collection.register_fonts(blob.clone(), None);
if registered.is_empty() {
return Err(FontError::NoFacesFound);
}
slot.push(blob);
self.app_fonts_applied = slot.len();
drop(slot);
let families = registered
.into_iter()
.map(|(family_id, faces)| RegisteredFamily {
name: self
.font_ctx
.collection
.family_name(family_id)
.unwrap_or_default()
.to_string(),
face_count: faces.len(),
})
.collect();
self.clear_shape_cache();
Ok(families)
}
pub fn sync_app_fonts(&mut self) -> bool {
let mut applied = self.sync_generic_fallbacks();
let slot = APP_FONTS.lock().unwrap_or_else(|e| e.into_inner());
if self.app_fonts_applied != slot.len() {
for blob in &slot[self.app_fonts_applied..] {
if !self
.font_ctx
.collection
.register_fonts(blob.clone(), None)
.is_empty()
{
applied = true;
}
}
self.app_fonts_applied = slot.len();
}
drop(slot);
if applied {
self.clear_shape_cache();
}
applied
}
fn sync_generic_fallbacks(&mut self) -> bool {
let slot = GENERIC_FALLBACKS.lock().unwrap_or_else(|e| e.into_inner());
apply_generic_fallbacks(
&mut self.font_ctx,
&slot,
&mut self.generic_fallbacks_applied,
)
}
pub fn clear_shape_cache(&mut self) {
self.shape_cache = ShapeCache::new(DEFAULT_CAPACITY);
}
pub(crate) fn driver_contexts(
&mut self,
) -> (&mut parley::FontContext, &mut parley::LayoutContext<Brush>) {
(&mut self.font_ctx, &mut self.layout_ctx)
}
}
impl Default for TextContext {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shape_cache::DEFAULT_CAPACITY;
use peniko::Color;
fn style(size: f32) -> TextStyle {
TextStyle::new(size, Color::BLACK)
}
const TUFFY: &[u8] = include_bytes!("../tests/fonts/Tuffy-Subset.ttf");
fn shaped_font_bytes(cx: &mut TextContext, text: &str, sty: &TextStyle) -> Vec<u8> {
let layout = cx.layout(text, sty, None);
let runs = layout.to_scene_runs(kurbo::Point::ORIGIN);
runs.first()
.expect("expected at least one glyph run")
.font
.font()
.data
.as_ref()
.to_vec()
}
#[test]
fn an_app_font_reaches_contexts_built_later_and_older_ones_on_sync() {
let s = TextStyle {
family: crate::FontFamily::named("Tuffy"),
..style(24.0)
};
let mut older = TextContext::new();
let mut shell = TextContext::new();
shell
.register_fonts(TUFFY.to_vec())
.expect("valid TTF bytes must register");
let mut later = TextContext::new();
assert!(
!later.sync_app_fonts(),
"a freshly built context is already current with the app-font record"
);
assert!(
shaped_font_bytes(&mut later, "0123456789", &s) == TUFFY,
"a context built after the registration must shape with the app font"
);
older.sync_app_fonts();
assert!(
shaped_font_bytes(&mut older, "0123456789", &s) == TUFFY,
"an already-built context must pick the app font up on sync_app_fonts"
);
}
#[test]
fn generic_fallback_maps_requested_slots_and_is_idempotent() {
use parley::fontique::{Collection, CollectionOptions, GenericFamily};
let entries = vec![PendingGenericFallback {
blob: Blob::from(TUFFY.to_vec()),
generics: vec![GenericSlot::SystemUi, GenericSlot::SansSerif],
}];
let mut watermark = 0;
let mut font_ctx = parley::FontContext {
collection: Collection::new(CollectionOptions {
system_fonts: false,
..Default::default()
}),
source_cache: Default::default(),
};
let applied = apply_generic_fallbacks(&mut font_ctx, &entries, &mut watermark);
assert!(
applied,
"registering a parseable face must report at least one applied family"
);
let system_ui: Vec<_> = font_ctx
.collection
.generic_families(GenericFamily::SystemUi)
.collect();
assert_eq!(
system_ui.len(),
1,
"SystemUi must resolve to exactly the registered fallback face"
);
assert!(
font_ctx
.collection
.generic_families(GenericFamily::Monospace)
.next()
.is_none(),
"Monospace must stay unmapped — only SystemUi/SansSerif were requested"
);
let mut cx = TextContext {
font_ctx,
layout_ctx: parley::LayoutContext::new(),
shape_cache: ShapeCache::new(DEFAULT_CAPACITY),
app_fonts_applied: 0,
generic_fallbacks_applied: watermark,
#[cfg(test)]
measurements: 0,
};
let layout = cx.layout("Hello", &style(20.0), None);
let runs = layout.to_scene_runs(kurbo::Point::ORIGIN);
assert!(
!runs.is_empty(),
"SystemUi text must shape with the registered fallback face even \
when the system font collection is empty"
);
let applied_again = apply_generic_fallbacks(&mut cx.font_ctx, &entries, &mut watermark);
assert!(
!applied_again,
"nothing new is pending — a repeat apply must be a no-op"
);
let system_ui_after: Vec<_> = cx
.font_ctx
.collection
.generic_families(GenericFamily::SystemUi)
.collect();
assert_eq!(
system_ui_after, system_ui,
"a repeat apply must not double-register the fallback family"
);
}
#[test]
fn register_generic_fallback_is_a_noop_with_no_generics() {
let before = GENERIC_FALLBACKS
.lock()
.unwrap_or_else(|e| e.into_inner())
.len();
register_generic_fallback(vec![1, 2, 3], &[]);
let after = GENERIC_FALLBACKS
.lock()
.unwrap_or_else(|e| e.into_inner())
.len();
assert_eq!(
before, after,
"no generic slots requested — nothing should be queued"
);
}
#[test]
fn same_text_two_widths_shapes_once() {
let mut cx = TextContext::new();
let text = "Hello from Frust, the pure Rust mobile UI toolkit";
let s = style(16.0);
let _ = cx.layout(text, &s, Some(200.0)); let _ = cx.layout(text, &s, Some(80.0)); let _ = cx.layout(text, &s, Some(80.0));
let stats = cx.shape_cache_stats();
assert_eq!(stats.shapes, 1, "shaping must run exactly once");
assert_eq!(stats.line_breaks, 1, "the differing width re-breaks once");
assert_eq!(stats.hits, 1, "the repeated width is a full reuse");
}
#[test]
fn text_change_forces_a_fresh_shape() {
let mut cx = TextContext::new();
let s = style(16.0);
let _ = cx.layout("hello", &s, None);
let _ = cx.layout("world", &s, None);
assert_eq!(
cx.shape_cache_stats().shapes,
2,
"a different string is a distinct key → fresh shape (no stale reuse)"
);
}
#[test]
fn style_and_color_changes_force_fresh_shapes() {
let mut cx = TextContext::new();
let _ = cx.layout("hello", &style(16.0), None);
let _ = cx.layout("hello", &style(24.0), None);
let _ = cx.layout("hello", &TextStyle::new(16.0, Color::WHITE), None);
assert_eq!(cx.shape_cache_stats().shapes, 3);
}
#[test]
fn invalidation_correct_across_all_mutation_orders() {
let styles = [style(16.0), style(28.0)];
let texts = ["alpha beta", "gamma delta epsilon zeta eta"];
let widths = [None, Some(60.0), Some(140.0)];
let mut cx = TextContext::new();
for _round in 0..3 {
for t in &texts {
for s in &styles {
for w in &widths {
let cached = cx.layout(t, s, *w).size();
let mut reference = TextContext::new();
let fresh = reference.layout(t, s, *w).size();
assert_eq!(
cached, fresh,
"cached layout for (text={t:?}, size={}, width={w:?}) \
is stale — got {cached:?}, expected {fresh:?}",
s.size
);
}
}
}
}
}
#[test]
fn cache_is_bounded_and_evicts_least_recently_used() {
let mut cx = TextContext::new();
let s = style(16.0);
let overflow = 8;
let n = DEFAULT_CAPACITY + overflow;
for i in 0..n {
let _ = cx.layout(&format!("entry number {i}"), &s, None);
}
let stats = cx.shape_cache_stats();
assert_eq!(stats.shapes, n as u64, "each distinct string shapes once");
assert_eq!(
stats.evictions, overflow as u64,
"capacity overflow evicts exactly the surplus, no unbounded growth"
);
let hits_before = cx.shape_cache_stats().hits;
let _ = cx.layout(&format!("entry number {}", n - 1), &s, None);
assert_eq!(
cx.shape_cache_stats().hits,
hits_before + 1,
"the most-recently-used entry survives eviction"
);
let shapes_before = cx.shape_cache_stats().shapes;
let _ = cx.layout("entry number 0", &s, None);
assert_eq!(
cx.shape_cache_stats().shapes,
shapes_before + 1,
"an evicted entry is re-shaped, not served stale"
);
}
use crate::style::TextAlign;
fn aligned_style(align: TextAlign) -> TextStyle {
TextStyle {
align,
..style(16.0)
}
}
fn line_min_x(cx: &mut TextContext, text: &str, style: &TextStyle, max_width: f32) -> Vec<f32> {
let layout = cx.layout(text, style, Some(max_width));
let runs = layout.to_scene_runs(kurbo::Point::ORIGIN);
let mut by_y: Vec<(f32, f32)> = Vec::new();
for run in &runs {
for g in &run.glyphs {
match by_y.iter_mut().find(|(y, _)| (*y - g.y).abs() < 0.01) {
Some((_, min_x)) => *min_x = min_x.min(g.x),
None => by_y.push((g.y, g.x)),
}
}
}
by_y.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
by_y.into_iter().map(|(_, x)| x).collect()
}
const TWO_LINES: &str = "A\nBBBBBBBBBB";
#[test]
fn center_and_right_align_position_wrapped_lines_correctly() {
let mut cx = TextContext::new();
let max_width = 400.0;
let start_x = line_min_x(
&mut cx,
TWO_LINES,
&aligned_style(TextAlign::Start),
max_width,
);
let center_x = line_min_x(
&mut cx,
TWO_LINES,
&aligned_style(TextAlign::Center),
max_width,
);
let right_x = line_min_x(
&mut cx,
TWO_LINES,
&aligned_style(TextAlign::Right),
max_width,
);
assert_eq!(start_x.len(), 2, "expected two hard-broken lines");
assert_eq!(center_x.len(), 2);
assert_eq!(right_x.len(), 2);
assert!(
start_x[0].abs() < 0.5 && start_x[1].abs() < 0.5,
"start-aligned lines must hug the left edge: {start_x:?}"
);
assert!(
center_x[0] > 1.0 && center_x[1] > 1.0,
"center-aligned lines must move off the left edge: {center_x:?}"
);
assert!(
center_x[0] > center_x[1] + 1.0,
"the shorter line must center further right than the longer one: {center_x:?}"
);
assert!(
right_x[0] > right_x[1] + 1.0,
"the shorter line's right-aligned left edge must sit further right: {right_x:?}"
);
}
#[test]
fn alignment_survives_a_width_change_through_the_shape_cache_rebreak() {
let mut cx = TextContext::new();
let centered = aligned_style(TextAlign::Center);
let _ = cx.layout(TWO_LINES, ¢ered, Some(400.0));
let x = line_min_x(&mut cx, TWO_LINES, ¢ered, 500.0);
assert_eq!(x.len(), 2);
assert!(
x[0] > x[1] + 1.0,
"center alignment must survive the width-change re-break: {x:?}"
);
let stats = cx.shape_cache_stats();
assert_eq!(stats.shapes, 1, "the width change must not re-shape");
assert_eq!(
stats.line_breaks, 1,
"sanity: this really went through the re-break path"
);
}
#[test]
fn same_text_different_alignment_does_not_collide_in_the_shape_cache() {
let mut cx = TextContext::new();
let max_width = 400.0;
let start_x = line_min_x(
&mut cx,
TWO_LINES,
&aligned_style(TextAlign::Start),
max_width,
);
let center_x = line_min_x(
&mut cx,
TWO_LINES,
&aligned_style(TextAlign::Center),
max_width,
);
assert_ne!(
start_x, center_x,
"a cache collision would make the second (center) request come back \
identical to the first (start)"
);
assert!(
start_x[0].abs() < 0.5,
"the start-aligned request must render correctly despite sharing text \
with a differently-aligned request: {start_x:?}"
);
assert!(
center_x[0] > center_x[1] + 1.0,
"the center-aligned request must render correctly despite sharing text \
with a differently-aligned request: {center_x:?}"
);
let stats = cx.shape_cache_stats();
assert_eq!(
stats.shapes, 2,
"distinct alignment must be a distinct shape, not a collision"
);
}
#[test]
fn default_alignment_matches_pre_findings_39_start_behavior() {
let mut cx = TextContext::new();
let default_x = line_min_x(&mut cx, TWO_LINES, &style(16.0), 400.0);
let explicit_start_x =
line_min_x(&mut cx, TWO_LINES, &aligned_style(TextAlign::Start), 400.0);
assert_eq!(default_x, explicit_start_x);
assert!(default_x.iter().all(|x| x.abs() < 0.5));
}
const WRAPPING_TEXT: &str = "Hello from Frust, the pure Rust mobile UI toolkit";
fn min_x_per_line(layout: &TextLayout) -> Vec<f32> {
let runs = layout.to_scene_runs(kurbo::Point::ORIGIN);
let mut by_y: Vec<(f32, f32)> = Vec::new();
for run in &runs {
for g in &run.glyphs {
match by_y.iter_mut().find(|(y, _)| (*y - g.y).abs() < 0.01) {
Some((_, min_x)) => *min_x = min_x.min(g.x),
None => by_y.push((g.y, g.x)),
}
}
}
by_y.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
by_y.into_iter().map(|(_, x)| x).collect()
}
#[test]
fn layout_bounded_with_no_max_lines_matches_plain_layout() {
let mut cx = TextContext::new();
let s = style(16.0);
let plain = cx.layout(WRAPPING_TEXT, &s, Some(200.0)).size();
let bounded = cx
.layout_bounded(WRAPPING_TEXT, &s, Some(200.0), None, TextOverflow::Ellipsis)
.size();
assert_eq!(plain, bounded);
}
#[test]
fn single_line_fits_is_left_unmodified() {
let mut cx = TextContext::new();
let s = style(16.0);
let text = "short";
let plain = cx.layout(text, &s, Some(400.0)).size();
let bounded = cx
.layout_bounded(text, &s, Some(400.0), Some(1), TextOverflow::Ellipsis)
.size();
assert_eq!(plain, bounded);
}
#[test]
fn single_line_exact_fit_is_not_truncated() {
let mut cx = TextContext::new();
let s = style(16.0);
let text = "exact";
let natural = cx.layout(text, &s, None).size().width;
let max_width = natural.ceil() as f32;
let bounded = cx.layout_bounded(text, &s, Some(max_width), Some(1), TextOverflow::Ellipsis);
assert_eq!(bounded.line_count(), 1);
assert_eq!(
bounded.size().width,
cx.layout(text, &s, Some(max_width)).size().width,
"an exactly-fitting line must render identically to an untruncated layout"
);
}
#[test]
fn single_line_overflow_truncates_and_fits_the_bound() {
let mut cx = TextContext::new();
let s = style(16.0);
let max_width = 80.0;
let plain = cx.layout(WRAPPING_TEXT, &s, Some(max_width));
assert!(
plain.line_count() > 1,
"fixture sanity: expected this phrase to soft-wrap at {max_width}px, got {} line(s)",
plain.line_count()
);
let bounded = cx.layout_bounded(
WRAPPING_TEXT,
&s,
Some(max_width),
Some(1),
TextOverflow::Ellipsis,
);
assert_eq!(bounded.line_count(), 1, "max_lines(1) must yield one line");
let width = bounded.line_info(0).expect("one line").width;
assert!(
width <= max_width,
"the truncated+ellipsized line must fit the bound: {width} > {max_width}"
);
}
#[test]
fn max_lines_two_wrapped_truncates_only_the_last_visible_line() {
let mut cx = TextContext::new();
let s = style(16.0);
let max_width = 60.0;
let plain = cx.layout(WRAPPING_TEXT, &s, Some(max_width));
assert!(
plain.line_count() > 2,
"fixture sanity: expected >2 wrapped lines at {max_width}px, got {}",
plain.line_count()
);
let plain_first_line_width = plain.line_info(0).expect("line 0").width;
let bounded = cx.layout_bounded(
WRAPPING_TEXT,
&s,
Some(max_width),
Some(2),
TextOverflow::Ellipsis,
);
assert_eq!(bounded.line_count(), 2, "max_lines(2) must yield two lines");
assert_eq!(
bounded.line_info(0).expect("line 0").width,
plain_first_line_width,
"the greedy line-breaker's earlier line must survive the truncation \
of a later line verbatim"
);
let last_width = bounded.line_info(1).expect("line 1").width;
assert!(
last_width <= max_width,
"the truncated+ellipsized last visible line must fit the bound: \
{last_width} > {max_width}"
);
}
#[test]
fn clip_drops_trailing_lines_without_touching_the_last_visible_line() {
let mut cx = TextContext::new();
let s = style(16.0);
let max_width = 60.0;
let plain = cx.layout(WRAPPING_TEXT, &s, Some(max_width));
assert!(plain.line_count() > 1, "fixture sanity");
let plain_first_line_width = plain.line_info(0).expect("line 0").width;
let clipped = cx.layout_bounded(
WRAPPING_TEXT,
&s,
Some(max_width),
Some(1),
TextOverflow::Clip,
);
assert_eq!(clipped.line_count(), 1);
assert_eq!(
clipped.line_info(0).expect("line 0").width,
plain_first_line_width,
"Clip drops trailing lines but never character-trims the last \
visible one — its content, and so its width, must be identical \
to the untruncated layout's own first line"
);
let ellipsized = cx.layout_bounded(
WRAPPING_TEXT,
&s,
Some(max_width),
Some(1),
TextOverflow::Ellipsis,
);
assert_eq!(ellipsized.line_count(), 1);
assert_ne!(
clipped.line_info(0).expect("line 0").width,
ellipsized.line_info(0).expect("line 0").width,
"Clip and Ellipsis must produce visibly different last lines for \
the same overflowing input"
);
}
#[test]
fn ellipsis_wider_than_the_box_still_renders_without_panicking() {
let mut cx = TextContext::new();
let s = style(16.0);
let bounded = cx.layout_bounded(
WRAPPING_TEXT,
&s,
Some(1.0),
Some(1),
TextOverflow::Ellipsis,
);
assert_eq!(bounded.line_count(), 1);
assert!(
bounded.size().width > 0.0,
"a best-effort bare ellipsis must still paint something"
);
}
#[test]
fn empty_string_is_not_truncated() {
let mut cx = TextContext::new();
let s = style(16.0);
let bounded = cx.layout_bounded("", &s, Some(80.0), Some(1), TextOverflow::Ellipsis);
assert_eq!(bounded.size().width, 0.0);
}
#[test]
fn max_lines_zero_yields_an_empty_layout() {
let mut cx = TextContext::new();
let s = style(16.0);
let bounded = cx.layout_bounded(
WRAPPING_TEXT,
&s,
Some(80.0),
Some(0),
TextOverflow::Ellipsis,
);
assert_eq!(bounded.size().width, 0.0);
}
fn long_token() -> String {
"aWi".repeat(167)[..500].to_string()
}
fn exhaustive_truncation(line: &str, s: &TextStyle, max_width: f32) -> String {
let mut cx = TextContext::new();
let mut ends: Vec<usize> = line.char_indices().map(|(i, _)| i).collect();
ends.push(line.len());
for &end in ends.iter().rev() {
let candidate = format!("{}{ELLIPSIS}", &line[..end]);
if cx.layout(&candidate, s, None).size().width <= f64::from(max_width) {
return candidate;
}
}
ELLIPSIS.to_string()
}
#[test]
fn a_long_unbreakable_token_truncates_within_the_measurement_cap() {
let mut cx = TextContext::new();
let s = style(16.0);
let token = long_token();
let max_width = 80.0;
let bounded =
cx.layout_bounded(&token, &s, Some(max_width), Some(1), TextOverflow::Ellipsis);
assert_eq!(bounded.line_count(), 1);
let width = bounded.line_info(0).expect("one line").width;
assert!(
width <= max_width,
"the truncated line must still fit the bound: {width} > {max_width}"
);
assert!(
cx.measurement_count() <= MAX_TRUNCATION_MEASUREMENTS,
"the seek must stay inside its cap: {} measurements for a {}-char token",
cx.measurement_count(),
token.chars().count()
);
let stats = cx.shape_cache_stats();
assert_eq!(
stats.shapes, 2,
"only the full and the final truncated layout may be cached — every \
candidate measurement is uncached"
);
assert_eq!(stats.evictions, 0);
}
#[test]
fn the_truncation_walk_never_evicts_live_cache_entries() {
let mut cx = TextContext::new();
let s = style(16.0);
for i in 0..DEFAULT_CAPACITY {
let _ = cx.layout(&format!("live entry {i}"), &s, None);
}
let evictions_before = cx.shape_cache_stats().evictions;
let _ = cx.layout_bounded(
&long_token(),
&s,
Some(60.0),
Some(1),
TextOverflow::Ellipsis,
);
assert_eq!(
cx.shape_cache_stats().evictions - evictions_before,
2,
"a full cache may only lose the two entries the two legitimate \
(full + final) inserts displace"
);
}
#[test]
fn the_bounded_seek_matches_an_exhaustive_longest_prefix_scan() {
let s = style(16.0);
let lines = [
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"iiiiiiiiiiiiiiiiiiiiWWWWWWWWWWWWWWWWWWWW",
"WWWWWWWWWWWWWWWWWWWWiiiiiiiiiiiiiiiiiiii",
"https://example.com/a/very/long/path?q=1",
&long_token(),
];
let mut worst = 0;
for line in lines {
for max_width in [12.0_f32, 40.0, 160.0, 400.0] {
let mut cx = TextContext::new();
let line_width = cx.layout(line, &s, None).size().width as f32;
let got = cx.truncate_last_line(line, &s, max_width, line_width);
let want = exhaustive_truncation(line, &s, max_width);
assert_eq!(
got, want,
"bounded seek disagreed with the exhaustive scan for \
{max_width}px of {line:?}"
);
worst = worst.max(cx.measurement_count());
}
}
assert!(
worst <= MAX_TRUNCATION_MEASUREMENTS,
"worst seek across the fixtures took {worst} measurements"
);
}
#[test]
fn the_seek_returns_a_fitting_candidate_even_when_the_cap_is_exhausted() {
let mut cx = TextContext::new();
let s = style(16.0);
let line = "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW";
let max_width = 60.0;
let got = cx.truncate_last_line(line, &s, max_width, 1.0);
assert!(
cx.measurement_count() <= MAX_TRUNCATION_MEASUREMENTS,
"the cap binds even when the estimate is useless: {}",
cx.measurement_count()
);
assert!(got.ends_with(ELLIPSIS));
let width = cx.layout(&got, &s, None).size().width;
assert!(
width <= f64::from(max_width),
"a cap-exhausted seek must still return a measured-fitting candidate: \
{width} > {max_width}"
);
}
#[test]
fn a_text_terminating_newline_is_not_an_extra_line() {
let s = style(16.0);
for overflow in [TextOverflow::Ellipsis, TextOverflow::Clip] {
let mut cx = TextContext::new();
let plain = cx.layout("Hello", &s, Some(400.0));
let fitting = plain.line_info(0).expect("one line").width;
let with_ellipsis = cx
.layout(&format!("Hello{ELLIPSIS}"), &s, Some(400.0))
.line_info(0)
.expect("one line")
.width;
let bounded = cx.layout_bounded("Hello\n", &s, Some(400.0), Some(1), overflow);
let got = bounded.line_info(0).expect("one content line").width;
assert_eq!(
got, fitting,
"{overflow:?}: text that fits must render verbatim despite its \
trailing newline"
);
assert_ne!(
got, with_ellipsis,
"{overflow:?}: no ellipsis may be appended to text that fits"
);
assert_eq!(
bounded.line_count(),
plain.line_count(),
"{overflow:?}: parley's phantom trailing line must not survive \
into the reported line count"
);
assert_eq!(
bounded.size().height,
plain.size().height,
"{overflow:?}: parley's phantom trailing line must not inflate \
the reported height — a text ending in one newline, capped to \
one line, must paint and measure exactly one line tall"
);
assert_eq!(
cx.measurement_count(),
0,
"{overflow:?}: a fitting line must not enter the truncation walk"
);
}
}
#[test]
fn a_trailing_newline_after_several_lines_is_discounted_too() {
let s = style(16.0);
for overflow in [TextOverflow::Ellipsis, TextOverflow::Clip] {
let mut cx = TextContext::new();
let plain = cx.layout("A\nBB", &s, Some(400.0));
let plain_last_width = plain.line_info(1).expect("two lines").width;
let bounded = cx.layout_bounded("A\nBB\n", &s, Some(400.0), Some(2), overflow);
assert_eq!(
bounded.line_info(1).expect("two content lines").width,
plain_last_width,
"{overflow:?}: two content lines plus a terminal newline fit \
max_lines(2)"
);
assert_eq!(
bounded.line_count(),
plain.line_count(),
"{overflow:?}: the phantom trailing line must not survive into \
the reported line count"
);
assert_eq!(
bounded.size().height,
plain.size().height,
"{overflow:?}: the phantom trailing line must not inflate the \
reported height — two content lines plus a terminal newline, \
capped to two lines, must measure exactly two lines tall"
);
}
}
#[test]
fn a_real_extra_line_still_truncates_when_the_text_ends_in_a_newline() {
let mut cx = TextContext::new();
let s = style(16.0);
let clipped = cx.layout_bounded("A\nBB\n", &s, Some(400.0), Some(1), TextOverflow::Clip);
assert_eq!(
clipped.line_count(),
1,
"the second content line is dropped"
);
assert_eq!(
clipped.size().width,
cx.layout("A", &s, Some(400.0)).size().width
);
let ellipsized =
cx.layout_bounded("A\nBB\n", &s, Some(400.0), Some(1), TextOverflow::Ellipsis);
assert_eq!(ellipsized.line_count(), 1);
assert!(
ellipsized.size().width > clipped.size().width,
"Ellipsis must append '…' to the surviving line"
);
}
#[test]
fn a_blank_line_inside_the_text_is_a_real_line() {
let mut cx = TextContext::new();
let s = style(16.0);
let fits = cx.layout_bounded("a\n\nb", &s, Some(400.0), Some(3), TextOverflow::Ellipsis);
assert_eq!(
fits.line_count(),
3,
"three content lines (one blank) fit max_lines(3) untouched"
);
assert_eq!(
cx.measurement_count(),
0,
"no truncation walk for text that fits"
);
let capped = cx.layout_bounded("a\n\nb", &s, Some(400.0), Some(2), TextOverflow::Clip);
assert!(
capped.line_count() < 3,
"the blank line occupies one of the two, so 'b' overflows"
);
}
#[test]
fn multi_byte_text_truncates_on_char_boundaries_without_panicking() {
let s = style(16.0);
let texts = [
"日本語のテキストです、これは折り返しの確認用の文章です",
"🙂🎉😀🚀🌍🙂🎉😀🚀🌍🙂🎉😀🚀🌍",
"Grüße aus München — Übergrößenträger",
];
for text in texts {
for overflow in [TextOverflow::Ellipsis, TextOverflow::Clip] {
for max_width in [20.0_f32, 70.0] {
let mut cx = TextContext::new();
let bounded = cx.layout_bounded(text, &s, Some(max_width), Some(1), overflow);
assert_eq!(bounded.line_count(), 1, "{text:?} at {max_width}px");
if overflow == TextOverflow::Ellipsis {
let width = bounded.line_info(0).expect("one line").width;
assert!(
width <= max_width,
"{text:?}: truncated width {width} exceeds {max_width}"
);
}
}
}
}
}
#[test]
fn ellipsis_truncation_preserves_center_alignment() {
let mut cx = TextContext::new();
let text = "A\nBBBBBBBBBB\nCCCCCCCCCC";
let max_width = 400.0;
let start = cx.layout_bounded(
text,
&aligned_style(TextAlign::Start),
Some(max_width),
Some(2),
TextOverflow::Ellipsis,
);
let center = cx.layout_bounded(
text,
&aligned_style(TextAlign::Center),
Some(max_width),
Some(2),
TextOverflow::Ellipsis,
);
assert_eq!(start.line_count(), 2);
assert_eq!(center.line_count(), 2);
let start_x = min_x_per_line(&start);
let center_x = min_x_per_line(¢er);
assert_eq!(start_x.len(), 2);
assert_eq!(center_x.len(), 2);
assert!(
start_x[1].abs() < 0.5,
"start-aligned truncated line must hug the left edge: {start_x:?}"
);
assert!(
center_x[1] > start_x[1] + 1.0,
"center-aligned truncated line must move off the left edge: {center_x:?}"
);
}
}