Skip to main content

armas_basic/layout/
bento_grid.rs

1//! Bento Grid Layout
2//!
3//! Grid layout with variable-sized tiles, inspired by macOS and Japanese bento boxes
4
5use crate::Theme;
6use egui::{Color32, Pos2, Rect, Sense, Stroke, Ui, Vec2};
7
8/// Grid item span configuration
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum GridSpan {
11    /// Single cell (1x1)
12    Single,
13    /// Wide (2x1)
14    Wide,
15    /// Tall (1x2)
16    Tall,
17    /// Large (2x2)
18    Large,
19}
20
21impl GridSpan {
22    const fn columns(self) -> usize {
23        match self {
24            Self::Single | Self::Tall => 1,
25            Self::Wide | Self::Large => 2,
26        }
27    }
28
29    const fn rows(self) -> usize {
30        match self {
31            Self::Single | Self::Wide => 1,
32            Self::Tall | Self::Large => 2,
33        }
34    }
35}
36
37/// Bento grid layout component
38///
39/// # Example
40///
41/// ```rust,no_run
42/// # use egui::Ui;
43/// # fn example(ui: &mut Ui) {
44/// use armas_basic::layout::{BentoGrid, GridSpan};
45///
46/// BentoGrid::new()
47///     .columns(3)
48///     .cell_size(120.0)
49///     .gap(8.0)
50///     .show(ui, |grid| {
51///         grid.item(GridSpan::Single, |ui| { ui.label("Cell 1"); });
52///         grid.item(GridSpan::Wide, |ui| { ui.label("Wide cell"); });
53///     });
54/// # }
55/// ```
56pub struct BentoGrid {
57    columns: usize,
58    cell_size: f32,
59    gap: f32,
60    corner_radius: f32,
61    padding: f32,
62}
63
64impl Default for BentoGrid {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl BentoGrid {
71    /// Create a new bento grid layout
72    #[must_use]
73    pub const fn new() -> Self {
74        Self {
75            columns: 3,
76            cell_size: 120.0,
77            gap: 12.0,
78            corner_radius: 12.0,
79            padding: 16.0,
80        }
81    }
82
83    /// Set the number of columns
84    #[must_use]
85    pub fn columns(mut self, columns: usize) -> Self {
86        self.columns = columns.max(1);
87        self
88    }
89
90    /// Set the base cell size
91    #[must_use]
92    pub const fn cell_size(mut self, size: f32) -> Self {
93        self.cell_size = size.max(50.0);
94        self
95    }
96
97    /// Set the gap between cells
98    #[must_use]
99    pub const fn gap(mut self, gap: f32) -> Self {
100        self.gap = gap;
101        self
102    }
103
104    /// Set the corner radius for cells
105    #[must_use]
106    pub const fn corner_radius(mut self, radius: f32) -> Self {
107        self.corner_radius = radius;
108        self
109    }
110
111    /// Set the padding around the grid
112    #[must_use]
113    pub const fn padding(mut self, padding: f32) -> Self {
114        self.padding = padding;
115        self
116    }
117
118    /// Show the bento grid with the given content
119    pub fn show<R>(self, ui: &mut Ui, content: impl FnOnce(&mut GridBuilder) -> R) -> R {
120        let theme = ui.ctx().data(|d| {
121            d.get_temp::<Theme>(egui::Id::new("armas_theme"))
122                .unwrap_or_else(Theme::dark)
123        });
124
125        ui.vertical(|ui| {
126            // Allocate the full grid area upfront
127            let start_pos = ui.cursor().min;
128
129            let mut builder = GridBuilder {
130                ui,
131                theme: &theme,
132                columns: self.columns,
133                cell_size: self.cell_size,
134                gap: self.gap,
135                corner_radius: self.corner_radius,
136                padding: self.padding,
137                grid_start_pos: start_pos,
138                current_col: 0,
139                current_row: 0,
140                occupied: Vec::new(),
141            };
142
143            let result = content(&mut builder);
144
145            // Calculate total height based on occupied rows
146            let max_row = builder.occupied.len();
147            let total_height = if max_row > 0 {
148                max_row as f32 * self.cell_size + (max_row - 1) as f32 * self.gap
149            } else {
150                0.0
151            };
152            let grid_width =
153                self.columns as f32 * self.cell_size + (self.columns - 1) as f32 * self.gap;
154            ui.allocate_space(Vec2::new(grid_width, total_height));
155
156            result
157        })
158        .inner
159    }
160}
161
162pub struct GridBuilder<'a> {
163    ui: &'a mut Ui,
164    theme: &'a Theme,
165    columns: usize,
166    cell_size: f32,
167    gap: f32,
168    corner_radius: f32,
169    padding: f32,
170    grid_start_pos: Pos2,
171    current_col: usize,
172    current_row: usize,
173    // Track occupied cells: (row, col) -> height in rows
174    occupied: Vec<Vec<usize>>,
175}
176
177impl GridBuilder<'_> {
178    /// Check if a cell is occupied
179    fn is_occupied(&self, row: usize, col: usize) -> bool {
180        if row >= self.occupied.len() {
181            return false;
182        }
183        if col >= self.occupied[row].len() {
184            return false;
185        }
186        self.occupied[row][col] > 0
187    }
188
189    /// Mark cells as occupied
190    fn mark_occupied(&mut self, row: usize, col: usize, cols: usize, rows: usize) {
191        // Ensure we have enough rows
192        while self.occupied.len() <= row + rows {
193            self.occupied.push(vec![0; self.columns]);
194        }
195
196        // Mark all cells occupied by this item
197        for r in row..row + rows {
198            for c in col..col + cols {
199                if c < self.columns {
200                    self.occupied[r][c] = rows;
201                }
202            }
203        }
204    }
205
206    /// Find next available position
207    fn find_next_position(&mut self, cols: usize, rows: usize) {
208        loop {
209            // Check if current position can fit the item
210            let mut can_fit = self.current_col + cols <= self.columns;
211
212            if can_fit {
213                // Check if all cells in the span are free
214                for r in 0..rows {
215                    for c in 0..cols {
216                        if self.is_occupied(self.current_row + r, self.current_col + c) {
217                            can_fit = false;
218                            break;
219                        }
220                    }
221                    if !can_fit {
222                        break;
223                    }
224                }
225            }
226
227            if can_fit {
228                return;
229            }
230
231            // Move to next position
232            self.current_col += 1;
233            if self.current_col >= self.columns {
234                self.current_col = 0;
235                self.current_row += 1;
236            }
237        }
238    }
239
240    pub fn item<R>(&mut self, span: GridSpan, content: impl FnOnce(&mut Ui) -> R) -> R {
241        self.item_with_style(span, None, None, content)
242    }
243
244    pub fn item_with_background<R>(
245        &mut self,
246        span: GridSpan,
247        background: Color32,
248        content: impl FnOnce(&mut Ui) -> R,
249    ) -> R {
250        self.item_with_style(span, Some(background), None, content)
251    }
252
253    pub fn item_with_style<R>(
254        &mut self,
255        span: GridSpan,
256        background: Option<Color32>,
257        border: Option<Color32>,
258        content: impl FnOnce(&mut Ui) -> R,
259    ) -> R {
260        let cols = span.columns();
261        let rows = span.rows();
262
263        // Find next available position that can fit this item
264        self.find_next_position(cols, rows);
265
266        // Calculate position and size
267        let x = self.grid_start_pos.x + self.current_col as f32 * (self.cell_size + self.gap);
268        let y = self.grid_start_pos.y + self.current_row as f32 * (self.cell_size + self.gap);
269
270        let width = cols as f32 * self.cell_size + (cols - 1) as f32 * self.gap;
271        let height = rows as f32 * self.cell_size + (rows - 1) as f32 * self.gap;
272
273        let rect = Rect::from_min_size(Pos2::new(x, y), Vec2::new(width, height));
274
275        // Draw background and border
276        let painter = self.ui.painter();
277        let bg_color = background.unwrap_or_else(|| self.theme.card());
278        let border_color = border.or_else(|| Some(self.theme.border()));
279
280        painter.rect_filled(rect, self.corner_radius, bg_color);
281
282        if let Some(border) = border_color {
283            painter.rect_stroke(
284                rect,
285                self.corner_radius,
286                Stroke::new(1.0, border),
287                egui::StrokeKind::Outside,
288            );
289        }
290
291        // Render content in the padded area
292        let content_rect = rect.shrink(self.padding);
293        let result = self
294            .ui
295            .scope_builder(egui::UiBuilder::new().max_rect(content_rect), |ui| {
296                content(ui)
297            })
298            .inner;
299
300        // Register interaction
301        let _response = self.ui.interact(
302            rect,
303            self.ui.id().with((self.current_col, self.current_row)),
304            Sense::hover(),
305        );
306
307        // Mark cells as occupied
308        self.mark_occupied(self.current_row, self.current_col, cols, rows);
309
310        // Update grid position - move to next cell
311        self.current_col += cols;
312        if self.current_col >= self.columns {
313            self.current_col = 0;
314            self.current_row += 1;
315        }
316
317        result
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn test_grid_span() {
327        assert_eq!(GridSpan::Single.columns(), 1);
328        assert_eq!(GridSpan::Single.rows(), 1);
329        assert_eq!(GridSpan::Wide.columns(), 2);
330        assert_eq!(GridSpan::Wide.rows(), 1);
331        assert_eq!(GridSpan::Tall.columns(), 1);
332        assert_eq!(GridSpan::Tall.rows(), 2);
333        assert_eq!(GridSpan::Large.columns(), 2);
334        assert_eq!(GridSpan::Large.rows(), 2);
335    }
336
337    #[test]
338    fn test_bento_grid_creation() {
339        let grid = BentoGrid::new().columns(3).cell_size(100.0);
340        assert_eq!(grid.columns, 3);
341        assert_eq!(grid.cell_size, 100.0);
342    }
343
344    #[test]
345    fn test_bento_grid_config() {
346        let grid = BentoGrid::new().gap(16.0).corner_radius(8.0);
347        assert_eq!(grid.gap, 16.0);
348        assert_eq!(grid.corner_radius, 8.0);
349    }
350}