1use std::collections::VecDeque;
6
7use frus_core::{
8 BorderRadius, Color, Insets, Point, Rect, ResolvedTextStyle, Role, Scene, SemanticsProperties,
9 ShapeBorder, TextStyle,
10};
11use frus_layout::{Align, Dimension, Justify, Style};
12
13use crate::iconbutton::{ICON_BUTTON_ICON_SIZE, ICON_BUTTON_SIZE};
14use crate::interaction::Status;
15use crate::theme::Theme;
16use crate::widget::Widget;
17
18pub const SNACK_BAR_RADIUS: f32 = 4.0;
20pub const SNACK_BAR_ELEVATION: f32 = 6.0;
21const SUCCESS_ACCENT: Color = Color::rgb(70.0 / 255.0, 190.0 / 255.0, 120.0 / 255.0);
29
30const PAD_X_FIXED: f32 = 24.0;
34const PAD_X_FLOATING: f32 = 16.0;
35const INSET_PADDING: Insets = Insets::new(5.0, 15.0, 10.0, 15.0);
38const PAD_Y: f32 = 12.0;
39const ACCENT: f32 = 4.0;
40const ACTION_PAD_X: f32 = 12.0;
42const ACTION_GAP: f32 = 8.0;
43const ACTION_H: f32 = 32.0;
44fn close_margin(pad_x: f32) -> f32 {
49 pad_x / 12.0
50}
51fn close_label() -> String {
55 crate::localizations::of().close_button_label().to_string()
56}
57
58fn content_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
65 over.or(theme.and_then(|t| t.widgets.snack_bar.content_text_style))
66 .unwrap_or_else(|| crate::theme::type_scale(theme).body_medium)
67 .resolved()
68}
69
70fn action_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
72 over.or(theme.and_then(|t| t.widgets.snack_bar.action_text_style))
73 .unwrap_or_else(|| crate::theme::type_scale(theme).label_large)
74 .resolved()
75}
76
77fn action_width(label: &str, style: &ResolvedTextStyle) -> f32 {
79 (frus_text::measure_resolved(label, style).width + ACTION_PAD_X * 2.0).ceil()
80}
81
82#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
89pub enum SnackBarBehavior {
90 #[default]
93 Fixed,
94 Floating,
97}
98
99#[derive(Copy, Clone, Debug, PartialEq, Eq)]
101pub enum SnackBarKind {
102 Info,
104 Success,
106 Error,
108}
109
110pub struct SnackBar<Msg = crate::callback::Callback> {
113 text: String,
114 kind: SnackBarKind,
115 behavior: Option<SnackBarBehavior>,
117 shape: Option<ShapeBorder>,
119 width: Option<f32>,
121 margin: Option<Insets>,
123 content_text_style: Option<TextStyle>,
124 action_text_style: Option<TextStyle>,
125 background: Option<Color>,
126 text_color: Option<Color>,
127 action_text_color: Option<Color>,
128 accent: Option<Color>,
129 elevation: Option<f32>,
131 action: Option<(String, Msg)>,
135 close: Option<Msg>,
137 close_icon_color: Option<Color>,
138 close_label: Option<String>,
139 children: Vec<Box<dyn Widget<Msg>>>,
141}
142
143impl<Msg: Clone + 'static> SnackBar<Msg> {
144 pub fn new(text: impl Into<String>) -> Self {
146 Self {
147 text: text.into(),
148 kind: SnackBarKind::Info,
149 behavior: None,
150 shape: None,
151 width: None,
152 margin: None,
153 content_text_style: None,
154 action_text_style: None,
155 background: None,
156 text_color: None,
157 action_text_color: None,
158 accent: None,
159 elevation: None,
160 action: None,
161 close: None,
162 close_icon_color: None,
163 close_label: None,
164 children: Vec::new(),
165 }
166 }
167
168 #[must_use]
173 pub fn behavior(mut self, behavior: SnackBarBehavior) -> Self {
174 self.behavior = Some(behavior);
175 self
176 }
177
178 #[must_use]
185 pub fn shape(mut self, shape: ShapeBorder) -> Self {
186 self.shape = Some(shape);
187 self
188 }
189
190 #[must_use]
196 pub fn width(mut self, width: f32) -> Self {
197 self.width = Some(width);
198 self
199 }
200
201 #[must_use]
206 pub fn margin(mut self, margin: Insets) -> Self {
207 self.margin = Some(margin);
208 self
209 }
210
211 #[must_use]
213 pub fn background(mut self, color: Color) -> Self {
214 self.background = Some(color);
215 self
216 }
217
218 #[must_use]
221 pub fn elevation(mut self, elevation: f32) -> Self {
222 self.elevation = Some(elevation);
223 self
224 }
225
226 #[must_use]
228 pub fn text_color(mut self, color: Color) -> Self {
229 self.text_color = Some(color);
230 self
231 }
232
233 #[must_use]
235 pub fn action_text_color(mut self, color: Color) -> Self {
236 self.action_text_color = Some(color);
237 self.rebuild_controls();
238 self
239 }
240
241 #[must_use]
243 pub fn accent(mut self, color: Color) -> Self {
244 self.accent = Some(color);
245 self
246 }
247
248 #[must_use]
250 pub fn content_text_style(mut self, style: TextStyle) -> Self {
251 self.content_text_style = Some(style);
252 self
253 }
254
255 #[must_use]
257 pub fn action_text_style(mut self, style: TextStyle) -> Self {
258 self.action_text_style = Some(style);
259 self.rebuild_controls();
260 self
261 }
262
263 pub fn success(mut self) -> Self {
265 self.kind = SnackBarKind::Success;
266 self
267 }
268
269 pub fn error(mut self) -> Self {
271 self.kind = SnackBarKind::Error;
272 self
273 }
274
275 pub fn action(mut self, label: impl Into<String>, message: Msg) -> Self {
278 self.action = Some((label.into().to_uppercase(), message));
279 self.rebuild_controls();
280 self
281 }
282
283 #[must_use]
293 pub fn close_icon(mut self, message: impl Into<Msg>) -> Self {
294 self.close = Some(message.into());
295 self.rebuild_controls();
296 self
297 }
298
299 #[must_use]
302 pub fn close_icon_color(mut self, color: Color) -> Self {
303 self.close_icon_color = Some(color);
304 self.rebuild_controls();
305 self
306 }
307
308 #[must_use]
311 pub fn close_icon_label(mut self, label: impl Into<String>) -> Self {
312 self.close_label = Some(label.into());
313 self.rebuild_controls();
314 self
315 }
316
317 fn rebuild_controls(&mut self) {
322 self.children = Vec::new();
323 if let Some((label, message)) = &self.action {
324 self.children.push(Box::new(ActionButton {
325 label: label.clone(),
326 text_style: self.action_text_style,
327 color: self.action_text_color,
328 message: message.clone(),
329 }));
330 }
331 if let Some(message) = &self.close {
335 self.children.push(Box::new(CloseButton {
336 color: self.close_icon_color,
337 label: self.close_label.clone().unwrap_or_else(close_label),
338 message: message.clone(),
339 }));
340 }
341 }
342}
343
344impl<Msg> SnackBar<Msg> {
345 fn sizing(&self, theme: Option<&Theme>) -> Style {
346 let measured =
347 frus_text::measure_resolved(&self.text, &content_style(self.content_text_style, theme));
348 let action_w = self.action.as_ref().map_or(0.0, |(label, _)| {
349 action_width(label, &action_style(self.action_text_style, theme)) + ACTION_GAP
350 });
351 let pad_x = self.pad_x(theme);
352 let close_margin = close_margin(pad_x);
353 let close_w = self
354 .close
355 .as_ref()
356 .map_or(0.0, |_| ICON_BUTTON_SIZE + close_margin * 2.0);
357 let floor = if self.close.is_some() {
361 ICON_BUTTON_SIZE
362 } else {
363 ACTION_H
364 };
365 let asked = match self.placing(theme) {
369 SnackBarBehavior::Floating => {
370 self.width.or(theme.and_then(|t| t.widgets.snack_bar.width))
371 }
372 SnackBarBehavior::Fixed => None,
373 };
374 let mut style = Style {
375 width: Dimension::Length(
376 asked
377 .unwrap_or((measured.width + pad_x * 2.0 + ACCENT + action_w + close_w).ceil()),
378 ),
379 height: Dimension::Length((measured.height + PAD_Y * 2.0).max(floor).ceil()),
380 margin: self.inset(theme),
383 ..Default::default()
384 };
385 if !self.children.is_empty() {
388 style.justify = Justify::End;
389 style.align = Align::Center;
390 style.gap = close_margin;
391 style.padding = Insets::new(
392 0.0,
393 if self.close.is_some() {
394 close_margin
395 } else {
396 pad_x
397 },
398 0.0,
399 0.0,
400 );
401 }
402 style
403 }
404
405 fn placing(&self, theme: Option<&Theme>) -> SnackBarBehavior {
408 self.behavior
409 .or(theme.and_then(|t| t.widgets.snack_bar.behavior))
410 .unwrap_or_default()
411 }
412
413 fn pad_x(&self, theme: Option<&Theme>) -> f32 {
415 match self.placing(theme) {
416 SnackBarBehavior::Fixed => PAD_X_FIXED,
417 SnackBarBehavior::Floating => PAD_X_FLOATING,
418 }
419 }
420
421 fn inset(&self, theme: Option<&Theme>) -> Insets {
424 match self.placing(theme) {
425 SnackBarBehavior::Fixed => Insets::ZERO,
426 SnackBarBehavior::Floating => self
427 .margin
428 .or(theme.and_then(|t| t.widgets.snack_bar.inset_padding))
429 .unwrap_or(INSET_PADDING),
430 }
431 }
432
433 fn accent_color(&self, theme: &Theme) -> Color {
437 if let Some(color) = self.accent.or(theme.widgets.snack_bar.accent_color) {
438 return color;
439 }
440 match self.kind {
441 SnackBarKind::Info => theme.scheme.inverse_primary,
444 SnackBarKind::Success => theme
445 .widgets
446 .snack_bar
447 .success_color
448 .unwrap_or(SUCCESS_ACCENT),
449 SnackBarKind::Error => theme.scheme.error,
450 }
451 }
452}
453
454impl<Msg: Clone> Widget<Msg> for SnackBar<Msg> {
455 fn style(&self) -> Style {
456 self.sizing(None)
457 }
458
459 fn style_themed(&self, theme: &Theme) -> Style {
460 self.sizing(Some(theme))
461 }
462
463 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
464 &self.children
465 }
466
467 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
468 let o = status.opacity;
469 let t = &theme.widgets.snack_bar;
470 let shape = crate::resolve_shape(
475 self.shape,
476 t.shape,
477 t.radius.map(BorderRadius::uniform),
478 ShapeBorder::rounded(match self.placing(Some(theme)) {
479 SnackBarBehavior::Fixed => 0.0,
480 SnackBarBehavior::Floating => SNACK_BAR_RADIUS,
481 }),
482 );
483 let radius = shape
485 .as_rounded(bounds)
486 .map(|(_, radius)| radius)
487 .unwrap_or(BorderRadius::ZERO);
488 let elevation = self
489 .elevation
490 .or(t.elevation)
491 .unwrap_or(SNACK_BAR_ELEVATION);
492 let fill = self
496 .background
497 .or(t.background_color)
498 .unwrap_or(theme.scheme.inverse_surface);
499 if elevation > 0.0 {
501 frus_core::paint_elevation(
503 scene,
504 bounds,
505 radius,
506 elevation,
507 theme.scheme.shadow.fade(o),
508 );
509 }
510 scene.draw_shape(bounds, shape, fill.fade(o));
513 scene.draw_rect(
516 Rect::new(bounds.x, bounds.y, ACCENT, bounds.height),
517 self.accent_color(theme).fade(o),
518 0.0,
519 0.0,
520 Color::TRANSPARENT,
521 );
522 scene.text(
523 Point::new(
524 bounds.x + ACCENT + self.pad_x(Some(theme)),
525 bounds.y + PAD_Y,
526 ),
527 self.text.clone(),
528 &content_style(self.content_text_style, Some(theme)),
529 self.text_color
530 .or(t.text_color)
531 .unwrap_or(theme.scheme.on_inverse_surface)
532 .fade(o),
533 );
534 }
535
536 fn on_click(&self) -> Option<Msg> {
537 None
538 }
539}
540
541struct ActionButton<Msg> {
543 label: String,
544 text_style: Option<TextStyle>,
545 color: Option<Color>,
547 message: Msg,
548}
549
550impl<Msg> ActionButton<Msg> {
551 fn color(&self, theme: &Theme) -> Color {
554 self.color
555 .or(theme.widgets.snack_bar.action_text_color)
556 .unwrap_or(theme.scheme.inverse_primary)
557 }
558}
559
560impl<Msg> ActionButton<Msg> {
561 fn sizing(&self, theme: Option<&Theme>) -> Style {
562 let style = action_style(self.text_style, theme);
563 Style {
564 width: Dimension::Length(action_width(&self.label, &style)),
565 height: Dimension::Length(frus_text::line_box(ACTION_H, &style, 0.0)),
566 ..Default::default()
567 }
568 }
569}
570
571impl<Msg: Clone> Widget<Msg> for ActionButton<Msg> {
572 fn style(&self) -> Style {
573 self.sizing(None)
574 }
575
576 fn style_themed(&self, theme: &Theme) -> Style {
577 self.sizing(Some(theme))
578 }
579
580 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
581 &[]
582 }
583
584 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
585 let o = status.opacity;
586 let label = self.color(theme);
591 let ground = theme
592 .widgets
593 .snack_bar
594 .background_color
595 .unwrap_or(theme.scheme.inverse_surface);
596 let bg = theme.state_layer(ground, label, &status);
597 scene.draw_rect(bounds, bg.fade(o), theme.radius, 0.0, Color::TRANSPARENT);
598 let style = action_style(self.text_style, Some(theme));
599 let w = frus_text::measure_resolved(&self.label, &style).width;
600 scene.text(
601 Point::new(
602 bounds.x + (bounds.width - w) * 0.5,
603 bounds.y + (bounds.height - style.line_height()) * 0.5,
604 ),
605 self.label.clone(),
606 &style,
607 label.fade(o),
608 );
609 }
610
611 fn on_click(&self) -> Option<Msg> {
612 Some(self.message.clone())
613 }
614
615 fn focusable(&self) -> bool {
616 true
617 }
618
619 fn semantics(&self) -> Option<SemanticsProperties> {
620 Some(
621 SemanticsProperties::new(Role::Button)
622 .label(self.label.clone())
623 .clickable(),
624 )
625 }
626}
627
628struct CloseButton<Msg> {
631 color: Option<Color>,
633 label: String,
634 message: Msg,
635}
636
637impl<Msg> CloseButton<Msg> {
638 fn color(&self, theme: &Theme) -> Color {
641 self.color
642 .or(theme.widgets.snack_bar.close_icon_color)
643 .unwrap_or(theme.scheme.on_inverse_surface)
644 }
645}
646
647impl<Msg: Clone> Widget<Msg> for CloseButton<Msg> {
648 fn style(&self) -> Style {
649 Style {
650 width: Dimension::Length(ICON_BUTTON_SIZE),
651 height: Dimension::Length(ICON_BUTTON_SIZE),
652 ..Default::default()
653 }
654 }
655
656 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
657 &[]
658 }
659
660 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
661 let o = status.opacity;
662 let ink = self.color(theme);
663 let ground = theme
668 .widgets
669 .snack_bar
670 .background_color
671 .unwrap_or(theme.scheme.inverse_surface);
672 let bg = theme.state_layer(ground, ink, &status);
673 scene.draw_rect(
674 bounds,
675 bg.fade(o),
676 bounds.height.min(bounds.width) * 0.5,
677 0.0,
678 Color::TRANSPARENT,
679 );
680 let size = ICON_BUTTON_ICON_SIZE;
681 let path = crate::Icons::CLOSE.placed(
682 size,
683 bounds.x + (bounds.width - size) * 0.5,
684 bounds.y + (bounds.height - size) * 0.5,
685 theme.direction,
686 );
687 scene.fill_path(&path, ink.fade(o));
688 }
689
690 fn on_click(&self) -> Option<Msg> {
691 Some(self.message.clone())
692 }
693
694 fn focusable(&self) -> bool {
695 true
696 }
697
698 fn semantics(&self) -> Option<SemanticsProperties> {
699 Some(
700 SemanticsProperties::new(Role::Button)
701 .label(self.label.clone())
702 .clickable(),
703 )
704 }
705}
706
707pub struct SnackBarQueue<T> {
716 items: VecDeque<(T, f32, bool)>,
720}
721
722impl<T> Default for SnackBarQueue<T> {
723 fn default() -> Self {
724 Self {
725 items: VecDeque::new(),
726 }
727 }
728}
729
730impl<T> SnackBarQueue<T> {
731 pub fn new() -> Self {
733 Self::default()
734 }
735
736 pub fn push(&mut self, item: T, seconds: f32) {
738 self.items.push_back((item, seconds.max(0.0), false));
739 }
740
741 pub fn current(&self) -> Option<&T> {
743 self.items.front().map(|(item, _, _)| item)
744 }
745
746 pub fn tick(&mut self, dt: f32) -> bool {
750 let Some(front) = self.items.front_mut() else {
751 return false;
752 };
753 front.1 -= dt;
754 if front.1 <= 0.0 {
755 self.items.pop_front();
756 true
757 } else {
758 false
759 }
760 }
761
762 pub fn start_leaving(&mut self) {
765 if let Some(front) = self.items.front_mut() {
766 front.2 = true;
767 }
768 }
769
770 pub fn is_leaving(&self) -> bool {
772 self.items.front().is_some_and(|(_, _, leaving)| *leaving)
773 }
774
775 pub fn dismiss(&mut self) -> Option<T> {
777 self.items.pop_front().map(|(item, _, _)| item)
778 }
779
780 pub fn is_empty(&self) -> bool {
782 self.items.is_empty()
783 }
784
785 pub fn len(&self) -> usize {
787 self.items.len()
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use frus_core::Primitive;
795
796 #[derive(Clone, Debug, PartialEq)]
797 enum Msg {
798 Undo,
799 Dismiss,
800 }
801
802 fn painted(bar: &SnackBar<()>, theme: &Theme) -> Scene {
804 let mut scene = Scene::new();
805 Widget::<()>::paint(
806 bar,
807 Rect::new(0.0, 0.0, 160.0, 44.0),
808 Status::default(),
809 theme,
810 &mut scene,
811 );
812 scene
813 }
814
815 fn corner_of(scene: &Scene) -> Option<f32> {
817 scene.primitives().iter().find_map(|p| match p {
818 Primitive::Rect {
819 rect, radius, blur, ..
820 } if *blur == 0.0 && rect.width == 160.0 => Some(radius.top_left),
821 _ => None,
822 })
823 }
824
825 fn text_x(scene: &Scene) -> Option<f32> {
827 scene.primitives().iter().find_map(|p| match p {
828 Primitive::Text { position, .. } => Some(position.x),
829 _ => None,
830 })
831 }
832
833 #[test]
841 fn a_bar_is_fixed_by_default_and_has_no_corners() {
842 let theme = Theme::default();
843 let bar = SnackBar::<Msg>::new("Saved");
844 let scene = painted(&SnackBar::<()>::new("Saved"), &theme);
845 assert_eq!(corner_of(&scene), Some(0.0), "square, being flush");
846 assert_eq!(
847 text_x(&scene),
848 Some(ACCENT + PAD_X_FIXED),
849 "and its text held in by a fixed bar's padding"
850 );
851 assert_eq!(
852 Widget::<Msg>::style_themed(&bar, &theme).margin,
853 Insets::ZERO,
854 "a fixed bar keeps nothing clear of the page: that is what fixed means"
855 );
856 }
857
858 #[test]
862 fn a_floating_bar_rounds_and_keeps_clear_of_the_page() {
863 let theme = Theme::default();
864 let scene = painted(
865 &SnackBar::<()>::new("Saved").behavior(SnackBarBehavior::Floating),
866 &theme,
867 );
868 assert_eq!(corner_of(&scene), Some(SNACK_BAR_RADIUS));
869 assert_eq!(
870 text_x(&scene),
871 Some(ACCENT + PAD_X_FLOATING),
872 "and its text is held in less, the margin having held it in already"
873 );
874
875 let bar = SnackBar::<Msg>::new("Saved").behavior(SnackBarBehavior::Floating);
876 assert_eq!(
877 Widget::<Msg>::style_themed(&bar, &theme).margin,
878 INSET_PADDING
879 );
880 let own = Insets::uniform(4.0);
881 assert_eq!(
882 Widget::<Msg>::style_themed(
883 &SnackBar::<Msg>::new("Saved")
884 .behavior(SnackBarBehavior::Floating)
885 .margin(own),
886 &theme
887 )
888 .margin,
889 own,
890 "and a caller may say what it keeps clear"
891 );
892 }
893
894 #[test]
898 fn a_width_is_a_floating_bar_s_alone() {
899 let theme = Theme::default();
900 let floating = SnackBar::<Msg>::new("Saved")
901 .behavior(SnackBarBehavior::Floating)
902 .width(300.0);
903 assert_eq!(width_of(&floating), 300.0);
904
905 let fixed = SnackBar::<Msg>::new("Saved")
906 .width(300.0)
907 .margin(Insets::uniform(9.0));
908 assert_ne!(
909 width_of(&fixed),
910 300.0,
911 "a fixed bar spans what it is given"
912 );
913 assert_eq!(
914 Widget::<Msg>::style_themed(&fixed, &theme).margin,
915 Insets::ZERO,
916 "and keeps nothing clear, whatever it was told"
917 );
918 }
919
920 #[test]
923 fn a_theme_places_every_bar_at_once() {
924 let mut theme = Theme::default();
925 theme.widgets.snack_bar.behavior = Some(SnackBarBehavior::Floating);
926 let scene = {
927 let mut scene = Scene::new();
928 Widget::<()>::paint(
929 &SnackBar::<()>::new("Saved"),
930 Rect::new(0.0, 0.0, 160.0, 44.0),
931 Status::default(),
932 &theme,
933 &mut scene,
934 );
935 scene
936 };
937 assert_eq!(
938 corner_of(&scene),
939 Some(SNACK_BAR_RADIUS),
940 "the theme's word"
941 );
942 assert_eq!(
943 Widget::<Msg>::style_themed(&SnackBar::<Msg>::new("Saved"), &theme).margin,
944 INSET_PADDING
945 );
946 assert_eq!(
947 Widget::<Msg>::style_themed(
948 &SnackBar::<Msg>::new("Saved").behavior(SnackBarBehavior::Fixed),
949 &theme
950 )
951 .margin,
952 Insets::ZERO,
953 "and one bar may still answer for itself"
954 );
955 }
956
957 fn surface_of(scene: &Scene) -> Option<Color> {
960 scene.primitives().iter().find_map(|p| match p {
961 Primitive::Rect {
962 rect, color, blur, ..
963 } if *blur == 0.0 && rect.width == 160.0 => Some(*color),
964 _ => None,
965 })
966 }
967
968 fn text_color(scene: &Scene) -> Option<Color> {
969 scene.primitives().iter().find_map(|p| match p {
970 Primitive::Text { color, .. } => Some(*color),
971 _ => None,
972 })
973 }
974
975 fn width_of(bar: &SnackBar<Msg>) -> f32 {
977 match Widget::<Msg>::style_themed(bar, &Theme::default()).width {
978 Dimension::Length(w) => w,
979 other => panic!("a notification names its width, got {other:?}"),
980 }
981 }
982
983 fn height_of(bar: &SnackBar<Msg>) -> f32 {
985 match Widget::<Msg>::style_themed(bar, &Theme::default()).height {
986 Dimension::Length(h) => h,
987 other => panic!("a notification names its height, got {other:?}"),
988 }
989 }
990
991 fn control(bar: &SnackBar<Msg>, index: usize, status: Status, theme: &Theme) -> Scene {
993 let mut scene = Scene::new();
994 Widget::<Msg>::children(bar)[index].paint(
995 Rect::new(0.0, 0.0, ICON_BUTTON_SIZE, ICON_BUTTON_SIZE),
996 status,
997 theme,
998 &mut scene,
999 );
1000 scene
1001 }
1002
1003 #[test]
1009 fn a_close_icon_is_a_button_that_says_what_it_does() {
1010 let bar = SnackBar::new("Message archived").close_icon(Msg::Dismiss);
1011 let children = Widget::<Msg>::children(&bar);
1012 assert_eq!(children.len(), 1, "the cross, and nothing else");
1013 assert_eq!(children[0].on_click(), Some(Msg::Dismiss));
1014 assert!(children[0].focusable(), "and the keyboard can reach it");
1015
1016 let heard = |bar: &SnackBar<Msg>| {
1017 Widget::<Msg>::children(bar)[0]
1018 .semantics()
1019 .and_then(|s| s.label)
1020 .expect("a cross with no name is a cross nobody can use")
1021 };
1022 assert_eq!(heard(&bar), close_label());
1023 assert_eq!(
1024 heard(
1025 &SnackBar::new("x")
1026 .close_icon(Msg::Dismiss)
1027 .close_icon_label("Fermer")
1028 ),
1029 "Fermer",
1030 "the caller is the only one who can translate it"
1031 );
1032 }
1033
1034 #[test]
1037 fn the_cross_comes_after_the_action() {
1038 let bar = SnackBar::new("Message archived")
1039 .close_icon(Msg::Dismiss)
1040 .action("Undo", Msg::Undo);
1041 let children = Widget::<Msg>::children(&bar);
1042 assert_eq!(children.len(), 2);
1043 assert_eq!(children[0].on_click(), Some(Msg::Undo));
1044 assert_eq!(children[1].on_click(), Some(Msg::Dismiss));
1045 }
1046
1047 #[test]
1050 fn a_bar_with_a_cross_makes_room_for_it() {
1051 let bare = SnackBar::new("Saved");
1052 let crossed = SnackBar::new("Saved").close_icon(Msg::Dismiss);
1053 assert!(
1054 (width_of(&crossed)
1055 - width_of(&bare)
1056 - (ICON_BUTTON_SIZE + close_margin(PAD_X_FIXED) * 2.0))
1057 .abs()
1058 < 1.0,
1059 "{} against {}",
1060 width_of(&crossed),
1061 width_of(&bare)
1062 );
1063 let small = TextStyle {
1066 size: Some(8.0),
1067 ..Default::default()
1068 };
1069 let tiny = SnackBar::new("Saved")
1070 .close_icon(Msg::Dismiss)
1071 .content_text_style(small);
1072 assert!(
1073 height_of(&SnackBar::new("Saved").content_text_style(small)) < ICON_BUTTON_SIZE,
1074 "the premise: a bar of small type is shorter than a cross"
1075 );
1076 assert!(
1077 height_of(&tiny) >= ICON_BUTTON_SIZE,
1078 "the cross would have been cut off: {}",
1079 height_of(&tiny)
1080 );
1081 }
1082
1083 #[test]
1086 fn the_cross_takes_the_ink_that_is_legible_on_the_bar() {
1087 let mut theme = Theme::default();
1088 let ink = |bar: &SnackBar<Msg>, theme: &Theme| {
1089 control(bar, 0, Status::default(), theme)
1090 .primitives()
1091 .iter()
1092 .find_map(|p| match p {
1093 Primitive::Path { fill, .. } => *fill,
1094 _ => None,
1095 })
1096 .expect("the cross is drawn")
1097 };
1098 let bar = SnackBar::new("x").close_icon(Msg::Dismiss);
1099 assert_eq!(ink(&bar, &theme), theme.scheme.on_inverse_surface);
1100
1101 theme.widgets.snack_bar.close_icon_color = Some(Color::rgb8(1, 2, 3));
1102 assert_eq!(ink(&bar, &theme), Color::rgb8(1, 2, 3));
1103
1104 let told = SnackBar::new("x")
1105 .close_icon(Msg::Dismiss)
1106 .close_icon_color(Color::rgb8(4, 5, 6));
1107 assert_eq!(ink(&told, &theme), Color::rgb8(4, 5, 6));
1108 }
1109
1110 #[test]
1115 fn the_cross_grounds_its_state_layer_on_the_bar() {
1116 let theme = Theme::default();
1117 let bar = SnackBar::new("x").close_icon(Msg::Dismiss);
1118 let hovered = Status {
1119 opacity: 1.0,
1120 hover_progress: 1.0,
1121 ..Default::default()
1122 };
1123 let fill = control(&bar, 0, hovered, &theme)
1124 .primitives()
1125 .iter()
1126 .find_map(|p| match p {
1127 Primitive::Rect { color, .. } => Some(*color),
1128 _ => None,
1129 })
1130 .expect("a hovered cross lights");
1131 assert_eq!(fill.a, 1.0, "resolved here, not handed over as an alpha");
1132 assert_eq!(
1133 fill,
1134 theme.state_layer(
1135 theme.scheme.inverse_surface,
1136 theme.scheme.on_inverse_surface,
1137 &hovered
1138 )
1139 );
1140 }
1141
1142 #[test]
1143 fn paints_accent_and_text() {
1144 let scene = painted(&SnackBar::<()>::new("Saved").success(), &Theme::default());
1145 assert!(scene
1146 .primitives()
1147 .iter()
1148 .any(|p| matches!(p, Primitive::Rect { color, .. } if *color == SUCCESS_ACCENT)));
1149 assert!(scene
1150 .primitives()
1151 .iter()
1152 .any(|p| matches!(p, Primitive::Text { text, .. } if text == "Saved")));
1153 }
1154
1155 #[test]
1159 fn a_notification_stands_out_rather_than_sitting_on_the_page() {
1160 let theme = Theme::default();
1161 let scene = painted(&SnackBar::<()>::new("Saved"), &theme);
1162 assert_eq!(
1163 surface_of(&scene),
1164 Some(theme.scheme.inverse_surface),
1165 "not a card on the page"
1166 );
1167 assert_ne!(
1168 theme.scheme.inverse_surface, theme.surface,
1169 "and the two have to be tellable apart for that to mean anything"
1170 );
1171 assert_eq!(
1172 text_color(&scene),
1173 Some(theme.scheme.on_inverse_surface),
1174 "with the message that is legible on it"
1175 );
1176 }
1177
1178 #[test]
1181 fn the_action_takes_the_inverted_accent() {
1182 let theme = Theme::default();
1183 let bar = SnackBar::<()>::new("Deleted").action("Undo", ());
1184 let kids = Widget::<()>::children(&bar);
1185 let mut scene = Scene::new();
1186 kids[0].paint(
1187 Rect::new(0.0, 0.0, 60.0, ACTION_H),
1188 Status::default(),
1189 &theme,
1190 &mut scene,
1191 );
1192 assert_eq!(text_color(&scene), Some(theme.scheme.inverse_primary));
1193 assert_ne!(
1194 theme.scheme.inverse_primary, theme.primary,
1195 "which is not the page's accent, and is the point"
1196 );
1197 }
1198
1199 #[test]
1203 fn the_kinds_name_a_role_wherever_one_exists() {
1204 let mut theme = Theme::default();
1205 assert_eq!(
1206 SnackBar::<()>::new("x").accent_color(&theme),
1207 theme.scheme.inverse_primary
1208 );
1209 assert_eq!(
1210 SnackBar::<()>::new("x").error().accent_color(&theme),
1211 theme.scheme.error
1212 );
1213 assert_eq!(
1214 SnackBar::<()>::new("x").success().accent_color(&theme),
1215 SUCCESS_ACCENT
1216 );
1217
1218 let told = Color::rgb8(1, 2, 3);
1219 theme.widgets.snack_bar.success_color = Some(told);
1220 assert_eq!(
1221 SnackBar::<()>::new("x").success().accent_color(&theme),
1222 told,
1223 "the one colour without a role is the one a theme most needs to reach"
1224 );
1225 assert_eq!(
1226 SnackBar::<()>::new("x").accent(told).accent_color(&theme),
1227 told,
1228 "and the caller outranks all three kinds"
1229 );
1230 }
1231
1232 #[test]
1233 fn action_is_clickable_and_uppercased() {
1234 let plain = SnackBar::<Msg>::new("Item deleted");
1236 assert!(Widget::<Msg>::children(&plain).is_empty());
1237 let toast = SnackBar::new("Item deleted").action("Undo", Msg::Undo);
1239 let kids = Widget::<Msg>::children(&toast);
1240 assert_eq!(kids.len(), 1);
1241 assert_eq!(kids[0].on_click(), Some(Msg::Undo));
1242 assert!(kids[0].focusable());
1243 }
1244
1245 #[test]
1246 fn queue_shows_one_at_a_time_and_expires() {
1247 let mut q: SnackBarQueue<&str> = SnackBarQueue::new();
1248 assert!(q.is_empty());
1249 q.push("first", 3.0);
1250 q.push("second", 3.0);
1251 assert_eq!(q.len(), 2);
1252 assert_eq!(q.current(), Some(&"first"), "the front is the visible one");
1253 assert!(!q.tick(1.0));
1255 assert_eq!(q.current(), Some(&"first"));
1256 assert!(q.tick(2.5), "a change at expiry");
1258 assert_eq!(q.current(), Some(&"second"));
1259 assert_eq!(q.dismiss(), Some("second"));
1261 assert!(q.is_empty());
1262 assert!(!q.tick(1.0), "an empty queue: nothing changes");
1263 }
1264
1265 #[test]
1266 fn leaving_phase_precedes_dismissal() {
1267 let mut q: SnackBarQueue<&str> = SnackBarQueue::new();
1268 assert!(!q.is_leaving(), "empty queue: nothing leaving");
1269 q.push("hello", 3.0);
1270 assert!(!q.is_leaving(), "on display: not leaving yet");
1271 q.start_leaving();
1273 assert!(q.is_leaving(), "leaving");
1274 assert_eq!(q.current(), Some(&"hello"), "still visible while leaving");
1275 assert_eq!(q.dismiss(), Some("hello"));
1277 assert!(!q.is_leaving());
1278 assert!(q.is_empty());
1279 }
1280
1281 #[test]
1286 fn a_bar_takes_its_own_elevation_and_casts_nothing_at_nought() {
1287 let shadows = |bar: &SnackBar<()>, theme: &Theme| {
1289 crate::shadowprobe::heights(painted(bar, theme).primitives())
1290 };
1291 let theme = Theme::default();
1292 assert_eq!(
1293 shadows(&SnackBar::new("Saved"), &theme),
1294 vec![SNACK_BAR_ELEVATION]
1295 );
1296 assert_eq!(
1297 shadows(&SnackBar::new("Saved").elevation(3.0), &theme),
1298 vec![3.0]
1299 );
1300 assert!(
1301 shadows(&SnackBar::new("Saved").elevation(0.0), &theme).is_empty(),
1302 "a bar on the page casts nothing"
1303 );
1304
1305 let mut flat = Theme::default();
1306 flat.widgets.snack_bar.elevation = Some(0.0);
1307 assert!(
1308 shadows(&SnackBar::new("Saved"), &flat).is_empty(),
1309 "nor one the theme put there"
1310 );
1311 assert_eq!(
1312 shadows(&SnackBar::new("Saved").elevation(2.0), &flat),
1313 vec![2.0],
1314 "the bar's word over the theme's"
1315 );
1316 }
1317}