iris: a scroll delta's sign is a screen direction, not a logical one
Positive scrolls the reader up or left and negative down or right, whichever way the widget receiving it lays its content out (Iris, 2026-09-08: "that way it always works as the user would expect"). `LazySpan` took the delta straight into the direction-relative space its walk works in, so a `Dir::UP` span -- whose later content is *above* -- panned the opposite way from every other scrollable in iris for the same number. `flip_delta` is the conversion, the counterpart of the `flip_pos` that positions already went through, and the two places that meet the outside world (`apply_scroll` and `moved`) are the only ones that use it. Nothing built a `Dir::UP` span yet, so this was latent; the existing sign test could not have found it either, since it asserts in the walk's own space where both halves agree with each other while disagreeing with the screen. The new test compares the two `dir`s against where rows were actually drawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
00e0a63887
commit
bf8658c404
4 files changed
+150
-35
No files matched your search
+21
-10
@@ -23,20 +23,31 @@ was just removed.
|
|||||||
|
|
||||||
## One convention for a delta
|
## One convention for a delta
|
||||||
|
|
||||||
**Positive moves the content the positive way along the axis — the
|
**Positive scrolls the reader up or left; negative down or right.** The
|
||||||
finger's direction — which brings *earlier* content into view.**
|
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
|
**It is a screen direction, not a logical one** (Iris, 2026-09-08:
|
||||||
`Widget::apply_scroll`'s. It holds from the gesture all the way down to a
|
"positive should always scroll up / left, and negative down / right ...
|
||||||
row's anchor. `LazySpan`'s internal `scroll` runs the other way (its
|
that way it always works as the user would expect"). The earlier wording
|
||||||
anchor offset says where the pinned edge *sits*), and it is private, with
|
— "positive brings *earlier* content into view" — is true only of a span
|
||||||
the single negation inside its `apply_scroll`.
|
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
|
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
|
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_
|
scrolling thing, it takes this one. Two tests pin it, and neither is
|
||||||
the_end` (in `lazy_span.rs`) pins this across the whole handoff, because
|
redundant: `a_negative_delta_moves_toward_the_end` follows the sign
|
||||||
nothing else can catch a list scrolling backwards.
|
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
|
## The `Widget` handoff
|
||||||
|
|
||||||
|
|||||||
@@ -86,9 +86,13 @@ pub trait Widget: Any {
|
|||||||
/// the rest in it. Only called on a widget whose
|
/// the rest in it. Only called on a widget whose
|
||||||
/// [`Self::scrolls_itself`] is `true`.
|
/// [`Self::scrolls_itself`] is `true`.
|
||||||
///
|
///
|
||||||
/// The sign is `Scroll::scroll`'s, which is the finger's: a positive
|
/// The sign is `Scroll::scroll`'s, and it is a **screen** direction
|
||||||
/// delta moves the content in the positive direction of the axis, and
|
/// rather than a logical one: positive scrolls the reader up or left,
|
||||||
/// so brings *earlier* content into view.
|
/// 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 --
|
/// 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 --
|
/// asked for 300, got 50 back means the content ran out 250 short --
|
||||||
|
|||||||
@@ -459,24 +459,23 @@ impl LazySpan {
|
|||||||
/// Move the anchor's edge by `amt` pixels, where positive brings
|
/// Move the anchor's edge by `amt` pixels, where positive brings
|
||||||
/// **later** content into view.
|
/// **later** content into view.
|
||||||
///
|
///
|
||||||
/// Private, and the opposite sign to [`Widget::apply_scroll`]'s
|
/// Private, and in the direction-relative space the walk works in
|
||||||
/// `delta`, which is the finger's direction (`Scroll::scroll`'s): the
|
/// rather than the screen space every public delta speaks in: the
|
||||||
/// anchor's offset says where the pinned edge *sits*, so moving the
|
/// anchor's offset says where the pinned edge *sits*, so moving the
|
||||||
/// content forward moves that number down. `apply_scroll` is the one
|
/// content forward moves that number down, and for a `Sign::Neg`
|
||||||
/// place that negates, so there is exactly one public convention for
|
/// `dir` "forward" is up the screen rather than down it.
|
||||||
/// a scroll delta in this crate rather than two that read alike and
|
/// [`Self::flip_delta`] is the one conversion, exactly as
|
||||||
/// mean opposite things -- which is what these two were before
|
/// [`Self::flip_pos`] is for positions.
|
||||||
/// `Scroll` took the position over.
|
|
||||||
///
|
///
|
||||||
/// Unclamped here, on purpose: it is one write. `apply_scroll` does
|
/// Unclamped here, on purpose: it is one write. `apply_scroll` does
|
||||||
/// the clamping, against walls the walk measured.
|
/// the clamping, against walls the walk measured.
|
||||||
fn scroll(&mut self, amt: f32) {
|
fn scroll(&mut self, amt: f32) {
|
||||||
if let Some(a) = &mut self.anchor {
|
if let Some(a) = &mut self.anchor {
|
||||||
a.offset -= amt;
|
a.offset -= amt;
|
||||||
// Negated into `apply_scroll`'s convention, which is the
|
// Converted into the screen-space convention every caller
|
||||||
// finger's -- this is the one place the two directions meet,
|
// outside this widget uses, since `moved` is what
|
||||||
// and `moved` is what `Widget::scroll_offset` hands upward.
|
// `Widget::scroll_offset` hands upward.
|
||||||
self.moved -= amt;
|
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
|
/// 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*
|
/// pointer event arrives in) the user last touched it, for the *next*
|
||||||
/// layout pass in which some row's height changes to resolve against
|
/// 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
|
/// Take what the loaded content allows and leave the rest, so the
|
||||||
/// `Scroll` above learns it hit a wall from what comes back.
|
/// `Scroll` above learns it hit a wall from what comes back.
|
||||||
///
|
///
|
||||||
/// `delta` arrives in the finger's direction (positive brings
|
/// `delta` arrives in screen space -- positive scrolls the reader up
|
||||||
/// *earlier* content into view) and the anchor's offset runs the other
|
/// or left -- and the walk works in direction-relative pixels, so
|
||||||
/// way, which is the one negation in this widget -- see
|
/// [`Self::flip_delta`] is the one conversion, on the way in and on
|
||||||
/// [`Self::scroll`].
|
/// the remainder's way back out.
|
||||||
///
|
///
|
||||||
/// **The bound is exact, not a "we are at the wall" flag.** With no
|
/// **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
|
/// more content past an edge, the travel left in that direction is the
|
||||||
@@ -1153,9 +1177,9 @@ impl Widget for LazySpan {
|
|||||||
} else {
|
} else {
|
||||||
f32::INFINITY
|
f32::INFINITY
|
||||||
};
|
};
|
||||||
let taken = (-*delta).clamp(-max_backward, max_forward);
|
let taken = self.flip_delta(*delta).clamp(-max_backward, max_forward);
|
||||||
self.scroll(taken);
|
self.scroll(taken);
|
||||||
*delta += taken;
|
*delta -= self.flip_delta(taken);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
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)));
|
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<RowKey> = (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
|
/// The conversion the reversed direction makes necessary: a pointer
|
||||||
/// position arrives in screen pixels while the walk works in
|
/// position arrives in screen pixels while the walk works in
|
||||||
/// direction-relative ones, so a hit test that skipped the flip would
|
/// direction-relative ones, so a hit test that skipped the flip would
|
||||||
|
|||||||
@@ -456,11 +456,12 @@ impl Scroll {
|
|||||||
self.axis
|
self.axis
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pan by `amt`, in the finger's direction: positive moves the
|
/// Pan by `amt`, in the finger's direction: **positive scrolls up or
|
||||||
/// content the positive way along the axis, which brings **earlier**
|
/// left**, moving the content the positive way along the axis. One
|
||||||
/// content into view. One convention, and the one
|
/// convention, and the one [`Widget::apply_scroll`] carries, so that
|
||||||
/// [`Widget::apply_scroll`] carries, so that a delta means the same
|
/// a delta means the same thing wherever it is handed on -- and a
|
||||||
/// thing wherever it is handed on.
|
/// 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`
|
/// A child that positions itself cannot be moved by writing `amt`
|
||||||
/// here -- where it can actually go is a question only its own layout
|
/// here -- where it can actually go is a question only its own layout
|
||||||
|
|||||||
Reference in new issue
Block a user