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 std::cell::Cell;
12
13use teksilo_canvas::{EdgeInsets, Point};
14
15use super::columns::{ColumnGeometry, ColumnOptions, column_at, geometry_for, with_options};
16use super::strategy::{BUFFER_ROWS, GridLayoutStrategy, GridSizing, TileRect, VisibleTileRange};
17
18/// A uniform grid: fixed row height, columns derived per [`GridSizing`].
19#[derive(Debug, Clone)]
20pub struct UniformGrid {
21    columns: Cell<ColumnGeometry>,
22    tile_height: f32,
23    row_gap: f32,
24    inset: EdgeInsets,
25}
26
27impl UniformGrid {
28    /// Apply the column-count bounds and the leftover alignment; `sizing` is
29    /// the one this strategy was built from.
30    pub(crate) fn with_columns(self, sizing: GridSizing, options: ColumnOptions) -> Self {
31        self.columns
32            .set(with_options(self.columns.get(), sizing, options));
33        self
34    }
35
36    /// Build from the public sizing description plus spacing/insets.
37    pub(crate) fn new(sizing: GridSizing, col_gap: f32, row_gap: f32, inset: EdgeInsets) -> Self {
38        Self {
39            columns: Cell::new(geometry_for(sizing, col_gap, inset)),
40            tile_height: sizing.tile_height().max(0.0),
41            row_gap: row_gap.max(0.0),
42            inset,
43        }
44    }
45
46    fn row_step(&self) -> f32 {
47        self.tile_height + self.row_gap
48    }
49
50    fn row_count(&self, item_count: usize, viewport_width: f32) -> usize {
51        if item_count == 0 {
52            return 0;
53        }
54        item_count.div_ceil(self.column_count(viewport_width).max(1))
55    }
56}
57
58impl GridLayoutStrategy for UniformGrid {
59    fn set_rtl(&self, rtl: bool) {
60        self.columns.set(self.columns.get().with_rtl(rtl));
61    }
62
63    fn column_count(&self, viewport_width: f32) -> usize {
64        self.columns.get().column_count(viewport_width)
65    }
66
67    fn column_x(&self, col: usize, viewport_width: f32) -> (f32, f32) {
68        self.columns.get().column_x(col, viewport_width)
69    }
70
71    fn total_content_height(&self, item_count: usize, viewport_width: f32) -> f32 {
72        let rows = self.row_count(item_count, viewport_width);
73        if rows == 0 {
74            return 0.0;
75        }
76        self.inset.top + rows as f32 * self.row_step() - self.row_gap + self.inset.bottom
77    }
78
79    fn visible_range(
80        &self,
81        scroll_y: f32,
82        viewport_height: f32,
83        viewport_width: f32,
84        item_count: usize,
85    ) -> VisibleTileRange {
86        if item_count == 0 || self.row_step() <= 0.0 {
87            return VisibleTileRange { start: 0, end: 0 };
88        }
89        let cols = self.column_count(viewport_width).max(1);
90        let row_step = self.row_step();
91        let content_scroll = (scroll_y - self.inset.top).max(0.0);
92        let first_row = (content_scroll / row_step).floor() as usize;
93        let last_row = ((content_scroll + viewport_height) / row_step).ceil() as usize;
94        let start_row = first_row.saturating_sub(BUFFER_ROWS);
95        let end_row = last_row + BUFFER_ROWS;
96        let start = (start_row * cols).min(item_count);
97        let end = (end_row.saturating_add(1).saturating_mul(cols)).min(item_count);
98        VisibleTileRange { start, end }
99    }
100
101    fn tile_rect(&self, index: usize, viewport_width: f32) -> TileRect {
102        let cols = self.column_count(viewport_width).max(1);
103        let row = index / cols;
104        let col = index % cols;
105        let (x, width) = self.column_x(col, viewport_width);
106        let y = self.inset.top + row as f32 * self.row_step();
107        TileRect {
108            x,
109            y,
110            width,
111            height: self.tile_height,
112        }
113    }
114
115    fn estimated_row_height(&self) -> f32 {
116        self.tile_height
117    }
118
119    fn index_at_point(
120        &self,
121        content_point: Point,
122        item_count: usize,
123        viewport_width: f32,
124    ) -> Option<usize> {
125        if item_count == 0 || self.row_step() <= 0.0 {
126            return None;
127        }
128        let y = content_point.y - self.inset.top;
129        if y < 0.0 {
130            return None;
131        }
132        let row = (y / self.row_step()) as usize;
133        if y - row as f32 * self.row_step() > self.tile_height {
134            return None; // row-gap
135        }
136        let col = column_at(&self.columns.get(), content_point.x, viewport_width)?;
137        let cols = self.column_count(viewport_width);
138        let idx = row * cols + col;
139        (idx < item_count).then_some(idx)
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn grid() -> UniformGrid {
148        // 100×50 tiles, 10px gaps, no insets → 4 columns in 430px.
149        UniformGrid::new(
150            GridSizing::Fixed {
151                width: 100.0,
152                height: 50.0,
153            },
154            10.0,
155            10.0,
156            EdgeInsets::ZERO,
157        )
158    }
159
160    #[test]
161    fn index_at_point_closed_form_matches_exact_edges() {
162        let g = grid();
163        // Tile 0 spans x 0..100, y 0..50 — both edges inclusive.
164        assert_eq!(g.index_at_point(Point::new(0.0, 0.0), 12, 430.0), Some(0));
165        assert_eq!(
166            g.index_at_point(Point::new(100.0, 50.0), 12, 430.0),
167            Some(0)
168        );
169        // Row 1 starts at y = 50 + 10 (gap) = 60.
170        assert_eq!(g.index_at_point(Point::new(0.0, 60.0), 12, 430.0), Some(4));
171    }
172
173    #[test]
174    fn index_at_point_closed_form_returns_none_in_gaps() {
175        let g = grid();
176        // Row-gap band (50..60).
177        assert_eq!(g.index_at_point(Point::new(50.0, 55.0), 12, 430.0), None);
178        // Column-gap band (100..110).
179        assert_eq!(g.index_at_point(Point::new(105.0, 25.0), 12, 430.0), None);
180        // Past the last row.
181        assert_eq!(g.index_at_point(Point::new(0.0, 9000.0), 12, 430.0), None);
182        // Above the first row.
183        assert_eq!(g.index_at_point(Point::new(0.0, -5.0), 12, 430.0), None);
184    }
185}