Shape a text once per width, not once per ask

A container measures a child by drawing it in a box it may not keep, so
one layout asks a text for a dozen widths and comes back to widths it
has already had -- the hottest text in the depth-8 tree draws 32 times.
Each ask re-ran the shaper, because the two caches in front of it held
one entry each and a trial width alternating with a final width evicts
the answer about to be wanted again. `perf record` put 63% of a resize
frame in text and 0.9% in `draw_inner`.

So keep more than one: a bounded store of shapings on `TextData`, keyed
by the text, the attrs and the width, holding the parley layout and the
glyphs placed from it. Bounding the store rather than each buffer is
what keeps it a fixed cost -- +4 MB on a tree of 4,000 texts, which is
19 MB less than the code before #16 holds after the same resizes.

`TextBuffer` now holds the glyphs of the shaping it is drawn as, which
is where `TextView::tex` was. That leaves one place to invalidate rather
than two, so the `MutDetect` flags on a view's text and attrs have no
reader and go, along with the `buf.changed = true` after every edit.

On a 40-row tree of distinct random paragraphs, 500 resize frames:
124.2M instructions per frame before, 17.7M after, and 45.9M when the
width never repeats. The five reference renders and the resize render
are byte-identical, and the 100-seed sweep passes.

`tests/revision_cost.rs` is that tree, written in the API subset
`43ce8c7` shares so the same source measures the code this replaced.
Report the worst frame and p99 beside the median, since a stutter is
what somebody sees. Count glyph placements, and count a text render per
ask rather than per shaping, so the store cannot hide how many times a
layout drew the same text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-14 18:49:45 -04:00
1 parent f1a47e9b7b
commit e5f8b6b244
7 files changed
+321 -43

No files matched your search

