frust_text/context.rs
1//! [`TextContext`]: the heavyweight, `!Sync` owner of parley's font and layout
2//! state.
3//!
4//! Created once and threaded through the app/render loop. It holds
5//! a [`parley::FontContext`] (system font sources, loaded via fontique — Core
6//! Text on macOS with no registration) and a [`parley::LayoutContext`] scratch
7//! buffer reused across layout passes.
8//!
9//! # App fonts across independent contexts
10//!
11//! A shell owns *the* context widgets shape through at layout time, but a
12//! widget that must shape outside the layout pass legitimately owns a private
13//! one (`frust_widgets::textinput`). [`APP_FONTS`] is the process-wide record
14//! that keeps those two in agreement about registered app fonts — see its docs
15//! for the layering rationale.
16//!
17//! # Generic-family fallback registry
18//!
19//! [`GENERIC_FALLBACKS`] is the process-wide counterpart for the *generic*
20//! font-family map (fontique's `SystemUi`/`SansSerif`/`Monospace`/`Serif`/
21//! `Emoji` slots) rather than a named family — see [`register_generic_fallback`]
22//! for why a host needs this seam on `wasm32`.
23
24use std::sync::Mutex;
25use std::time::{Duration, Instant};
26
27use parley::fontique::{Blob, GenericFamily};
28use parley::style::StyleProperty;
29use peniko::Brush;
30
31use crate::layout::TextLayout;
32use crate::shape_cache::{DEFAULT_CAPACITY, ShapeCache, ShapeCacheStats, ShapeKey};
33use crate::style::{
34 GenericSlot, TextOverflow, TextStyle, to_parley_align, to_parley_family, to_parley_line_height,
35 to_parley_style, to_parley_weight,
36};
37
38/// The single character [`TextOverflow::Ellipsis`] appends/substitutes —
39/// U+2026 HORIZONTAL ELLIPSIS, one glyph rather than three ASCII periods.
40const ELLIPSIS: char = '\u{2026}';
41
42/// The hard cap on how many candidate measurements one
43/// [`TextContext::truncate_last_line`] call may perform.
44///
45/// The seek starts from a width-proportional estimate and normally converges
46/// in a handful of measurements; the cap is what makes the *worst* case a
47/// constant rather than a function of the line's length — an ellipsized
48/// 500-character session id can never cost 500 shaping passes on the UI
49/// thread. On exhaustion the longest candidate measured as fitting so far
50/// wins (see that fn's docs).
51const MAX_TRUNCATION_MEASUREMENTS: usize = 32;
52
53/// A font family registered via [`TextContext::register_fonts`], reported back
54/// to the caller so it can resolve widget styles against the exact name(s)
55/// fontique assigned.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct RegisteredFamily {
58 /// The family name fontique resolved the registered face(s) into (from
59 /// the font's own `name` table, or an override).
60 pub name: String,
61 /// How many faces (weights/styles) were registered into this family from
62 /// the supplied data.
63 pub face_count: usize,
64}
65
66/// Errors from [`TextContext::register_fonts`].
67#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
68pub enum FontError {
69 /// The supplied bytes contained no parseable font faces (invalid, empty,
70 /// or unrecognized data).
71 #[error("font data contained no parseable faces")]
72 NoFacesFound,
73}
74
75/// The process-wide record of every font blob a [`TextContext::register_fonts`]
76/// call accepted, in registration order and **never drained**.
77///
78/// # Why this lives here
79///
80/// App fonts enter the framework through `frust::register_app_fonts`, whose
81/// pending-byte slot (`frust_shell_common::font_registry`) each shell drains —
82/// destructively — into the one shell-owned `TextContext`. That context is the
83/// one `LayoutCtx::text_context` threads to widgets during layout, so every
84/// widget shaping through it (`Text`) already sees app fonts. The gap is the
85/// widget that legitimately owns a *private* context: `TextInput` applies edits
86/// synchronously during the event pass, where no context is threaded (see
87/// `frust_widgets::textinput`'s module docs), and a private context built after
88/// the shell drained the pending slot would otherwise carry no app font at all.
89///
90/// `frust-widgets` cannot read the shell's slot — `frust-shell-common` sits
91/// *above* it (`docs/ARCHITECTURE.md`'s layer dependencies) — so the durable
92/// record lives at the one layer the shell drain and every widget both already
93/// depend on. Registering into any context records the blob here; every other
94/// context picks it up through [`TextContext::sync_app_fonts`], which
95/// [`TextContext::new`] calls for free.
96///
97/// Append-only by design: the payloads must survive to seed contexts created
98/// *later*, which is exactly what a drain-once slot cannot do. Blobs are
99/// `Arc`-backed, so seeding a fresh context costs a refcount bump plus
100/// fontique's own parse, never a copy of the font bytes.
101static APP_FONTS: Mutex<Vec<Blob<u8>>> = Mutex::new(Vec::new());
102
103/// One [`register_generic_fallback`] call's payload: the raw font bytes plus
104/// the generic slot(s) it should be appended to.
105struct PendingGenericFallback {
106 blob: Blob<u8>,
107 generics: Vec<GenericSlot>,
108}
109
110/// The process-wide record of every [`register_generic_fallback`] call
111/// accepted so far, in registration order and **never drained** — the
112/// generic-family counterpart of [`APP_FONTS`], applied by
113/// [`TextContext::sync_app_fonts`] (and so, for free, by [`TextContext::new`])
114/// to every context's `fontique::Collection` generic-family map rather than
115/// its named-family table.
116static GENERIC_FALLBACKS: Mutex<Vec<PendingGenericFallback>> = Mutex::new(Vec::new());
117
118/// Registers `data` (raw font bytes) as a fallback face for each generic
119/// family slot in `generics`, applied to every [`TextContext`] — existing,
120/// on its next [`TextContext::sync_app_fonts`], and future, seeded for free
121/// by [`TextContext::new`] — via `fontique::Collection::append_generic_families`.
122///
123/// # Why this exists
124///
125/// [`TextContext::new`] builds `parley::FontContext::new()`'s fontique
126/// collection with the platform's real system-font backend on every target
127/// except `wasm32-unknown-unknown`, where fontique falls back to a dummy
128/// backend whose generic-family map is empty: [`crate::FontFamily::SystemUi`]
129/// (this crate's default family) and any bare [`GenericSlot`] then resolve to
130/// nothing and shape zero glyph runs. This function is the seam a host (a
131/// shell) calls once at startup with a bundled face to give a generic slot
132/// something to resolve to on that target; a platform whose backend already
133/// populates the generic map is unaffected unless it opts in too, since the
134/// registered face is only ever *appended* — never a replacement.
135///
136/// A stack that names a concrete family
137/// ([`crate::FontFamily::Named`]/[`crate::FontFamily::NamedWithGeneric`],
138/// registered via [`TextContext::register_fonts`]) still resolves before a
139/// generic fallback, since a named lookup is always tried first.
140///
141/// No-op (records nothing) when `generics` is empty. Bytes fontique cannot
142/// parse into any face register zero families when eventually applied, so
143/// this is a harmless no-op then too — the same "no error surface" contract
144/// [`TextContext::sync_app_fonts`] already follows for app fonts.
145pub fn register_generic_fallback(data: Vec<u8>, generics: &[GenericSlot]) {
146 if generics.is_empty() {
147 return;
148 }
149 let mut slot = GENERIC_FALLBACKS.lock().unwrap_or_else(|e| e.into_inner());
150 slot.push(PendingGenericFallback {
151 blob: Blob::from(data),
152 generics: generics.to_vec(),
153 });
154}
155
156/// Converts a Frust [`GenericSlot`] into parley's `GenericFamily` — the
157/// [`register_generic_fallback`] seam's own copy of
158/// `crate::style::generic_slot_to_parley` (private to that module), so this
159/// module still speaks only [`GenericSlot`] outward.
160fn generic_slot_to_parley(slot: GenericSlot) -> GenericFamily {
161 match slot {
162 GenericSlot::Monospace => GenericFamily::Monospace,
163 GenericSlot::SansSerif => GenericFamily::SansSerif,
164 GenericSlot::Serif => GenericFamily::Serif,
165 GenericSlot::SystemUi => GenericFamily::SystemUi,
166 GenericSlot::Emoji => GenericFamily::Emoji,
167 }
168}
169
170/// Applies `entries[*watermark..]` to `font_ctx`'s generic-family map,
171/// registering each entry's face and appending it to every generic slot the
172/// entry requested, then advances `*watermark` to `entries.len()`. A no-op,
173/// returning `false`, when `*watermark` already equals `entries.len()`.
174///
175/// Pure over its arguments — no process-wide state — so [`TextContext`]'s
176/// [`TextContext::sync_app_fonts`] can drive it against the shared
177/// [`GENERIC_FALLBACKS`] record for production use, while a test drives it
178/// against a locally built `entries`/`watermark` pair to exercise the exact
179/// registration/append logic without leaking a face into the process-wide
180/// record — [`register_generic_fallback`] never drains, so a test call
181/// through the real seam would otherwise remain registered for, and change
182/// the font resolution of, every other `TextContext` built later in the same
183/// test binary process.
184fn apply_generic_fallbacks(
185 font_ctx: &mut parley::FontContext,
186 entries: &[PendingGenericFallback],
187 watermark: &mut usize,
188) -> bool {
189 if *watermark == entries.len() {
190 return false;
191 }
192 let mut applied = false;
193 for entry in &entries[*watermark..] {
194 let registered = font_ctx.collection.register_fonts(entry.blob.clone(), None);
195 for (family_id, _faces) in registered {
196 applied = true;
197 for &generic in &entry.generics {
198 font_ctx.collection.append_generic_families(
199 generic_slot_to_parley(generic),
200 std::iter::once(family_id),
201 );
202 }
203 }
204 }
205 *watermark = entries.len();
206 applied
207}
208
209/// Owns parley's font matching and layout scratch state.
210///
211/// This is deliberately not `Clone`/`Sync`: it is expensive per-instance state
212/// meant to be constructed once and borrowed mutably for each layout pass. The
213/// generic brush parameter is fixed to [`peniko::Brush`] so glyph runs carry the
214/// same paint vocabulary as [`frust_scene`].
215pub struct TextContext {
216 font_ctx: parley::FontContext,
217 layout_ctx: parley::LayoutContext<Brush>,
218 /// Width-independent shape cache: a bounded LRU of
219 /// shaped layouts keyed by (text, style), so a width change re-runs
220 /// line-breaking only and repeated content shapes once. See
221 /// [`crate::shape_cache`].
222 shape_cache: ShapeCache,
223 /// How many of [`APP_FONTS`]' blobs this context has already registered —
224 /// its watermark into that append-only record. Bumped by
225 /// [`Self::sync_app_fonts`] and [`Self::register_fonts`].
226 app_fonts_applied: usize,
227 /// How many of [`GENERIC_FALLBACKS`]' entries this context has already
228 /// applied — its watermark into that append-only record, mirroring
229 /// `app_fonts_applied`. Bumped by [`Self::sync_app_fonts`] only; unlike an
230 /// app font, a generic fallback has no direct `register_*` method of its
231 /// own on `TextContext`.
232 generic_fallbacks_applied: usize,
233 /// How many [`Self::measure_uncached`] passes this context has run — the
234 /// observable hook the truncation walk's measurement bound is asserted
235 /// against (an uncached measure is invisible to [`ShapeCacheStats`], which
236 /// is the whole point of it).
237 #[cfg(test)]
238 measurements: usize,
239}
240
241/// Pushes `style` onto a fresh parley builder as its default run properties.
242///
243/// Shared by the caching [`TextContext::layout`] and the throwaway
244/// [`TextContext::measure_uncached`] so the two can never drift into shaping
245/// the same string against different fonts.
246fn push_style_defaults(builder: &mut parley::RangedBuilder<'_, Brush>, style: &TextStyle) {
247 // SystemUi resolves to the platform UI font (e.g. San Francisco on
248 // macOS) with no registration.
249 builder.push_default(to_parley_family(&style.family));
250 builder.push_default(StyleProperty::FontSize(style.size));
251 builder.push_default(StyleProperty::FontWeight(to_parley_weight(style.weight)));
252 builder.push_default(StyleProperty::FontStyle(to_parley_style(style.style)));
253 builder.push_default(StyleProperty::LetterSpacing(style.letter_spacing));
254 builder.push_default(StyleProperty::LineHeight(to_parley_line_height(
255 style.line_height,
256 )));
257 builder.push_default(StyleProperty::Brush(Brush::Solid(style.color)));
258}
259
260/// The number of lines a layout actually puts content on, discounting the
261/// empty line parley emits after a text-terminating `'\n'`.
262///
263/// parley attributes a hard break's newline to the line it terminates and then
264/// opens a further, zero-width line for the caret position after it — so
265/// `"Hello\n"` reports two lines while painting one. Counting that phantom
266/// line as overflow is what made `text("Hello\n").max_lines(1)` render
267/// "Hello…": a false ellipsis on text that fits. Only a *trailing* empty line
268/// at `text_len` is discounted; an empty line in the middle of the text
269/// (`"a\n\nb"`) is a real blank line the caller asked for.
270fn visible_line_count(layout: &TextLayout, text_len: usize) -> usize {
271 let count = layout.line_count();
272 if count <= 1 {
273 return count;
274 }
275 match layout.line_info(count - 1) {
276 Some(last) if last.range.is_empty() && last.range.end >= text_len => count - 1,
277 _ => count,
278 }
279}
280
281/// The truncation candidate for the prefix of `line_text` ending at byte
282/// `end`: that prefix plus [`ELLIPSIS`].
283///
284/// `end` is always one of `line_text`'s own `char_indices` boundaries, so the
285/// slice is in bounds by construction; `get` keeps an out-of-bounds/mid-char
286/// index a graceful degradation to the bare ellipsis rather than a panic.
287fn ellipsized(line_text: &str, end: usize) -> String {
288 format!("{}{ELLIPSIS}", line_text.get(..end).unwrap_or_default())
289}
290
291/// `span` scaled by `target / measured`, floored and clamped to `0..=span`.
292///
293/// The truncation seek's start estimate: `span` characters spanning `measured`
294/// pixels put roughly `span * target / measured` of them inside `target`. A
295/// degenerate `measured` (zero, negative, or non-finite — an unmeasurable
296/// line) falls back to the whole `span`, which merely starts the linear walk
297/// where an exhaustive longest-prefix-first scan would have.
298fn proportional_estimate(span: usize, target: f32, measured: f32) -> usize {
299 if measured <= 0.0 || !measured.is_finite() || !target.is_finite() {
300 return span;
301 }
302 if target <= 0.0 {
303 return 0;
304 }
305 let span_f = span as f64;
306 let estimate = (span_f * f64::from(target) / f64::from(measured)).floor();
307 if estimate <= 0.0 {
308 0
309 } else if estimate >= span_f {
310 span
311 } else {
312 estimate as usize
313 }
314}
315
316/// Where [`TextContext::new_with_timings`] spent its time (startup evidence).
317#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
318pub struct NewTimings {
319 /// `parley::FontContext::new` — the platform font-source scan.
320 pub font_system: Duration,
321 /// [`TextContext::sync_app_fonts`] — seeding the app fonts and generic fallbacks.
322 pub app_sync: Duration,
323}
324
325impl TextContext {
326 /// Builds a context with system fonts available, seeded with every app font
327 /// registered so far.
328 ///
329 /// [`parley::FontContext::new`] populates the fontique source collection from
330 /// the platform (Core Text on macOS); no manual font registration is
331 /// required for the default [`crate::FontFamily::SystemUi`] to resolve.
332 ///
333 /// The seeding step ([`Self::sync_app_fonts`]) is what makes a *private*
334 /// context (a `TextInput`'s) shape with the same app fonts the shell-owned
335 /// context does, however late it is constructed — see [`APP_FONTS`]. It
336 /// also applies every pending [`register_generic_fallback`] entry, so a
337 /// generic-family fallback a shell registered before this call is already
338 /// live in the returned context — see [`GENERIC_FALLBACKS`].
339 pub fn new() -> Self {
340 Self::new_with_timings().0
341 }
342
343 /// [`Self::new`] that also reports where its time went. `new` delegates
344 /// here, so the work and its order are identical.
345 pub fn new_with_timings() -> (Self, NewTimings) {
346 let font_started = Instant::now();
347 let font_ctx = parley::FontContext::new();
348 let font_system = font_started.elapsed();
349 let mut cx = Self {
350 font_ctx,
351 layout_ctx: parley::LayoutContext::new(),
352 shape_cache: ShapeCache::new(DEFAULT_CAPACITY),
353 app_fonts_applied: 0,
354 generic_fallbacks_applied: 0,
355 #[cfg(test)]
356 measurements: 0,
357 };
358 let sync_started = Instant::now();
359 cx.sync_app_fonts();
360 let app_sync = sync_started.elapsed();
361 (
362 cx,
363 NewTimings {
364 font_system,
365 app_sync,
366 },
367 )
368 }
369
370 /// Lays out `text` with `style`, wrapping to `max_width` when supplied.
371 ///
372 /// `max_width` is in the same logical-pixel units as `style.size` (the
373 /// layout scale is fixed at `1.0` here — physical-pixel scaling is applied
374 /// downstream via the scene transform). Passing `None` produces a single
375 /// unwrapped line per hard break in `text`. An empty `text` yields a layout
376 /// with no glyph runs and a near-zero size.
377 ///
378 /// `style.align` positions every line within the layout's width (a
379 /// no-op distinction from [`crate::TextAlign::Start`] until `max_width`
380 /// is bounded, since an unbounded line's width already equals its
381 /// content). Applies here **and** on the width-change re-break path in
382 /// [`crate::shape_cache::ShapeCache::get`] — see that fn's docs.
383 pub fn layout(&mut self, text: &str, style: &TextStyle, max_width: Option<f32>) -> TextLayout {
384 let key = ShapeKey::new(text, style);
385
386 // Shape-cache fast path: reuse the shaped layout, re-running
387 // line-breaking only on a width change (never re-shaping). Shaping is
388 // width-independent, so `max_width` is not part of the key.
389 if let Some(layout) = self.shape_cache.get(&key, max_width) {
390 return TextLayout::new(layout);
391 }
392
393 // Miss: shape from scratch, then cache the shaped/broken result.
394 //
395 // scale = 1.0: lay out in logical pixels; the render tier applies the
396 // device scale factor. quantize = true snaps advances for crisp glyphs.
397 let mut builder = self
398 .layout_ctx
399 .ranged_builder(&mut self.font_ctx, text, 1.0, true);
400 push_style_defaults(&mut builder, style);
401
402 let mut layout = builder.build(text);
403 layout.break_all_lines(max_width);
404 // Both hardcoded-alignment sites (this one and the width-change
405 // re-break path in `ShapeCache::get`) must apply `style.align` — see
406 // `to_parley_align`'s docs.
407 layout.align(
408 to_parley_align(style.align),
409 parley::layout::AlignmentOptions::default(),
410 );
411
412 self.shape_cache.insert(key, layout.clone(), max_width);
413 TextLayout::new(layout)
414 }
415
416 /// Measures `text` as one unwrapped line, **without touching the shape
417 /// cache** — no lookup, and crucially no insert.
418 ///
419 /// The truncation walk measures throwaway candidate strings that will
420 /// never be laid out again; routing them through [`Self::layout`] would
421 /// insert every one of them into the bounded LRU and evict that many live
422 /// entries app-wide — a whole-cache flush for a long line, on top of the
423 /// shaping cost. Shaping here is the same parley build [`Self::layout`]
424 /// performs minus the cache insert and the alignment pass (alignment moves
425 /// lines within a bounded width; it cannot change an unbounded line's
426 /// advance, which is all this returns).
427 fn measure_uncached(&mut self, text: &str, style: &TextStyle) -> f32 {
428 #[cfg(test)]
429 {
430 self.measurements += 1;
431 }
432 let mut builder = self
433 .layout_ctx
434 .ranged_builder(&mut self.font_ctx, text, 1.0, true);
435 push_style_defaults(&mut builder, style);
436 let mut layout = builder.build(text);
437 layout.break_all_lines(None);
438 layout.width()
439 }
440
441 /// How many uncached measurements ([`Self::measure_uncached`]) this
442 /// context has performed since it was built.
443 #[cfg(test)]
444 fn measurement_count(&self) -> usize {
445 self.measurements
446 }
447
448 /// Lays out `text` exactly like [`Self::layout`], then caps it to
449 /// `max_lines` (when `Some`), applying `overflow` to whatever is cut.
450 ///
451 /// `max_lines = None` delegates straight to [`Self::layout`] — the
452 /// zero-cost, behavior-unchanged path every existing caller keeps taking.
453 /// parley 0.11 has no native `max_lines`/ellipsis support, so a bounded
454 /// call does two *cached* shaping passes on the truncating path: once to
455 /// measure the full text, once more (in [`Self::layout`], so still
456 /// shape-cache-backed) to shape the truncated result — post-shaping
457 /// measure-and-truncate, not a parley feature. The ellipsis walk in
458 /// between adds only bounded, cache-invisible measurements
459 /// ([`Self::measure_uncached`]).
460 ///
461 /// # Algorithm
462 ///
463 /// A layout overflows `max_lines` in one of two ways parley itself
464 /// exposes no direct query for, so both are checked explicitly against
465 /// the full (untruncated) layout:
466 /// - **extra lines**: line-breaking produced more than `max_lines`
467 /// *content* lines (wrapping, or `max_lines` hard `\n` breaks in the
468 /// source) — see [`visible_line_count`] for why the raw line count is
469 /// not that number when the text ends in `\n`.
470 /// - **an unbreakable overrun**: exactly `max_lines` lines came out, but
471 /// the last visible one is itself wider than `max_width` — a run with
472 /// no break opportunity (one long unspaced word) that parley lets
473 /// overflow rather than force-break.
474 ///
475 /// Neither condition holds → the text already fits (this is also why an
476 /// exact-fit line, width `== max_width`, never gets truncated: the
477 /// comparison is a strict `>`); both overflow modes then return the full
478 /// layout as-is, *unless* `full` itself still carries the raw phantom
479 /// line parley opens after a terminal `'\n'` — in which case it's
480 /// reshaped with the trailing newline(s) stripped first, so a fitting
481 /// layout's reported line count and height always match what paints
482 /// (see [`visible_line_count`]).
483 ///
484 /// On overflow, [`TextOverflow::Clip`] only ever drops whole trailing
485 /// lines — the source text is cut at the end of line `max_lines - 1`'s
486 /// span and reshaped; an unbreakable-overrun-only case (no extra lines
487 /// to drop) is left untouched, since Clip never character-trims.
488 /// [`TextOverflow::Ellipsis`] does the same line drop, then further
489 /// truncates the last visible line's own text via
490 /// [`Self::truncate_last_line`] and appends [`ELLIPSIS`], before
491 /// reshaping the whole (earlier lines + truncated last line) string —
492 /// which is also why earlier lines reliably survive verbatim: parley's
493 /// line-breaker is greedy/left-to-right, so shortening what follows a
494 /// line never changes how that line itself broke.
495 pub fn layout_bounded(
496 &mut self,
497 text: &str,
498 style: &TextStyle,
499 max_width: Option<f32>,
500 max_lines: Option<usize>,
501 overflow: TextOverflow,
502 ) -> TextLayout {
503 let Some(max_lines) = max_lines else {
504 return self.layout(text, style, max_width);
505 };
506 if max_lines == 0 {
507 // No visible lines at all — never index `max_lines - 1` below.
508 return self.layout("", style, max_width);
509 }
510
511 let full = self.layout(text, style, max_width);
512 let last_visible = max_lines - 1;
513 let visible_lines = visible_line_count(&full, text.len());
514 let has_extra_lines = visible_lines > max_lines;
515 let Some(last_line) = full.line_info(last_visible) else {
516 // Fewer than `max_lines` lines exist at all: nothing overflowed.
517 return full;
518 };
519 let width_overflows = matches!(max_width, Some(w) if last_line.width > w);
520
521 if !has_extra_lines && !width_overflows {
522 if full.line_count() == visible_lines {
523 // No phantom trailing line to discount — `full`'s raw line
524 // count already matches what's painted.
525 return full;
526 }
527 // The text fits, but `full` is parley's raw, undiscounted
528 // layout: it still counts the phantom line opened after a
529 // terminal `'\n'` in both its line count and its height (the
530 // sum of every raw line's height, `visible_line_count`'s docs)
531 // — so returning it unchanged reports a taller block than what
532 // actually paints. Reshape with the trailing newline(s)
533 // stripped — the same trim-then-reshape the `Clip` arm below
534 // performs — so the returned layout's line count and height
535 // match the visible content exactly.
536 let Some(cut) = text.get(..last_line.range.end) else {
537 return full;
538 };
539 return self.layout(cut.trim_end(), style, max_width);
540 }
541
542 match overflow {
543 TextOverflow::Clip => {
544 if !has_extra_lines {
545 // Only an unbreakable-run width overrun, no extra lines
546 // to drop — Clip never character-trims a line.
547 return full;
548 }
549 // `trim_end`: a line's own text range can include the very
550 // whitespace/`\n` that ends it (parley attributes a hard
551 // break's newline to the line it terminates), so a naive cut
552 // could leave a trailing `\n` in the reshaped text — which
553 // parley reads as *another* hard break, silently growing the
554 // line count back past `max_lines`.
555 //
556 // `get` (not `[..]`): the index is parley-derived, and a
557 // truncated layout is a far better failure mode for a caller
558 // than a panic if it ever stops landing on a char boundary of
559 // *this* string — see the fn docs' slicing contract.
560 let Some(cut) = text.get(..last_line.range.end) else {
561 return full;
562 };
563 self.layout(cut.trim_end(), style, max_width)
564 }
565 TextOverflow::Ellipsis => {
566 // See the `Clip` arm above for the `get` and `trim_end`
567 // rationale (a trailing `\n` must not survive into a reshaped
568 // fragment).
569 let (Some(before), Some(line_text)) = (
570 text.get(..last_line.range.start),
571 text.get(last_line.range.start..last_line.range.end),
572 ) else {
573 return full;
574 };
575 let line_text = line_text.trim_end();
576 let truncated_last = match max_width {
577 Some(w) => self.truncate_last_line(line_text, style, w, last_line.width),
578 // No width to truncate against — keep the whole line,
579 // just mark it cut.
580 None => format!("{line_text}{ELLIPSIS}"),
581 };
582 let final_text = format!("{before}{truncated_last}");
583 self.layout(&final_text, style, max_width)
584 }
585 }
586 }
587
588 /// The [`TextOverflow::Ellipsis`] truncation walk: returns a prefix of
589 /// `line_text` (cut on a char boundary) plus `'…'`, measured alone as a
590 /// single unwrapped line to be no wider than `max_width`. Falls back to a
591 /// bare `'…'` if not even that fits (never returns an empty string with no
592 /// overflow marker at all). `line_width` is the line's already-measured
593 /// natural advance — the seek's scale reference, not a bound.
594 ///
595 /// # Bounded seek
596 ///
597 /// The walk *starts near the answer* rather than at one end: the ellipsis
598 /// is measured once, and the first candidate is the width-proportional
599 /// character estimate `chars * (max_width - ellipsis) / line_width`. One
600 /// proportional re-estimate from that candidate's own measured prefix
601 /// width follows (which lands a line mixing very narrow and very wide
602 /// glyphs within a few characters of the answer), and only then a linear,
603 /// one-character-at-a-time correction in whichever direction the probe
604 /// pointed.
605 ///
606 /// The correction stays *linear* deliberately: kerning/ligature reshaping
607 /// around a truncation point is not provably monotonic in every font, so a
608 /// binary search could converge one character off in an adversarial font.
609 /// This is the muxr `clipped_label` precedent's walk — only its starting
610 /// point is estimated instead of being the whole string.
611 ///
612 /// # Cost bound
613 ///
614 /// Every measurement goes through [`Self::measure_uncached`], and the walk
615 /// performs at most [`MAX_TRUNCATION_MEASUREMENTS`] of them. Both halves
616 /// matter: a longest-prefix-first scan of an unbreakable token (a URL,
617 /// hash, or session id wider than its box — the primary ellipsis case)
618 /// cost one *cached* shaping pass per character, i.e. `O(len²)` work on
619 /// the UI thread plus `len` inserts that evicted the shape cache's live
620 /// entries app-wide. On cap exhaustion the longest candidate measured as
621 /// fitting so far wins (the bare `'…'` in the worst case): the seek never
622 /// loops unbounded, and never returns a candidate it did not measure as
623 /// fitting.
624 fn truncate_last_line(
625 &mut self,
626 line_text: &str,
627 style: &TextStyle,
628 max_width: f32,
629 line_width: f32,
630 ) -> String {
631 let mut budget = MAX_TRUNCATION_MEASUREMENTS;
632
633 // The empty-prefix candidate first: it is both the fallback and the
634 // headroom every other candidate is measured against.
635 let ellipsis_width = self.measure_uncached(&ellipsized(line_text, 0), style);
636 budget -= 1;
637 if ellipsis_width > max_width {
638 // Even a bare ellipsis overflows — best effort, still signal the
639 // truncation rather than silently rendering nothing.
640 return ELLIPSIS.to_string();
641 }
642 let available = max_width - ellipsis_width;
643
644 // `ends[i]` is the byte length of the prefix holding the line's first
645 // `i` characters; `ends[longest]` is the whole line.
646 let mut ends: Vec<usize> = line_text.char_indices().map(|(i, _)| i).collect();
647 ends.push(line_text.len());
648 let longest = ends.len() - 1;
649
650 let mut cursor = proportional_estimate(longest, available, line_width);
651 let mut width = self.measure_uncached(&ellipsized(line_text, ends[cursor]), style);
652 budget -= 1;
653 // One re-estimate from what the probe actually measured, so a line
654 // whose glyph widths are nowhere near uniform still starts the linear
655 // walk close to the answer.
656 if budget > 0 && cursor > 0 {
657 let refined = proportional_estimate(cursor, available, width - ellipsis_width);
658 if refined != cursor {
659 cursor = refined;
660 width = self.measure_uncached(&ellipsized(line_text, ends[cursor]), style);
661 budget -= 1;
662 }
663 }
664
665 // The empty prefix is known to fit (checked above), so a fitting
666 // answer always exists no matter where the budget runs out.
667 let mut best = 0;
668 if width <= max_width {
669 best = cursor;
670 // Grow a character at a time while candidates keep fitting.
671 while best < longest && budget > 0 {
672 budget -= 1;
673 let next = best + 1;
674 if self.measure_uncached(&ellipsized(line_text, ends[next]), style) > max_width {
675 break;
676 }
677 best = next;
678 }
679 } else {
680 // Shrink a character at a time until one fits.
681 while cursor > 0 && budget > 0 {
682 budget -= 1;
683 cursor -= 1;
684 if self.measure_uncached(&ellipsized(line_text, ends[cursor]), style) <= max_width {
685 best = cursor;
686 break;
687 }
688 }
689 }
690 ellipsized(line_text, ends[best])
691 }
692
693 /// The shape cache's instrumentation counters (shapes performed,
694 /// line-break-only relayouts, full hits, evictions).
695 ///
696 /// The observable hook the shape-cache tests assert against, and a
697 /// perf signal otherwise. Plain scalar data — no `parley`/`vello`/`wgpu`
698 /// type leaks through (scene-layer purity).
699 pub fn shape_cache_stats(&self) -> ShapeCacheStats {
700 self.shape_cache.stats()
701 }
702
703 /// Registers font faces from raw bytes (TTF/OTF, or a TTC/OTC
704 /// collection) so they resolve by family name via
705 /// [`crate::FontFamily::named`]/[`crate::FontFamily::stack`].
706 ///
707 /// Wraps fontique's [`parley::fontique::Collection::register_fonts`]. A
708 /// registered family **shadows** a same-named system family (fontique
709 /// 0.11 semantics: the registered map is checked before the system map),
710 /// so bundling a family already present on the platform (e.g. "Roboto")
711 /// deterministically wins over the platform's own copy.
712 ///
713 /// Always clears the shape cache on success — a same-named registered
714 /// family changes shaping without changing the cache key, so any layout
715 /// shaped before this call could otherwise be served stale. Returns
716 /// [`FontError::NoFacesFound`] (no panic) for invalid/empty data, an
717 /// empty byte slice, or bytes with no faces fontique can parse.
718 ///
719 /// **Caller-visible relayout contract**: registering fonts after a shell
720 /// has already laid out text does not retroactively re-shape anything
721 /// still cached elsewhere (e.g. a widget's own retained layout) — a shell
722 /// calling this must force `ChangeFlags::LAYOUT | PAINT` the same way a
723 /// theme swap does (see `docs/ARCHITECTURE.md`'s Theme delivery), so the
724 /// next layout pass re-shapes against the newly registered faces. This
725 /// crate only owns the shape-cache half of that contract.
726 ///
727 /// An accepted payload is also recorded process-wide ([`APP_FONTS`]), so
728 /// every `TextContext` constructed later — and every existing one that
729 /// calls [`Self::sync_app_fonts`] — resolves the same family. That is what
730 /// carries an app font from a shell's drain into a widget-owned private
731 /// context.
732 pub fn register_fonts(&mut self, data: Vec<u8>) -> Result<Vec<RegisteredFamily>, FontError> {
733 let blob = Blob::from(data);
734
735 // Held across the registration below so the watermark this sets cannot
736 // skip a blob another context records concurrently.
737 let mut slot = APP_FONTS.lock().unwrap_or_else(|e| e.into_inner());
738 // Catch up first: this context may be behind the record (another
739 // context registered since it was built), and the watermark below
740 // would otherwise declare those blobs applied without applying them.
741 for missed in &slot[self.app_fonts_applied..] {
742 let _ = self
743 .font_ctx
744 .collection
745 .register_fonts(missed.clone(), None);
746 }
747 self.app_fonts_applied = slot.len();
748
749 let registered = self.font_ctx.collection.register_fonts(blob.clone(), None);
750 if registered.is_empty() {
751 return Err(FontError::NoFacesFound);
752 }
753 slot.push(blob);
754 self.app_fonts_applied = slot.len();
755 drop(slot);
756
757 let families = registered
758 .into_iter()
759 .map(|(family_id, faces)| RegisteredFamily {
760 name: self
761 .font_ctx
762 .collection
763 .family_name(family_id)
764 .unwrap_or_default()
765 .to_string(),
766 face_count: faces.len(),
767 })
768 .collect();
769
770 self.clear_shape_cache();
771 Ok(families)
772 }
773
774 /// Registers every app font this context is missing (see [`APP_FONTS`])
775 /// and applies every [`register_generic_fallback`] entry it is missing
776 /// (see [`GENERIC_FALLBACKS`]), returning whether either actually
777 /// changed this context's resolution.
778 ///
779 /// Cheap when there is nothing to do — a lock and a length compare per
780 /// record, no allocation and no font parsing — so a widget owning a
781 /// private context can call it once per layout pass to pick up a font (or
782 /// fallback) registered *after* the context was built (the shells'
783 /// per-frame late drain).
784 ///
785 /// A `true` return carries the same caller-visible relayout contract as
786 /// [`Self::register_fonts`]: this context's shape cache is cleared, but a
787 /// layout retained *outside* it (a [`crate::TextEditor`]'s parley layout)
788 /// must be re-shaped by its owner.
789 pub fn sync_app_fonts(&mut self) -> bool {
790 let mut applied = self.sync_generic_fallbacks();
791
792 let slot = APP_FONTS.lock().unwrap_or_else(|e| e.into_inner());
793 if self.app_fonts_applied != slot.len() {
794 for blob in &slot[self.app_fonts_applied..] {
795 if !self
796 .font_ctx
797 .collection
798 .register_fonts(blob.clone(), None)
799 .is_empty()
800 {
801 applied = true;
802 }
803 }
804 self.app_fonts_applied = slot.len();
805 }
806 drop(slot);
807
808 if applied {
809 self.clear_shape_cache();
810 }
811 applied
812 }
813
814 /// Applies every [`GENERIC_FALLBACKS`] entry this context is missing,
815 /// appending each face's registered family(ies) to every generic slot the
816 /// entry requested. Idempotent: an entry already applied to this context
817 /// (tracked by [`Self::generic_fallbacks_applied`], a watermark into that
818 /// append-only record, exactly like [`Self::app_fonts_applied`]) is never
819 /// re-registered, so a repeated [`Self::sync_app_fonts`] call never
820 /// double-appends the same family into a generic slot's fallback list.
821 ///
822 /// Returns whether at least one face actually registered — the same
823 /// signal [`Self::sync_app_fonts`] folds together with the app-font half
824 /// to decide whether to clear the shape cache.
825 ///
826 /// Thin wrapper over [`apply_generic_fallbacks`], the pure logic this
827 /// drives against the process-wide [`GENERIC_FALLBACKS`] record — see
828 /// that function's docs for why the split exists.
829 fn sync_generic_fallbacks(&mut self) -> bool {
830 let slot = GENERIC_FALLBACKS.lock().unwrap_or_else(|e| e.into_inner());
831 apply_generic_fallbacks(
832 &mut self.font_ctx,
833 &slot,
834 &mut self.generic_fallbacks_applied,
835 )
836 }
837
838 /// Drops every cached shaped layout, forcing the next [`Self::layout`]
839 /// call for any (text, style) pair to re-shape from scratch.
840 ///
841 /// Exposed (not just an internal helper) for the shell late-drain path
842 /// (a shell registering fonts after startup, once widgets may already
843 /// hold cached layouts elsewhere) — [`Self::register_fonts`] already
844 /// calls this internally on success, so a caller registering fonts
845 /// doesn't need to call it separately.
846 pub fn clear_shape_cache(&mut self) {
847 self.shape_cache = ShapeCache::new(DEFAULT_CAPACITY);
848 }
849
850 /// Borrows the parley font and layout contexts together, for constructing a
851 /// transient [`parley::PlainEditorDriver`] in [`crate::TextEditor::apply`].
852 ///
853 /// Returned as a tuple so a single mutable borrow of `self` yields both
854 /// contexts the driver's borrow-triple needs. `pub(crate)` — parley types
855 /// must not leak past the crate boundary (scene-layer purity).
856 pub(crate) fn driver_contexts(
857 &mut self,
858 ) -> (&mut parley::FontContext, &mut parley::LayoutContext<Brush>) {
859 (&mut self.font_ctx, &mut self.layout_ctx)
860 }
861}
862
863impl Default for TextContext {
864 fn default() -> Self {
865 Self::new()
866 }
867}
868
869#[cfg(test)]
870mod tests {
871 use super::*;
872 use crate::shape_cache::DEFAULT_CAPACITY;
873 use peniko::Color;
874
875 #[test]
876 fn new_with_timings_builds_the_same_context_as_new() {
877 let (mut timed, timings) = TextContext::new_with_timings();
878 let mut plain = TextContext::new();
879 let style = style(16.0);
880 let a = timed.layout("Hello", &style, None);
881 let b = plain.layout("Hello", &style, None);
882 assert_eq!(a.size(), b.size());
883 // Both phases ran: the font-system scan is never free.
884 assert!(timings.font_system > Duration::ZERO);
885 assert!(timings.font_system + timings.app_sync < Duration::from_secs(60));
886 }
887
888 /// A body-text style at `size`.
889 fn style(size: f32) -> TextStyle {
890 TextStyle::new(size, Color::BLACK)
891 }
892
893 /// The registered-font fixture, shared with `tests/register_fonts.rs`.
894 const TUFFY: &[u8] = include_bytes!("../tests/fonts/Tuffy-Subset.ttf");
895
896 /// The raw bytes of the face a shaped run of `text` actually resolved to.
897 fn shaped_font_bytes(cx: &mut TextContext, text: &str, sty: &TextStyle) -> Vec<u8> {
898 let layout = cx.layout(text, sty, None);
899 let runs = layout.to_scene_runs(kurbo::Point::ORIGIN);
900 runs.first()
901 .expect("expected at least one glyph run")
902 .font
903 .font()
904 .data
905 .as_ref()
906 .to_vec()
907 }
908
909 #[test]
910 fn an_app_font_reaches_contexts_built_later_and_older_ones_on_sync() {
911 // The seam under test: `register_fonts` is what a shell's
912 // font drain calls, and a widget-owned context (a `TextInput`'s) is
913 // built from `new()` long after that drain. Both legs below are
914 // monotone — the record is append-only and never reset, so nothing
915 // here depends on the order tests run in.
916 let s = TextStyle {
917 family: crate::FontFamily::named("Tuffy"),
918 ..style(24.0)
919 };
920
921 // A context that exists *before* the registration.
922 let mut older = TextContext::new();
923
924 // The shell's drain.
925 let mut shell = TextContext::new();
926 shell
927 .register_fonts(TUFFY.to_vec())
928 .expect("valid TTF bytes must register");
929
930 // A context built after it needs no explicit sync.
931 let mut later = TextContext::new();
932 assert!(
933 !later.sync_app_fonts(),
934 "a freshly built context is already current with the app-font record"
935 );
936 assert!(
937 shaped_font_bytes(&mut later, "0123456789", &s) == TUFFY,
938 "a context built after the registration must shape with the app font"
939 );
940
941 // An older one picks it up on the explicit sync a per-frame caller makes.
942 older.sync_app_fonts();
943 assert!(
944 shaped_font_bytes(&mut older, "0123456789", &s) == TUFFY,
945 "an already-built context must pick the app font up on sync_app_fonts"
946 );
947 }
948
949 /// The generic-fallback registry, exercised against a collection built
950 /// with `system_fonts: false` — the same empty-generic-family-map shape
951 /// `wasm32`'s dummy fontique backend has (see
952 /// [`register_generic_fallback`]'s docs) — since a real host's system
953 /// collection already has a resolvable `SystemUi`, so it could never
954 /// reproduce the defect this seam fixes.
955 #[test]
956 fn generic_fallback_maps_requested_slots_and_is_idempotent() {
957 use parley::fontique::{Collection, CollectionOptions, GenericFamily};
958
959 // Entries built locally, driven straight through `apply_generic_fallbacks`
960 // rather than the real `register_generic_fallback` seam — that seam
961 // writes to the process-wide `GENERIC_FALLBACKS` record, which is
962 // never drained, so a real call here would keep applying this face to
963 // every other test's (real-system-font) `TextContext` built later in
964 // this same test binary process and could change which face they
965 // resolve. See `apply_generic_fallbacks`'s docs.
966 let entries = vec![PendingGenericFallback {
967 blob: Blob::from(TUFFY.to_vec()),
968 generics: vec![GenericSlot::SystemUi, GenericSlot::SansSerif],
969 }];
970 let mut watermark = 0;
971
972 // A systemless collection: fontique's own stand-in for the wasm32
973 // dummy backend's empty generic-family map.
974 let mut font_ctx = parley::FontContext {
975 collection: Collection::new(CollectionOptions {
976 system_fonts: false,
977 ..Default::default()
978 }),
979 source_cache: Default::default(),
980 };
981
982 let applied = apply_generic_fallbacks(&mut font_ctx, &entries, &mut watermark);
983 assert!(
984 applied,
985 "registering a parseable face must report at least one applied family"
986 );
987
988 let system_ui: Vec<_> = font_ctx
989 .collection
990 .generic_families(GenericFamily::SystemUi)
991 .collect();
992 assert_eq!(
993 system_ui.len(),
994 1,
995 "SystemUi must resolve to exactly the registered fallback face"
996 );
997 assert!(
998 font_ctx
999 .collection
1000 .generic_families(GenericFamily::Monospace)
1001 .next()
1002 .is_none(),
1003 "Monospace must stay unmapped — only SystemUi/SansSerif were requested"
1004 );
1005
1006 // The defect under test: SystemUi-styled text on an otherwise-empty
1007 // system collection must still shape into glyph runs.
1008 let mut cx = TextContext {
1009 font_ctx,
1010 layout_ctx: parley::LayoutContext::new(),
1011 shape_cache: ShapeCache::new(DEFAULT_CAPACITY),
1012 app_fonts_applied: 0,
1013 generic_fallbacks_applied: watermark,
1014 #[cfg(test)]
1015 measurements: 0,
1016 };
1017 let layout = cx.layout("Hello", &style(20.0), None);
1018 let runs = layout.to_scene_runs(kurbo::Point::ORIGIN);
1019 assert!(
1020 !runs.is_empty(),
1021 "SystemUi text must shape with the registered fallback face even \
1022 when the system font collection is empty"
1023 );
1024
1025 // Idempotence: a second apply with nothing new pending must not
1026 // re-append the same family into the generic-family list.
1027 let applied_again = apply_generic_fallbacks(&mut cx.font_ctx, &entries, &mut watermark);
1028 assert!(
1029 !applied_again,
1030 "nothing new is pending — a repeat apply must be a no-op"
1031 );
1032 let system_ui_after: Vec<_> = cx
1033 .font_ctx
1034 .collection
1035 .generic_families(GenericFamily::SystemUi)
1036 .collect();
1037 assert_eq!(
1038 system_ui_after, system_ui,
1039 "a repeat apply must not double-register the fallback family"
1040 );
1041 }
1042
1043 #[test]
1044 fn register_generic_fallback_is_a_noop_with_no_generics() {
1045 // Deliberately the *empty-generics* case only: `register_generic_fallback`
1046 // writes to the process-wide, never-drained `GENERIC_FALLBACKS` record,
1047 // so a call naming a real slot here would keep applying for the rest of
1048 // this test binary's run — see `apply_generic_fallbacks`'s docs and the
1049 // isolated-collection test above, which exercises the real
1050 // registration/append behavior without that leak.
1051 let before = GENERIC_FALLBACKS
1052 .lock()
1053 .unwrap_or_else(|e| e.into_inner())
1054 .len();
1055 register_generic_fallback(vec![1, 2, 3], &[]);
1056 let after = GENERIC_FALLBACKS
1057 .lock()
1058 .unwrap_or_else(|e| e.into_inner())
1059 .len();
1060 assert_eq!(
1061 before, after,
1062 "no generic slots requested — nothing should be queued"
1063 );
1064 }
1065
1066 #[test]
1067 fn same_text_two_widths_shapes_once() {
1068 // The core claim: shaping is width-independent. Laying out
1069 // the same text+style at two different widths shapes exactly once; the
1070 // width change re-runs line-breaking only, and a repeated width is a
1071 // full reuse.
1072 let mut cx = TextContext::new();
1073 let text = "Hello from Frust, the pure Rust mobile UI toolkit";
1074 let s = style(16.0);
1075
1076 let _ = cx.layout(text, &s, Some(200.0)); // miss → shape
1077 let _ = cx.layout(text, &s, Some(80.0)); // width change → line-break only
1078 let _ = cx.layout(text, &s, Some(80.0)); // same width → full hit
1079
1080 let stats = cx.shape_cache_stats();
1081 assert_eq!(stats.shapes, 1, "shaping must run exactly once");
1082 assert_eq!(stats.line_breaks, 1, "the differing width re-breaks once");
1083 assert_eq!(stats.hits, 1, "the repeated width is a full reuse");
1084 }
1085
1086 #[test]
1087 fn text_change_forces_a_fresh_shape() {
1088 let mut cx = TextContext::new();
1089 let s = style(16.0);
1090 let _ = cx.layout("hello", &s, None);
1091 let _ = cx.layout("world", &s, None);
1092 assert_eq!(
1093 cx.shape_cache_stats().shapes,
1094 2,
1095 "a different string is a distinct key → fresh shape (no stale reuse)"
1096 );
1097 }
1098
1099 #[test]
1100 fn style_and_color_changes_force_fresh_shapes() {
1101 let mut cx = TextContext::new();
1102 let _ = cx.layout("hello", &style(16.0), None);
1103 // Size change.
1104 let _ = cx.layout("hello", &style(24.0), None);
1105 // Color change (the glyph brush is baked into the shaped layout, so a
1106 // color change must not reuse an earlier shape — the theme-swap
1107 // correctness contract at the shaping layer).
1108 let _ = cx.layout("hello", &TextStyle::new(16.0, Color::WHITE), None);
1109 assert_eq!(cx.shape_cache_stats().shapes, 3);
1110 }
1111
1112 #[test]
1113 fn invalidation_correct_across_all_mutation_orders() {
1114 // Property-style: whatever the interleaving of text/style/width, every
1115 // layout the cache returns matches a freshly-shaped reference — the
1116 // stale-text/style/width failure mode is what this kills.
1117 let styles = [style(16.0), style(28.0)];
1118 let texts = ["alpha beta", "gamma delta epsilon zeta eta"];
1119 let widths = [None, Some(60.0), Some(140.0)];
1120
1121 let mut cx = TextContext::new();
1122 for _round in 0..3 {
1123 for t in &texts {
1124 for s in &styles {
1125 for w in &widths {
1126 let cached = cx.layout(t, s, *w).size();
1127 // A pristine context shapes this exact combination fresh.
1128 let mut reference = TextContext::new();
1129 let fresh = reference.layout(t, s, *w).size();
1130 assert_eq!(
1131 cached, fresh,
1132 "cached layout for (text={t:?}, size={}, width={w:?}) \
1133 is stale — got {cached:?}, expected {fresh:?}",
1134 s.size
1135 );
1136 }
1137 }
1138 }
1139 }
1140 }
1141
1142 #[test]
1143 fn cache_is_bounded_and_evicts_least_recently_used() {
1144 let mut cx = TextContext::new();
1145 let s = style(16.0);
1146 let overflow = 8;
1147 let n = DEFAULT_CAPACITY + overflow;
1148
1149 // Fill past capacity with distinct strings (entry 0 is the oldest).
1150 for i in 0..n {
1151 let _ = cx.layout(&format!("entry number {i}"), &s, None);
1152 }
1153 let stats = cx.shape_cache_stats();
1154 assert_eq!(stats.shapes, n as u64, "each distinct string shapes once");
1155 assert_eq!(
1156 stats.evictions, overflow as u64,
1157 "capacity overflow evicts exactly the surplus, no unbounded growth"
1158 );
1159
1160 // The most recently used entry is still cached → a full hit.
1161 let hits_before = cx.shape_cache_stats().hits;
1162 let _ = cx.layout(&format!("entry number {}", n - 1), &s, None);
1163 assert_eq!(
1164 cx.shape_cache_stats().hits,
1165 hits_before + 1,
1166 "the most-recently-used entry survives eviction"
1167 );
1168
1169 // The oldest entry was evicted → re-requesting it re-shapes.
1170 let shapes_before = cx.shape_cache_stats().shapes;
1171 let _ = cx.layout("entry number 0", &s, None);
1172 assert_eq!(
1173 cx.shape_cache_stats().shapes,
1174 shapes_before + 1,
1175 "an evicted entry is re-shaped, not served stale"
1176 );
1177 }
1178
1179 // --- Paragraph alignment ---
1180
1181 use crate::style::TextAlign;
1182
1183 /// A style at `align`, otherwise default.
1184 fn aligned_style(align: TextAlign) -> TextStyle {
1185 TextStyle {
1186 align,
1187 ..style(16.0)
1188 }
1189 }
1190
1191 /// Lays out `text` at `max_width` and returns each line's minimum glyph
1192 /// `x` (its rendered left edge), in line order.
1193 ///
1194 /// Glyphs on the same line share a `y` (`crate::convert`'s coordinate
1195 /// contract), so grouping by `y` recovers per-line positions from the
1196 /// flat glyph-run output — the only origin-independent signal that
1197 /// alignment (baked into `positioned_glyphs()` by parley) actually moved
1198 /// a line, as opposed to just the style being set.
1199 fn line_min_x(cx: &mut TextContext, text: &str, style: &TextStyle, max_width: f32) -> Vec<f32> {
1200 let layout = cx.layout(text, style, Some(max_width));
1201 let runs = layout.to_scene_runs(kurbo::Point::ORIGIN);
1202 let mut by_y: Vec<(f32, f32)> = Vec::new();
1203 for run in &runs {
1204 for g in &run.glyphs {
1205 match by_y.iter_mut().find(|(y, _)| (*y - g.y).abs() < 0.01) {
1206 Some((_, min_x)) => *min_x = min_x.min(g.x),
1207 None => by_y.push((g.y, g.x)),
1208 }
1209 }
1210 }
1211 by_y.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
1212 by_y.into_iter().map(|(_, x)| x).collect()
1213 }
1214
1215 /// Two hard-broken lines of very different length, so alignment moves
1216 /// them by clearly different, non-accidental amounts. The long line is
1217 /// short enough to stay well under `max_width` (400px) even under a
1218 /// pessimistically wide glyph metric, so it never itself soft-wraps —
1219 /// keeping the line count at exactly two regardless of the host's
1220 /// resolved system font.
1221 const TWO_LINES: &str = "A\nBBBBBBBBBB";
1222
1223 #[test]
1224 fn center_and_right_align_position_wrapped_lines_correctly() {
1225 // Asserts on per-line origins, not on the style merely being set —
1226 // this is what catches the v1 defect where `Align(CENTER, text(..))`
1227 // centred only the block, leaving every line hugging the leading
1228 // edge.
1229 let mut cx = TextContext::new();
1230 let max_width = 400.0;
1231
1232 let start_x = line_min_x(
1233 &mut cx,
1234 TWO_LINES,
1235 &aligned_style(TextAlign::Start),
1236 max_width,
1237 );
1238 let center_x = line_min_x(
1239 &mut cx,
1240 TWO_LINES,
1241 &aligned_style(TextAlign::Center),
1242 max_width,
1243 );
1244 let right_x = line_min_x(
1245 &mut cx,
1246 TWO_LINES,
1247 &aligned_style(TextAlign::Right),
1248 max_width,
1249 );
1250
1251 assert_eq!(start_x.len(), 2, "expected two hard-broken lines");
1252 assert_eq!(center_x.len(), 2);
1253 assert_eq!(right_x.len(), 2);
1254
1255 // Start (default, v1-compatible): every line hugs the left edge.
1256 assert!(
1257 start_x[0].abs() < 0.5 && start_x[1].abs() < 0.5,
1258 "start-aligned lines must hug the left edge: {start_x:?}"
1259 );
1260
1261 // Center: both lines move off the left edge, and the short line ("A")
1262 // centers further right than the long line, since each line is
1263 // centered independently within the 400px container.
1264 assert!(
1265 center_x[0] > 1.0 && center_x[1] > 1.0,
1266 "center-aligned lines must move off the left edge: {center_x:?}"
1267 );
1268 assert!(
1269 center_x[0] > center_x[1] + 1.0,
1270 "the shorter line must center further right than the longer one: {center_x:?}"
1271 );
1272
1273 // Right: same relationship — the short line's left edge sits further
1274 // right than the long line's, since both trailing edges align.
1275 assert!(
1276 right_x[0] > right_x[1] + 1.0,
1277 "the shorter line's right-aligned left edge must sit further right: {right_x:?}"
1278 );
1279 }
1280
1281 #[test]
1282 fn alignment_survives_a_width_change_through_the_shape_cache_rebreak() {
1283 // The resize regression the adversarial pass flagged: `ShapeCache::get`
1284 // had its own hardcoded `Alignment::Start` on the re-break path, so a
1285 // resized layout would silently revert to `Start` even though the
1286 // initial (from-scratch) layout in this fn correctly centered.
1287 let mut cx = TextContext::new();
1288 let centered = aligned_style(TextAlign::Center);
1289
1290 // First pass: shapes and breaks from scratch at 400px.
1291 let _ = cx.layout(TWO_LINES, ¢ered, Some(400.0));
1292
1293 // Second pass at a different width: a shape-cache hit that re-breaks
1294 // (not a fresh shape) — exactly the path `ShapeCache::get` owns.
1295 let x = line_min_x(&mut cx, TWO_LINES, ¢ered, 500.0);
1296 assert_eq!(x.len(), 2);
1297 assert!(
1298 x[0] > x[1] + 1.0,
1299 "center alignment must survive the width-change re-break: {x:?}"
1300 );
1301
1302 let stats = cx.shape_cache_stats();
1303 assert_eq!(stats.shapes, 1, "the width change must not re-shape");
1304 assert_eq!(
1305 stats.line_breaks, 1,
1306 "sanity: this really went through the re-break path"
1307 );
1308 }
1309
1310 #[test]
1311 fn same_text_different_alignment_does_not_collide_in_the_shape_cache() {
1312 // The `ShapeKey` extension regression: two texts identical but for
1313 // alignment must shape (and render) independently, not share one
1314 // cache entry.
1315 let mut cx = TextContext::new();
1316 let max_width = 400.0;
1317
1318 let start_x = line_min_x(
1319 &mut cx,
1320 TWO_LINES,
1321 &aligned_style(TextAlign::Start),
1322 max_width,
1323 );
1324 let center_x = line_min_x(
1325 &mut cx,
1326 TWO_LINES,
1327 &aligned_style(TextAlign::Center),
1328 max_width,
1329 );
1330
1331 assert_ne!(
1332 start_x, center_x,
1333 "a cache collision would make the second (center) request come back \
1334 identical to the first (start)"
1335 );
1336 assert!(
1337 start_x[0].abs() < 0.5,
1338 "the start-aligned request must render correctly despite sharing text \
1339 with a differently-aligned request: {start_x:?}"
1340 );
1341 assert!(
1342 center_x[0] > center_x[1] + 1.0,
1343 "the center-aligned request must render correctly despite sharing text \
1344 with a differently-aligned request: {center_x:?}"
1345 );
1346
1347 let stats = cx.shape_cache_stats();
1348 assert_eq!(
1349 stats.shapes, 2,
1350 "distinct alignment must be a distinct shape, not a collision"
1351 );
1352 }
1353
1354 #[test]
1355 fn default_alignment_matches_pre_findings_39_start_behavior() {
1356 // Byte-for-byte parity: the default style's layout is unchanged from
1357 // before this retrofit (both hardcoded call sites now apply
1358 // `TextAlign::Start`, exactly what they hardcoded before).
1359 let mut cx = TextContext::new();
1360 let default_x = line_min_x(&mut cx, TWO_LINES, &style(16.0), 400.0);
1361 let explicit_start_x =
1362 line_min_x(&mut cx, TWO_LINES, &aligned_style(TextAlign::Start), 400.0);
1363 assert_eq!(default_x, explicit_start_x);
1364 assert!(default_x.iter().all(|x| x.abs() < 0.5));
1365 }
1366
1367 // --- max_lines / TextOverflow truncation ---
1368
1369 /// A long, multi-word phrase that reliably soft-wraps to several lines
1370 /// under a narrow `max_width` (shared with this file's other wrap tests).
1371 const WRAPPING_TEXT: &str = "Hello from Frust, the pure Rust mobile UI toolkit";
1372
1373 /// Groups a [`TextLayout`]'s painted glyphs by line (glyphs sharing a
1374 /// `y` are the same line — the coordinate contract [`line_min_x`] also
1375 /// relies on) and returns each line's minimum `x`, in line order.
1376 fn min_x_per_line(layout: &TextLayout) -> Vec<f32> {
1377 let runs = layout.to_scene_runs(kurbo::Point::ORIGIN);
1378 let mut by_y: Vec<(f32, f32)> = Vec::new();
1379 for run in &runs {
1380 for g in &run.glyphs {
1381 match by_y.iter_mut().find(|(y, _)| (*y - g.y).abs() < 0.01) {
1382 Some((_, min_x)) => *min_x = min_x.min(g.x),
1383 None => by_y.push((g.y, g.x)),
1384 }
1385 }
1386 }
1387 by_y.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
1388 by_y.into_iter().map(|(_, x)| x).collect()
1389 }
1390
1391 #[test]
1392 fn layout_bounded_with_no_max_lines_matches_plain_layout() {
1393 // The zero-cost, unchanged-behavior contract: `max_lines: None`
1394 // delegates straight to `layout`, so no existing caller (none of
1395 // which pass `max_lines`) can regress.
1396 let mut cx = TextContext::new();
1397 let s = style(16.0);
1398 let plain = cx.layout(WRAPPING_TEXT, &s, Some(200.0)).size();
1399 let bounded = cx
1400 .layout_bounded(WRAPPING_TEXT, &s, Some(200.0), None, TextOverflow::Ellipsis)
1401 .size();
1402 assert_eq!(plain, bounded);
1403 }
1404
1405 #[test]
1406 fn single_line_fits_is_left_unmodified() {
1407 // "fits": comfortable width, well under the box — no truncation, no
1408 // ellipsis; byte-for-byte the same shape as a plain `layout` call.
1409 let mut cx = TextContext::new();
1410 let s = style(16.0);
1411 let text = "short";
1412 let plain = cx.layout(text, &s, Some(400.0)).size();
1413 let bounded = cx
1414 .layout_bounded(text, &s, Some(400.0), Some(1), TextOverflow::Ellipsis)
1415 .size();
1416 assert_eq!(plain, bounded);
1417 }
1418
1419 #[test]
1420 fn single_line_exact_fit_is_not_truncated() {
1421 // The `>` (not `>=`) boundary: a width equal to the line's own
1422 // natural width is a fit, not an overflow.
1423 let mut cx = TextContext::new();
1424 let s = style(16.0);
1425 let text = "exact";
1426 let natural = cx.layout(text, &s, None).size().width;
1427 // `ceil()` keeps the bound at or a hair above the natural width —
1428 // avoids f64->f32 rounding noise making a bit-identical bound look
1429 // like a sub-pixel overflow.
1430 let max_width = natural.ceil() as f32;
1431 let bounded = cx.layout_bounded(text, &s, Some(max_width), Some(1), TextOverflow::Ellipsis);
1432 assert_eq!(bounded.line_count(), 1);
1433 assert_eq!(
1434 bounded.size().width,
1435 cx.layout(text, &s, Some(max_width)).size().width,
1436 "an exactly-fitting line must render identically to an untruncated layout"
1437 );
1438 }
1439
1440 #[test]
1441 fn single_line_overflow_truncates_and_fits_the_bound() {
1442 // `max_lines(1)` forces a phrase that would otherwise soft-wrap onto
1443 // one truncated line.
1444 let mut cx = TextContext::new();
1445 let s = style(16.0);
1446 let max_width = 80.0;
1447
1448 let plain = cx.layout(WRAPPING_TEXT, &s, Some(max_width));
1449 assert!(
1450 plain.line_count() > 1,
1451 "fixture sanity: expected this phrase to soft-wrap at {max_width}px, got {} line(s)",
1452 plain.line_count()
1453 );
1454
1455 let bounded = cx.layout_bounded(
1456 WRAPPING_TEXT,
1457 &s,
1458 Some(max_width),
1459 Some(1),
1460 TextOverflow::Ellipsis,
1461 );
1462 assert_eq!(bounded.line_count(), 1, "max_lines(1) must yield one line");
1463 let width = bounded.line_info(0).expect("one line").width;
1464 assert!(
1465 width <= max_width,
1466 "the truncated+ellipsized line must fit the bound: {width} > {max_width}"
1467 );
1468 }
1469
1470 #[test]
1471 fn max_lines_two_wrapped_truncates_only_the_last_visible_line() {
1472 let mut cx = TextContext::new();
1473 let s = style(16.0);
1474 let max_width = 60.0;
1475
1476 let plain = cx.layout(WRAPPING_TEXT, &s, Some(max_width));
1477 assert!(
1478 plain.line_count() > 2,
1479 "fixture sanity: expected >2 wrapped lines at {max_width}px, got {}",
1480 plain.line_count()
1481 );
1482 let plain_first_line_width = plain.line_info(0).expect("line 0").width;
1483
1484 let bounded = cx.layout_bounded(
1485 WRAPPING_TEXT,
1486 &s,
1487 Some(max_width),
1488 Some(2),
1489 TextOverflow::Ellipsis,
1490 );
1491 assert_eq!(bounded.line_count(), 2, "max_lines(2) must yield two lines");
1492 assert_eq!(
1493 bounded.line_info(0).expect("line 0").width,
1494 plain_first_line_width,
1495 "the greedy line-breaker's earlier line must survive the truncation \
1496 of a later line verbatim"
1497 );
1498 let last_width = bounded.line_info(1).expect("line 1").width;
1499 assert!(
1500 last_width <= max_width,
1501 "the truncated+ellipsized last visible line must fit the bound: \
1502 {last_width} > {max_width}"
1503 );
1504 }
1505
1506 #[test]
1507 fn clip_drops_trailing_lines_without_touching_the_last_visible_line() {
1508 let mut cx = TextContext::new();
1509 let s = style(16.0);
1510 let max_width = 60.0;
1511
1512 let plain = cx.layout(WRAPPING_TEXT, &s, Some(max_width));
1513 assert!(plain.line_count() > 1, "fixture sanity");
1514 let plain_first_line_width = plain.line_info(0).expect("line 0").width;
1515
1516 let clipped = cx.layout_bounded(
1517 WRAPPING_TEXT,
1518 &s,
1519 Some(max_width),
1520 Some(1),
1521 TextOverflow::Clip,
1522 );
1523 assert_eq!(clipped.line_count(), 1);
1524 assert_eq!(
1525 clipped.line_info(0).expect("line 0").width,
1526 plain_first_line_width,
1527 "Clip drops trailing lines but never character-trims the last \
1528 visible one — its content, and so its width, must be identical \
1529 to the untruncated layout's own first line"
1530 );
1531
1532 let ellipsized = cx.layout_bounded(
1533 WRAPPING_TEXT,
1534 &s,
1535 Some(max_width),
1536 Some(1),
1537 TextOverflow::Ellipsis,
1538 );
1539 assert_eq!(ellipsized.line_count(), 1);
1540 // Ellipsis appends '…', which Clip never does — the two modes' last
1541 // lines for the same overflowing input must not coincide.
1542 assert_ne!(
1543 clipped.line_info(0).expect("line 0").width,
1544 ellipsized.line_info(0).expect("line 0").width,
1545 "Clip and Ellipsis must produce visibly different last lines for \
1546 the same overflowing input"
1547 );
1548 }
1549
1550 #[test]
1551 fn ellipsis_wider_than_the_box_still_renders_without_panicking() {
1552 let mut cx = TextContext::new();
1553 let s = style(16.0);
1554 // Narrower than a single glyph at this size — even a bare '…' can't
1555 // fit; the truncation walk must still return *something* rather than
1556 // panicking or yielding an empty layout.
1557 let bounded = cx.layout_bounded(
1558 WRAPPING_TEXT,
1559 &s,
1560 Some(1.0),
1561 Some(1),
1562 TextOverflow::Ellipsis,
1563 );
1564 assert_eq!(bounded.line_count(), 1);
1565 assert!(
1566 bounded.size().width > 0.0,
1567 "a best-effort bare ellipsis must still paint something"
1568 );
1569 }
1570
1571 #[test]
1572 fn empty_string_is_not_truncated() {
1573 let mut cx = TextContext::new();
1574 let s = style(16.0);
1575 let bounded = cx.layout_bounded("", &s, Some(80.0), Some(1), TextOverflow::Ellipsis);
1576 assert_eq!(bounded.size().width, 0.0);
1577 }
1578
1579 #[test]
1580 fn max_lines_zero_yields_an_empty_layout() {
1581 let mut cx = TextContext::new();
1582 let s = style(16.0);
1583 let bounded = cx.layout_bounded(
1584 WRAPPING_TEXT,
1585 &s,
1586 Some(80.0),
1587 Some(0),
1588 TextOverflow::Ellipsis,
1589 );
1590 assert_eq!(bounded.size().width, 0.0);
1591 }
1592
1593 // --- Bounded ellipsis seek ---
1594
1595 /// A 500-character unbreakable token: the primary ellipsis case (a URL,
1596 /// hash, or session id far wider than its box), and the input the
1597 /// pre-bound walk shaped once per character.
1598 fn long_token() -> String {
1599 // Mixed-width characters, so the walk's proportional estimate can't
1600 // be trivially exact.
1601 "aWi".repeat(167)[..500].to_string()
1602 }
1603
1604 /// The exhaustive longest-fitting-prefix scan (every char boundary,
1605 /// longest first) that the bounded seek approximates — the reference
1606 /// implementation for the seek's quality, kept only here.
1607 fn exhaustive_truncation(line: &str, s: &TextStyle, max_width: f32) -> String {
1608 let mut cx = TextContext::new();
1609 let mut ends: Vec<usize> = line.char_indices().map(|(i, _)| i).collect();
1610 ends.push(line.len());
1611 for &end in ends.iter().rev() {
1612 let candidate = format!("{}{ELLIPSIS}", &line[..end]);
1613 if cx.layout(&candidate, s, None).size().width <= f64::from(max_width) {
1614 return candidate;
1615 }
1616 }
1617 ELLIPSIS.to_string()
1618 }
1619
1620 #[test]
1621 fn a_long_unbreakable_token_truncates_within_the_measurement_cap() {
1622 // The O(n²)-on-the-UI-thread defect: one full shaping pass per
1623 // character of the overflowing line. The bound is a constant now, and
1624 // none of those measurements may reach the shape cache.
1625 let mut cx = TextContext::new();
1626 let s = style(16.0);
1627 let token = long_token();
1628 let max_width = 80.0;
1629
1630 let bounded =
1631 cx.layout_bounded(&token, &s, Some(max_width), Some(1), TextOverflow::Ellipsis);
1632
1633 assert_eq!(bounded.line_count(), 1);
1634 let width = bounded.line_info(0).expect("one line").width;
1635 assert!(
1636 width <= max_width,
1637 "the truncated line must still fit the bound: {width} > {max_width}"
1638 );
1639 assert!(
1640 cx.measurement_count() <= MAX_TRUNCATION_MEASUREMENTS,
1641 "the seek must stay inside its cap: {} measurements for a {}-char token",
1642 cx.measurement_count(),
1643 token.chars().count()
1644 );
1645 let stats = cx.shape_cache_stats();
1646 assert_eq!(
1647 stats.shapes, 2,
1648 "only the full and the final truncated layout may be cached — every \
1649 candidate measurement is uncached"
1650 );
1651 assert_eq!(stats.evictions, 0);
1652 }
1653
1654 #[test]
1655 fn the_truncation_walk_never_evicts_live_cache_entries() {
1656 // The cache-flush half of the same defect: with the LRU full, a
1657 // per-candidate insert evicted one live entry per character.
1658 let mut cx = TextContext::new();
1659 let s = style(16.0);
1660 for i in 0..DEFAULT_CAPACITY {
1661 let _ = cx.layout(&format!("live entry {i}"), &s, None);
1662 }
1663 let evictions_before = cx.shape_cache_stats().evictions;
1664
1665 let _ = cx.layout_bounded(
1666 &long_token(),
1667 &s,
1668 Some(60.0),
1669 Some(1),
1670 TextOverflow::Ellipsis,
1671 );
1672
1673 assert_eq!(
1674 cx.shape_cache_stats().evictions - evictions_before,
1675 2,
1676 "a full cache may only lose the two entries the two legitimate \
1677 (full + final) inserts displace"
1678 );
1679 }
1680
1681 #[test]
1682 fn the_bounded_seek_matches_an_exhaustive_longest_prefix_scan() {
1683 // The bound must not cost accuracy: the seek's cut is the same one the
1684 // exhaustive scan finds, including for a line whose glyph widths are
1685 // nowhere near uniform (the case a single proportional estimate would
1686 // land far from).
1687 let s = style(16.0);
1688 let lines = [
1689 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1690 "iiiiiiiiiiiiiiiiiiiiWWWWWWWWWWWWWWWWWWWW",
1691 "WWWWWWWWWWWWWWWWWWWWiiiiiiiiiiiiiiiiiiii",
1692 "https://example.com/a/very/long/path?q=1",
1693 &long_token(),
1694 ];
1695 let mut worst = 0;
1696 for line in lines {
1697 for max_width in [12.0_f32, 40.0, 160.0, 400.0] {
1698 let mut cx = TextContext::new();
1699 let line_width = cx.layout(line, &s, None).size().width as f32;
1700 let got = cx.truncate_last_line(line, &s, max_width, line_width);
1701 let want = exhaustive_truncation(line, &s, max_width);
1702 assert_eq!(
1703 got, want,
1704 "bounded seek disagreed with the exhaustive scan for \
1705 {max_width}px of {line:?}"
1706 );
1707 worst = worst.max(cx.measurement_count());
1708 }
1709 }
1710 // Comfortably inside the cap (12 on the reference host), so the
1711 // agreement above is real convergence, not a capped coincidence.
1712 assert!(
1713 worst <= MAX_TRUNCATION_MEASUREMENTS,
1714 "worst seek across the fixtures took {worst} measurements"
1715 );
1716 }
1717
1718 #[test]
1719 fn the_seek_returns_a_fitting_candidate_even_when_the_cap_is_exhausted() {
1720 // Cap exhaustion is a quality fallback, never a correctness one: the
1721 // returned candidate is always one measured as fitting. Forced here by
1722 // a line whose natural width lies about where the fitting prefix ends
1723 // (a deliberately misleading `line_width`).
1724 let mut cx = TextContext::new();
1725 let s = style(16.0);
1726 let line = "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW";
1727 let max_width = 60.0;
1728
1729 let got = cx.truncate_last_line(line, &s, max_width, 1.0);
1730
1731 assert!(
1732 cx.measurement_count() <= MAX_TRUNCATION_MEASUREMENTS,
1733 "the cap binds even when the estimate is useless: {}",
1734 cx.measurement_count()
1735 );
1736 assert!(got.ends_with(ELLIPSIS));
1737 let width = cx.layout(&got, &s, None).size().width;
1738 assert!(
1739 width <= f64::from(max_width),
1740 "a cap-exhausted seek must still return a measured-fitting candidate: \
1741 {width} > {max_width}"
1742 );
1743 }
1744
1745 // --- Trailing-newline discount ---
1746
1747 #[test]
1748 fn a_text_terminating_newline_is_not_an_extra_line() {
1749 // parley opens a zero-width line after a terminal '\n'; counting it as
1750 // overflow rendered "Hello…" for `text("Hello\n").max_lines(1)`.
1751 let s = style(16.0);
1752 for overflow in [TextOverflow::Ellipsis, TextOverflow::Clip] {
1753 let mut cx = TextContext::new();
1754 let plain = cx.layout("Hello", &s, Some(400.0));
1755 let fitting = plain.line_info(0).expect("one line").width;
1756 let with_ellipsis = cx
1757 .layout(&format!("Hello{ELLIPSIS}"), &s, Some(400.0))
1758 .line_info(0)
1759 .expect("one line")
1760 .width;
1761
1762 let bounded = cx.layout_bounded("Hello\n", &s, Some(400.0), Some(1), overflow);
1763 let got = bounded.line_info(0).expect("one content line").width;
1764
1765 assert_eq!(
1766 got, fitting,
1767 "{overflow:?}: text that fits must render verbatim despite its \
1768 trailing newline"
1769 );
1770 assert_ne!(
1771 got, with_ellipsis,
1772 "{overflow:?}: no ellipsis may be appended to text that fits"
1773 );
1774 assert_eq!(
1775 bounded.line_count(),
1776 plain.line_count(),
1777 "{overflow:?}: parley's phantom trailing line must not survive \
1778 into the reported line count"
1779 );
1780 assert_eq!(
1781 bounded.size().height,
1782 plain.size().height,
1783 "{overflow:?}: parley's phantom trailing line must not inflate \
1784 the reported height — a text ending in one newline, capped to \
1785 one line, must paint and measure exactly one line tall"
1786 );
1787 assert_eq!(
1788 cx.measurement_count(),
1789 0,
1790 "{overflow:?}: a fitting line must not enter the truncation walk"
1791 );
1792 }
1793 }
1794
1795 #[test]
1796 fn a_trailing_newline_after_several_lines_is_discounted_too() {
1797 let s = style(16.0);
1798 for overflow in [TextOverflow::Ellipsis, TextOverflow::Clip] {
1799 let mut cx = TextContext::new();
1800 let plain = cx.layout("A\nBB", &s, Some(400.0));
1801 let plain_last_width = plain.line_info(1).expect("two lines").width;
1802 let bounded = cx.layout_bounded("A\nBB\n", &s, Some(400.0), Some(2), overflow);
1803 assert_eq!(
1804 bounded.line_info(1).expect("two content lines").width,
1805 plain_last_width,
1806 "{overflow:?}: two content lines plus a terminal newline fit \
1807 max_lines(2)"
1808 );
1809 assert_eq!(
1810 bounded.line_count(),
1811 plain.line_count(),
1812 "{overflow:?}: the phantom trailing line must not survive into \
1813 the reported line count"
1814 );
1815 assert_eq!(
1816 bounded.size().height,
1817 plain.size().height,
1818 "{overflow:?}: the phantom trailing line must not inflate the \
1819 reported height — two content lines plus a terminal newline, \
1820 capped to two lines, must measure exactly two lines tall"
1821 );
1822 }
1823 }
1824
1825 #[test]
1826 fn a_real_extra_line_still_truncates_when_the_text_ends_in_a_newline() {
1827 // The discount must not swallow genuine overflow.
1828 let mut cx = TextContext::new();
1829 let s = style(16.0);
1830 let clipped = cx.layout_bounded("A\nBB\n", &s, Some(400.0), Some(1), TextOverflow::Clip);
1831 assert_eq!(
1832 clipped.line_count(),
1833 1,
1834 "the second content line is dropped"
1835 );
1836 assert_eq!(
1837 clipped.size().width,
1838 cx.layout("A", &s, Some(400.0)).size().width
1839 );
1840
1841 let ellipsized =
1842 cx.layout_bounded("A\nBB\n", &s, Some(400.0), Some(1), TextOverflow::Ellipsis);
1843 assert_eq!(ellipsized.line_count(), 1);
1844 assert!(
1845 ellipsized.size().width > clipped.size().width,
1846 "Ellipsis must append '…' to the surviving line"
1847 );
1848 }
1849
1850 #[test]
1851 fn a_blank_line_inside_the_text_is_a_real_line() {
1852 // Only a *trailing* empty line is phantom: "a\n\nb" genuinely paints a
1853 // blank second line the caller asked for, so all three count.
1854 let mut cx = TextContext::new();
1855 let s = style(16.0);
1856 let fits = cx.layout_bounded("a\n\nb", &s, Some(400.0), Some(3), TextOverflow::Ellipsis);
1857 assert_eq!(
1858 fits.line_count(),
1859 3,
1860 "three content lines (one blank) fit max_lines(3) untouched"
1861 );
1862 assert_eq!(
1863 cx.measurement_count(),
1864 0,
1865 "no truncation walk for text that fits"
1866 );
1867
1868 let capped = cx.layout_bounded("a\n\nb", &s, Some(400.0), Some(2), TextOverflow::Clip);
1869 assert!(
1870 capped.line_count() < 3,
1871 "the blank line occupies one of the two, so 'b' overflows"
1872 );
1873 }
1874
1875 // --- Multi-byte truncation boundaries ---
1876
1877 #[test]
1878 fn multi_byte_text_truncates_on_char_boundaries_without_panicking() {
1879 // Every byte index in the truncation path is parley-derived or
1880 // char-boundary-derived; a mid-char slice would panic on this input.
1881 let s = style(16.0);
1882 let texts = [
1883 "日本語のテキストです、これは折り返しの確認用の文章です",
1884 "🙂🎉😀🚀🌍🙂🎉😀🚀🌍🙂🎉😀🚀🌍",
1885 "Grüße aus München — Übergrößenträger",
1886 ];
1887 for text in texts {
1888 for overflow in [TextOverflow::Ellipsis, TextOverflow::Clip] {
1889 for max_width in [20.0_f32, 70.0] {
1890 let mut cx = TextContext::new();
1891 let bounded = cx.layout_bounded(text, &s, Some(max_width), Some(1), overflow);
1892 assert_eq!(bounded.line_count(), 1, "{text:?} at {max_width}px");
1893 if overflow == TextOverflow::Ellipsis {
1894 let width = bounded.line_info(0).expect("one line").width;
1895 assert!(
1896 width <= max_width,
1897 "{text:?}: truncated width {width} exceeds {max_width}"
1898 );
1899 }
1900 }
1901 }
1902 }
1903 }
1904
1905 #[test]
1906 fn ellipsis_truncation_preserves_center_alignment() {
1907 // Hard-broken lines with generous width headroom, so the centering
1908 // offset can't be swamped by the truncation search converging on a
1909 // near-max-width candidate (see `max_lines_two_wrapped_...` above for
1910 // the width-tight case) — the same robust shape as this file's other
1911 // alignment tests (`TWO_LINES` at 400px).
1912 let mut cx = TextContext::new();
1913 let text = "A\nBBBBBBBBBB\nCCCCCCCCCC";
1914 let max_width = 400.0;
1915
1916 let start = cx.layout_bounded(
1917 text,
1918 &aligned_style(TextAlign::Start),
1919 Some(max_width),
1920 Some(2),
1921 TextOverflow::Ellipsis,
1922 );
1923 let center = cx.layout_bounded(
1924 text,
1925 &aligned_style(TextAlign::Center),
1926 Some(max_width),
1927 Some(2),
1928 TextOverflow::Ellipsis,
1929 );
1930
1931 assert_eq!(start.line_count(), 2);
1932 assert_eq!(center.line_count(), 2);
1933
1934 let start_x = min_x_per_line(&start);
1935 let center_x = min_x_per_line(¢er);
1936 assert_eq!(start_x.len(), 2);
1937 assert_eq!(center_x.len(), 2);
1938
1939 assert!(
1940 start_x[1].abs() < 0.5,
1941 "start-aligned truncated line must hug the left edge: {start_x:?}"
1942 );
1943 assert!(
1944 center_x[1] > start_x[1] + 1.0,
1945 "center-aligned truncated line must move off the left edge: {center_x:?}"
1946 );
1947 }
1948}