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