From 4bd8607968f08cb00c9bae86860081a6d0d560a4 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Thu, 17 Sep 2026 03:18:53 -0400 Subject: [PATCH] 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. --- core/src/fixed.rs | 37 +++++++++++++++++++++++ core/src/primitive/text.rs | 21 ++++++------- src/widget/text/mod.rs | 14 +++++++-- tests/cases/unsettled.rs | 62 +++++++++++++++++++++++++++++++++++++- 4 files changed, 119 insertions(+), 15 deletions(-) diff --git a/core/src/fixed.rs b/core/src/fixed.rs index 9e71070..1850ac1 100644 --- a/core/src/fixed.rs +++ b/core/src/fixed.rs @@ -109,6 +109,18 @@ impl Fixed { }) } + /// The first step at or above `v`, where [`Self::from_f32`] takes the + /// nearest one and is below it half the time. For a bound that has to + /// admit the value it came from: a measurement rounded down is a bound + /// that leaves out the thing it was measured from. + pub const fn ceil_from_f32(v: f32) -> Self { + let nearest = Self::from_f32(v); + match nearest.to_f32() < v { + true => nearest.next_up(), + false => nearest, + } + } + /// From a number as it is written in source -- `16`, `1.5` -- which is /// the other place a value enters the grid. pub fn from_num(v: impl UiNum) -> Self { @@ -372,6 +384,12 @@ impl FixedVec2 { Self::new(Fixed::from_f32(v.x), Fixed::from_f32(v.y)) } + /// The first step at or above each part, for a measurement reported as a + /// box: what it occupies is not less than what was measured. + pub fn ceil_from_f32(v: Vec2) -> Self { + Self::new(Fixed::ceil_from_f32(v.x), Fixed::ceil_from_f32(v.y)) + } + pub fn to_f32(self) -> Vec2 { Vec2::new(self.x.to_f32(), self.y.to_f32()) } @@ -488,6 +506,25 @@ mod tests { assert_eq!(Px::from_int(100) / Rel::from_f32(0.5), Px::from_int(200)); } + /// The bound a greedy line break needs: the width it was measured at is + /// not on the grid, and the narrowest box the break still holds for is + /// the step at or above it, never the one below. + #[test] + fn a_ceiling_never_lands_below_the_number_it_came_from() { + let step = 1.0 / (1 << PX_SHIFT) as f32; + for n in 0..64 { + let v = 189.0 + n as f32 * step / 3.0; + let up = Px::ceil_from_f32(v); + assert!(up.to_f32() >= v, "{up:?} is below {v}"); + assert!( + up.to_f32() - v < step, + "{up:?} is more than a step above {v}" + ); + } + // An exact step is its own ceiling. + assert_eq!(Px::ceil_from_f32(189.5), Px::from_f32(189.5)); + } + #[test] fn a_number_from_outside_is_clamped_to_the_grid() { assert_eq!(Px::from_f32(1e12), Px::MAX); diff --git a/core/src/primitive/text.rs b/core/src/primitive/text.rs index 1fa9f92..e3274a5 100644 --- a/core/src/primitive/text.rs +++ b/core/src/primitive/text.rs @@ -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); diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index 303d713..eb776a3 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -55,8 +55,13 @@ impl TextView { // line up to the one it was made at: each line still fits, and none // could take a word that did not fit in the wider box. A line too // long to fit at all says nothing about narrower boxes. + // + // The step at or above that longest line rather than the nearest + // one, since the shaper measures in floats: the nearest step is + // under the line half the time, and a range starting there admits a + // box the line does not fit in, where the break is not this one. if let Some(width) = width { - painter.holds(Axis::X, Px::from_f32(text.size.x).min(width)..=width); + painter.holds(Axis::X, Px::ceil_from_f32(text.size.x).min(width)..=width); } text } @@ -78,7 +83,12 @@ impl TextView { let tex = self.render(painter); let region = tex.size.align(align); - let size = Size::px(tex.size); + // The step at or above what the shaper measured, so a parent that + // hands back the length this reports hands back a box the longest + // line fits in. Rounded to the nearest step it is half the time a + // hair under that line, and the break made in it is not the break a + // cold layout makes there. + let size = Size::from_px(PxVec2::ceil_from_f32(tex.size)); let within = region.within(&painter.region()); painter.glyphs(tex, within); (region, size) diff --git a/tests/cases/unsettled.rs b/tests/cases/unsettled.rs index ba4ab77..d851606 100644 --- a/tests/cases/unsettled.rs +++ b/tests/cases/unsettled.rs @@ -7,7 +7,8 @@ //! neither: one box length, composed two ways, landing either side of the //! boundary that decided whether a child was drawn at all, and two boxes //! reached through a region node's own entry rather than through the offer -//! that node was given. +//! that node was given. The last is a wrapping text handed back the width +//! it measured, rounded to a step below the line it measured there. use iris::harness::Harness; use iris::prelude::*; @@ -554,3 +555,62 @@ fn a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered() { } assert!(wrong.is_empty(), "{}", wrong.join("\n")); } + +const PARAGRAPH: &str = "Wrapping shapes one source into as many lines as the \ + box leaves room for, so a paragraph's height is an answer and not a setting."; + +/// Eight widgets, shrunk from a 118-widget tree (seed 1121, depth 4, +/// `shuffle-swap-for-three`). The stack takes its size from the span above, +/// the span takes its width from the longest line of the texts in it, and +/// the text below the span is then wrapped at that width -- so a width the +/// shaper measured comes back to it as the box to break in. +fn plant_a_measured_width(h: &mut Harness, swapped: bool) -> (WeakWidget, WidgetId) { + let first: StrongWidget = rect(Color::YELLOW).add_strong(&mut h.rsc); + let mut inner = Span::empty(Dir::UP); + inner.children = match swapped { + true => swapped_in(h), + false => vec![first], + }; + let inner = inner.height(142).add(&mut h.rsc); + let text = wtext(PARAGRAPH).size(16).wrap(true).add(&mut h.rsc); + let stack = Stack { + children: vec![inner.add_strong(&mut h.rsc), text.add_strong(&mut h.rsc)], + size: StackSize::Child(0), + } + .add(&mut h.rsc); + h.set_root((stack,).span(Dir::DOWN).width(195)); + (inner, text.id()) +} + +/// What the span holds once its children have been swapped, which is what +/// the warm tree is changed to and what the cold one is grown with. +fn swapped_in(h: &mut Harness) -> Vec { + let paragraph = |h: &mut Harness| -> StrongWidget { + wtext(PARAGRAPH).size(16).wrap(true).add_strong(&mut h.rsc) + }; + vec![ + paragraph(h), + rect(Color::YELLOW).add_strong(&mut h.rsc), + paragraph(h), + ] +} + +/// A text handed back the width it measured breaks there the way it broke +/// when it measured it. The width the shaper answers is not on the grid, and +/// a report rounded to the nearest step is under the longest line half the +/// time: a warm tree then keeps a break made in a wider box while a cold one +/// makes a narrower break in the same box, and the paragraph gains a line. +#[test] +fn a_text_is_given_back_a_box_the_line_it_measured_fits_in() { + let mut warm = Harness::new((900, 1200)); + let (inner, text) = plant_a_measured_width(&mut warm, false); + warm.frame(); + warm.rsc[inner].children = swapped_in(&mut warm); + warm.frame(); + + let mut cold = Harness::new((900, 1200)); + let (_, cold_text) = plant_a_measured_width(&mut cold, true); + cold.frame(); + + assert_eq!(warm.region(&text), cold.region(&cold_text)); +}