Report the step at or above a text's longest line

A wrapping text reported the width it used rounded to the nearest step,
which is under the line it measured half the time. A parent that sizes
itself from that report then hands the text back a box its own longest line
does not fit in, and breaking there is a different break -- one line more.

Two tolerances were hiding it and both go. `TextBuffer::shape` answered a
width up to 0.05 px under the longest line from the break in hand, which is
a structural decision taken on a hair's breadth: it kept a warm tree
self-consistent while a cold tree at the same width broke differently, and
0.05 px is fifty steps of the grid. The `Holds` range the text declares
started at the nearest step to its longest line for the same reason, so it
admitted boxes the line does not fit in. Both are the line itself now,
exactly, because the report no longer lands under it.

Found by seeds 1121 and 1839 at depth 4, which fail on `ea6dbae` and every
commit before it: a defect older than anything on this branch, reached by
running 2000 seeds at a depth the long runs do not use. Shrunk to the eight
widgets `a_text_is_given_back_a_box_the_line_it_measured_fits_in` builds.
2000 seeds at depth 4 over all fifteen cases are clean now, as are the
three long runs.

`text` is the one reference render that moves: its lower paragraph shifts a
pixel, the box being a step wider and its left edge crossing a snap
boundary. Same words, same lines, same breaks; `tabs`, `view`, `minimal`
and `random` are byte-identical.
This commit is contained in:
iris-ai committed 2026-09-17 03:18:53 -04:00
1 parent ffd79f32d3
commit 4bd8607968
4 files changed
+119 -15

No files matched your search

+9 -12
View File
@@ -107,13 +107,6 @@ impl Default for TextAttrs {
}
}
/// How far below the longest line a width may fall and still be answered by
/// the break in hand. A parent that offers a child the length it reported
/// composes that length back through the box chain, so the two differ in the
/// last bits -- and at exactly the longest line, that decides whether a line
/// fits. Sub-pixel, so no break it admits is one a reader could see.
const BREAK_EPSILON_PX: f32 = 0.05;
/// Keeps text and its corresponding layout from getting out of sync.
pub struct TextBuffer {
text: String,
@@ -200,15 +193,19 @@ impl TextBuffer {
// A greedy break at one width is the same break at every width down
// to the longest line it produced: each line still fits, and none can
// take a word that would not fit in the wider box. So the layout in
// hand already answers, and re-breaking would only be a chance to
// disagree with itself -- which is what happens when a parent offers
// a child the length that child just reported, and the two land
// either side of a float.
// hand already answers, and re-breaking would only be work.
//
// At the longest line exactly, with no margin below it. A narrower
// width really does break differently, so answering one from the
// break in hand is how a warm tree keeps lines a cold tree would
// never produce. The margin was here because a text reports the
// width it used and a parent hands that back; the report is the step
// at or above its longest line now, so what comes back fits.
if let Some(key) = &self.layout_key
&& key.attrs == *attrs
&& let (Some(broke_at), Some(want)) = (key.max_width, width)
&& want <= broke_at
&& want + BREAK_EPSILON_PX >= self.layout.width()
&& want >= self.layout.width()
{
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::TextShapeHits);