Skip to main content

fret_ui_headless/
grid_viewport.rs

1use std::fmt;
2use std::hash::Hash;
3use std::sync::Arc;
4
5use fret_core::Px;
6
7const VIRTUALIZER_PX_SCALE: f32 = 64.0;
8
9fn px_to_units_u32(px: Px) -> u32 {
10    let scaled = (px.0.max(0.0) * VIRTUALIZER_PX_SCALE).round();
11    scaled.clamp(0.0, u32::MAX as f32) as u32
12}
13
14fn px_to_units_u64(px: Px) -> u64 {
15    let scaled = (px.0.max(0.0) * VIRTUALIZER_PX_SCALE).round();
16    scaled.clamp(0.0, u64::MAX as f32) as u64
17}
18
19fn units_u32_to_px(units: u32) -> Px {
20    Px(units as f32 / VIRTUALIZER_PX_SCALE)
21}
22
23fn units_u64_to_px(units: u64) -> Px {
24    Px(units as f32 / VIRTUALIZER_PX_SCALE)
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum GridAxisMeasureMode {
29    Fixed,
30    Measured,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct GridAxisRange {
35    pub start_index: usize,
36    pub end_index: usize,
37    pub overscan: usize,
38    pub count: usize,
39}
40
41pub fn default_range_extractor(range: GridAxisRange) -> Vec<usize> {
42    if range.count == 0 {
43        return Vec::new();
44    }
45    let start = range.start_index.saturating_sub(range.overscan);
46    let end = (range.end_index + range.overscan).min(range.count.saturating_sub(1));
47    (start..=end).collect()
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub struct GridAxisItem<K> {
52    pub key: K,
53    pub index: usize,
54    pub start: Px,
55    pub end: Px,
56    pub size: Px,
57}
58
59#[derive(Debug, Clone)]
60struct FixedAxisMetrics {
61    count: usize,
62    estimate_units: u32,
63    gap_units: u32,
64    padding_start_units: u32,
65}
66
67/// Variable-size axis metrics for 2D grid virtualization.
68///
69/// This is a headless helper that tracks an axis (rows or columns) and computes:
70/// - total content size
71/// - visible range for a scroll offset and viewport length
72/// - per-index start/size/end offsets
73///
74/// It supports both fixed and measured strategies:
75/// - `Fixed`: constant item size (fast path)
76/// - `Measured`: size estimates plus measurement write-back per key/index
77///
78/// Notes:
79/// - Size caching is keyed by `K` to preserve measured sizes across reordering.
80/// - We scale `Px` into integer units to reduce float precision drift in hot offset math.
81#[derive(Clone)]
82pub struct GridAxisMetrics<K> {
83    mode: GridAxisMeasureMode,
84    estimate: Px,
85    gap: Px,
86    padding_start: Px,
87    keys_signature: (u64, usize),
88    inner: virtualizer::Virtualizer<K>,
89    fixed: FixedAxisMetrics,
90    get_item_key: Arc<dyn Fn(usize) -> K + Send + Sync + 'static>,
91}
92
93impl<K> fmt::Debug for GridAxisMetrics<K> {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        f.debug_struct("GridAxisMetrics")
96            .field("mode", &self.mode)
97            .field("estimate", &self.estimate)
98            .field("gap", &self.gap)
99            .field("padding_start", &self.padding_start)
100            .field("signature", &self.keys_signature)
101            .field("fixed_count", &self.fixed.count)
102            .finish()
103    }
104}
105
106impl<K> Default for GridAxisMetrics<K>
107where
108    K: Hash + Eq + Clone + Send + Sync + 'static,
109{
110    fn default() -> Self {
111        let options: virtualizer::VirtualizerOptions<K> =
112            virtualizer::VirtualizerOptions::new_with_key(
113                0,
114                |_| 0,
115                |_| {
116                    panic!(
117                        "GridAxisMetrics default key resolver should not be called for empty axes"
118                    )
119                },
120            );
121        Self {
122            mode: GridAxisMeasureMode::Measured,
123            estimate: Px(0.0),
124            gap: Px(0.0),
125            padding_start: Px(0.0),
126            keys_signature: (0, 0),
127            inner: virtualizer::Virtualizer::new(options),
128            fixed: FixedAxisMetrics {
129                count: 0,
130                estimate_units: 0,
131                gap_units: 0,
132                padding_start_units: 0,
133            },
134            get_item_key: Arc::new(|_| {
135                panic!("GridAxisMetrics get_item_key should not be called before ensure_*")
136            }),
137        }
138    }
139}
140
141impl<K> GridAxisMetrics<K>
142where
143    K: Hash + Eq + Clone + Send + Sync + 'static,
144{
145    pub fn ensure_with_mode(
146        &mut self,
147        mode: GridAxisMeasureMode,
148        keys: Arc<Vec<K>>,
149        items_revision: u64,
150        estimate: Px,
151        gap: Px,
152        padding_start: Px,
153    ) {
154        match mode {
155            GridAxisMeasureMode::Fixed => {
156                self.ensure_fixed(keys, items_revision, estimate, gap, padding_start);
157            }
158            GridAxisMeasureMode::Measured => {
159                self.ensure_measured(keys, items_revision, estimate, gap, padding_start);
160            }
161        }
162    }
163
164    pub fn ensure_measured(
165        &mut self,
166        keys: Arc<Vec<K>>,
167        items_revision: u64,
168        estimate: Px,
169        gap: Px,
170        padding_start: Px,
171    ) {
172        let count = keys.len();
173        let get_key = move |i: usize| {
174            keys.get(i)
175                .cloned()
176                .unwrap_or_else(|| keys.last().expect("non-empty keys").clone())
177        };
178        self.ensure_measured_with_key(count, items_revision, estimate, gap, padding_start, get_key);
179    }
180
181    pub fn ensure_measured_with_key(
182        &mut self,
183        count: usize,
184        items_revision: u64,
185        estimate: Px,
186        gap: Px,
187        padding_start: Px,
188        get_key: impl Fn(usize) -> K + Send + Sync + 'static,
189    ) {
190        let estimate = Px(estimate.0.max(0.0));
191        let gap = Px(gap.0.max(0.0));
192        let padding_start = Px(padding_start.0.max(0.0));
193
194        let signature = (items_revision, count);
195        if self.mode == GridAxisMeasureMode::Measured
196            && self.keys_signature == signature
197            && self.estimate == estimate
198            && self.gap == gap
199            && self.padding_start == padding_start
200        {
201            return;
202        }
203
204        self.mode = GridAxisMeasureMode::Measured;
205        self.estimate = estimate;
206        self.gap = gap;
207        self.padding_start = padding_start;
208        self.keys_signature = signature;
209
210        let estimate_units = px_to_units_u32(estimate);
211        let gap_units = px_to_units_u32(gap);
212        let padding_start_units = px_to_units_u32(padding_start);
213
214        let mut options = self.inner.options().clone();
215        options.count = count;
216        options.gap = gap_units;
217        options.padding_start = padding_start_units;
218        options.padding_end = 0;
219        options.scroll_margin = 0;
220        options.estimate_size = Arc::new(move |_| estimate_units);
221
222        let key_fn = Arc::new(get_key);
223        let key_fn_clamped: Arc<dyn Fn(usize) -> K + Send + Sync + 'static> =
224            Arc::new(move |i: usize| {
225                if count == 0 {
226                    panic!("GridAxisMetrics measured key resolver called with count=0");
227                }
228                (key_fn)(i.min(count.saturating_sub(1)))
229            });
230        self.get_item_key = Arc::clone(&key_fn_clamped);
231        options.get_item_key = key_fn_clamped;
232        self.inner.set_options(options);
233    }
234
235    pub fn ensure_fixed(
236        &mut self,
237        keys: Arc<Vec<K>>,
238        items_revision: u64,
239        estimate: Px,
240        gap: Px,
241        padding_start: Px,
242    ) {
243        let count = keys.len();
244        let get_key = move |i: usize| {
245            keys.get(i)
246                .cloned()
247                .unwrap_or_else(|| keys.last().expect("non-empty keys").clone())
248        };
249        self.ensure_fixed_with_key(count, items_revision, estimate, gap, padding_start, get_key);
250    }
251
252    pub fn ensure_fixed_with_key(
253        &mut self,
254        count: usize,
255        items_revision: u64,
256        estimate: Px,
257        gap: Px,
258        padding_start: Px,
259        get_key: impl Fn(usize) -> K + Send + Sync + 'static,
260    ) {
261        let estimate = Px(estimate.0.max(0.0));
262        let gap = Px(gap.0.max(0.0));
263        let padding_start = Px(padding_start.0.max(0.0));
264
265        let signature = (items_revision, count);
266        if self.mode == GridAxisMeasureMode::Fixed
267            && self.keys_signature == signature
268            && self.estimate == estimate
269            && self.gap == gap
270            && self.padding_start == padding_start
271        {
272            return;
273        }
274
275        self.mode = GridAxisMeasureMode::Fixed;
276        self.estimate = estimate;
277        self.gap = gap;
278        self.padding_start = padding_start;
279        self.keys_signature = signature;
280
281        let key_fn = Arc::new(get_key);
282        let key_fn_clamped: Arc<dyn Fn(usize) -> K + Send + Sync + 'static> =
283            Arc::new(move |i: usize| {
284                if count == 0 {
285                    panic!("GridAxisMetrics fixed key resolver called with count=0");
286                }
287                (key_fn)(i.min(count.saturating_sub(1)))
288            });
289        self.get_item_key = key_fn_clamped;
290
291        self.fixed = FixedAxisMetrics {
292            count,
293            estimate_units: px_to_units_u32(estimate),
294            gap_units: px_to_units_u32(gap),
295            padding_start_units: px_to_units_u32(padding_start),
296        };
297    }
298
299    pub fn total_size(&self) -> Px {
300        match self.mode {
301            GridAxisMeasureMode::Measured => units_u64_to_px(self.inner.total_size()),
302            GridAxisMeasureMode::Fixed => {
303                let count = self.fixed.count as u64;
304                if count == 0 {
305                    return Px(0.0);
306                }
307                let estimate = self.fixed.estimate_units as u64;
308                let gap = self.fixed.gap_units as u64;
309                let padding_start = self.fixed.padding_start_units as u64;
310                let gaps = count.saturating_sub(1);
311                let total_units = padding_start
312                    .saturating_add(count.saturating_mul(estimate))
313                    .saturating_add(gaps.saturating_mul(gap));
314                units_u64_to_px(total_units)
315            }
316        }
317    }
318
319    pub fn clamp_scroll_offset(&self, offset: Px, viewport: Px) -> Px {
320        let viewport = Px(viewport.0.max(0.0));
321        let total_units = px_to_units_u64(self.total_size());
322        let max_offset_units = total_units.saturating_sub(px_to_units_u64(viewport));
323        let max_offset = units_u64_to_px(max_offset_units);
324        let offset = Px(offset.0.max(0.0));
325        Px(offset.0.min(max_offset.0))
326    }
327
328    pub fn axis_item(&self, index: usize) -> Option<GridAxisItem<K>> {
329        let count = match self.mode {
330            GridAxisMeasureMode::Measured => self.inner.options().count,
331            GridAxisMeasureMode::Fixed => self.fixed.count,
332        };
333        if index >= count {
334            return None;
335        }
336        let key = (self.get_item_key)(index);
337        let start = self.offset_for_index(index);
338        let size = self.size_at(index);
339        let end = Px((start.0 + size.0).max(0.0));
340        Some(GridAxisItem {
341            key,
342            index,
343            start,
344            end,
345            size,
346        })
347    }
348
349    pub fn size_at(&self, index: usize) -> Px {
350        match self.mode {
351            GridAxisMeasureMode::Measured => self
352                .inner
353                .item_size(index)
354                .map(units_u32_to_px)
355                .unwrap_or(Px(0.0)),
356            GridAxisMeasureMode::Fixed => {
357                if index >= self.fixed.count {
358                    return Px(0.0);
359                }
360                units_u32_to_px(self.fixed.estimate_units)
361            }
362        }
363    }
364
365    pub fn offset_for_index(&self, index: usize) -> Px {
366        match self.mode {
367            GridAxisMeasureMode::Measured => {
368                if index >= self.inner.options().count {
369                    return self.total_size();
370                }
371                self.inner
372                    .item_start(index)
373                    .map(units_u64_to_px)
374                    .unwrap_or(Px(0.0))
375            }
376            GridAxisMeasureMode::Fixed => {
377                if index >= self.fixed.count {
378                    return self.total_size();
379                }
380                let stride =
381                    (self.fixed.estimate_units as u64).saturating_add(self.fixed.gap_units as u64);
382                let start_units = (self.fixed.padding_start_units as u64)
383                    .saturating_add((index as u64).saturating_mul(stride));
384                units_u64_to_px(start_units)
385            }
386        }
387    }
388
389    pub fn index_for_offset(&self, offset: Px) -> usize {
390        match self.mode {
391            GridAxisMeasureMode::Measured => {
392                if self.inner.options().count == 0 {
393                    return 0;
394                }
395                if offset.0 >= self.total_size().0 {
396                    return self.inner.options().count;
397                }
398                self.inner
399                    .index_at_offset(px_to_units_u64(offset))
400                    .unwrap_or(0)
401            }
402            GridAxisMeasureMode::Fixed => {
403                let count = self.fixed.count;
404                if count == 0 {
405                    return 0;
406                }
407                if offset.0 >= self.total_size().0 {
408                    return count;
409                }
410
411                let offset_units = px_to_units_u64(offset);
412                let padding_start = self.fixed.padding_start_units as u64;
413                if offset_units <= padding_start {
414                    return 0;
415                }
416                let stride =
417                    (self.fixed.estimate_units as u64).saturating_add(self.fixed.gap_units as u64);
418                if stride == 0 {
419                    return 0;
420                }
421
422                let adjusted = offset_units.saturating_sub(padding_start);
423                let idx = adjusted / stride;
424                (idx as usize).min(count.saturating_sub(1))
425            }
426        }
427    }
428
429    /// Returns a [`GridAxisRange`] with **inclusive** indices (`start_index..=end_index`).
430    pub fn visible_range(
431        &self,
432        offset: Px,
433        viewport: Px,
434        overscan: usize,
435    ) -> Option<GridAxisRange> {
436        let viewport = Px(viewport.0.max(0.0));
437        let count = match self.mode {
438            GridAxisMeasureMode::Measured => self.inner.options().count,
439            GridAxisMeasureMode::Fixed => self.fixed.count,
440        };
441        if viewport.0 <= 0.0 || count == 0 {
442            return None;
443        }
444
445        let (start, end) = match self.mode {
446            GridAxisMeasureMode::Measured => {
447                let range = self
448                    .inner
449                    .visible_range_for(px_to_units_u64(offset), px_to_units_u32(viewport));
450                if range.is_empty() {
451                    return None;
452                }
453                let start = range.start_index;
454                let end = range
455                    .end_index
456                    .saturating_sub(1)
457                    .min(count.saturating_sub(1));
458                (start, end)
459            }
460            GridAxisMeasureMode::Fixed => {
461                let start = self.index_for_offset(offset);
462                if start >= count {
463                    return None;
464                }
465
466                let end_exclusive = {
467                    let offset = Px(offset.0 + viewport.0);
468                    let total = self.total_size();
469                    if offset.0 >= total.0 {
470                        count
471                    } else {
472                        let offset_units = px_to_units_u64(offset);
473                        let padding_start = self.fixed.padding_start_units as u64;
474                        if offset_units <= padding_start {
475                            1
476                        } else {
477                            let stride = (self.fixed.estimate_units as u64)
478                                .saturating_add(self.fixed.gap_units as u64);
479                            if stride == 0 {
480                                1
481                            } else {
482                                let adjusted = offset_units.saturating_sub(padding_start);
483                                let idx = adjusted / stride;
484                                (idx as usize).saturating_add(1).min(count)
485                            }
486                        }
487                    }
488                };
489
490                let end = end_exclusive.saturating_sub(1).min(count.saturating_sub(1));
491                (start, end)
492            }
493        };
494
495        Some(GridAxisRange {
496            start_index: start,
497            end_index: end,
498            overscan,
499            count,
500        })
501    }
502
503    pub fn measure(&mut self, index: usize, size: Px) {
504        if self.mode != GridAxisMeasureMode::Measured {
505            return;
506        }
507        let size_units = px_to_units_u32(size);
508        let Some(old_units) = self.inner.item_size(index) else {
509            return;
510        };
511        if old_units == size_units {
512            return;
513        }
514        self.inner.measure_unadjusted(index, size_units);
515    }
516
517    pub fn reset_measurements(&mut self) {
518        if self.mode != GridAxisMeasureMode::Measured {
519            return;
520        }
521        self.inner.reset_measurements();
522    }
523}
524
525#[derive(Debug, Clone, PartialEq)]
526pub struct GridViewport2D {
527    pub row_range: GridAxisRange,
528    pub col_range: GridAxisRange,
529    pub scroll_x: Px,
530    pub scroll_y: Px,
531    pub total_width: Px,
532    pub total_height: Px,
533}
534
535pub fn compute_grid_viewport_2d<KR, KC>(
536    rows: &GridAxisMetrics<KR>,
537    cols: &GridAxisMetrics<KC>,
538    scroll_x: Px,
539    scroll_y: Px,
540    viewport_w: Px,
541    viewport_h: Px,
542    overscan_rows: usize,
543    overscan_cols: usize,
544) -> Option<GridViewport2D>
545where
546    KR: Hash + Eq + Clone + Send + Sync + 'static,
547    KC: Hash + Eq + Clone + Send + Sync + 'static,
548{
549    let scroll_x = cols.clamp_scroll_offset(scroll_x, viewport_w);
550    let scroll_y = rows.clamp_scroll_offset(scroll_y, viewport_h);
551
552    let row_range = rows.visible_range(scroll_y, viewport_h, overscan_rows)?;
553    let col_range = cols.visible_range(scroll_x, viewport_w, overscan_cols)?;
554
555    Some(GridViewport2D {
556        row_range,
557        col_range,
558        scroll_x,
559        scroll_y,
560        total_width: cols.total_size(),
561        total_height: rows.total_size(),
562    })
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    #[test]
570    fn fixed_axis_total_size_includes_padding_and_gaps() {
571        let mut axis: GridAxisMetrics<u64> = GridAxisMetrics::default();
572        axis.ensure_with_mode(
573            GridAxisMeasureMode::Fixed,
574            Arc::new(vec![0, 1, 2]),
575            1,
576            Px(10.0),
577            Px(2.0),
578            Px(5.0),
579        );
580
581        // padding 5 + (3 * 10) + (2 gaps * 2) = 39
582        assert_eq!(axis.total_size(), Px(39.0));
583        assert_eq!(axis.offset_for_index(0), Px(5.0));
584        assert_eq!(axis.offset_for_index(1), Px(17.0));
585        assert_eq!(axis.offset_for_index(2), Px(29.0));
586    }
587
588    #[test]
589    fn measured_axis_preserves_sizes_across_reorder_by_key() {
590        let mut axis: GridAxisMetrics<u64> = GridAxisMetrics::default();
591        axis.ensure_measured(Arc::new(vec![10, 20, 30]), 1, Px(10.0), Px(0.0), Px(0.0));
592
593        // Override the middle item size (key 20) to 50.
594        axis.measure(1, Px(50.0));
595        assert_eq!(axis.size_at(1), Px(50.0));
596
597        // Reorder keys: move key 20 to index 0.
598        axis.ensure_measured(Arc::new(vec![20, 10, 30]), 2, Px(10.0), Px(0.0), Px(0.0));
599        assert_eq!(axis.size_at(0), Px(50.0));
600    }
601
602    #[test]
603    fn grid_viewport_2d_returns_ranges() {
604        let mut rows: GridAxisMetrics<u64> = GridAxisMetrics::default();
605        rows.ensure_with_mode(
606            GridAxisMeasureMode::Fixed,
607            Arc::new((0..100).collect()),
608            1,
609            Px(10.0),
610            Px(0.0),
611            Px(0.0),
612        );
613
614        let mut cols: GridAxisMetrics<u64> = GridAxisMetrics::default();
615        cols.ensure_with_mode(
616            GridAxisMeasureMode::Fixed,
617            Arc::new((0..50).collect()),
618            1,
619            Px(20.0),
620            Px(0.0),
621            Px(0.0),
622        );
623
624        let vp =
625            compute_grid_viewport_2d(&rows, &cols, Px(30.0), Px(25.0), Px(100.0), Px(50.0), 2, 1)
626                .expect("viewport");
627
628        // Rows: offset 25, viewport 50 => start 2, end 7 (10px rows)
629        assert_eq!(vp.row_range.start_index, 2);
630        assert_eq!(vp.row_range.end_index, 7);
631
632        // Cols: offset 30, viewport 100 => start 1, end 6 (20px cols)
633        assert_eq!(vp.col_range.start_index, 1);
634        assert_eq!(vp.col_range.end_index, 6);
635    }
636}