internal/screen/follow.go
Rama main · réplica de lectura del repositorio original.
// SPDX-FileCopyrightText: 2026 Rashik Adhikari
// SPDX-License-Identifier: Apache-2.0
package screen
// Following content between frames.
//
// The tile index answers "where is this content now" only when the content
// landed back on the tile grid, which a scroll by whole lines does and a
// scroll by a hundred pixels does not. A hundred pixels is the common case,
// so the grid alone is not enough.
//
// What saves it is that a scroll is a translation: every row of the new
// frame is some row of the old one, moved. So hash the frame a pixel row at
// a time, and the shift falls out of which rows matched where. One pass over
// the pixels, no search, and the answer is exact rather than rounded to a
// tile.
// rowHash is FNV-1a over one row of pixels.
func (f Frame) rowHash(y int) uint64 {
const (
offset64 = 14695981039346656037
prime64 = 1099511628211
)
h := uint64(offset64)
row := f.Pix[y*f.Width*4 : (y+1)*f.Width*4]
for _, b := range row {
h ^= uint64(b)
h *= prime64
}
return h
}
// Rows is a hash for every pixel row of a frame.
func (f Frame) Rows() []uint64 {
out := make([]uint64, f.Height)
for y := range f.Height {
out[y] = f.rowHash(y)
}
return out
}
// common is how many times a row may repeat before it stops being evidence.
//
// A row of blank background appears hundreds of times and matches every
// other blank row at every possible shift, so it votes for everything and
// therefore for nothing. Dropping those rows is what keeps this one pass
// instead of a search.
const common = 8
// MinEvidence is the share of rows that have to agree before a measured
// scroll is believed.
//
// Below this the frames are not the same content moved, they are different
// content that happens to have some rows in common — a window changed, a
// menu opened, the presenter switched application. Guessing a shift there
// would move every mark on the screen by a number nobody can check.
const MinEvidence = 8 // one row in eight
// Scrolled measures how far a frame's content moved since the last one.
//
// A positive dy means the content moved down the screen. ok is false when
// the two frames are not the same thing translated, which is the answer
// whenever somebody switches window — and the answer that stops a mark being
// dragged somewhere arbitrary.
func Scrolled(before, after Frame) (dy int, ok bool) {
if before.Width != after.Width || before.Height != after.Height ||
before.Height == 0 {
return 0, false
}
was := make(map[uint64][]int, before.Height)
for y, h := range before.Rows() {
was[h] = append(was[h], y)
}
votes := make(map[int]int, before.Height)
for y, h := range after.Rows() {
from := was[h]
if len(from) == 0 || len(from) > common {
continue
}
for _, o := range from {
votes[y-o]++
}
}
best, bestN, tied := 0, 0, 0
for shift, n := range votes {
switch {
case n > bestN:
best, bestN, tied = shift, n, 1
case n == bestN:
tied++
}
}
// A rival is a different answer, not the same one off by a pixel. Rows
// a few pixels apart can be identical — the flat top of a line of text,
// the inside of a filled box — so shifts next to the winner are the
// winner blurred, and a blur of a few pixels moves a mark by a few
// pixels. A shift from somewhere else on the screen is a different
// claim about what happened, and that is what has to be ruled out.
rival := 0
for shift, n := range votes {
if abs(shift-best) <= blur {
continue
}
if n > rival {
rival = n
}
}
need := after.Height / MinEvidence
if need < 4 {
need = 4
}
if tied > 1 || bestN < need || bestN < rival*2 {
return 0, false
}
return best, true
}
// blur is how far apart two shifts have to be to count as rivals.
//
// About half a line of text.
const blur = 8
func abs(n int) int {
if n < 0 {
return -n
}
return n
}