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)); +}