+3 -1
View File
@@ -54,10 +54,11 @@ pub(crate) enum Counter {
TextRenders,
TextShapeHits,
TextShapes,
GlyphPlacements,
}
impl Counter {
const COUNT: usize = Self::TextShapes as usize + 1;
const COUNT: usize = Self::GlyphPlacements as usize + 1;
const NAMES: [&'static str; Self::COUNT] = [
"updates",
@@ -89,6 +90,7 @@ impl Counter {
"text renders",
"text shape hits",
"text shapes",
"glyph placements",
];
}
+96 -13
View File
@@ -7,7 +7,10 @@ use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout,
LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::{
collections::VecDeque,
hash::{DefaultHasher, Hash, Hasher},
};
use swash::{
FontRef,
scale::{Render, ScaleContext, Source, StrikeWith},
@@ -19,8 +22,31 @@ pub struct TextData {
pub layout_ctx: LayoutContext<UiColor>,
scale_ctx: ScaleContext,
pub atlas: GlyphAtlas,
spare: VecDeque<Shaping>,
}
/// One shaping of some text, and the glyphs placed from it. A buffer holds
/// the one it is drawn as; these are the ones it had before, kept because a
/// container measures a child by drawing it in a box it may not keep, and so
/// comes back to widths it has already asked for.
struct Shaping {
/// A shaping is a function of these three and nothing else, so no widget
/// or buffer identity is involved and two texts of the same words share
/// an answer.
text: String,
key: LayoutKey,
layout: Layout<UiColor>,
placed: Option<RenderedText>,
}
/// How many to keep. Bounding the whole cache rather than each buffer is
/// what makes this a fixed cost instead of one a tree of ten thousand texts
/// pays ten thousand times; the re-asks come from laying out one subtree, so
/// they are close together and few are needed. On `tests/revision_cost.rs`
/// under `SWEEP=1`, the case that cannot hit across frames, 32 is not enough
/// (6.6 ms) and 64 is (4.4 ms).
const SPARE_SHAPINGS: usize = 128;
impl Default for TextData {
fn default() -> Self {
Self {
@@ -28,6 +54,7 @@ impl Default for TextData {
layout_ctx: LayoutContext::new(),
scale_ctx: ScaleContext::new(),
atlas: GlyphAtlas::default(),
spare: VecDeque::new(),
}
}
}
@@ -83,6 +110,9 @@ pub struct TextBuffer {
text: String,
layout: Layout<UiColor>,
layout_key: Option<LayoutKey>,
/// The glyphs placed from `layout`, so drawing this text again at the
/// width it already has places them once.
placed: Option<RenderedText>,
}
#[derive(PartialEq)]
@@ -97,6 +127,7 @@ impl TextBuffer {
text: text.into(),
layout: Layout::new(),
layout_key: None,
placed: None,
}
}
@@ -121,15 +152,28 @@ impl TextBuffer {
if text != self.text {
self.text = text;
self.layout_key = None;
self.placed = None;
}
}
/// Invalidates the layout and returns the underlying string for editing.
pub fn edit(&mut self) -> &mut String {
self.layout_key = None;
self.placed = None;
&mut self.text
}
/// The glyphs of the shaping it is drawn as, once they are placed.
pub fn rendered(&self) -> Option<&RenderedText> {
self.placed.as_ref()
}
/// The width its shaping wraps at, and `None` where it does not wrap or
/// has not been shaped.
pub fn wrap_width(&self) -> Option<f32> {
self.layout_key.as_ref()?.max_width
}
pub fn size(&self) -> Vec2 {
Vec2::new(self.layout.width(), self.layout.height())
}
@@ -144,6 +188,23 @@ impl TextBuffer {
diag::bump(Counter::TextShapeHits);
return;
}
let kept = data.take_shaping(&self.text, &layout_key);
if let Some(key) = self.layout_key.take() {
data.keep_shaping(Shaping {
text: self.text.clone(),
key,
layout: std::mem::replace(&mut self.layout, Layout::new()),
placed: self.placed.take(),
});
}
if let Some(shaping) = kept {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::TextShapeHits);
self.layout = shaping.layout;
self.placed = shaping.placed;
self.layout_key = Some(shaping.key);
return;
}
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::TextShapes);
#[cfg(feature = "layout-diagnostics")]
@@ -273,26 +334,48 @@ pub struct RenderedText {
}
impl TextData {
pub fn render(
/// The shaping for this text at this width, taken out of what is kept.
fn take_shaping(&mut self, text: &str, key: &LayoutKey) -> Option<Shaping> {
// From the newest, since a re-ask is usually of something recent.
let at = self
.spare
.iter()
.rposition(|spare| spare.key == *key && spare.text == text)?;
self.spare.remove(at)
}
fn keep_shaping(&mut self, shaping: Shaping) {
if self.spare.len() >= SPARE_SHAPINGS {
self.spare.pop_front();
}
self.spare.push_back(shaping);
}
pub fn render<'b>(
&mut self,
buffer: &mut TextBuffer,
buffer: &'b mut TextBuffer,
attrs: &TextAttrs,
width: Option<f32>,
) -> RenderedText {
) -> &'b RenderedText {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::TextRenders);
#[cfg(feature = "layout-diagnostics")]
let _render = diag::timer(TimerKind::TextRender);
buffer.shape(self, attrs, width);
let glyphs = {
#[cfg(feature = "layout-diagnostics")]
let _place = diag::timer(TimerKind::GlyphPlacement);
self.place(buffer)
let placed = match buffer.placed.take() {
Some(placed) => placed,
None => {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::GlyphPlacements);
#[cfg(feature = "layout-diagnostics")]
let _place = diag::timer(TimerKind::GlyphPlacement);
RenderedText {
glyphs: self.place(buffer),
size: buffer.size(),
color: attrs.color,
}
}
};
RenderedText {
glyphs,
size: buffer.size(),
color: attrs.color,
}
buffer.placed.insert(placed)
}
}
+3 -3
View File
@@ -239,12 +239,12 @@ impl<'a> Painter<'a> {
}
}
pub fn render_text(
pub fn render_text<'b>(
&mut self,
buffer: &mut TextBuffer,
buffer: &'b mut TextBuffer,
attrs: &TextAttrs,
width: Option<f32>,
) -> RenderedText {
) -> &'b RenderedText {
#[cfg(feature = "layout-diagnostics")]
diag::render_text(self.id, self.rsc.widgets().label(self.id), width);
let ui = self.rsc.ui_mut();