ankurah_virtual_scroll/
lib.rs

1//! Virtual Scroll - Ankurah-integrated virtual scroll state machine
2
3pub mod windowing;
4
5use ankql::ast::{
6    ComparisonOperator, Expr, Literal, OrderByItem, OrderDirection, PathExpr, Predicate, Selection,
7};
8use ankurah::changes::ChangeSet;
9use ankurah::core::selection::filter::Filterable;
10use ankurah::core::value::Value;
11use ankurah::{model::View, Context, LiveQuery};
12use ankurah_proto::EntityId;
13use ankurah_signals::{Mut, Peek, Read, Subscribe};
14
15// Re-export key types
16pub use ankql::ast::{OrderByItem as OrderBy, Predicate as Filter};
17pub use ankurah_proto::EntityId as Id;
18pub use ankurah_signals;
19
20// ============================================================================
21// Core Types
22// ============================================================================
23
24/// The visible set of items exposed to the renderer
25#[derive(Clone, Debug)]
26pub struct VisibleSet<V> {
27    /// Items in display_order (first item at index 0)
28    pub items: Vec<V>,
29    /// Anchor item for scroll stability when items change
30    pub intersection: Option<Intersection>,
31    /// True if there are items preceding the current window (earlier in display_order)
32    pub has_more_preceding: bool,
33    /// True if there are items following the current window (later in display_order)
34    pub has_more_following: bool,
35    /// True if renderer should auto-scroll to end when items change
36    pub should_auto_scroll: bool,
37    /// Error if intersection calculation failed (continuation item not found in result)
38    pub error: Option<String>,
39}
40
41impl<V> Default for VisibleSet<V> {
42    fn default() -> Self {
43        Self {
44            items: Vec::new(),
45            intersection: None,
46            has_more_preceding: true,
47            has_more_following: false,
48            should_auto_scroll: true,
49            error: None,
50        }
51    }
52}
53
54/// Identifies an item that exists in both the old and new result sets
55#[derive(Clone, Debug)]
56pub struct Intersection {
57    pub entity_id: EntityId,
58    pub index: usize,
59    pub direction: LoadDirection,
60}
61
62/// Direction for loading more items, relative to display_order.
63///
64/// The display_order is set on the ScrollManager constructor and can be any valid
65/// ORDER BY clause (e.g., "timestamp DESC", "priority ASC, created_at DESC").
66///
67/// - `Backward`: Load items that appear earlier in display_order (preceding items)
68/// - `Forward`: Load items that appear later in display_order (following items)
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum LoadDirection {
71    /// Load items preceding current window in display_order
72    Backward,
73    /// Load items following current window in display_order
74    Forward,
75}
76
77/// Pending window slide operation
78#[derive(Clone, Debug)]
79struct PendingSlide {
80    /// Entity to anchor scroll position after slide
81    continuation: EntityId,
82    /// Expected result count (request limit+1 to detect has_more)
83    limit: usize,
84    /// Direction of the slide
85    direction: LoadDirection,
86    /// Whether ORDER BY is reversed (for forward slides)
87    reversed_order: bool,
88}
89
90/// Current scroll mode
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum ScrollMode {
93    Live,     // At newest, receiving real-time updates
94    Backward, // User scrolled up, loading older items
95    Forward,  // User scrolling back toward live
96}
97
98// ============================================================================
99// Helper Functions
100// ============================================================================
101
102/// Convert an Ankurah Value to an AnkQL Literal for predicate construction
103fn value_to_literal(value: &Value) -> Literal {
104    match value {
105        Value::I16(v) => Literal::I16(*v),
106        Value::I32(v) => Literal::I32(*v),
107        Value::I64(v) => Literal::I64(*v),
108        Value::F64(v) => Literal::F64(*v),
109        Value::Bool(v) => Literal::Bool(*v),
110        Value::String(v) => Literal::String(v.clone()),
111        // For other types, convert to string representation
112        _ => Literal::String(format!("{:?}", value)),
113    }
114}
115
116// ============================================================================
117// ScrollManager
118// ============================================================================
119
120/// Virtual scroll manager with Ankurah LiveQuery integration
121pub struct ScrollManager<V: View + Clone + Send + Sync + 'static> {
122    livequery: LiveQuery<V>,
123    predicate: Predicate,
124    display_order: Vec<OrderByItem>,
125    visible_set: Mut<VisibleSet<V>>,
126    mode: Mut<ScrollMode>,
127    /// Pending slide operation (set before query, consumed in callback)
128    pending: Mut<Option<PendingSlide>>,
129    minimum_row_height: u32,
130    buffer_factor: f64,
131    viewport_height: u32,
132    _subscription: ankurah_signals::SubscriptionGuard,
133}
134
135impl<V: View + Clone + Send + Sync + 'static> ScrollManager<V> {
136    /// Create a new scroll manager
137    ///
138    /// # Arguments
139    /// * `ctx` - Ankurah context
140    /// * `predicate` - Filter predicate (e.g., `"room_id = 'abc'"`)
141    /// * `display_order` - Visual order (e.g., `"timestamp DESC"` for chat)
142    /// * `minimum_row_height` - Guaranteed minimum item height in pixels
143    /// * `buffer_factor` - Buffer as multiple of viewport (2.0 = 2x viewport buffer)
144    /// * `viewport_height` - Viewport height in pixels
145    pub fn new(
146        ctx: &Context,
147        predicate: impl TryInto<Predicate, Error = impl std::fmt::Debug>,
148        display_order: impl IntoOrderBy,
149        minimum_row_height: u32,
150        buffer_factor: f64,
151        viewport_height: u32,
152    ) -> Result<Self, ankurah::error::RetrievalError> {
153        let predicate = predicate.try_into().expect("Failed to parse predicate");
154        let display_order = display_order
155            .into_order_by()
156            .expect("Failed to parse order");
157        let buffer_factor = buffer_factor.max(2.0);
158
159        // Compute initial limit
160        let screen_items = windowing::screen_items(viewport_height, minimum_row_height);
161        let threshold = buffer_factor / 2.0;
162        let limit = windowing::live_window_size(screen_items, threshold);
163
164        // Create livequery with initial selection
165        let selection = Selection {
166            predicate: predicate.clone(),
167            order_by: Some(display_order.clone()),
168            limit: Some(limit as u64),
169        };
170        let livequery: LiveQuery<V> = ctx.query(selection)?;
171
172        // Create signals
173        let visible_set: Mut<VisibleSet<V>> = Mut::new(VisibleSet::default());
174        let pending: Mut<Option<PendingSlide>> = Mut::new(None);
175        let mode: Mut<ScrollMode> = Mut::new(ScrollMode::Live);
176
177        // Determine if we need to reverse results for display
178        let is_desc = display_order
179            .first()
180            .map(|o| o.direction == OrderDirection::Desc)
181            .unwrap_or(false);
182
183        // Subscribe to livequery changes (for updates after initialization)
184        let visible_set_clone = visible_set.clone();
185        let pending_clone = pending.clone();
186        let mode_clone = mode.clone();
187        let subscription = livequery.subscribe(move |changeset: ChangeSet<V>| {
188            let current = visible_set_clone.peek();
189            // Skip if not yet initialized (start() will handle initial set)
190            if current.items.is_empty() && !changeset.resultset.peek().is_empty() {
191                return;
192            }
193            let mut items: Vec<V> = changeset.resultset.peek();
194
195            // Consume pending slide state
196            let slide = pending_clone.peek();
197            pending_clone.set(None);
198
199            // Normally, DESC order needs reversal to get oldest-first display order
200            // But if we used reversed order (ASC for forward), items are already oldest-first
201            let used_reversed_order = slide.as_ref().map(|s| s.reversed_order).unwrap_or(false);
202            if is_desc && !used_reversed_order {
203                items.reverse();
204            }
205
206            // Process result based on pending slide direction
207            let (has_more_preceding, has_more_following, intersection, error) = if let Some(ref slide) = slide {
208                // Detect end of data: we requested limit+1, so len > limit means more exist
209                let (has_more_preceding, has_more_following) = match slide.direction {
210                    LoadDirection::Backward => {
211                        let more_older = if items.len() > slide.limit {
212                            items.remove(0); // Remove extra oldest item
213                            true
214                        } else {
215                            false
216                        };
217                        (more_older, true) // Backward slide means we left live edge
218                    }
219                    LoadDirection::Forward => {
220                        let more_newer = if items.len() > slide.limit {
221                            items.pop(); // Remove extra newest item
222                            true
223                        } else {
224                            // Reached live edge - transition back to Live mode
225                            mode_clone.set(ScrollMode::Live);
226                            false
227                        };
228                        // Detect if we left items behind
229                        let more_older = current.has_more_preceding ||
230                            current.items.first().map(|old| items.first().map(|new|
231                                old.entity().id() != new.entity().id()
232                            ).unwrap_or(false)).unwrap_or(false);
233                        (more_older, more_newer)
234                    }
235                };
236
237                // Find intersection item for scroll anchoring
238                let (intersection, error) = match items.iter().position(|item| item.entity().id() == slide.continuation) {
239                    Some(index) => (
240                        Some(Intersection {
241                            entity_id: slide.continuation,
242                            index,
243                            direction: slide.direction,
244                        }),
245                        None
246                    ),
247                    None => {
248                        if slide.direction == LoadDirection::Forward {
249                            tracing::debug!("Forward slide: no overlap, jumping to live");
250                            (None, None)
251                        } else {
252                            (None, Some(format!(
253                                "Intersection failed: {} not found in result",
254                                slide.continuation
255                            )))
256                        }
257                    }
258                };
259
260                (has_more_preceding, has_more_following, intersection, error)
261            } else {
262                (current.has_more_preceding, current.has_more_following, None, None)
263            };
264
265            visible_set_clone.set(VisibleSet {
266                items,
267                intersection,
268                has_more_preceding,
269                has_more_following,
270                should_auto_scroll: mode_clone.peek() == ScrollMode::Live,
271                error,
272            });
273        });
274
275        Ok(Self {
276            livequery,
277            predicate,
278            display_order,
279            visible_set,
280            mode,
281            pending,
282            minimum_row_height,
283            buffer_factor,
284            viewport_height,
285            _subscription: subscription,
286        })
287    }
288
289    /// Initialize the scroll manager (waits for initial query results)
290    /// generally this should be backgrounded and not awaited on.
291    pub async fn start(&self) {
292        self.livequery.wait_initialized().await;
293
294        let mut items: Vec<V> = self.livequery.peek();
295
296        let is_desc = self
297            .display_order
298            .first()
299            .map(|o| o.direction == OrderDirection::Desc)
300            .unwrap_or(false);
301        if is_desc {
302            items.reverse();
303        }
304
305        let live_window = self.live_window_size();
306        let has_more_preceding = items.len() >= live_window;
307
308        self.visible_set.set(VisibleSet {
309            items,
310            intersection: None,
311            has_more_preceding,
312            has_more_following: false,
313            should_auto_scroll: true,
314            error: None,
315        });
316    }
317
318    // Computed properties
319    fn threshold(&self) -> f64 {
320        self.buffer_factor / 2.0
321    }
322
323    fn screen_items(&self) -> usize {
324        windowing::screen_items(self.viewport_height, self.minimum_row_height)
325    }
326
327    fn live_window_size(&self) -> usize {
328        windowing::live_window_size(self.screen_items(), self.threshold())
329    }
330
331    // Accessors
332    pub fn visible_set(&self) -> Read<VisibleSet<V>> {
333        self.visible_set.read()
334    }
335
336    pub fn mode(&self) -> ScrollMode {
337        self.mode.peek()
338    }
339
340    /// Get the current selection (predicate + order by) as a string.
341    pub fn current_selection(&self) -> String {
342        let (selection, _version) = self.livequery.selection().peek();
343        format!("{}", selection)
344    }
345
346    /// Notify the scroll manager of visible item changes
347    ///
348    /// # Arguments
349    /// * `first_visible` - EntityId of the first (oldest) visible item
350    /// * `last_visible` - EntityId of the last (newest) visible item
351    /// * `scrolling_backward` - True if user is scrolling toward older items
352    pub fn on_scroll(&self, first_visible: EntityId, last_visible: EntityId, scrolling_backward: bool) {
353        let current = self.visible_set.peek();
354        let screen = self.screen_items();
355
356        // Find indices of visible items in current window
357        let first_idx = current.items.iter().position(|item| item.entity().id() == first_visible);
358        let last_idx = current.items.iter().position(|item| item.entity().id() == last_visible);
359
360        let (first_visible_index, last_visible_index) = match (first_idx, last_idx) {
361            (Some(f), Some(l)) => (f, l),
362            _ => return, // Visible items not found in window - shouldn't happen
363        };
364
365        let items_above = first_visible_index;
366        let items_below = current.items.len().saturating_sub(last_visible_index + 1);
367
368        tracing::debug!(
369            "on_scroll: first={}, last={}, items_above={}, items_below={}, screen={}, scrolling_backward={}, has_more_preceding={}",
370            first_visible_index, last_visible_index, items_above, items_below, screen, scrolling_backward, current.has_more_preceding
371        );
372
373        // Trigger when buffer is at or below S items (one screenful remaining)
374        if scrolling_backward && items_above <= screen && current.has_more_preceding {
375            self.mode.set(ScrollMode::Backward);
376            self.slide_window(&current, first_visible_index, last_visible_index, LoadDirection::Backward);
377        } else if !scrolling_backward && items_below <= screen && current.has_more_following {
378            self.mode.set(ScrollMode::Forward);
379            self.slide_window(&current, first_visible_index, last_visible_index, LoadDirection::Forward);
380        }
381    }
382
383    /// Slide the window in the given direction
384    ///
385    /// - Backward: anchor on newest_visible, cursor B items newer, query older items
386    /// - Forward: anchor on oldest_visible, cursor B items older, query newer items (reversed ORDER BY)
387    fn slide_window(
388        &self,
389        current: &VisibleSet<V>,
390        oldest_visible_index: usize,
391        newest_visible_index: usize,
392        direction: LoadDirection,
393    ) {
394        let buffer = 2 * self.screen_items(); // B = 2S
395        let max_index = current.items.len().saturating_sub(1);
396
397        // Direction-specific: cursor position, intersection anchor, and comparison operator
398        let (cursor_index, intersection_index, operator, reversed_order) = match direction {
399            LoadDirection::Backward => (
400                (newest_visible_index + buffer).min(max_index),
401                newest_visible_index,
402                ComparisonOperator::LessThanOrEqual,
403                false,
404            ),
405            LoadDirection::Forward => (
406                // If at oldest edge, start from beginning to include all items
407                if current.has_more_preceding {
408                    oldest_visible_index.saturating_sub(buffer)
409                } else {
410                    0
411                },
412                oldest_visible_index,
413                ComparisonOperator::GreaterThanOrEqual,
414                true,
415            ),
416        };
417
418        // Dynamic limit: items from cursor to far visible edge + buffer
419        let limit = (cursor_index.max(newest_visible_index) - cursor_index.min(oldest_visible_index) + 1) + buffer;
420
421        tracing::debug!(
422            "slide_window({:?}): visible=[{},{}], cursor={}, limit={}",
423            direction, oldest_visible_index, newest_visible_index, cursor_index, limit
424        );
425
426        // Set pending state for callback
427        let continuation = current.items.get(intersection_index)
428            .map(|item| item.entity().id())
429            .expect("intersection item must exist");
430
431        self.pending.set(Some(PendingSlide {
432            continuation,
433            limit,
434            direction,
435            reversed_order,
436        }));
437
438        // Build cursor-constrained predicate
439        let predicate = self.build_cursor_predicate(current, cursor_index, operator);
440
441        // Build ORDER BY (reversed for forward pagination)
442        let order_by = if reversed_order {
443            self.display_order.iter().map(|item| OrderByItem {
444                direction: match item.direction {
445                    OrderDirection::Asc => OrderDirection::Desc,
446                    OrderDirection::Desc => OrderDirection::Asc,
447                },
448                ..item.clone()
449            }).collect()
450        } else {
451            self.display_order.clone()
452        };
453
454        let selection = Selection {
455            predicate,
456            order_by: Some(order_by),
457            limit: Some((limit + 1) as u64), // +1 to detect has_more
458        };
459
460        if let Err(e) = self.livequery.update_selection(selection) {
461            tracing::error!("Failed to update selection for {:?} slide: {}", direction, e);
462        }
463    }
464
465    /// Build a predicate constrained by cursor: `base AND field OP cursor_value`
466    fn build_cursor_predicate(
467        &self,
468        current: &VisibleSet<V>,
469        cursor_index: usize,
470        operator: ComparisonOperator,
471    ) -> Predicate {
472        let Some(cursor_item) = current.items.get(cursor_index) else {
473            return self.predicate.clone();
474        };
475        let Some(order_item) = self.display_order.first() else {
476            return self.predicate.clone();
477        };
478        let field_name = order_item.path.first();
479        let Some(cursor_value) = cursor_item.entity().value(field_name) else {
480            return self.predicate.clone();
481        };
482
483        let cursor_predicate = Predicate::Comparison {
484            left: Box::new(Expr::Path(PathExpr::simple(field_name))),
485            operator,
486            right: Box::new(Expr::Literal(value_to_literal(&cursor_value))),
487        };
488
489        Predicate::And(
490            Box::new(self.predicate.clone()),
491            Box::new(cursor_predicate),
492        )
493    }
494}
495
496// ============================================================================
497// Parsing Helpers
498// ============================================================================
499
500pub fn parse_order_by(s: &str) -> Result<Vec<OrderByItem>, String> {
501    use ankql::parser::parse_selection;
502    let selection_str = format!("true ORDER BY {}", s);
503    let selection =
504        parse_selection(&selection_str).map_err(|e| format!("Failed to parse ORDER BY: {}", e))?;
505    selection
506        .order_by
507        .ok_or_else(|| "No ORDER BY parsed".to_string())
508}
509
510pub trait IntoOrderBy {
511    fn into_order_by(self) -> Result<Vec<OrderByItem>, String>;
512}
513
514impl IntoOrderBy for &str {
515    fn into_order_by(self) -> Result<Vec<OrderByItem>, String> {
516        parse_order_by(self)
517    }
518}
519
520impl IntoOrderBy for Vec<OrderByItem> {
521    fn into_order_by(self) -> Result<Vec<OrderByItem>, String> {
522        Ok(self)
523    }
524}
525
526pub use ankurah_virtual_scroll_derive::generate_scroll_manager;