Skip to main content

long_screen/kernels/
overscroll.rs

1//! Elastic overscroll (docs/ARCHITECTURE.md §十 "合成与瞬态像素"): where a canvas's page ends, read from how its
2//! placements move, and how much of one frame past that end is the background the browser shows there.
3//!
4//! At a page edge macOS, iOS and photo viewers let the content run past the edge and spring back. Tracking follows
5//! the content, so those frames' poses pass the edge and the strip of the frame beyond it shows the window's
6//! background, not the page. [`page_edges`] finds each edge from the trajectory alone; [`background_lines`] counts how
7//! many lines of one frame, from the region's outer side inward, hold nothing that belongs to the page.
8
9use std::collections::BTreeMap;
10
11/// One side of a region, in the order [`Edges::overshoot`] reports them.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Side {
14    Top,
15    Bottom,
16    Left,
17    Right,
18}
19
20pub const SIDES: [Side; 4] = [Side::Top, Side::Bottom, Side::Left, Side::Right];
21
22/// One painted placement of a canvas: its frame and the pose the compositor paints at (rounded).
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct Pose {
25    pub frame: u32,
26    pub x: i64,
27    pub y: i64,
28}
29
30/// The page's edges as pose limits: a placement whose pose passes one shows background past that side.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub struct Edges {
33    /// Smallest y, largest y, smallest x, largest x a placement can take without leaving the page.
34    pub top: Option<i64>,
35    pub bottom: Option<i64>,
36    pub left: Option<i64>,
37    pub right: Option<i64>,
38}
39
40impl Edges {
41    /// How far a placement at `(x, y)` runs past each side ([`SIDES`] order), 0 where it does not.
42    pub fn overshoot(&self, x: i64, y: i64) -> [i64; 4] {
43        let past = |edge: Option<i64>, outward: i64| edge.map_or(0, |e| (outward - e).max(0));
44        [
45            past(self.top.map(|e| -e), -y),
46            past(self.bottom, y),
47            past(self.left.map(|e| -e), -x),
48            past(self.right, x),
49        ]
50    }
51
52    pub fn is_empty(&self) -> bool {
53        *self == Edges::default()
54    }
55}
56
57/// Frames further apart than this are not one motion (another fragment, frames the plan left out).
58const GAP: u32 = 3;
59/// A spring comes to rest in small steps: the last [`TAIL`] moves back toward the page are at most this many pixels.
60const CONVERGE: i64 = 5;
61const TAIL: usize = 3;
62/// A return comes back from at least this far past where it ends.
63const MIN_DEPTH: i64 = 2;
64/// No rubber band runs further past the edge than a quarter of the region's extent along that axis.
65const CAP_NUM: i64 = 1;
66const CAP_DEN: i64 = 4;
67/// Pose values this close together are one place.
68const NEAR: i64 = 3;
69/// Frames the page rests at a place, counted over [`NEAR`], before that place counts as somewhere it stays.
70const HEAVY_REST: u32 = 8;
71
72/// The page's edges of one canvas from its placements in frame order. `width` and `height` are the region's size.
73///
74/// Each side is read on its own, as an outward coordinate `u` (−y for the top, y for the bottom, −x for the left,
75/// x for the right):
76/// 1. A *return* is a run of frames past some value `r`, entered from the page side, that comes back to `r` in
77///    small, settling steps (the last [`TAIL`] moves back are at most [`CONVERGE`] px) and stops there or turns
78///    out again; it reached at least [`MIN_DEPTH`] and at most a quarter of the extent past `r`. It is *settled* when the
79///    page then stays at `r` for two more frames.
80/// 2. The edge is near the outermost group of settled returns that holds at least a quarter as many returns as the
81///    largest group (returns within [`NEAR`] px are a group). Without settled returns it is near the outermost group
82///    of returns; if the page never stayed there ([`HEAVY_REST`] frames), the spring was left before it settled,
83///    and the edge is the outermost place on the page side, within the cap, where the page did stay.
84/// 3. Of the values around that group, the edge is the one the page rested at most often (the outer one on a tie):
85///    a held drag rests past the edge for a moment, the page rests at the edge for much longer.
86/// 4. A frame further past the edge than the cap means the page goes on past it, so that side has no edge.
87pub fn page_edges(track: &[Pose], width: i64, height: i64) -> Edges {
88    let along = |f: &dyn Fn(&Pose) -> i64| -> Vec<(u32, i64)> {
89        track.iter().map(|p| (p.frame, f(p))).collect()
90    };
91    Edges {
92        top: edge(&along(&|p| -p.y), height).map(|e| -e),
93        bottom: edge(&along(&|p| p.y), height),
94        left: edge(&along(&|p| -p.x), width).map(|e| -e),
95        right: edge(&along(&|p| p.x), width),
96    }
97}
98
99/// One return: where it ended and whether the page stayed there.
100struct Return {
101    end: i64,
102    settled: bool,
103}
104
105/// Whether frames `a` and `b` (in order) are consecutive enough to be one motion.
106fn joined(a: u32, b: u32) -> bool {
107    b.saturating_sub(a) <= GAP
108}
109
110fn returns(seq: &[(u32, i64)], extent: i64) -> Vec<Return> {
111    let mut out = Vec::new();
112    let mut start = 0;
113    for i in 0..seq.len() {
114        if i > 0 && !joined(seq[i - 1].0, seq[i].0) {
115            start = i;
116        }
117        if i == start {
118            continue;
119        }
120        let u = seq[i].1;
121        // Arrived moving back toward the page, and does not keep going.
122        if seq[i - 1].1 <= u {
123            continue;
124        }
125        let next = seq.get(i + 1).filter(|n| joined(seq[i].0, n.0));
126        if next.is_some_and(|n| n.1 < u) {
127            continue;
128        }
129        // The run past `u` that ends here must have been entered from the page side.
130        let mut j = i - 1;
131        while seq[j].1 > u && j > start {
132            j -= 1;
133        }
134        if seq[j].1 > u {
135            continue;
136        }
137        let mut tail = 0;
138        let mut k = i;
139        while k > j + 1 && tail < TAIL {
140            let step = seq[k - 1].1 - seq[k].1;
141            if !(0..=CONVERGE).contains(&step) {
142                break;
143            }
144            if step > 0 {
145                tail += 1;
146            }
147            k -= 1;
148        }
149        if tail < TAIL {
150            continue;
151        }
152        let depth = seq[j + 1..i].iter().map(|s| s.1).max().unwrap_or(u) - u;
153        if depth < MIN_DEPTH || depth * CAP_DEN > extent * CAP_NUM {
154            continue;
155        }
156        let stays = |k: usize| {
157            seq.get(k)
158                .is_some_and(|s| s.1 == u && joined(seq[k - 1].0, s.0))
159        };
160        out.push(Return {
161            end: u,
162            settled: stays(i + 1) && stays(i + 2),
163        });
164    }
165    out
166}
167
168/// The outermost group of values (within [`NEAR`] of each other) holding at least a quarter of the largest group,
169/// as its innermost and outermost value.
170fn outermost_group(mut values: Vec<i64>) -> Option<(i64, i64)> {
171    values.sort_unstable();
172    let mut groups: Vec<(i64, i64, usize)> = Vec::new();
173    for v in values {
174        match groups.last_mut() {
175            Some(g) if v - g.1 <= NEAR => {
176                g.1 = v;
177                g.2 += 1;
178            }
179            _ => groups.push((v, v, 1)),
180        }
181    }
182    let largest = groups.iter().map(|g| g.2).max()?;
183    let need = largest.div_ceil(4);
184    groups
185        .iter()
186        .rev()
187        .find(|g| g.2 >= need)
188        .map(|g| (g.0, g.1))
189}
190
191fn edge(seq: &[(u32, i64)], extent: i64) -> Option<i64> {
192    let found = returns(seq, extent);
193    let mut rests: BTreeMap<i64, u32> = BTreeMap::new();
194    for w in seq.windows(2) {
195        if joined(w[0].0, w[1].0) && w[0].1 == w[1].1 {
196            *rests.entry(w[1].1).or_default() += 1;
197        }
198    }
199    let mass = |lo: i64, hi: i64| rests.range(lo..=hi).map(|(_, &n)| n).sum::<u32>();
200    let settled: Vec<i64> = found.iter().filter(|r| r.settled).map(|r| r.end).collect();
201    let (mut lo, mut hi) = if settled.is_empty() {
202        outermost_group(found.iter().map(|r| r.end).collect())?
203    } else {
204        outermost_group(settled.clone())?
205    };
206    if settled.is_empty() && mass(lo - NEAR, hi + NEAR) < HEAVY_REST {
207        let reach = extent * CAP_NUM / CAP_DEN;
208        let stayed = rests
209            .range(lo - reach..lo)
210            .rev()
211            .map(|(&v, _)| v)
212            .find(|&v| mass(v - NEAR, v + NEAR) >= HEAVY_REST);
213        if let Some(v) = stayed {
214            (lo, hi) = (v, v);
215        }
216    }
217    let edge = rests
218        .range(lo - NEAR..=hi + NEAR)
219        .max_by_key(|(&v, &n)| (n, v))
220        .map_or(lo, |(&v, _)| v);
221    let beyond = seq.iter().map(|s| s.1 - edge).max().unwrap_or(0);
222    (beyond * CAP_DEN <= extent * CAP_NUM).then_some(edge)
223}
224
225/// One frame of a region and what [`background_lines`] compares it with.
226pub struct Lines<'a> {
227    /// The frame, RGBA, `width × height`.
228    pub rgba: &'a [u8],
229    pub width: usize,
230    pub height: usize,
231    /// The atlas label plane and the region's code; `None` for a rectangular region (every pixel of the rect is its).
232    pub labels: Option<(&'a [u8], u8)>,
233    /// The region's rect on screen in whole pixels: x, y, width, height.
234    pub region: (i64, i64, i64, i64),
235    /// The neighbouring frames whose placement differs from this frame's: a pixel equal to one of them at the same
236    /// screen position stays put on screen while the page moves.
237    pub neighbours: [Option<&'a [u8]>; 2],
238    /// The source's per-channel decode noise.
239    pub noise: f64,
240}
241
242/// Share of a line, out of 20, that must be background for the line to be: 19 (95 %), so a pointer tip or a
243/// scrollbar thumb's edge does not stop the count.
244const LINE_SHARE: usize = 19;
245
246/// The most lines in a row that may hold something pinned to the screen over the band and still be counted: a
247/// scrollbar along the viewport's edge, not a panel or a button.
248pub const PINNED_RUN: usize = 32;
249
250/// What one line of the strip shows.
251#[derive(Clone, Copy, Debug, PartialEq, Eq)]
252enum Line {
253    Background,
254    /// Background apart from something that stays put on screen while the page moves.
255    Pinned,
256    Content,
257}
258
259/// How many lines of `side`, from the region's outer side inward and at most `max`, show only background: each line
260/// one colour, apart from a few stray pixels (a pointer tip, a scrollbar thumb's edge).
261///
262/// A line that is one colour only once the pixels pinned to the screen are left aside (equal to a neighbouring
263/// frame at the same screen position although the page moved) is counted when at most [`PINNED_RUN`] such lines
264/// come in a row and background follows them: a scrollbar along the viewport's edge (test_5's
265/// bottom). A longer run stops the count like page content does, and so does a run the strip ends in: a pinned
266/// panel is not told apart from the page. Counting every pinned line removed test_3's sign-in pill above the page
267/// top, but it also took test_2's sidebar promo, which sits in the moving layer, out of the bounce frames at the
268/// page end, and the promo's clean copy there gave way to a torn one.
269pub fn background_lines(input: &Lines, side: Side, max: usize) -> usize {
270    let (w, h) = (input.width as i64, input.height as i64);
271    let x0 = input.region.0.clamp(0, w);
272    let y0 = input.region.1.clamp(0, h);
273    let x1 = (input.region.0 + input.region.2).clamp(x0, w);
274    let y1 = (input.region.1 + input.region.3).clamp(y0, h);
275    let count = match side {
276        Side::Top | Side::Bottom => y1 - y0,
277        Side::Left | Side::Right => x1 - x0,
278    } as usize;
279    let mut pixels: Vec<usize> = Vec::new();
280    let (mut counted, mut pinned) = (0, 0);
281    for line in 0..count.min(max) {
282        let l = line as i64;
283        pixels.clear();
284        let mut push = |x: i64, y: i64| {
285            let i = (y * w + x) as usize;
286            if input.labels.is_none_or(|(labels, code)| labels[i] == code) {
287                pixels.push(i);
288            }
289        };
290        match side {
291            Side::Top => (x0..x1).for_each(|x| push(x, y0 + l)),
292            Side::Bottom => (x0..x1).for_each(|x| push(x, y1 - 1 - l)),
293            Side::Left => (y0..y1).for_each(|y| push(x0 + l, y)),
294            Side::Right => (y0..y1).for_each(|y| push(x1 - 1 - l, y)),
295        }
296        match classify(input, &pixels) {
297            Line::Background => {
298                counted = line + 1;
299                pinned = 0;
300            }
301            Line::Pinned if pinned < PINNED_RUN => pinned += 1,
302            _ => break,
303        }
304    }
305    counted
306}
307
308/// What one line's pixels show: background when at least [`LINE_SHARE`]/20 of them are within the source's noise of
309/// the line's median colour; pinned when that share is reached only with the pixels equal to a neighbouring frame at
310/// the same screen position.
311fn classify(input: &Lines, pixels: &[usize]) -> Line {
312    if pixels.is_empty() {
313        return Line::Background;
314    }
315    let rgba = input.rgba;
316    let mut histogram = [[0u32; 256]; 3];
317    for &i in pixels {
318        for (c, h) in histogram.iter_mut().enumerate() {
319            h[rgba[i * 4 + c] as usize] += 1;
320        }
321    }
322    let half = pixels.len().div_ceil(2) as u32;
323    let median = histogram.map(|h| {
324        let mut seen = 0;
325        h.iter()
326            .position(|&n| {
327                seen += n;
328                seen >= half
329            })
330            .unwrap_or(0) as u8
331    });
332    // The mean per-channel difference is at most the source's noise.
333    let limit = 3.0 * input.noise;
334    let near = |a: &[u8], b: &[u8]| {
335        let d: i32 = (0..3).map(|c| (a[c] as i32 - b[c] as i32).abs()).sum();
336        d as f64 <= limit
337    };
338    let (mut plain, mut pinned) = (0, 0);
339    for &i in pixels {
340        let p = &rgba[i * 4..i * 4 + 3];
341        if near(p, &median) {
342            plain += 1;
343        } else if input
344            .neighbours
345            .iter()
346            .flatten()
347            .any(|n| near(p, &n[i * 4..i * 4 + 3]))
348        {
349            pinned += 1;
350        }
351    }
352    let enough = |n: usize| n * 20 >= pixels.len() * LINE_SHARE;
353    if enough(plain) {
354        Line::Background
355    } else if enough(plain + pinned) {
356        Line::Pinned
357    } else {
358        Line::Content
359    }
360}
361
362#[cfg(test)]
363mod tests;