diff --git a/core/src/orientation/pos.rs b/core/src/orientation/pos.rs index 53df60d..9d73394 100644 --- a/core/src/orientation/pos.rs +++ b/core/src/orientation/pos.rs @@ -186,6 +186,8 @@ impl UiScalar { /// Both channels by the same factor, which is what a fraction of a /// length means when the length is part pixels and part a share. + /// Both channels by the same fraction, which is what a part of a length + /// means when the length is part pixels and part a share. pub const fn scale(&self, by: Rel) -> Self { Self { rel: self.rel.mul(by), diff --git a/src/widget/position/scroll.rs b/src/widget/position/scroll.rs index 4dd2c10..fb2e26b 100644 --- a/src/widget/position/scroll.rs +++ b/src/widget/position/scroll.rs @@ -46,13 +46,23 @@ impl Widget for Scroll { // have placed the whole scroll in a box longer than it. let slack = (self.container_len - self.content_len).max(Px::ZERO); let anchor = slack.mul(align.rel()); - let offset = UiVec2::from_axis( - self.axis, - UiScalar::from_parts(Rel::ZERO, anchor - self.amt), - UiScalar::ZERO, - ); - let mut region = UiRegion::FULL.offset(offset); - region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len); + let mut region = UiRegion::FULL; + // Content that fills the viewport and has not been scrolled is the + // viewport, and is handed back as it came. Writing the same box as + // its own length in pixels is the same box in another form, and the + // two do not round alike: a part centred in `rel 1` lands a step from + // one centred in `px 900`, since halving a difference is not halving + // each part of it. + let moved = anchor != Px::ZERO || self.amt != Px::ZERO; + if moved || self.content_len != self.container_len { + let offset = UiVec2::from_axis( + self.axis, + UiScalar::from_parts(Rel::ZERO, anchor - self.amt), + UiScalar::ZERO, + ); + region = region.offset(offset); + region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len); + } painter.widget_aligned(&self.inner, region, RegionAlign::NEAR); // What it occupies is its box, on both axes: it clips its content to // that box, so it can neither take less of one nor honestly ask for diff --git a/src/widget/position/span.rs b/src/widget/position/span.rs index 481014c..b92fa6f 100644 --- a/src/widget/position/span.rs +++ b/src/widget/position/span.rs @@ -83,6 +83,15 @@ impl Widget for Span { // rule beside it already says how long it is, and then reading them // answers nothing and makes its size depend on theirs for it. let shrinks = !painter.ruled(!axis); + // What the fixed parts and the gaps before here take, which is a sum + // of lengths and exact, and how much of the leftover weight is + // spoken for. A position is one from the other rather than a step + // from the last child: the share of the room is rounded, and taking + // each from the one before it would carry every rounding along the + // row. + let mut fixed = UiScalar::rel_min(); + let mut taken = Weight::ZERO; + let room = UiScalar::rel_max() - UiScalar::from_parts(total.rel, total.px); let mut start = UiScalar::rel_min(); let mut ortho = Len::ZERO; for (child, len) in self.children.iter().zip(&lens) { @@ -92,20 +101,17 @@ impl Widget for Span { if len.leftover > Weight::ZERO && len.px == Px::ZERO && len.rel == Rel::ZERO && !shares { painter.undraw(child); - start.px += self.gap; + fixed.px += self.gap; continue; } let mut span = UiSpan::FULL; span.start = start; if len.leftover > Weight::ZERO && shares { - let offset = UiScalar::from_parts(total.rel, total.px); - let share = Rel::ratio(len.leftover, total.leftover); - let rel_end = UiScalar::from_parts(share, Px::ZERO); - let end = (UiScalar::rel_max() + start) - offset; - start = rel_end.within(&start.to(end)); + taken += len.leftover; } - start.px += len.px; - start.rel += len.rel; + fixed.px += len.px; + fixed.rel += len.rel; + start = shared(fixed, taken, total.leftover, room); span.end = start; let mut region = UiRegion::from_axis(axis, span, UiSpan::FULL); if self.dir.sign == Sign::Neg { @@ -124,7 +130,8 @@ impl Widget for Span { ortho.px = ortho.px.max(used.px); } } - start.px += self.gap; + fixed.px += self.gap; + start = shared(fixed, taken, total.leftover, room); } // Carried whole rather than collapsed to one share: a span that sizes @@ -143,6 +150,16 @@ impl Widget for Span { } } +/// Where a row has reached: everything fixed before this point, which is a +/// sum and exact, plus the share of the room the weights so far are worth, +/// which is one rounding wherever it is asked for. +fn shared(fixed: UiScalar, taken: Weight, weight: Weight, room: UiScalar) -> UiScalar { + if taken == Weight::ZERO { + return fixed; + } + fixed + room.scale(Rel::ratio(taken, weight)) +} + impl Span { pub fn empty(dir: Dir) -> Self { Self { diff --git a/tests/cases/layout.rs b/tests/cases/layout.rs index 51a957f..497e532 100644 --- a/tests/cases/layout.rs +++ b/tests/cases/layout.rs @@ -285,6 +285,43 @@ fn an_uneven_nesting_still_gives_every_share_the_same_length() { } } +/// However many ways a row is divided, the shares add up to the row: each +/// one is the fixed parts before it plus a share of the room, rather than a +/// step from where the last one ended, so the roundings do not accumulate +/// along it. Chained, two hundred of them ended a step short of the edge. +#[test] +fn a_row_of_equal_shares_fills_it_exactly() { + for n in [2usize, 3, 7, 64, 200] { + let mut h = Harness::new((1000, 100)); + let mut ids = Vec::new(); + let mut kids: Vec = Vec::new(); + for _ in 0..n { + let kid = rect(Color::RED).add(&mut h.rsc); + ids.push(kid.id()); + kids.push(kid.add_strong(&mut h.rsc)); + } + let span = Span { + children: kids, + dir: Dir::RIGHT, + gap: Px::ZERO, + } + .add(&mut h.rsc); + h.set_root(span); + h.frame(); + + for (i, id) in ids.iter().enumerate() { + let at = h.region(id).expect("a share drew nothing").top_left.x; + let want = Px::from_f32(1000.0 * (i as f32) / (n as f32)); + assert!( + (at - want).abs() <= Px::STEP, + "{n} shares: the {i}th starts at {at:?}, not {want:?}" + ); + } + let end = h.region(ids.last().unwrap()).unwrap().bot_right.x; + assert_eq!(end, Px::from_int(1000), "{n} shares do not reach the edge"); + } +} + /// Where the shader puts an edge: the two parts of a scalar are floored /// apart, so a fraction and a pixel offset snap independently, and each is /// taken to the boundary it composes to within half a step of. Kept in step diff --git a/tests/generated.rs b/tests/generated.rs index 24fbbf8..b1ded0d 100644 --- a/tests/generated.rs +++ b/tests/generated.rs @@ -31,12 +31,11 @@ fn env(name: &str, fallback: T) -> T { } const SEEDS: [u64; 9] = [1, 2, 3, 5, 8, 10, 13, 86, 98]; -/// The same box, to a step of the grid per operation. Warm and cold reach a -/// coordinate by different arithmetic: a move lands on the same number now, -/// and a length composed one way against the same length measured another can -/// land one step out. These cases apply two operations in turn, so they allow -/// two steps -- a thousandth of a pixel each, where this was a twentieth of -/// one before any of it was on a grid. +/// The same box, to a step of the grid per level of nesting between the two +/// ways of reaching it. A move, a repaint and a row of shares land on the +/// same number now; what is left is a box centred in a fraction of its parent +/// against the same box centred in its own pixels. A step is a thousandth of +/// a pixel, where this was a twentieth of one before any of it was on a grid. const AGREE_STEPS: i32 = 2; fn same_region(got: Option, want: Option) -> bool { diff --git a/tests/shrink.rs b/tests/shrink.rs index 8e3977f..724b774 100644 --- a/tests/shrink.rs +++ b/tests/shrink.rs @@ -51,9 +51,9 @@ const WORDS: &[&str] = &[ const ONE_LINE: &str = "one line, overflowing whatever it is given"; const OUTER: (f32, f32) = (1920.0, 1200.0); -/// Steps of the grid two ways of reaching a box may differ by. See -/// `docs/HANDOFF.md`'s "Fixed point" in `ai-app-2` for where the last of -/// them is. +/// Steps of the grid two ways of reaching a box may differ by: one per level +/// of nesting between them, and these trees are five deep. See +/// `docs/HANDOFF.md`'s "Fixed point" in `ai-app-2` for what is left. const AGREE_STEPS: i32 = 2; const INNER: (f32, f32) = (640.0, 900.0);