Skip to main content

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, &centered, 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, &centered, 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(&center);
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}