diff --git a/docs/SCROLL.md b/docs/SCROLL.md index 243779b..c5ee78c 100644 --- a/docs/SCROLL.md +++ b/docs/SCROLL.md @@ -23,20 +23,31 @@ was just removed. ## One convention for a delta -**Positive moves the content the positive way along the axis — the -finger's direction — which brings *earlier* content into view.** +**Positive scrolls the reader up or left; negative down or right.** The +content's pixels therefore move the positive way along the axis for a +positive delta — the finger's direction — and that is `Scroll::scroll`'s +sign, `Scroll::fling`'s, and `Widget::apply_scroll`'s, from the gesture +all the way down to a row's anchor. -That is `Scroll::scroll`'s sign, `Scroll::fling`'s, and -`Widget::apply_scroll`'s. It holds from the gesture all the way down to a -row's anchor. `LazySpan`'s internal `scroll` runs the other way (its -anchor offset says where the pinned edge *sits*), and it is private, with -the single negation inside its `apply_scroll`. +**It is a screen direction, not a logical one** (Iris, 2026-09-08: +"positive should always scroll up / left, and negative down / right ... +that way it always works as the user would expect"). The earlier wording +— "positive brings *earlier* content into view" — is true only of a span +laid out forwards: a `Dir::UP` list's earlier content is *below*, so the +same delta panned it the opposite way from every other scrollable in +iris. `LazySpan::flip_delta` is the conversion into the walk's own +direction-relative space, the exact counterpart of `flip_pos` for +positions, and its `scroll` (private) is the only thing that speaks that +space. There used to be two public conventions under the same name, and every call site had to know which widget it was talking to. If you add a third -scrolling thing, it takes the finger's. `a_negative_delta_moves_toward_ -the_end` (in `lazy_span.rs`) pins this across the whole handoff, because -nothing else can catch a list scrolling backwards. +scrolling thing, it takes this one. Two tests pin it, and neither is +redundant: `a_negative_delta_moves_toward_the_end` follows the sign +across the whole handoff, and `a_delta_moves_both_directions_the_same_ +way_on_screen` checks the two `dir`s against **where rows were drawn** — +an assertion written in the walk's own space passes with the flip +deleted, because it checks the bookkeeping against itself. ## The `Widget` handoff diff --git a/iris/core/src/widget/mod.rs b/iris/core/src/widget/mod.rs index 0be49ac..ecfa790 100644 --- a/iris/core/src/widget/mod.rs +++ b/iris/core/src/widget/mod.rs @@ -86,9 +86,13 @@ pub trait Widget: Any { /// the rest in it. Only called on a widget whose /// [`Self::scrolls_itself`] is `true`. /// - /// The sign is `Scroll::scroll`'s, which is the finger's: a positive - /// delta moves the content in the positive direction of the axis, and - /// so brings *earlier* content into view. + /// The sign is `Scroll::scroll`'s, and it is a **screen** direction + /// rather than a logical one: positive scrolls the reader up or left, + /// negative down or right, whichever way this widget's own content + /// happens to be laid out (Iris, 2026-09-08). A convention phrased as + /// "positive brings earlier content into view" points the opposite + /// way for a widget laid out backwards, so the same delta would pan + /// one list up and another down. /// /// What is left behind is how the caller learns it reached a wall -- /// asked for 300, got 50 back means the content ran out 250 short -- diff --git a/iris/src/widget/position/lazy_span.rs b/iris/src/widget/position/lazy_span.rs index 11ae7de..445377a 100644 --- a/iris/src/widget/position/lazy_span.rs +++ b/iris/src/widget/position/lazy_span.rs @@ -459,24 +459,23 @@ impl LazySpan { /// Move the anchor's edge by `amt` pixels, where positive brings /// **later** content into view. /// - /// Private, and the opposite sign to [`Widget::apply_scroll`]'s - /// `delta`, which is the finger's direction (`Scroll::scroll`'s): the + /// Private, and in the direction-relative space the walk works in + /// rather than the screen space every public delta speaks in: the /// anchor's offset says where the pinned edge *sits*, so moving the - /// content forward moves that number down. `apply_scroll` is the one - /// place that negates, so there is exactly one public convention for - /// a scroll delta in this crate rather than two that read alike and - /// mean opposite things -- which is what these two were before - /// `Scroll` took the position over. + /// content forward moves that number down, and for a `Sign::Neg` + /// `dir` "forward" is up the screen rather than down it. + /// [`Self::flip_delta`] is the one conversion, exactly as + /// [`Self::flip_pos`] is for positions. /// /// Unclamped here, on purpose: it is one write. `apply_scroll` does /// the clamping, against walls the walk measured. fn scroll(&mut self, amt: f32) { if let Some(a) = &mut self.anchor { a.offset -= amt; - // Negated into `apply_scroll`'s convention, which is the - // finger's -- this is the one place the two directions meet, - // and `moved` is what `Widget::scroll_offset` hands upward. - self.moved -= amt; + // Converted into the screen-space convention every caller + // outside this widget uses, since `moved` is what + // `Widget::scroll_offset` hands upward. + self.moved += self.flip_delta(amt); } } @@ -542,6 +541,31 @@ impl LazySpan { } } + /// [`Self::flip_pos`] for a *delta*: convert between the screen-space + /// scroll deltas every caller speaks in -- positive scrolls the + /// reader up or left, whatever this span's `dir` is -- and the + /// direction-relative amount [`Self::scroll`] takes, where positive + /// always brings later content into view. Its own inverse, so one + /// function covers both directions and both ways round. + /// + /// **The sign is a screen direction, not a logical one** (Iris, + /// 2026-09-08: "positive should always scroll up / left, and negative + /// down / right ... that way it always works as the user would + /// expect"). Without this a `Dir::UP` span pans backwards against + /// every other scrollable in iris for the same delta, because its + /// later content is *above* rather than below -- and a test written + /// in the walk's own space cannot see it, since both halves agree + /// with each other while the screen disagrees with both. + /// + /// A position needs `viewport_len` to flip about and a delta does + /// not, which is why they are two functions rather than one. + fn flip_delta(&self, amt: f32) -> f32 { + match self.dir.sign { + Sign::Pos => -amt, + Sign::Neg => amt, + } + } + /// Record where (in pixels from this widget's top edge, the space a /// pointer event arrives in) the user last touched it, for the *next* /// layout pass in which some row's height changes to resolve against @@ -1126,10 +1150,10 @@ impl Widget for LazySpan { /// Take what the loaded content allows and leave the rest, so the /// `Scroll` above learns it hit a wall from what comes back. /// - /// `delta` arrives in the finger's direction (positive brings - /// *earlier* content into view) and the anchor's offset runs the other - /// way, which is the one negation in this widget -- see - /// [`Self::scroll`]. + /// `delta` arrives in screen space -- positive scrolls the reader up + /// or left -- and the walk works in direction-relative pixels, so + /// [`Self::flip_delta`] is the one conversion, on the way in and on + /// the remainder's way back out. /// /// **The bound is exact, not a "we are at the wall" flag.** With no /// more content past an edge, the travel left in that direction is the @@ -1153,9 +1177,9 @@ impl Widget for LazySpan { } else { f32::INFINITY }; - let taken = (-*delta).clamp(-max_backward, max_forward); + let taken = self.flip_delta(*delta).clamp(-max_backward, max_forward); self.scroll(taken); - *delta += taken; + *delta -= self.flip_delta(taken); } fn draw(&mut self, painter: &mut Painter) -> Size { @@ -1411,6 +1435,81 @@ mod tests { assert_eq!(list_ref.extent(0), Some((40.0, 60.0))); } + /// A delta means a screen direction, not a logical one: the same + /// negative delta moves the content up the screen whichever way the + /// span is laid out (Iris, 2026-09-08 -- "positive should always + /// scroll up / left, and negative down / right"). A `Dir::UP` span + /// used to pan the opposite way for the same number, because + /// `apply_scroll` handed the delta to the walk without the flip its + /// positions already went through. + /// + /// Asserted on where rows were **drawn**, for the reason + /// `a_dir_up_span_grows_upward_from_item_zero` gives: an assertion in + /// the walk's own space checks the bookkeeping against itself and + /// passes with the flip deleted. + #[test] + fn a_delta_moves_both_directions_the_same_way_on_screen() { + // 10 rows of 20px in a 100px viewport. Both spans open pinned to + // the end of their content -- which is the bottom of the screen + // for `Dir::DOWN` and the top of it for `Dir::UP` -- so each is + // first walked into the middle, where both have content to move + // in either direction, and only then handed the same delta. + let moved_by = |dir: Dir| { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(dir, true); + let keys: Vec = (0..10).collect(); + let rows = push_rows(&mut rsc, &mut list, &keys, 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + let push = |rsc: &mut TestRsc, render: &mut UiRenderState, mut delta: f32| { + rsc.ui + .widgets + .get_mut(&list_weak) + .unwrap() + .apply_scroll(&mut delta); + assert_eq!(delta, 0.0, "there was content to take the whole delta"); + render.update(&root, rsc); + }; + // Away from the pinned end, in whichever screen direction + // that is for this `dir`. + push( + &mut rsc, + &mut render, + match dir.sign { + Sign::Pos => 60.0, + Sign::Neg => -60.0, + }, + ); + + // Row 4 is on screen in both spans now, and stays drawn + // across a move this small whichever way it goes. + let top = |render: &UiRenderState| { + render.active[&rows[4].id()] + .region + .to_px((100.0, 100.0).into()) + .top_left + .y + }; + let before = top(&render); + push(&mut rsc, &mut render, -10.0); + top(&render) - before + }; + + for dir in [Dir::DOWN, Dir::UP] { + let moved = moved_by(dir); + assert!( + (moved + 10.0).abs() < 0.5, + "a negative delta must move the content 10px up the screen, not {moved}px", + ); + } + } + /// The conversion the reversed direction makes necessary: a pointer /// position arrives in screen pixels while the walk works in /// direction-relative ones, so a hit test that skipped the flip would diff --git a/iris/src/widget/position/scroll.rs b/iris/src/widget/position/scroll.rs index 32533b1..aa95fb2 100644 --- a/iris/src/widget/position/scroll.rs +++ b/iris/src/widget/position/scroll.rs @@ -456,11 +456,12 @@ impl Scroll { self.axis } - /// Pan by `amt`, in the finger's direction: positive moves the - /// content the positive way along the axis, which brings **earlier** - /// content into view. One convention, and the one - /// [`Widget::apply_scroll`] carries, so that a delta means the same - /// thing wherever it is handed on. + /// Pan by `amt`, in the finger's direction: **positive scrolls up or + /// left**, moving the content the positive way along the axis. One + /// convention, and the one [`Widget::apply_scroll`] carries, so that + /// a delta means the same thing wherever it is handed on -- and a + /// screen direction rather than a logical one, so that it means the + /// same thing to a widget laid out backwards too. /// /// A child that positions itself cannot be moved by writing `amt` /// here -- where it can actually go is a question only its own layout