#13254·slint

`TextLayoutCache` is keyed by item alone, so one layout pass reshapes the same wrapping text twice

Author: tilladamCreated Sep 5, 2026Updated Sep 14, 2026
Labelsa:textneed triaging

Version

git 2bb5a20694e75d2e8d50cbea91595f8ebff0d9a2 (pre-release/1.18).

Summary

TextLayoutCache holds one shaped-paragraph entry per ItemRc, stamped with the wrap mode it was shaped with. But a single layout_info evaluation of a wrapping text legitimately asks that one entry for two different wrap modes — NoWrap for the horizontal orientation and the item's real wrap for the vertical one. Each ask evicts the other's shaping, so the text is reshaped twice per layout pass.

Neither measurement is wrong; the cache simply has one slot where its callers need two.

The mechanism

The entry records its mode (internal/core/textlayout/sharedparley/cache.rs:26):

rust
struct CachedParagraphs {
    wrap: TextWrap,          // the mode these glyphs were shaped with
    line_breaking: Option<RetainedLineBreaking>,
    paragraphs: Option<Vec<TextParagraph>>,
    last_used: u32,
}

and cached_paragraphs correctly refuses to serve a mismatch (cache.rs:204):

rust
let stale = cache.inner.with_entry(item_rc, |entry| {
    (entry.wrap != wrap || entry.paragraphs.is_none()).then_some(())
}).is_some();
if stale {
    cache.inner.release(item_rc);
}

That is right — parley bakes break opportunities into the shaped layout via WordBreak/OverflowWrap/TextWrapMode on the ranged_builder, so glyphs shaped for one mode cannot serve another.

The two orientations then disagree, for good reasons:

caller wrap requested why
draw_text / draw_text_input text.wrap() draws what the user set
Text::text_layout_info, horizontal (items/text.rs:685) NoWrap preferred width is the single-line width
Text::text_layout_info, vertical (items/text.rs:710) the item's wrap height needs the real break positions
TextInput::layout_info, horizontal (items/text.rs:859) NoWrap, unconditionally same as above
TextInput::layout_info, vertical (items/text.rs:880) the item's wrap same as above

For a Text { wrap: word-wrap; } (or any wrapping TextInput) whose layout is invalidated:

step asks for entry holds outcome
horizontal layout_info NoWrap WordWrap mismatch → evict + reshape
vertical layout_info WordWrap NoWrap mismatch → evict + reshape
draw WordWrap WordWrap hit

Both reshapes are on the measure path. The draw is a clean hit, which makes the symptom easy to misread — see "How it shows up" below.

Affected combinations, as far as I can tell from the source:

  • any TextInput with wrap other than no-wrap — its horizontal branch passes NoWrap regardless of self.wrap()
  • Text with wrap: word-wrap/char-wrap except (overflow: clip, wrap: word-wrap), which takes the text_content_widths branch instead (see below)
  • no-wrap items are unaffected: every caller asks NoWrap, so the entry is shared as designed

Related, and I think the same root cause

text_content_widths_impl (sharedparley.rs:493) never consults the cache at all — it calls create_text_paragraphs directly, so a Text with (overflow: clip, wrap: word-wrap) reshapes from scratch on every horizontal layout_info evaluation.

That looks less like an oversight than the same key problem: content_widths_builder (shaping.rs:351) is a third shaping configuration, not just a third wrap mode — it sets overflow_wrap_anywhere = false, giving OverflowWrap::Normal where the display path gets OverflowWrap::Anywhere (shaping.rs:132). That is a genuine shaping input, so those paragraphs could not share a slot with the display ones either. With the cache keyed by item alone there is simply nowhere to put them.

How it shows up in a profile

Worth recording because it sent me down the wrong path first: all of the reshaping appears under text_size, and none under draw_text/draw_text_input. I read that as "the draw cache is working and something else is cold", when in fact it is the signature of this bug — the alternation is horizontal↔vertical, and both are measure-side.

The absolute cost is modest. Across two applications profiled the same way, shaping was ~0.8% of main-thread samples in one whose scene uses word-wrap in a couple of dozen places, and ~0.2% in one that uses none — in both, 100% of it under text_size and 0% under any draw path. The two scenes differ in more than their wrap settings, so treat that contrast as consistent-with rather than proof-of; the mechanism above is from the source, not from those numbers.

So this is a papercut rather than a hotspot — but a structural one that scales with the amount of wrapping text on screen, and it defeats a cache that is otherwise doing exactly what it promises.

Suggested fixes

  1. Key the entry on the shaping configuration, not just the item(ItemRc, wrap) at minimum, or an entry with a small number of slots. That fixes the alternation and gives the content-widths paragraphs somewhere to live. Cost is memory: the comment at cache.rs:43 puts an entry at ~4.7 KB with ENTRY_LIMIT = 1024, so a second slot roughly doubles the worst case for wrapping items only.

  2. Independently, and much cheaper: memoize the content widths themselves rather than their shaped paragraphs. ContentWidths is two scalars, and content_widths_builder's own doc comment notes they are intrinsic to the text and independent of the item's wrap mode — so the result depends only on (text, font_request, scale_factor). Caching ~8 bytes instead of ~4.7 KB of paragraphs, through the same ItemCache + property-tracker machinery (and the same scale-factor invalidation), removes the repeated shaping without touching CachedParagraphs at all, and cannot disturb the draw path's entry.

(2) stands on its own and is the smaller change; (1) is the one that fixes the alternation.