1use crate::theme::get_global_color;
2use egui::{epaint::CornerRadius, Color32, Rect, Response, Sense, Ui, Vec2, Widget};
3
4#[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 pub fn new() -> Self {
41 Self {
42 cells: Vec::new(),
43 columns: 12, gutter: 16.0, margin: 24.0, max_width: None,
47 debug_mode: false,
48 }
49 }
50
51 pub fn columns(mut self, columns: usize) -> Self {
53 self.columns = columns.max(1);
54 self
55 }
56
57 pub fn gutter(mut self, gutter: f32) -> Self {
59 self.gutter = gutter;
60 self
61 }
62
63 pub fn margin(mut self, margin: f32) -> Self {
65 self.margin = margin;
66 self
67 }
68
69 pub fn max_width(mut self, max_width: f32) -> Self {
71 self.max_width = Some(max_width);
72 self
73 }
74
75 pub fn debug_mode(mut self, debug: bool) -> Self {
77 self.debug_mode = debug;
78 self
79 }
80
81 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 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 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 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 for cell in cells {
177 if let Some(offset) = cell.offset {
179 current_column += offset;
180 }
181
182 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 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 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 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 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 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 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 let (_grid_response_rect, mut grid_response) =
247 ui.allocate_at_least(Vec2::new(effective_width, total_height), Sense::hover());
248
249 for response in responses {
251 grid_response = grid_response.union(response);
252 }
253
254 grid_response
255 }
256}
257
258pub fn layout_grid() -> MaterialLayoutGrid<'static> {
260 MaterialLayoutGrid::new()
261}
262
263pub fn debug_layout_grid() -> MaterialLayoutGrid<'static> {
265 MaterialLayoutGrid::new().debug_mode(true)
266}
267
268#[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 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 pub fn background_color(mut self, color: Color32) -> Self {
306 self.background_color = Some(color);
307 self
308 }
309
310 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 pub fn title(mut self, title: impl Into<String>) -> Self {
321 self.title = Some(title.into());
322 self
323 }
324
325 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
327 self.subtitle = Some(subtitle.into());
328 self
329 }
330
331 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 let height = if title.is_some() && subtitle.is_some() {
359 68.0
360 } else {
361 48.0
362 };
363
364 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 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 if let Some(leading_fn) = leading {
382 leading_fn(ui);
383 ui.add_space(8.0);
384 }
385
386 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 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#[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 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 pub fn header(mut self, header: GridTileBar<'a>) -> Self {
463 self.header = Some(header);
464 self
465 }
466
467 pub fn footer(mut self, footer: GridTileBar<'a>) -> Self {
469 self.footer = Some(footer);
470 self
471 }
472
473 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 let response = ui.vertical(|ui| {
492 ui.set_min_height(min_height);
493 child(ui);
494 });
495 return response.response;
496 }
497
498 let available_width = ui.available_width();
500 let start_pos = ui.next_widget_position();
501
502 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 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 if let Some(footer_bar) = footer {
523 let footer_height = 68.0; 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}