ankurah_virtual_scroll/
windowing.rs

1//! Windowing Math Module
2//!
3//! Pure functions for virtual scroll windowing calculations.
4//! See specs/windowing.md for the full specification.
5
6// ============================================================================
7// Core Formulas
8// ============================================================================
9
10/// Maximum items that could be visible on one screen (worst case with minimum heights)
11pub fn screen_items(viewport_height: u32, minimum_row_height: u32) -> usize {
12    let items = (viewport_height as f64 / minimum_row_height as f64).ceil() as usize;
13    items.max(1) // At least 1 item per screen
14}
15
16/// Window size for live mode: (2N + 1) * screen_items
17pub fn live_window_size(screen_items: usize, threshold_screens: f64) -> usize {
18    ((2.0 * threshold_screens + 1.0) * screen_items as f64).ceil() as usize
19}
20
21/// Window size for pagination: (4N + 1) * screen_items
22pub fn full_window_size(screen_items: usize, threshold_screens: f64) -> usize {
23    ((4.0 * threshold_screens + 1.0) * screen_items as f64).ceil() as usize
24}
25
26/// Trigger threshold in pixels: N * viewport_height
27pub fn trigger_threshold_px(viewport_height: u32, threshold_screens: f64) -> u32 {
28    (threshold_screens * viewport_height as f64).ceil() as u32
29}
30
31/// Continuation offset from passing-side end: N * screen_items
32pub fn continuation_offset(screen_items: usize, threshold_screens: f64) -> usize {
33    (threshold_screens * screen_items as f64).ceil() as usize
34}
35
36/// Minimum buffer before pagination triggers: N * screen_items
37pub fn min_buffer(screen_items: usize, threshold_screens: f64) -> usize {
38    (threshold_screens * screen_items as f64).ceil() as usize
39}
40
41// ============================================================================
42// Trigger Logic
43// ============================================================================
44
45/// Direction of scroll/pagination
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Direction {
48    /// Scrolling up toward older items
49    Backward,
50    /// Scrolling down toward newer items
51    Forward,
52}
53
54/// Result of checking if pagination should trigger
55#[derive(Debug, Clone, PartialEq)]
56pub enum TriggerCheck {
57    /// No pagination needed
58    None,
59    /// Should trigger pagination in the given direction
60    Trigger(Direction),
61}
62
63/// Check if pagination should trigger based on scroll position
64pub fn check_trigger(
65    trigger_threshold_px: u32,
66    top_gap_px: u32,
67    bottom_gap_px: u32,
68    scrolling_up: bool,
69    at_earliest: bool,
70    at_latest: bool,
71) -> TriggerCheck {
72    // Backward pagination: scrolling up, near top, not at earliest
73    if scrolling_up && top_gap_px < trigger_threshold_px && !at_earliest {
74        return TriggerCheck::Trigger(Direction::Backward);
75    }
76
77    // Forward pagination: scrolling down, near bottom, not at latest
78    if !scrolling_up && bottom_gap_px < trigger_threshold_px && !at_latest {
79        return TriggerCheck::Trigger(Direction::Forward);
80    }
81
82    TriggerCheck::None
83}
84
85/// Calculate the continuation index for pagination
86///
87/// Returns the index in the current item set where the continuation item should be selected.
88pub fn continuation_index(
89    continuation_offset: usize,
90    current_len: usize,
91    direction: Direction,
92) -> usize {
93    match direction {
94        Direction::Backward => {
95            // N screens from the bottom (newest end)
96            current_len.saturating_sub(continuation_offset)
97        }
98        Direction::Forward => {
99            // N screens from the top (oldest end), but as an index (0-based)
100            continuation_offset.saturating_sub(1).min(current_len.saturating_sub(1))
101        }
102    }
103}
104
105// ============================================================================
106// Test Utilities
107// ============================================================================
108
109/// Computed windowing parameters (for testing)
110///
111/// Groups all derived values for easy assertion in tests.
112#[derive(Debug, Clone, PartialEq)]
113pub struct WindowingParams {
114    pub screen_items: usize,
115    pub live_window_size: usize,
116    pub full_window_size: usize,
117    pub min_buffer: usize,
118    pub trigger_threshold_px: u32,
119    pub continuation_offset: usize,
120}
121
122impl WindowingParams {
123    /// Compute all windowing parameters from raw inputs
124    pub fn compute(
125        viewport_height: u32,
126        minimum_row_height: u32,
127        threshold_screens: f64,
128    ) -> Self {
129        let screen_items = screen_items(viewport_height, minimum_row_height);
130        Self {
131            screen_items,
132            live_window_size: live_window_size(screen_items, threshold_screens),
133            full_window_size: full_window_size(screen_items, threshold_screens),
134            min_buffer: min_buffer(screen_items, threshold_screens),
135            trigger_threshold_px: trigger_threshold_px(viewport_height, threshold_screens),
136            continuation_offset: continuation_offset(screen_items, threshold_screens),
137        }
138    }
139}
140
141/// Simulate what the new result set would look like after pagination
142///
143/// Given current items as (start_id, end_id) range and a continuation_id,
144/// returns the expected (new_start_id, new_end_id) range.
145///
146/// This is for testing the math - assumes items are sequential IDs.
147pub fn simulate_pagination(
148    full_window_size: usize,
149    _current_start_id: i64,
150    _current_end_id: i64,
151    continuation_id: i64,
152    direction: Direction,
153    total_items_in_db: i64,
154) -> (i64, i64) {
155    let window_size = full_window_size as i64;
156
157    match direction {
158        Direction::Backward => {
159            // Query: id <= continuation_id ORDER BY id DESC LIMIT window_size
160            let new_end = continuation_id;
161            let new_start = (continuation_id - window_size + 1).max(0);
162            (new_start, new_end)
163        }
164        Direction::Forward => {
165            // Query: id >= continuation_id ORDER BY id ASC LIMIT window_size
166            let new_start = continuation_id;
167            let new_end = (continuation_id + window_size - 1).min(total_items_in_db - 1);
168            (new_start, new_end)
169        }
170    }
171}
172
173/// Find the intersection between two ID ranges
174///
175/// Returns (intersection_start, intersection_end) or None if no overlap
176pub fn find_intersection_range(
177    old_start: i64,
178    old_end: i64,
179    new_start: i64,
180    new_end: i64,
181) -> Option<(i64, i64)> {
182    let inter_start = old_start.max(new_start);
183    let inter_end = old_end.min(new_end);
184
185    if inter_start <= inter_end {
186        Some((inter_start, inter_end))
187    } else {
188        None
189    }
190}
191
192/// Calculate which item should be the anchor for scroll stability
193///
194/// Returns the ID of the anchor item (first or last of intersection based on direction)
195pub fn select_anchor_id(
196    intersection_start: i64,
197    intersection_end: i64,
198    direction: Direction,
199) -> i64 {
200    match direction {
201        // For backward, use first (oldest) intersecting item
202        Direction::Backward => intersection_start,
203        // For forward, use last (newest) intersecting item
204        Direction::Forward => intersection_end,
205    }
206}
207
208/// Verify that visible items are preserved in the new set
209pub fn visible_items_preserved(
210    visible_start_id: i64,
211    visible_end_id: i64,
212    new_start_id: i64,
213    new_end_id: i64,
214) -> bool {
215    visible_start_id >= new_start_id && visible_end_id <= new_end_id
216}
217
218/// Calculate buffer sizes after pagination
219///
220/// Returns (items_above_visible, items_below_visible)
221pub fn calculate_buffers(
222    visible_start_id: i64,
223    visible_end_id: i64,
224    new_start_id: i64,
225    new_end_id: i64,
226) -> (i64, i64) {
227    let above = visible_start_id - new_start_id;
228    let below = new_end_id - visible_end_id;
229    (above.max(0), below.max(0))
230}