Skip to main content

teksilo_widgets/grid_view/layout/
uniform.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The uniform-tile grid strategy: every row has the same fixed height.
5//!
6//! This is the exact, O(1) common case — photo galleries, icon views,
7//! file-manager grids. The column count is derived from a fixed tile width
8//! (`Fixed`), an explicit count (`FixedColumnCount`), or a minimum tile
9//! width (`Adaptive`, the CSS `repeat(auto-fill, minmax(...))` model).
10
11use teksilo_canvas::{EdgeInsets, Point};
12
13use super::columns::{ColumnGeometry, column_at, geometry_for};
14use super::strategy::{BUFFER_ROWS, GridLayoutStrategy, GridSizing, TileRect, VisibleTileRange};
15
16/// A uniform grid: fixed row height, columns derived per [`GridSizing`].
17#[derive(Debug, Clone)]
18pub struct UniformGrid {
19    columns: ColumnGeometry,
20    tile_height: f32,
21    row_gap: f32,
22    inset: EdgeInsets,
23}
24
25impl UniformGrid {
26    /// Build from the public sizing description plus spacing/insets.
27    pub(crate) fn new(sizing: GridSizing, col_gap: f32, row_gap: f32, inset: EdgeInsets) -> Self {
28        Self {
29            columns: geometry_for(sizing, col_gap, inset),
30            tile_height: sizing.tile_height().max(0.0),
31            row_gap: row_gap.max(0.0),
32            inset,
33        }
34    }
35
36    fn row_step(&self) -> f32 {
37        self.tile_height + self.row_gap
38    }
39
40    fn row_count(&self, item_count: usize, viewport_width: f32) -> usize {
41        if item_count == 0 {
42            return 0;
43        }
44        item_count.div_ceil(self.column_count(viewport_width).max(1))
45    }
46}
47
48impl GridLayoutStrategy for UniformGrid {
49    fn column_count(&self, viewport_width: f32) -> usize {
50        self.columns.column_count(viewport_width)
51    }
52
53    fn column_x(&self, col: usize, viewport_width: f32) -> (f32, f32) {
54        self.columns.column_x(col, viewport_width)
55    }
56
57    fn total_content_height(&self, item_count: usize, viewport_width: f32) -> f32 {
58        let rows = self.row_count(item_count, viewport_width);
59        if rows == 0 {
60            return 0.0;
61        }
62        self.inset.top + rows as f32 * self.row_step() - self.row_gap + self.inset.bottom
63    }
64
65    fn visible_range(
66        &self,
67        scroll_y: f32,
68        viewport_height: f32,
69        viewport_width: f32,
70        item_count: usize,
71    ) -> VisibleTileRange {
72        if item_count == 0 || self.row_step() <= 0.0 {
73            return VisibleTileRange { start: 0, end: 0 };
74        }
75        let cols = self.column_count(viewport_width).max(1);
76        let row_step = self.row_step();
77        let content_scroll = (scroll_y - self.inset.top).max(0.0);
78        let first_row = (content_scroll / row_step).floor() as usize;
79        let last_row = ((content_scroll + viewport_height) / row_step).ceil() as usize;
80        let start_row = first_row.saturating_sub(BUFFER_ROWS);
81        let end_row = last_row + BUFFER_ROWS;
82        let start = (start_row * cols).min(item_count);
83        let end = (end_row.saturating_add(1).saturating_mul(cols)).min(item_count);
84        VisibleTileRange { start, end }
85    }
86
87    fn tile_rect(&self, index: usize, viewport_width: f32) -> TileRect {
88        let cols = self.column_count(viewport_width).max(1);
89        let row = index / cols;
90        let col = index % cols;
91        let (x, width) = self.column_x(col, viewport_width);
92        let y = self.inset.top + row as f32 * self.row_step();
93        TileRect {
94            x,
95            y,
96            width,
97            height: self.tile_height,
98        }
99    }
100
101    fn estimated_row_height(&self) -> f32 {
102        self.tile_height
103    }
104
105    fn index_at_point(
106        &self,
107        content_point: Point,
108        item_count: usize,
109        viewport_width: f32,
110    ) -> Option<usize> {
111        if item_count == 0 || self.row_step() <= 0.0 {
112            return None;
113        }
114        let y = content_point.y - self.inset.top;
115        if y < 0.0 {
116            return None;
117        }
118        let row = (y / self.row_step()) as usize;
119        if y - row as f32 * self.row_step() > self.tile_height {
120            return None; // row-gap
121        }
122        let col = column_at(&self.columns, content_point.x, viewport_width)?;
123        let cols = self.column_count(viewport_width);
124        let idx = row * cols + col;
125        (idx < item_count).then_some(idx)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn grid() -> UniformGrid {
134        // 100×50 tiles, 10px gaps, no insets → 4 columns in 430px.
135        UniformGrid::new(
136            GridSizing::Fixed {
137                width: 100.0,
138                height: 50.0,
139            },
140            10.0,
141            10.0,
142            EdgeInsets::ZERO,
143        )
144    }
145
146    #[test]
147    fn index_at_point_closed_form_matches_exact_edges() {
148        let g = grid();
149        // Tile 0 spans x 0..100, y 0..50 — both edges inclusive.
150        assert_eq!(g.index_at_point(Point::new(0.0, 0.0), 12, 430.0), Some(0));
151        assert_eq!(
152            g.index_at_point(Point::new(100.0, 50.0), 12, 430.0),
153            Some(0)
154        );
155        // Row 1 starts at y = 50 + 10 (gap) = 60.
156        assert_eq!(g.index_at_point(Point::new(0.0, 60.0), 12, 430.0), Some(4));
157    }
158
159    #[test]
160    fn index_at_point_closed_form_returns_none_in_gaps() {
161        let g = grid();
162        // Row-gap band (50..60).
163        assert_eq!(g.index_at_point(Point::new(50.0, 55.0), 12, 430.0), None);
164        // Column-gap band (100..110).
165        assert_eq!(g.index_at_point(Point::new(105.0, 25.0), 12, 430.0), None);
166        // Past the last row.
167        assert_eq!(g.index_at_point(Point::new(0.0, 9000.0), 12, 430.0), None);
168        // Above the first row.
169        assert_eq!(g.index_at_point(Point::new(0.0, -5.0), 12, 430.0), None);
170    }
171}