Skip to main content

egui_material3/
layoutgrid.rs

1use crate::theme::get_global_color;
2use egui::{epaint::CornerRadius, Color32, Rect, Response, Sense, Ui, Vec2, Widget};
3
4/// Material Design layout grid component.
5///
6/// Layout grids provide structure and organize content across multiple screen sizes.
7/// They help create consistent layouts following Material Design principles.
8///
9/// ```
10/// # egui::__run_test_ui(|ui| {
11/// let grid = MaterialLayoutGrid::new()
12///     .columns(12)
13///     .gutter(16.0)
14///     .margin(24.0)
15///     .cell(4, |ui| { ui.label("Column 1-4"); })
16///     .cell(4, |ui| { ui.label("Column 5-8"); })
17///     .cell(4, |ui| { ui.label("Column 9-12"); });
18///
19/// ui.add(grid);
20/// # });
21/// ```
22#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
23pub struct MaterialLayoutGrid<'a> {
24    cells: Vec<GridCell<'a>>,
25    columns: usize,
26    gutter: f32,
27    margin: f32,
28    max_width: Option<f32>,
29    debug_mode: bool,
30}
31
32struct GridCell<'a> {
33    span: usize,
34    content: Box<dyn FnOnce(&mut Ui) -> Response + 'a>,
35    offset: Option<usize>,
36}
37
38impl<'a> MaterialLayoutGrid<'a> {
39    /// Create a new layout grid.
40    pub fn new() -> Self {
41        Self {
42            cells: Vec::new(),
43            columns: 12,  // Standard 12-column grid
44            gutter: 16.0, // Standard gutter size
45            margin: 24.0, // Standard margin
46            max_width: None,
47            debug_mode: false,
48        }
49    }
50
51    /// Set the number of columns.
52    pub fn columns(mut self, columns: usize) -> Self {
53        self.columns = columns.max(1);
54        self
55    }
56
57    /// Set the gutter size (space between columns).
58    pub fn gutter(mut self, gutter: f32) -> Self {
59        self.gutter = gutter;
60        self
61    }
62
63    /// Set the margin (space around the grid).
64    pub fn margin(mut self, margin: f32) -> Self {
65        self.margin = margin;
66        self
67    }
68
69    /// Set maximum width of the grid.
70    pub fn max_width(mut self, max_width: f32) -> Self {
71        self.max_width = Some(max_width);
72        self
73    }
74
75    /// Enable debug mode to visualize grid structure.
76    pub fn debug_mode(mut self, debug: bool) -> Self {
77        self.debug_mode = debug;
78        self
79    }
80
81    /// Add a cell that spans the specified number of columns.
82    pub fn cell<F>(mut self, span: usize, content: F) -> Self
83    where
84        F: FnOnce(&mut Ui) + 'a,
85    {
86        self.cells.push(GridCell {
87            span: span.clamp(1, self.columns),
88            content: Box::new(move |ui| {
89                content(ui);
90                ui.allocate_response(Vec2::ZERO, Sense::hover())
91            }),
92            offset: None,
93        });
94        self
95    }
96
97    /// Add a cell with an offset (empty columns before this cell).
98    pub fn cell_with_offset<F>(mut self, span: usize, offset: usize, content: F) -> Self
99    where
100        F: FnOnce(&mut Ui) + 'a,
101    {
102        self.cells.push(GridCell {
103            span: span.clamp(1, self.columns),
104            content: Box::new(move |ui| {
105                content(ui);
106                ui.allocate_response(Vec2::ZERO, Sense::hover())
107            }),
108            offset: Some(offset),
109        });
110        self
111    }
112
113    /// Add an empty cell (spacer).
114    pub fn spacer(mut self, span: usize) -> Self {
115        self.cells.push(GridCell {
116            span: span.clamp(1, self.columns),
117            content: Box::new(|ui| ui.allocate_response(Vec2::ZERO, Sense::hover())),
118            offset: None,
119        });
120        self
121    }
122
123    fn calculate_column_width(&self, available_width: f32) -> f32 {
124        let effective_width = if let Some(max_width) = self.max_width {
125            available_width.min(max_width)
126        } else {
127            available_width
128        };
129
130        let total_gutter_width = (self.columns - 1) as f32 * self.gutter;
131        let content_width = effective_width - 2.0 * self.margin - total_gutter_width;
132        content_width / self.columns as f32
133    }
134}
135
136impl<'a> Default for MaterialLayoutGrid<'a> {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142impl Widget for MaterialLayoutGrid<'_> {
143    fn ui(self, ui: &mut Ui) -> Response {
144        let available_width = ui.available_width();
145        let column_width = self.calculate_column_width(available_width);
146
147        let MaterialLayoutGrid {
148            cells,
149            columns,
150            gutter,
151            margin,
152            max_width,
153            debug_mode,
154        } = self;
155
156        if cells.is_empty() {
157            return ui.allocate_response(Vec2::ZERO, Sense::hover());
158        }
159
160        let effective_width = if let Some(max_width) = max_width {
161            available_width.min(max_width)
162        } else {
163            available_width
164        };
165
166        // Start layout
167        let start_pos = ui.next_widget_position();
168        let mut current_row_y = start_pos.y + margin;
169        let mut current_column = 0;
170        let mut row_height: f32 = 0.0;
171        let mut max_y = current_row_y;
172
173        let mut responses = Vec::new();
174
175        // Process each cell
176        for cell in cells {
177            // Handle offset
178            if let Some(offset) = cell.offset {
179                current_column += offset;
180            }
181
182            // Check if we need to wrap to next row
183            if current_column + cell.span > columns {
184                current_row_y = max_y + gutter;
185                current_column = 0;
186                row_height = 0.0;
187            }
188
189            // Calculate cell position and size
190            let cell_x = start_pos.x + margin + current_column as f32 * (column_width + gutter);
191            let cell_width = cell.span as f32 * column_width + (cell.span - 1) as f32 * gutter;
192
193            // Create a constrained UI for this cell
194            let cell_rect = Rect::from_min_size(
195                egui::pos2(cell_x, current_row_y),
196                Vec2::new(cell_width, ui.available_height()),
197            );
198
199            let cell_response =
200                ui.scope_builder(egui::UiBuilder::new().max_rect(cell_rect), |ui| {
201                    // Debug visualization
202                    if debug_mode {
203                        let debug_color = get_global_color("primary").linear_multiply(0.12);
204                        ui.painter()
205                            .rect_filled(cell_rect, CornerRadius::from(2.0), debug_color);
206                    }
207
208                    (cell.content)(ui)
209                });
210
211            let cell_height = cell_response.response.rect.height();
212            row_height = row_height.max(cell_height);
213            max_y = max_y.max(current_row_y + row_height);
214
215            responses.push(cell_response.response);
216            current_column += cell.span;
217        }
218
219        // Calculate total grid size
220        let total_height = max_y - start_pos.y + margin;
221        let grid_rect = Rect::from_min_size(start_pos, Vec2::new(effective_width, total_height));
222
223        // Debug: Draw grid outline
224        if debug_mode {
225            let outline_color = get_global_color("primary");
226            ui.painter().rect_stroke(
227                grid_rect,
228                CornerRadius::from(4.0),
229                egui::epaint::Stroke::new(1.0, outline_color),
230                egui::epaint::StrokeKind::Outside,
231            );
232
233            // Draw column guides
234            for i in 0..=columns {
235                let x = start_pos.x + margin + i as f32 * (column_width + gutter) - gutter / 2.0;
236                if i > 0 && i < columns {
237                    ui.painter().line_segment(
238                        [egui::pos2(x, start_pos.y + margin), egui::pos2(x, max_y)],
239                        egui::epaint::Stroke::new(0.5, get_global_color("outlineVariant")),
240                    );
241                }
242            }
243        }
244
245        // Allocate the full grid space
246        let (_grid_response_rect, mut grid_response) =
247            ui.allocate_at_least(Vec2::new(effective_width, total_height), Sense::hover());
248
249        // Union all cell responses
250        for response in responses {
251            grid_response = grid_response.union(response);
252        }
253
254        grid_response
255    }
256}
257
258/// Convenience function to create a new layout grid.
259pub fn layout_grid() -> MaterialLayoutGrid<'static> {
260    MaterialLayoutGrid::new()
261}
262
263/// Convenience function to create a layout grid with debug mode enabled.
264pub fn debug_layout_grid() -> MaterialLayoutGrid<'static> {
265    MaterialLayoutGrid::new().debug_mode(true)
266}
267
268/// Material Design grid tile bar.
269///
270/// A header or footer bar typically used with [GridTile].
271/// Contains optional leading/trailing icons and title/subtitle text.
272///
273/// ```
274/// # egui::__run_test_ui(|ui| {
275/// let tile_bar = GridTileBar::new()
276///     .title("Image Title")
277///     .subtitle("Subtitle text")
278///     .background_color(egui::Color32::from_black_alpha(128));
279///
280/// ui.add(tile_bar);
281/// # });
282/// ```
283#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
284pub struct GridTileBar<'a> {
285    background_color: Option<Color32>,
286    leading: Option<Box<dyn FnOnce(&mut Ui) + 'a>>,
287    title: Option<String>,
288    subtitle: Option<String>,
289    trailing: Option<Box<dyn FnOnce(&mut Ui) + 'a>>,
290}
291
292impl<'a> GridTileBar<'a> {
293    /// Create a new grid tile bar.
294    pub fn new() -> Self {
295        Self {
296            background_color: None,
297            leading: None,
298            title: None,
299            subtitle: None,
300            trailing: None,
301        }
302    }
303
304    /// Set the background color.
305    pub fn background_color(mut self, color: Color32) -> Self {
306        self.background_color = Some(color);
307        self
308    }
309
310    /// Set a leading widget (left side icon/widget).
311    pub fn leading<F>(mut self, content: F) -> Self
312    where
313        F: FnOnce(&mut Ui) + 'a,
314    {
315        self.leading = Some(Box::new(content));
316        self
317    }
318
319    /// Set the title text.
320    pub fn title(mut self, title: impl Into<String>) -> Self {
321        self.title = Some(title.into());
322        self
323    }
324
325    /// Set the subtitle text.
326    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
327        self.subtitle = Some(subtitle.into());
328        self
329    }
330
331    /// Set a trailing widget (right side icon/widget).
332    pub fn trailing<F>(mut self, content: F) -> Self
333    where
334        F: FnOnce(&mut Ui) + 'a,
335    {
336        self.trailing = Some(Box::new(content));
337        self
338    }
339}
340
341impl<'a> Default for GridTileBar<'a> {
342    fn default() -> Self {
343        Self::new()
344    }
345}
346
347impl Widget for GridTileBar<'_> {
348    fn ui(self, ui: &mut Ui) -> Response {
349        let GridTileBar {
350            background_color,
351            leading,
352            title,
353            subtitle,
354            trailing,
355        } = self;
356
357        // Calculate height based on content
358        let height = if title.is_some() && subtitle.is_some() {
359            68.0
360        } else {
361            48.0
362        };
363
364        // Calculate padding
365        let padding_start = if leading.is_some() { 8.0 } else { 16.0 };
366        let padding_end = if trailing.is_some() { 8.0 } else { 16.0 };
367
368        let available_width = ui.available_width();
369        let start_pos = ui.next_widget_position();
370
371        // Draw background if specified
372        if let Some(bg_color) = background_color {
373            let bg_rect = Rect::from_min_size(start_pos, Vec2::new(available_width, height));
374            ui.painter().rect_filled(bg_rect, CornerRadius::ZERO, bg_color);
375        }
376
377        let _response = ui.horizontal(|ui| {
378            ui.add_space(padding_start);
379
380            // Leading widget
381            if let Some(leading_fn) = leading {
382                leading_fn(ui);
383                ui.add_space(8.0);
384            }
385
386            // Title and subtitle
387            if title.is_some() || subtitle.is_some() {
388                ui.vertical(|ui| {
389                    ui.set_min_height(height);
390                    ui.add_space((height - if subtitle.is_some() { 40.0 } else { 20.0 }) / 2.0);
391
392                    if let Some(title_text) = &title {
393                        ui.style_mut().override_text_style = Some(egui::TextStyle::Body);
394                        ui.label(egui::RichText::new(title_text).color(Color32::WHITE));
395                    }
396
397                    if let Some(subtitle_text) = &subtitle {
398                        ui.style_mut().override_text_style = Some(egui::TextStyle::Small);
399                        ui.label(egui::RichText::new(subtitle_text).color(Color32::WHITE));
400                    }
401                });
402            }
403
404            // Trailing widget
405            if let Some(trailing_fn) = trailing {
406                ui.add_space(8.0);
407                trailing_fn(ui);
408            }
409
410            ui.add_space(padding_end);
411        });
412
413        ui.allocate_rect(
414            Rect::from_min_size(start_pos, Vec2::new(available_width, height)),
415            Sense::hover(),
416        )
417    }
418}
419
420/// Material Design grid tile.
421///
422/// A tile in a grid list with optional header and footer overlays.
423/// Based on Flutter's GridTile component.
424///
425/// ```
426/// # egui::__run_test_ui(|ui| {
427/// let tile = GridTile::new(|ui| {
428///     ui.label("Tile content");
429/// })
430/// .footer(
431///     GridTileBar::new()
432///         .title("Image Title")
433///         .background_color(egui::Color32::from_black_alpha(128))
434/// );
435///
436/// ui.add(tile);
437/// # });
438/// ```
439#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
440pub struct GridTile<'a> {
441    header: Option<GridTileBar<'a>>,
442    footer: Option<GridTileBar<'a>>,
443    child: Box<dyn FnOnce(&mut Ui) + 'a>,
444    min_height: f32,
445}
446
447impl<'a> GridTile<'a> {
448    /// Create a new grid tile with the given content.
449    pub fn new<F>(content: F) -> Self
450    where
451        F: FnOnce(&mut Ui) + 'a,
452    {
453        Self {
454            header: None,
455            footer: None,
456            child: Box::new(content),
457            min_height: 100.0,
458        }
459    }
460
461    /// Set the header bar (shown at the top).
462    pub fn header(mut self, header: GridTileBar<'a>) -> Self {
463        self.header = Some(header);
464        self
465    }
466
467    /// Set the footer bar (shown at the bottom).
468    pub fn footer(mut self, footer: GridTileBar<'a>) -> Self {
469        self.footer = Some(footer);
470        self
471    }
472
473    /// Set the minimum height of the tile.
474    pub fn min_height(mut self, height: f32) -> Self {
475        self.min_height = height;
476        self
477    }
478}
479
480impl Widget for GridTile<'_> {
481    fn ui(self, ui: &mut Ui) -> Response {
482        let GridTile {
483            header,
484            footer,
485            child,
486            min_height,
487        } = self;
488
489        if header.is_none() && footer.is_none() {
490            // Simple case: no overlays
491            let response = ui.vertical(|ui| {
492                ui.set_min_height(min_height);
493                child(ui);
494            });
495            return response.response;
496        }
497
498        // Complex case: with header/footer overlays
499        let available_width = ui.available_width();
500        let start_pos = ui.next_widget_position();
501
502        // First, render the child to get the content height
503        let child_response = ui.vertical(|ui| {
504            ui.set_min_height(min_height);
505            child(ui);
506        });
507
508        let content_height = child_response.response.rect.height().max(min_height);
509        let tile_rect = Rect::from_min_size(start_pos, Vec2::new(available_width, content_height));
510
511        // Draw header overlay if present
512        if let Some(header_bar) = header {
513            let header_ui = &mut ui.new_child(
514                egui::UiBuilder::new()
515                    .max_rect(Rect::from_min_size(start_pos, Vec2::new(available_width, 68.0)))
516                    .layout(egui::Layout::top_down(egui::Align::LEFT)),
517            );
518            header_ui.add(header_bar);
519        }
520
521        // Draw footer overlay if present
522        if let Some(footer_bar) = footer {
523            let footer_height = 68.0; // Will be adjusted by the GridTileBar itself
524            let footer_pos = egui::pos2(start_pos.x, start_pos.y + content_height - footer_height);
525            let footer_ui = &mut ui.new_child(
526                egui::UiBuilder::new()
527                    .max_rect(Rect::from_min_size(footer_pos, Vec2::new(available_width, footer_height)))
528                    .layout(egui::Layout::top_down(egui::Align::LEFT)),
529            );
530            footer_ui.add(footer_bar);
531        }
532
533        ui.allocate_rect(tile_rect, Sense::hover())
534    }
535}