From f8aa0c5cdf7633146c6ad2c4bb12cade0409bd6e Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Sun, 20 Sep 2026 02:53:02 -0400 Subject: [PATCH] Stop a scroll asking a question it has already answered A seventh sweep, over the parts no earlier round named: the tree generator and the scenario harness, `Fixed`, the headless rig, and once more over the commit the sixth sweep left, which was itself unreviewed. `Scroll`'s content box is `answer_px.max(container_len)`, so a scroll whose content fits has nothing to scroll through and `update_amt` has already put `amt` at zero. The test choosing between the viewport and a scrolled span asked `amt != ZERO || content_len != container_len`, where the first disjunct can never decide it -- the same defect `b7b8d09` removed from the line above, one operand over. A `debug_assert` of the implication held across the whole suite, including every scrolling test. `Fixed::to_scale` and its private `shift_round` arrived on this branch with no caller and never got one; the only thing that called either was the test written for them. `Len::align` wrote `Len` arithmetic out a component at a time, around an `at.px` that is always zero, where `Len::scale` and the `Add`/`Sub` beside it say the whole rule in two lines. `LayoutLen::without_leftover` took `self` where the `apply_leftover` its own doc calls the opposite reading of the same value takes `&self`. `run-headless.sh --resize` changed the output's mode but not `out_w`/`out_h`, which is the extent `replay-touch` scales a recording against -- so `--resize` with `--replay` put every sample of the gesture somewhere else and still finished like a run that worked. Both come from one function now. The generator's plan/build split stranded a comment: "a row takes the height it is given" describes the size rule `build` derives from `dir`, and it was left above the `gap` draw, which is the one line it is not about and which does consume randomness. Format, clippy with and without layout-diagnostics, and the suite (131 + 19 + 13 + 4) are clean. The cold dump over 400 depth-5 trees is byte-identical to b7b8d09 across all 34,492 boxes, and all three seed scans pass: 400 at depth 5 in 62.75s, 1,000 at depth 6 in 162.37s, 2,000 at depth 4 in 305.25s. Co-Authored-By: Claude Opus 5 --- core/src/fixed.rs | 27 --------------------------- core/src/orientation/align.rs | 8 +++++--- core/src/orientation/len.rs | 2 +- scripts/run-headless.sh | 17 +++++++++++------ src/random.rs | 8 ++++---- src/widget/position/scroll.rs | 15 ++++++++------- 6 files changed, 29 insertions(+), 48 deletions(-) diff --git a/core/src/fixed.rs b/core/src/fixed.rs index 1850ac1..8da9d48 100644 --- a/core/src/fixed.rs +++ b/core/src/fixed.rs @@ -131,14 +131,6 @@ impl Fixed { self.0 as f32 / Self::one().0 as f32 } - /// The same value on another grid, rounded where the new one is coarser. - pub const fn to_scale(self) -> Fixed { - Fixed(match TO >= SHIFT { - true => self.0 << (TO - SHIFT), - false => shift_round(self.0 as i64, SHIFT - TO) as i32, - }) - } - pub const fn add(self, rhs: Self) -> Self { Self(self.0.wrapping_add(rhs.0)) } @@ -246,16 +238,6 @@ impl Fixed { } } -/// Back to a single step, rounding halves away from zero so that a value and -/// its negation round to the same distance. -const fn shift_round(v: i64, bits: u32) -> i64 { - let half = (1i64 << bits) >> 1; - match v < 0 { - true => -((-v + half) >> bits), - false => (v + half) >> bits, - } -} - const fn div_round(num: i64, den: i64) -> i64 { let (q, rem) = (num / den, num % den); match rem.unsigned_abs() * 2 >= den.unsigned_abs() { @@ -531,15 +513,6 @@ mod tests { assert_eq!(Px::from_f32(-1e12), Px::MIN); } - #[test] - fn a_coarser_grid_rounds_and_a_finer_one_does_not() { - // A third, which neither grid holds exactly. - let third = Rel::ONE / Rel::from_int(3); - assert_eq!(third.to_scale::<6>(), Fixed::<6>::from_raw(21)); - let coarse = Fixed::<6>::from_raw(21); - assert_eq!(coarse.to_scale::<24>().to_scale::<6>(), coarse); - } - #[test] fn lerp_takes_the_fraction_as_the_receiver() { let (from, to) = (Px::from_int(10), Px::from_int(20)); diff --git a/core/src/orientation/align.rs b/core/src/orientation/align.rs index 605fcc8..99d952b 100644 --- a/core/src/orientation/align.rs +++ b/core/src/orientation/align.rs @@ -151,13 +151,15 @@ impl Vec2 { } impl Len { + /// This length placed in the box it is measured in: the alignment names a + /// point along that box, and the two ends are that point less the part of + /// the length falling before it and plus the part falling after. pub const fn align(&self, align: AxisAlign) -> UiSpan { let rel = align.rel(); - let rest = Rel::ONE.sub(rel); let at = Len::from_parts(rel, Px::ZERO); UiSpan { - start: Len::from_parts(at.rel.sub(self.rel.mul(rel)), at.px.sub(self.px.mul(rel))), - end: Len::from_parts(at.rel.add(self.rel.mul(rest)), at.px.add(self.px.mul(rest))), + start: at - self.scale(rel), + end: at + self.scale(Rel::ONE.sub(rel)), } } } diff --git a/core/src/orientation/len.rs b/core/src/orientation/len.rs index 7c31074..0ef6bdc 100644 --- a/core/src/orientation/len.rs +++ b/core/src/orientation/len.rs @@ -169,7 +169,7 @@ impl LayoutLen { /// anyone not dividing a box between siblings, where a share is a claim /// on someone else's room rather than a length of its own. /// [`Self::apply_leftover`] is the opposite reading of the same value. - pub const fn without_leftover(self) -> Len { + pub const fn without_leftover(&self) -> Len { Len::from_parts(self.rel, self.px) } diff --git a/scripts/run-headless.sh b/scripts/run-headless.sh index b2081c8..d46d393 100755 --- a/scripts/run-headless.sh +++ b/scripts/run-headless.sh @@ -106,11 +106,16 @@ export WAYLAND_DISPLAY echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2 -swaymsg output HEADLESS-1 mode "$mode" >/dev/null -# The extent `replay-touch` positions against, so a script's coordinates -# are the output's own pixels. -out_w=${mode%x*} -out_h=${mode#*x}; out_h=${out_h%@*} +# The extent `replay-touch` positions against, so a script's coordinates are +# the output's own pixels. Set beside every mode change, since a gesture +# scaled against a mode the output no longer has lands somewhere else and +# still looks like a run that worked. +set_mode() { + swaymsg output HEADLESS-1 mode "$1" >/dev/null + out_w=${1%x*} + out_h=${1#*x}; out_h=${out_h%@*} +} +set_mode "$mode" # Built before the app starts, so a compile error is not reported as a # window that failed to move. @@ -149,7 +154,7 @@ while [ $i -lt "$((seconds * 2))" ]; do done if [ -n "$resize" ] && kill -0 "$pid" 2>/dev/null; then - swaymsg output HEADLESS-1 mode "$resize" >/dev/null + set_mode "$resize" echo "run-headless: resized to $resize" >&2 sleep 2 fi diff --git a/src/random.rs b/src/random.rs index 2f37937..f4e06cf 100644 --- a/src/random.rs +++ b/src/random.rs @@ -773,10 +773,6 @@ impl Sow<'_> { self.spans += 1; let edit = self.edits.spans.get(&idx).cloned().unwrap_or_default(); let dir = self.rng.below(4); - // A row takes the height it is given rather than its tallest child, - // which is a rule beside it. Derived from an existing choice and - // consuming no randomness: a seed must keep growing the same tree - // when the generator gains another configuration. let gap = self.rng.below(3) as i32 * 4; let grown: Vec = (0..children.len()).collect(); let order = span_edited(&grown, children.len(), spares.len(), &edit); @@ -915,6 +911,10 @@ impl Build<'_, Rsc> { gap: Px::from_int(*gap), } .add(self.rsc); + // A row takes the height it is given rather than its tallest + // child, which is a rule beside the span rather than anything + // it draws. Derived from `dir` rather than stored, so a plan + // that says the direction says this too. if dir.axis == Axis::X { self.rsc .widgets_mut() diff --git a/src/widget/position/scroll.rs b/src/widget/position/scroll.rs index 788f355..fe44ac2 100644 --- a/src/widget/position/scroll.rs +++ b/src/widget/position/scroll.rs @@ -45,13 +45,14 @@ impl Widget for Scroll { painter.holds(self.axis, Px::MIN..=left); } - // 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 content = match self.amt != Px::ZERO || self.content_len != self.container_len { + // Content that fills the viewport is the viewport, and is handed back + // as it came -- it has nothing to scroll through, so the clamp above + // has already put `amt` at zero. 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 content = match self.content_len > self.container_len { true => { let start = Len::from_parts(Rel::ZERO, -self.amt); UiSpan::new(start, start.offset(self.content_len)).shifted_desc()