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