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;