1use std::cell::{OnceCell, RefCell};
30
31use frus_core::{Color, Rect, Scene, TextOverflow, TextStyle};
32use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
33
34use crate::flex::Flex;
35use crate::interaction::Status;
36use crate::theme::Theme;
37use crate::themed::Themed;
38use crate::widget::Widget;
39
40pub const GRID_TILE_BAR_HEIGHT: f32 = 48.0;
42
43pub const GRID_TILE_BAR_TWO_LINE_HEIGHT: f32 = 68.0;
45
46const TIGHT_PADDING: f32 = 8.0;
48
49const LOOSE_PADDING: f32 = 16.0;
51
52const GAP: f32 = 8.0;
54
55pub struct GridTile<Msg = crate::callback::Callback> {
57 child: RefCell<Option<Box<dyn Widget<Msg>>>>,
58 header: RefCell<Option<Box<dyn Widget<Msg>>>>,
59 footer: RefCell<Option<Box<dyn Widget<Msg>>>>,
60 built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
61}
62
63impl<Msg: Clone + 'static> GridTile<Msg> {
64 pub fn new(child: impl Widget<Msg> + 'static) -> Self {
66 Self::new_boxed(Box::new(child))
67 }
68
69 pub fn new_boxed(child: Box<dyn Widget<Msg>>) -> Self {
71 Self {
72 child: RefCell::new(Some(child)),
73 header: RefCell::new(None),
74 footer: RefCell::new(None),
75 built: OnceCell::new(),
76 }
77 }
78
79 #[must_use]
82 pub fn header(self, header: impl Widget<Msg> + 'static) -> Self {
83 *self.header.borrow_mut() = Some(Box::new(header));
84 self
85 }
86
87 #[must_use]
93 pub fn footer(self, footer: impl Widget<Msg> + 'static) -> Self {
94 *self.footer.borrow_mut() = Some(Box::new(footer));
95 self
96 }
97
98 fn layers(&self) -> &[Box<dyn Widget<Msg>>] {
104 self.built.get_or_init(|| {
105 let mut layers: Vec<Box<dyn Widget<Msg>>> = Vec::with_capacity(3);
106 if let Some(child) = self.child.borrow_mut().take() {
107 layers.push(child);
108 }
109 if let Some(header) = self.header.borrow_mut().take() {
110 layers.push(Box::new(
111 crate::Positioned::new(header).top(0.0).left(0.0).right(0.0),
112 ));
113 }
114 if let Some(footer) = self.footer.borrow_mut().take() {
115 layers.push(Box::new(
116 crate::Positioned::new(footer)
117 .bottom(0.0)
118 .left(0.0)
119 .right(0.0),
120 ));
121 }
122 layers
123 })
124 }
125
126 fn stacked(&self) -> bool {
132 self.layers().len() > 1
133 }
134}
135
136impl<Msg: Clone + 'static> Widget<Msg> for GridTile<Msg> {
137 fn style(&self) -> Style {
139 match self.layers().first() {
140 Some(child) => Widget::<Msg>::style(child),
141 None => Style::default(),
142 }
143 }
144
145 fn style_themed(&self, theme: &Theme) -> Style {
146 match self.layers().first() {
147 Some(child) => Widget::<Msg>::style_themed(child, theme),
148 None => Style::default(),
149 }
150 }
151
152 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
153 self.layers()
154 }
155
156 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
157
158 fn on_click(&self) -> Option<Msg> {
159 None
160 }
161
162 fn stack(&self) -> bool {
163 self.stacked()
164 }
165
166 fn stack_fit(&self) -> crate::StackFit {
169 crate::StackFit::Loose
170 }
171}
172
173pub struct GridTileBar<Msg = crate::callback::Callback> {
175 background_color: Option<Color>,
176 foreground_color: Option<Color>,
177 title_style: Option<TextStyle>,
178 subtitle_style: Option<TextStyle>,
179 height: Option<f32>,
180 leading: RefCell<Option<Box<dyn Widget<Msg>>>>,
181 title: RefCell<Option<Box<dyn Widget<Msg>>>>,
182 subtitle: RefCell<Option<Box<dyn Widget<Msg>>>>,
183 trailing: RefCell<Option<Box<dyn Widget<Msg>>>>,
184 has_title: bool,
185 has_subtitle: bool,
186 has_leading: bool,
187 has_trailing: bool,
188 built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
189}
190
191impl<Msg: Clone + 'static> Default for GridTileBar<Msg> {
192 fn default() -> Self {
193 Self::new()
194 }
195}
196
197impl<Msg: Clone + 'static> GridTileBar<Msg> {
198 pub fn new() -> Self {
201 Self {
202 background_color: None,
203 foreground_color: None,
204 title_style: None,
205 subtitle_style: None,
206 height: None,
207 leading: RefCell::new(None),
208 title: RefCell::new(None),
209 subtitle: RefCell::new(None),
210 trailing: RefCell::new(None),
211 has_title: false,
212 has_subtitle: false,
213 has_leading: false,
214 has_trailing: false,
215 built: OnceCell::new(),
216 }
217 }
218
219 #[must_use]
222 pub fn background_color(mut self, color: Color) -> Self {
223 self.background_color = Some(color);
224 self
225 }
226
227 #[must_use]
233 pub fn foreground_color(mut self, color: Color) -> Self {
234 self.foreground_color = Some(color);
235 self
236 }
237
238 #[must_use]
240 pub fn leading(mut self, leading: impl Widget<Msg> + 'static) -> Self {
241 *self.leading.borrow_mut() = Some(Box::new(leading));
242 self.has_leading = true;
243 self
244 }
245
246 #[must_use]
249 pub fn title(mut self, title: impl Widget<Msg> + 'static) -> Self {
250 *self.title.borrow_mut() = Some(Box::new(title));
251 self.has_title = true;
252 self
253 }
254
255 #[must_use]
259 pub fn subtitle(mut self, subtitle: impl Widget<Msg> + 'static) -> Self {
260 *self.subtitle.borrow_mut() = Some(Box::new(subtitle));
261 self.has_subtitle = true;
262 self
263 }
264
265 #[must_use]
267 pub fn trailing(mut self, trailing: impl Widget<Msg> + 'static) -> Self {
268 *self.trailing.borrow_mut() = Some(Box::new(trailing));
269 self.has_trailing = true;
270 self
271 }
272
273 #[must_use]
275 pub fn height(mut self, height: f32) -> Self {
276 self.height = Some(height);
277 self
278 }
279
280 #[must_use]
282 pub fn title_style(mut self, style: TextStyle) -> Self {
283 self.title_style = Some(style);
284 self
285 }
286
287 #[must_use]
289 pub fn subtitle_style(mut self, style: TextStyle) -> Self {
290 self.subtitle_style = Some(style);
291 self
292 }
293
294 fn resolved_height(&self, theme: &Theme) -> f32 {
296 if let Some(told) = self.height {
297 return told;
298 }
299 let t = &theme.widgets.grid_tile_bar;
300 if self.has_title && self.has_subtitle {
301 t.two_line_height.unwrap_or(GRID_TILE_BAR_TWO_LINE_HEIGHT)
302 } else {
303 t.height.unwrap_or(GRID_TILE_BAR_HEIGHT)
304 }
305 }
306
307 fn room(&self) -> (f32, f32) {
310 (
311 if self.has_leading {
312 TIGHT_PADDING
313 } else {
314 LOOSE_PADDING
315 },
316 if self.has_trailing {
317 TIGHT_PADDING
318 } else {
319 LOOSE_PADDING
320 },
321 )
322 }
323
324 fn line(
326 slot: Box<dyn Widget<Msg>>,
327 own: Option<TextStyle>,
328 pick: fn(&Theme) -> TextStyle,
329 themed: fn(&Theme) -> Option<TextStyle>,
330 ) -> Box<dyn Widget<Msg>> {
331 Box::new(Themed::tweak(
332 move |theme| {
333 let ink = theme.widgets.text.style.color;
337 let mut style = own.or_else(|| themed(theme)).unwrap_or_else(|| pick(theme));
338 style.color = style.color.or(ink);
339 theme.widgets.text.style = style;
340 },
341 slot,
342 ))
343 }
344
345 fn assemble(&self) -> Vec<Box<dyn Widget<Msg>>> {
346 let mut row = Flex::<Msg>::row().flex(1.0).align(Align::Center).gap(GAP);
347 if let Some(leading) = self.leading.borrow_mut().take() {
348 row = row.child_boxed(leading);
349 }
350 let title = self.title.borrow_mut().take();
351 let subtitle = self.subtitle.borrow_mut().take();
352 let own_title = self.title_style;
353 let own_subtitle = self.subtitle_style;
354 let lines: Option<Box<dyn Widget<Msg>>> = match (title, subtitle) {
355 (Some(title), Some(subtitle)) => Some(Box::new(
356 Flex::<Msg>::column()
357 .justify(Justify::Center)
358 .align(Align::Start)
359 .child_boxed(Self::line(
360 title,
361 own_title,
362 |theme| crate::theme::type_scale(Some(theme)).title_medium,
363 |theme| theme.widgets.grid_tile_bar.title_style,
364 ))
365 .child_boxed(Self::line(
366 subtitle,
367 own_subtitle,
368 |theme| crate::theme::type_scale(Some(theme)).body_small,
369 |theme| theme.widgets.grid_tile_bar.subtitle_style,
370 )),
371 )),
372 (Some(only), None) | (None, Some(only)) => Some(Self::line(
376 only,
377 own_title,
378 |theme| crate::theme::type_scale(Some(theme)).title_medium,
379 |theme| theme.widgets.grid_tile_bar.title_style,
380 )),
381 (None, None) => None,
382 };
383 if let Some(lines) = lines {
384 row = row.child(crate::Expanded::new(lines));
385 }
386 if let Some(trailing) = self.trailing.borrow_mut().take() {
387 row = row.child_boxed(trailing);
388 }
389
390 let own_ink = self.foreground_color;
391 vec![Box::new(Themed::tweak(
392 move |theme| {
393 let mut dark = Theme::dark();
401 dark.direction = theme.direction;
402 dark.text = theme.text;
403 dark.radius = theme.radius;
404 dark.spacing = theme.spacing;
405 dark.tap_target = theme.tap_target;
406 dark.widgets = theme.widgets.clone();
407 *theme = dark;
408
409 let ink = own_ink
410 .or(theme.widgets.grid_tile_bar.foreground_color)
411 .unwrap_or(Color::WHITE);
412 theme.widgets.text.style.color = Some(ink);
413 theme.widgets.icon.color = Some(ink);
414 theme.widgets.text.soft_wrap = Some(false);
417 theme.widgets.text.overflow = Some(TextOverflow::Ellipsis);
418 theme.widgets.text.max_lines = Some(1);
419 },
420 row,
421 )) as Box<dyn Widget<Msg>>]
422 }
423}
424
425impl<Msg: Clone + 'static> Widget<Msg> for GridTileBar<Msg> {
426 fn style(&self) -> Style {
427 Widget::<Msg>::style_themed(self, &Theme::default())
428 }
429
430 fn style_themed(&self, theme: &Theme) -> Style {
431 let (start, end) = self.room();
432 Style {
433 height: Dimension::Length(self.resolved_height(theme)),
434 width: Dimension::Percent(1.0),
435 flex_direction: FlexDirection::Row,
436 align: Align::Stretch,
437 padding: frus_core::Insets::new(0.0, end, 0.0, start),
438 ..Default::default()
439 }
440 }
441
442 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
443 self.built.get_or_init(|| self.assemble())
444 }
445
446 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
447 let fill = self
448 .background_color
449 .or(theme.widgets.grid_tile_bar.background_color);
450 if let Some(fill) = fill.filter(|c| c.a > 0.0) {
453 scene.fill_rect(bounds, fill.fade(status.opacity));
454 }
455 }
456
457 fn on_click(&self) -> Option<Msg> {
458 None
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::{build_ui, Container, Icon, Icons, Runtime, Size, Text};
466 use frus_core::Primitive;
467
468 fn picture() -> Container<()> {
469 Container::<()>::new()
470 .width(120.0)
471 .height(120.0)
472 .color(Color::rgb(0.2, 0.4, 0.8))
473 }
474
475 fn scene_of(tile: impl Widget<()> + 'static) -> Vec<Primitive> {
476 let root = crate::flex::Flex::<()>::column()
477 .width(200.0)
478 .height(200.0)
479 .child(tile);
480 build_ui(
481 &root,
482 Size::new(200.0, 200.0),
483 &Runtime::default(),
484 &Theme::default(),
485 )
486 .scene()
487 .primitives()
488 .to_vec()
489 }
490
491 #[test]
494 fn a_tile_with_no_strips_is_its_child() {
495 let bare = GridTile::new(picture());
496 assert!(!Widget::<()>::stack(&bare));
497 assert_eq!(Widget::<()>::children(&bare).len(), 1);
498 let with_footer = GridTile::new(picture()).footer(GridTileBar::new().title(Text::new("A")));
499 assert!(Widget::<()>::stack(&with_footer));
500 assert_eq!(Widget::<()>::children(&with_footer).len(), 2);
501 }
502
503 #[test]
506 fn the_strips_do_not_change_the_tile() {
507 let bare = Widget::<()>::style(&GridTile::new(picture()));
508 let dressed = Widget::<()>::style(
509 &GridTile::new(picture())
510 .header(GridTileBar::new().title(Text::new("A")))
511 .footer(GridTileBar::new().title(Text::new("B"))),
512 );
513 assert_eq!(bare.width, dressed.width);
514 assert_eq!(bare.height, dressed.height);
515 assert_eq!(bare.width, Dimension::Length(120.0));
516 }
517
518 #[test]
521 fn a_header_is_at_the_top_and_a_footer_at_the_bottom() {
522 let ink = Color::rgb(1.0, 0.0, 0.0);
523 let paper = Color::rgb(0.0, 1.0, 0.0);
524 let tile = || {
525 GridTile::new(picture())
526 .header(
527 GridTileBar::new()
528 .background_color(ink)
529 .title(Text::new("H")),
530 )
531 .footer(
532 GridTileBar::new()
533 .background_color(paper)
534 .title(Text::new("F")),
535 )
536 };
537 let strips = scene_of(tile());
538 let rect_of = |c: Color| {
539 strips
540 .iter()
541 .find_map(|p| match p {
542 Primitive::Rect { color, rect, .. } if *color == c => Some(*rect),
543 _ => None,
544 })
545 .expect("the strip is drawn")
546 };
547 let header = rect_of(ink);
548 let footer = rect_of(paper);
549 assert_eq!(header.y, 0.0, "the header is at the top");
550 assert_eq!(header.height, GRID_TILE_BAR_HEIGHT);
551 assert_eq!(
552 footer.y + footer.height,
553 120.0,
554 "and the footer at the bottom of the tile, not of the page"
555 );
556 assert_eq!(header.width, 120.0, "both run the tile's full width");
557 assert_eq!(footer.width, 120.0);
558 }
559
560 #[test]
563 fn a_subtitle_makes_the_bar_taller() {
564 let theme = Theme::default();
565 let height = |bar: GridTileBar<()>| match Widget::<()>::style_themed(&bar, &theme).height {
566 Dimension::Length(h) => h,
567 other => panic!("a bar has a height, not {other:?}"),
568 };
569 assert_eq!(height(GridTileBar::new().title(Text::new("A"))), 48.0);
570 assert_eq!(
571 height(
572 GridTileBar::new()
573 .title(Text::new("A"))
574 .subtitle(Text::new("B"))
575 ),
576 68.0
577 );
578 assert_eq!(height(GridTileBar::new().subtitle(Text::new("B"))), 48.0);
580 assert_eq!(height(GridTileBar::new().height(90.0)), 90.0);
581 }
582
583 #[test]
585 fn a_slot_at_an_end_tightens_it() {
586 let theme = Theme::default();
587 let padding = |bar: GridTileBar<()>| Widget::<()>::style_themed(&bar, &theme).padding;
588 let plain = padding(GridTileBar::new().title(Text::new("A")));
589 assert_eq!((plain.left, plain.right), (LOOSE_PADDING, LOOSE_PADDING));
590 let dressed = padding(
591 GridTileBar::new()
592 .leading(Icon::new(Icons::CHECK))
593 .title(Text::new("A")),
594 );
595 assert_eq!(
596 (dressed.left, dressed.right),
597 (TIGHT_PADDING, LOOSE_PADDING),
598 "only the end with something in it"
599 );
600 }
601
602 #[test]
605 fn the_bar_is_light_on_the_picture() {
606 let colours: Vec<Color> = scene_of(
607 GridTile::new(picture()).footer(GridTileBar::new().title(Text::new("Cliffs"))),
608 )
609 .into_iter()
610 .filter_map(|p| match p {
611 Primitive::Text { color, .. } => Some(color),
612 _ => None,
613 })
614 .collect();
615 assert_eq!(colours, vec![Color::WHITE]);
616 let told = Color::rgb(0.1, 0.1, 0.1);
618 let mine: Vec<Color> = scene_of(
619 GridTile::new(picture()).footer(
620 GridTileBar::new()
621 .foreground_color(told)
622 .title(Text::new("Cliffs")),
623 ),
624 )
625 .into_iter()
626 .filter_map(|p| match p {
627 Primitive::Text { color, .. } => Some(color),
628 _ => None,
629 })
630 .collect();
631 assert_eq!(mine, vec![told]);
632 }
633
634 #[test]
637 fn a_bar_with_no_colour_paints_nothing() {
638 let backgrounds = |bar: GridTileBar<()>| {
639 scene_of(GridTile::new(picture()).footer(bar))
640 .into_iter()
641 .filter(|p| matches!(p, Primitive::Rect { rect, .. } if rect.height == 48.0))
642 .count()
643 };
644 assert_eq!(backgrounds(GridTileBar::new().title(Text::new("A"))), 0);
645 let told = Color::rgb(0.0, 0.0, 0.0);
646 assert_eq!(
647 backgrounds(
648 GridTileBar::new()
649 .background_color(told)
650 .title(Text::new("A"))
651 ),
652 1
653 );
654 }
655
656 #[test]
658 fn the_theme_answers_what_the_caller_did_not() {
659 let mut theme = Theme::default();
660 theme.widgets.grid_tile_bar.height = Some(40.0);
661 theme.widgets.grid_tile_bar.two_line_height = Some(80.0);
662 let one = GridTileBar::<()>::new().title(Text::new("A"));
663 assert_eq!(
664 Widget::<()>::style_themed(&one, &theme).height,
665 Dimension::Length(40.0)
666 );
667 let two = GridTileBar::<()>::new()
668 .title(Text::new("A"))
669 .subtitle(Text::new("B"));
670 assert_eq!(
671 Widget::<()>::style_themed(&two, &theme).height,
672 Dimension::Length(80.0)
673 );
674 }
675}