`TextLayoutCache` is keyed by item alone, so one layout pass reshapes the same wrapping text twice
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):
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):
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
TextInputwithwrapother thanno-wrap— its horizontal branch passesNoWrapregardless ofself.wrap() Textwithwrap: word-wrap/char-wrapexcept(overflow: clip, wrap: word-wrap), which takes thetext_content_widthsbranch instead (see below)no-wrapitems are unaffected: every caller asksNoWrap, 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
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 atcache.rs:43puts an entry at ~4.7 KB withENTRY_LIMIT = 1024, so a second slot roughly doubles the worst case for wrapping items only.Independently, and much cheaper: memoize the content widths themselves rather than their shaped paragraphs.
ContentWidthsis two scalars, andcontent_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 sameItemCache+ property-tracker machinery (and the same scale-factor invalidation), removes the repeated shaping without touchingCachedParagraphsat 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.
Source: slint-ui/slint