Skip to main content

heatsync_core/
scroll.rs

1//! message-cursor math. a cursor names a line by its absolute sequence number
2//! (how many lines the channel had appended before it), which stays put while
3//! the ring evicts old lines, grows at the live end, or gets history
4//! backfilled in front — so the view stays anchored without any bookkeeping on
5//! arrival. `seq` = lines ever appended, `len` = lines held now; the held
6//! lines are `seq - len .. seq`.
7
8/// absolute number of the held line at `idx`.
9pub fn abs_of(seq: u64, len: usize, idx: usize) -> u64 {
10    seq - len as u64 + idx as u64
11}
12
13/// index of absolute line `abs`, clamped into what is still held (an evicted
14/// line snaps to the oldest, a future one to the newest). `None` when empty.
15pub fn index_of(seq: u64, len: usize, abs: u64) -> Option<usize> {
16    if len == 0 {
17        return None;
18    }
19    let first = seq - len as u64;
20    Some(abs.saturating_sub(first).min(len as u64 - 1) as usize)
21}
22
23/// move a cursor by `delta` lines, staying inside the held lines.
24pub fn step(seq: u64, len: usize, abs: u64, delta: i64) -> u64 {
25    let Some(i) = index_of(seq, len, abs) else {
26        return abs;
27    };
28    let to = (i as i64).saturating_add(delta).clamp(0, len as i64 - 1);
29    abs_of(seq, len, to as usize)
30}
31
32/// lines newer than the cursor — what arrived while the view is parked.
33pub fn newer(seq: u64, len: usize, abs: u64) -> u64 {
34    match index_of(seq, len, abs) {
35        Some(i) => (len - 1 - i) as u64,
36        None => 0,
37    }
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    #[test]
45    fn absolute_numbers_survive_eviction_growth_and_backfill() {
46        // 5 lines held, 105 ever seen → held are 100..105
47        assert_eq!(abs_of(105, 5, 0), 100);
48        assert_eq!(abs_of(105, 5, 4), 104);
49        // a new line arrives and the ring evicts one: same line, same number
50        let cursor = abs_of(105, 5, 3); // 103
51        assert_eq!(index_of(106, 5, cursor), Some(2));
52        // history backfilled in front: len grows, seq doesn't
53        assert_eq!(index_of(105, 9, cursor), Some(7));
54        assert_eq!(abs_of(105, 9, 7), cursor);
55    }
56
57    #[test]
58    fn index_clamps_to_what_is_held() {
59        assert_eq!(index_of(105, 5, 10), Some(0), "evicted → oldest");
60        assert_eq!(index_of(105, 5, 999), Some(4), "future → newest");
61        assert_eq!(index_of(0, 0, 0), None);
62    }
63
64    #[test]
65    fn step_moves_and_stops_at_both_ends() {
66        let (seq, len) = (105, 5);
67        let mid = abs_of(seq, len, 2);
68        assert_eq!(step(seq, len, mid, -1), 101);
69        assert_eq!(step(seq, len, mid, 1), 103);
70        assert_eq!(step(seq, len, mid, -50), 100, "top");
71        assert_eq!(step(seq, len, mid, 50), 104, "bottom");
72        assert_eq!(
73            step(seq, len, 3, 1),
74            101,
75            "an evicted cursor snaps in first"
76        );
77        assert_eq!(step(0, 0, 7, 3), 7, "empty buffer: unchanged");
78        assert_eq!(step(seq, len, mid, i64::MIN), 100);
79        assert_eq!(step(seq, len, mid, i64::MAX), 104);
80    }
81
82    #[test]
83    fn newer_counts_lines_below_the_cursor() {
84        assert_eq!(newer(105, 5, 104), 0);
85        assert_eq!(newer(105, 5, 100), 4);
86        // parked at 102 while 3 more arrive (ring evicts 3)
87        // parked at 102 while 3 more arrive: 103..108 held, 102 evicted → oldest
88        assert_eq!(newer(108, 5, 102), 4);
89        assert_eq!(newer(108, 5, 105), 2);
90        assert_eq!(newer(0, 0, 0), 0);
91    }
92}