Re-break a text's lines for a new width instead of shaping it again

Only the line breaking depends on the width. The shaped runs under it --
the font selection, the unicode analysis, harfrust -- are a function of
the text and the attrs, and parley re-breaks them in place; its own
editor does exactly this on every resize. So a new width is a break and
a placement, not a shaping.

On the depth-8 tree that is 107 breaks at 0.119 ms where the shapings
they replace were 4.0 ms, and it holds however far the width moves,
which is what the store could not do: a width the layout has not seen
before is a miss, and a drag never sees one twice. Instructions per
frame over 500 resize frames of `tests/revision_cost.rs`, for widths
that alternate and widths that never repeat:

    #18 head             124.2M   123.6M
    a store of shapings   17.7M    45.9M
    re-breaking alone     32.9M    32.8M
    both                  20.6M    24.2M

The store stays because re-breaking does not place the glyphs, so it now
holds those instead: fewer instructions than either alone in the case
that never repeats, and 3 MB rather than 4 MB on a tree of 4,000 texts,
against the 132 MB the code before #16 reaches after the same resizes.
The worst frame is 2.54 ms where that code's is 6.47 ms, and the two
gestures are within a millisecond of each other rather than a factor of
two apart.

Count the breaks and time them separately from shaping, since which of
the two a frame is doing is the whole question here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-14 19:21:20 -04:00
1 parent e5f8b6b244
commit 2525637e26
2 files changed
+60 -36

No files matched your search

+4
View File
@@ -54,6 +54,7 @@ pub(crate) enum Counter {
TextRenders,
TextShapeHits,
TextShapes,
TextBreaks,
GlyphPlacements,
}
@@ -90,6 +91,7 @@ impl Counter {
"text renders",
"text shape hits",
"text shapes",
"text line breaks",
"glyph placements",
];
}
@@ -102,6 +104,7 @@ pub(crate) enum TimerKind {
IncrementalLayout,
TextRender,
TextShape,
TextBreak,
GlyphPlacement,
}
@@ -115,6 +118,7 @@ impl TimerKind {
"incremental layout",
"text render",
"text shape",
"text line break",
"glyph placement",
];
}
+56 -36
View File
@@ -22,30 +22,31 @@ pub struct TextData {
pub layout_ctx: LayoutContext<UiColor>,
scale_ctx: ScaleContext,
pub atlas: GlyphAtlas,
spare: VecDeque<Shaping>,
spare: VecDeque<Placed>,
}
/// 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.
/// The glyphs of one text at one width. A buffer holds the ones 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 Placed {
/// Where the glyphs land 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>,
glyphs: 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;
/// How many to keep. Bounding the whole store 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. Instructions over 500 resize frames
/// of `tests/revision_cost.rs`, both the repeating widths and the sweep that
/// cannot hit across frames: 13.7B at 32, 12.1B at 64, 10.4B and 12.1B at 128,
/// and nothing past that -- so 128, which is no worse in the case that never
/// repeats and better in the one that does.
const SPARE_PLACED: usize = 128;
impl Default for TextData {
fn default() -> Self {
@@ -188,21 +189,31 @@ 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 {
let same_shaping = self
.layout_key
.as_ref()
.is_some_and(|key| key.attrs == *attrs);
let old_key = self.layout_key.replace(layout_key);
// The glyphs it holds are of the width it held, which the layout may
// well come back to.
if let Some(key) = old_key
&& let Some(glyphs) = self.placed.take()
{
data.keep_placed(Placed {
text: self.text.clone(),
key,
layout: std::mem::replace(&mut self.layout, Layout::new()),
placed: self.placed.take(),
glyphs,
});
}
if let Some(shaping) = kept {
// Only the line breaking depends on the width: the shaped runs under
// it are a function of the text and the attrs, and parley re-breaks
// them in place. So a new width is a break, not a shaping.
if same_shaping {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::TextShapeHits);
self.layout = shaping.layout;
self.placed = shaping.placed;
self.layout_key = Some(shaping.key);
diag::bump(Counter::TextBreaks);
#[cfg(feature = "layout-diagnostics")]
let _break = diag::timer(TimerKind::TextBreak);
self.break_lines(width);
return;
}
#[cfg(feature = "layout-diagnostics")]
@@ -219,10 +230,13 @@ impl TextBuffer {
)));
builder.push_default(StyleProperty::Brush(attrs.color));
builder.build_into(&mut self.layout, &self.text);
self.break_lines(width);
}
fn break_lines(&mut self, width: Option<f32>) {
self.layout.break_all_lines(width);
self.layout
.align(Alignment::Start, AlignmentOptions::default());
self.layout_key = Some(layout_key);
}
}
@@ -334,21 +348,21 @@ pub struct RenderedText {
}
impl TextData {
/// 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> {
/// The glyphs of this text at this width, taken out of what is kept.
fn take_placed(&mut self, text: &str, key: &LayoutKey) -> Option<RenderedText> {
// 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)
self.spare.remove(at).map(|spare| spare.glyphs)
}
fn keep_shaping(&mut self, shaping: Shaping) {
if self.spare.len() >= SPARE_SHAPINGS {
fn keep_placed(&mut self, placed: Placed) {
if self.spare.len() >= SPARE_PLACED {
self.spare.pop_front();
}
self.spare.push_back(shaping);
self.spare.push_back(placed);
}
pub fn render<'b>(
@@ -362,7 +376,13 @@ impl TextData {
#[cfg(feature = "layout-diagnostics")]
let _render = diag::timer(TimerKind::TextRender);
buffer.shape(self, attrs, width);
let placed = match buffer.placed.take() {
// Only asked for when the buffer no longer holds them: taking one out
// of the store to then drop it would throw an answer away.
let placed = buffer.placed.take().or_else(|| {
let key = buffer.layout_key.as_ref()?;
self.take_placed(&buffer.text, key)
});
let placed = match placed {
Some(placed) => placed,
None => {
#[cfg(feature = "layout-diagnostics")]