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 scene.shadow(
502 Rect::new(
503 bounds.x - elevation,
504 bounds.y - elevation * 0.5,
505 bounds.width + elevation * 2.0,
506 bounds.height + elevation * 2.0,
507 ),
508 theme.scheme.shadow.with_alpha(0.3).fade(o),
509 radius.inflate(elevation),
510 elevation,
511 );
512 }
513 scene.draw_shape(bounds, shape, fill.fade(o));
516 scene.draw_rect(
519 Rect::new(bounds.x, bounds.y, ACCENT, bounds.height),
520 self.accent_color(theme).fade(o),
521 0.0,
522 0.0,
523 Color::TRANSPARENT,
524 );
525 scene.text(
526 Point::new(
527 bounds.x + ACCENT + self.pad_x(Some(theme)),
528 bounds.y + PAD_Y,
529 ),
530 self.text.clone(),
531 &content_style(self.content_text_style, Some(theme)),
532 self.text_color
533 .or(t.text_color)
534 .unwrap_or(theme.scheme.on_inverse_surface)
535 .fade(o),
536 );
537 }
538
539 fn on_click(&self) -> Option<Msg> {
540 None
541 }
542}
543
544struct ActionButton<Msg> {
546 label: String,
547 text_style: Option<TextStyle>,
548 color: Option<Color>,
550 message: Msg,
551}
552
553impl<Msg> ActionButton<Msg> {
554 fn color(&self, theme: &Theme) -> Color {
557 self.color
558 .or(theme.widgets.snack_bar.action_text_color)
559 .unwrap_or(theme.scheme.inverse_primary)
560 }
561}
562
563impl<Msg> ActionButton<Msg> {
564 fn sizing(&self, theme: Option<&Theme>) -> Style {
565 let style = action_style(self.text_style, theme);
566 Style {
567 width: Dimension::Length(action_width(&self.label, &style)),
568 height: Dimension::Length(frus_text::line_box(ACTION_H, &style, 0.0)),
569 ..Default::default()
570 }
571 }
572}
573
574impl<Msg: Clone> Widget<Msg> for ActionButton<Msg> {
575 fn style(&self) -> Style {
576 self.sizing(None)
577 }
578
579 fn style_themed(&self, theme: &Theme) -> Style {
580 self.sizing(Some(theme))
581 }
582
583 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
584 &[]
585 }
586
587 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
588 let o = status.opacity;
589 let label = self.color(theme);
594 let ground = theme
595 .widgets
596 .snack_bar
597 .background_color
598 .unwrap_or(theme.scheme.inverse_surface);
599 let bg = theme.state_layer(ground, label, &status);
600 scene.draw_rect(bounds, bg.fade(o), theme.radius, 0.0, Color::TRANSPARENT);
601 let style = action_style(self.text_style, Some(theme));
602 let w = frus_text::measure_resolved(&self.label, &style).width;
603 scene.text(
604 Point::new(
605 bounds.x + (bounds.width - w) * 0.5,
606 bounds.y + (bounds.height - style.line_height()) * 0.5,
607 ),
608 self.label.clone(),
609 &style,
610 label.fade(o),
611 );
612 }
613
614 fn on_click(&self) -> Option<Msg> {
615 Some(self.message.clone())
616 }
617
618 fn focusable(&self) -> bool {
619 true
620 }
621
622 fn semantics(&self) -> Option<SemanticsProperties> {
623 Some(
624 SemanticsProperties::new(Role::Button)
625 .label(self.label.clone())
626 .clickable(),
627 )
628 }
629}
630
631struct CloseButton<Msg> {
634 color: Option<Color>,
636 label: String,
637 message: Msg,
638}
639
640impl<Msg> CloseButton<Msg> {
641 fn color(&self, theme: &Theme) -> Color {
644 self.color
645 .or(theme.widgets.snack_bar.close_icon_color)
646 .unwrap_or(theme.scheme.on_inverse_surface)
647 }
648}
649
650impl<Msg: Clone> Widget<Msg> for CloseButton<Msg> {
651 fn style(&self) -> Style {
652 Style {
653 width: Dimension::Length(ICON_BUTTON_SIZE),
654 height: Dimension::Length(ICON_BUTTON_SIZE),
655 ..Default::default()
656 }
657 }
658
659 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
660 &[]
661 }
662
663 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
664 let o = status.opacity;
665 let ink = self.color(theme);
666 let ground = theme
671 .widgets
672 .snack_bar
673 .background_color
674 .unwrap_or(theme.scheme.inverse_surface);
675 let bg = theme.state_layer(ground, ink, &status);
676 scene.draw_rect(
677 bounds,
678 bg.fade(o),
679 bounds.height.min(bounds.width) * 0.5,
680 0.0,
681 Color::TRANSPARENT,
682 );
683 let size = ICON_BUTTON_ICON_SIZE;
684 let path = crate::Icons::CLOSE.placed(
685 size,
686 bounds.x + (bounds.width - size) * 0.5,
687 bounds.y + (bounds.height - size) * 0.5,
688 theme.direction,
689 );
690 scene.fill_path(&path, ink.fade(o));
691 }
692
693 fn on_click(&self) -> Option<Msg> {
694 Some(self.message.clone())
695 }
696
697 fn focusable(&self) -> bool {
698 true
699 }
700
701 fn semantics(&self) -> Option<SemanticsProperties> {
702 Some(
703 SemanticsProperties::new(Role::Button)
704 .label(self.label.clone())
705 .clickable(),
706 )
707 }
708}
709
710pub struct SnackBarQueue<T> {
719 items: VecDeque<(T, f32, bool)>,
723}
724
725impl<T> Default for SnackBarQueue<T> {
726 fn default() -> Self {
727 Self {
728 items: VecDeque::new(),
729 }
730 }
731}
732
733impl<T> SnackBarQueue<T> {
734 pub fn new() -> Self {
736 Self::default()
737 }
738
739 pub fn push(&mut self, item: T, seconds: f32) {
741 self.items.push_back((item, seconds.max(0.0), false));
742 }
743
744 pub fn current(&self) -> Option<&T> {
746 self.items.front().map(|(item, _, _)| item)
747 }
748
749 pub fn tick(&mut self, dt: f32) -> bool {
753 let Some(front) = self.items.front_mut() else {
754 return false;
755 };
756 front.1 -= dt;
757 if front.1 <= 0.0 {
758 self.items.pop_front();
759 true
760 } else {
761 false
762 }
763 }
764
765 pub fn start_leaving(&mut self) {
768 if let Some(front) = self.items.front_mut() {
769 front.2 = true;
770 }
771 }
772
773 pub fn is_leaving(&self) -> bool {
775 self.items.front().is_some_and(|(_, _, leaving)| *leaving)
776 }
777
778 pub fn dismiss(&mut self) -> Option<T> {
780 self.items.pop_front().map(|(item, _, _)| item)
781 }
782
783 pub fn is_empty(&self) -> bool {
785 self.items.is_empty()
786 }
787
788 pub fn len(&self) -> usize {
790 self.items.len()
791 }
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797 use frus_core::Primitive;
798
799 #[derive(Clone, Debug, PartialEq)]
800 enum Msg {
801 Undo,
802 Dismiss,
803 }
804
805 fn painted(bar: &SnackBar<()>, theme: &Theme) -> Scene {
807 let mut scene = Scene::new();
808 Widget::<()>::paint(
809 bar,
810 Rect::new(0.0, 0.0, 160.0, 44.0),
811 Status::default(),
812 theme,
813 &mut scene,
814 );
815 scene
816 }
817
818 fn corner_of(scene: &Scene) -> Option<f32> {
820 scene.primitives().iter().find_map(|p| match p {
821 Primitive::Rect {
822 rect, radius, blur, ..
823 } if *blur == 0.0 && rect.width == 160.0 => Some(radius.top_left),
824 _ => None,
825 })
826 }
827
828 fn text_x(scene: &Scene) -> Option<f32> {
830 scene.primitives().iter().find_map(|p| match p {
831 Primitive::Text { position, .. } => Some(position.x),
832 _ => None,
833 })
834 }
835
836 #[test]
844 fn a_bar_is_fixed_by_default_and_has_no_corners() {
845 let theme = Theme::default();
846 let bar = SnackBar::<Msg>::new("Saved");
847 let scene = painted(&SnackBar::<()>::new("Saved"), &theme);
848 assert_eq!(corner_of(&scene), Some(0.0), "square, being flush");
849 assert_eq!(
850 text_x(&scene),
851 Some(ACCENT + PAD_X_FIXED),
852 "and its text held in by a fixed bar's padding"
853 );
854 assert_eq!(
855 Widget::<Msg>::style_themed(&bar, &theme).margin,
856 Insets::ZERO,
857 "a fixed bar keeps nothing clear of the page: that is what fixed means"
858 );
859 }
860
861 #[test]
865 fn a_floating_bar_rounds_and_keeps_clear_of_the_page() {
866 let theme = Theme::default();
867 let scene = painted(
868 &SnackBar::<()>::new("Saved").behavior(SnackBarBehavior::Floating),
869 &theme,
870 );
871 assert_eq!(corner_of(&scene), Some(SNACK_BAR_RADIUS));
872 assert_eq!(
873 text_x(&scene),
874 Some(ACCENT + PAD_X_FLOATING),
875 "and its text is held in less, the margin having held it in already"
876 );
877
878 let bar = SnackBar::<Msg>::new("Saved").behavior(SnackBarBehavior::Floating);
879 assert_eq!(
880 Widget::<Msg>::style_themed(&bar, &theme).margin,
881 INSET_PADDING
882 );
883 let own = Insets::uniform(4.0);
884 assert_eq!(
885 Widget::<Msg>::style_themed(
886 &SnackBar::<Msg>::new("Saved")
887 .behavior(SnackBarBehavior::Floating)
888 .margin(own),
889 &theme
890 )
891 .margin,
892 own,
893 "and a caller may say what it keeps clear"
894 );
895 }
896
897 #[test]
901 fn a_width_is_a_floating_bar_s_alone() {
902 let theme = Theme::default();
903 let floating = SnackBar::<Msg>::new("Saved")
904 .behavior(SnackBarBehavior::Floating)
905 .width(300.0);
906 assert_eq!(width_of(&floating), 300.0);
907
908 let fixed = SnackBar::<Msg>::new("Saved")
909 .width(300.0)
910 .margin(Insets::uniform(9.0));
911 assert_ne!(
912 width_of(&fixed),
913 300.0,
914 "a fixed bar spans what it is given"
915 );
916 assert_eq!(
917 Widget::<Msg>::style_themed(&fixed, &theme).margin,
918 Insets::ZERO,
919 "and keeps nothing clear, whatever it was told"
920 );
921 }
922
923 #[test]
926 fn a_theme_places_every_bar_at_once() {
927 let mut theme = Theme::default();
928 theme.widgets.snack_bar.behavior = Some(SnackBarBehavior::Floating);
929 let scene = {
930 let mut scene = Scene::new();
931 Widget::<()>::paint(
932 &SnackBar::<()>::new("Saved"),
933 Rect::new(0.0, 0.0, 160.0, 44.0),
934 Status::default(),
935 &theme,
936 &mut scene,
937 );
938 scene
939 };
940 assert_eq!(
941 corner_of(&scene),
942 Some(SNACK_BAR_RADIUS),
943 "the theme's word"
944 );
945 assert_eq!(
946 Widget::<Msg>::style_themed(&SnackBar::<Msg>::new("Saved"), &theme).margin,
947 INSET_PADDING
948 );
949 assert_eq!(
950 Widget::<Msg>::style_themed(
951 &SnackBar::<Msg>::new("Saved").behavior(SnackBarBehavior::Fixed),
952 &theme
953 )
954 .margin,
955 Insets::ZERO,
956 "and one bar may still answer for itself"
957 );
958 }
959
960 fn surface_of(scene: &Scene) -> Option<Color> {
963 scene.primitives().iter().find_map(|p| match p {
964 Primitive::Rect {
965 rect, color, blur, ..
966 } if *blur == 0.0 && rect.width == 160.0 => Some(*color),
967 _ => None,
968 })
969 }
970
971 fn text_color(scene: &Scene) -> Option<Color> {
972 scene.primitives().iter().find_map(|p| match p {
973 Primitive::Text { color, .. } => Some(*color),
974 _ => None,
975 })
976 }
977
978 fn width_of(bar: &SnackBar<Msg>) -> f32 {
980 match Widget::<Msg>::style_themed(bar, &Theme::default()).width {
981 Dimension::Length(w) => w,
982 other => panic!("a notification names its width, got {other:?}"),
983 }
984 }
985
986 fn height_of(bar: &SnackBar<Msg>) -> f32 {
988 match Widget::<Msg>::style_themed(bar, &Theme::default()).height {
989 Dimension::Length(h) => h,
990 other => panic!("a notification names its height, got {other:?}"),
991 }
992 }
993
994 fn control(bar: &SnackBar<Msg>, index: usize, status: Status, theme: &Theme) -> Scene {
996 let mut scene = Scene::new();
997 Widget::<Msg>::children(bar)[index].paint(
998 Rect::new(0.0, 0.0, ICON_BUTTON_SIZE, ICON_BUTTON_SIZE),
999 status,
1000 theme,
1001 &mut scene,
1002 );
1003 scene
1004 }
1005
1006 #[test]
1012 fn a_close_icon_is_a_button_that_says_what_it_does() {
1013 let bar = SnackBar::new("Message archived").close_icon(Msg::Dismiss);
1014 let children = Widget::<Msg>::children(&bar);
1015 assert_eq!(children.len(), 1, "the cross, and nothing else");
1016 assert_eq!(children[0].on_click(), Some(Msg::Dismiss));
1017 assert!(children[0].focusable(), "and the keyboard can reach it");
1018
1019 let heard = |bar: &SnackBar<Msg>| {
1020 Widget::<Msg>::children(bar)[0]
1021 .semantics()
1022 .and_then(|s| s.label)
1023 .expect("a cross with no name is a cross nobody can use")
1024 };
1025 assert_eq!(heard(&bar), close_label());
1026 assert_eq!(
1027 heard(
1028 &SnackBar::new("x")
1029 .close_icon(Msg::Dismiss)
1030 .close_icon_label("Fermer")
1031 ),
1032 "Fermer",
1033 "the caller is the only one who can translate it"
1034 );
1035 }
1036
1037 #[test]
1040 fn the_cross_comes_after_the_action() {
1041 let bar = SnackBar::new("Message archived")
1042 .close_icon(Msg::Dismiss)
1043 .action("Undo", Msg::Undo);
1044 let children = Widget::<Msg>::children(&bar);
1045 assert_eq!(children.len(), 2);
1046 assert_eq!(children[0].on_click(), Some(Msg::Undo));
1047 assert_eq!(children[1].on_click(), Some(Msg::Dismiss));
1048 }
1049
1050 #[test]
1053 fn a_bar_with_a_cross_makes_room_for_it() {
1054 let bare = SnackBar::new("Saved");
1055 let crossed = SnackBar::new("Saved").close_icon(Msg::Dismiss);
1056 assert!(
1057 (width_of(&crossed)
1058 - width_of(&bare)
1059 - (ICON_BUTTON_SIZE + close_margin(PAD_X_FIXED) * 2.0))
1060 .abs()
1061 < 1.0,
1062 "{} against {}",
1063 width_of(&crossed),
1064 width_of(&bare)
1065 );
1066 let small = TextStyle {
1069 size: Some(8.0),
1070 ..Default::default()
1071 };
1072 let tiny = SnackBar::new("Saved")
1073 .close_icon(Msg::Dismiss)
1074 .content_text_style(small);
1075 assert!(
1076 height_of(&SnackBar::new("Saved").content_text_style(small)) < ICON_BUTTON_SIZE,
1077 "the premise: a bar of small type is shorter than a cross"
1078 );
1079 assert!(
1080 height_of(&tiny) >= ICON_BUTTON_SIZE,
1081 "the cross would have been cut off: {}",
1082 height_of(&tiny)
1083 );
1084 }
1085
1086 #[test]
1089 fn the_cross_takes_the_ink_that_is_legible_on_the_bar() {
1090 let mut theme = Theme::default();
1091 let ink = |bar: &SnackBar<Msg>, theme: &Theme| {
1092 control(bar, 0, Status::default(), theme)
1093 .primitives()
1094 .iter()
1095 .find_map(|p| match p {
1096 Primitive::Path { fill, .. } => *fill,
1097 _ => None,
1098 })
1099 .expect("the cross is drawn")
1100 };
1101 let bar = SnackBar::new("x").close_icon(Msg::Dismiss);
1102 assert_eq!(ink(&bar, &theme), theme.scheme.on_inverse_surface);
1103
1104 theme.widgets.snack_bar.close_icon_color = Some(Color::rgb8(1, 2, 3));
1105 assert_eq!(ink(&bar, &theme), Color::rgb8(1, 2, 3));
1106
1107 let told = SnackBar::new("x")
1108 .close_icon(Msg::Dismiss)
1109 .close_icon_color(Color::rgb8(4, 5, 6));
1110 assert_eq!(ink(&told, &theme), Color::rgb8(4, 5, 6));
1111 }
1112
1113 #[test]
1118 fn the_cross_grounds_its_state_layer_on_the_bar() {
1119 let theme = Theme::default();
1120 let bar = SnackBar::new("x").close_icon(Msg::Dismiss);
1121 let hovered = Status {
1122 opacity: 1.0,
1123 hover_progress: 1.0,
1124 ..Default::default()
1125 };
1126 let fill = control(&bar, 0, hovered, &theme)
1127 .primitives()
1128 .iter()
1129 .find_map(|p| match p {
1130 Primitive::Rect { color, .. } => Some(*color),
1131 _ => None,
1132 })
1133 .expect("a hovered cross lights");
1134 assert_eq!(fill.a, 1.0, "resolved here, not handed over as an alpha");
1135 assert_eq!(
1136 fill,
1137 theme.state_layer(
1138 theme.scheme.inverse_surface,
1139 theme.scheme.on_inverse_surface,
1140 &hovered
1141 )
1142 );
1143 }
1144
1145 #[test]
1146 fn paints_accent_and_text() {
1147 let scene = painted(&SnackBar::<()>::new("Saved").success(), &Theme::default());
1148 assert!(scene
1149 .primitives()
1150 .iter()
1151 .any(|p| matches!(p, Primitive::Rect { color, .. } if *color == SUCCESS_ACCENT)));
1152 assert!(scene
1153 .primitives()
1154 .iter()
1155 .any(|p| matches!(p, Primitive::Text { text, .. } if text == "Saved")));
1156 }
1157
1158 #[test]
1162 fn a_notification_stands_out_rather_than_sitting_on_the_page() {
1163 let theme = Theme::default();
1164 let scene = painted(&SnackBar::<()>::new("Saved"), &theme);
1165 assert_eq!(
1166 surface_of(&scene),
1167 Some(theme.scheme.inverse_surface),
1168 "not a card on the page"
1169 );
1170 assert_ne!(
1171 theme.scheme.inverse_surface, theme.surface,
1172 "and the two have to be tellable apart for that to mean anything"
1173 );
1174 assert_eq!(
1175 text_color(&scene),
1176 Some(theme.scheme.on_inverse_surface),
1177 "with the message that is legible on it"
1178 );
1179 }
1180
1181 #[test]
1184 fn the_action_takes_the_inverted_accent() {
1185 let theme = Theme::default();
1186 let bar = SnackBar::<()>::new("Deleted").action("Undo", ());
1187 let kids = Widget::<()>::children(&bar);
1188 let mut scene = Scene::new();
1189 kids[0].paint(
1190 Rect::new(0.0, 0.0, 60.0, ACTION_H),
1191 Status::default(),
1192 &theme,
1193 &mut scene,
1194 );
1195 assert_eq!(text_color(&scene), Some(theme.scheme.inverse_primary));
1196 assert_ne!(
1197 theme.scheme.inverse_primary, theme.primary,
1198 "which is not the page's accent, and is the point"
1199 );
1200 }
1201
1202 #[test]
1206 fn the_kinds_name_a_role_wherever_one_exists() {
1207 let mut theme = Theme::default();
1208 assert_eq!(
1209 SnackBar::<()>::new("x").accent_color(&theme),
1210 theme.scheme.inverse_primary
1211 );
1212 assert_eq!(
1213 SnackBar::<()>::new("x").error().accent_color(&theme),
1214 theme.scheme.error
1215 );
1216 assert_eq!(
1217 SnackBar::<()>::new("x").success().accent_color(&theme),
1218 SUCCESS_ACCENT
1219 );
1220
1221 let told = Color::rgb8(1, 2, 3);
1222 theme.widgets.snack_bar.success_color = Some(told);
1223 assert_eq!(
1224 SnackBar::<()>::new("x").success().accent_color(&theme),
1225 told,
1226 "the one colour without a role is the one a theme most needs to reach"
1227 );
1228 assert_eq!(
1229 SnackBar::<()>::new("x").accent(told).accent_color(&theme),
1230 told,
1231 "and the caller outranks all three kinds"
1232 );
1233 }
1234
1235 #[test]
1236 fn action_is_clickable_and_uppercased() {
1237 let plain = SnackBar::<Msg>::new("Item deleted");
1239 assert!(Widget::<Msg>::children(&plain).is_empty());
1240 let toast = SnackBar::new("Item deleted").action("Undo", Msg::Undo);
1242 let kids = Widget::<Msg>::children(&toast);
1243 assert_eq!(kids.len(), 1);
1244 assert_eq!(kids[0].on_click(), Some(Msg::Undo));
1245 assert!(kids[0].focusable());
1246 }
1247
1248 #[test]
1249 fn queue_shows_one_at_a_time_and_expires() {
1250 let mut q: SnackBarQueue<&str> = SnackBarQueue::new();
1251 assert!(q.is_empty());
1252 q.push("first", 3.0);
1253 q.push("second", 3.0);
1254 assert_eq!(q.len(), 2);
1255 assert_eq!(q.current(), Some(&"first"), "the front is the visible one");
1256 assert!(!q.tick(1.0));
1258 assert_eq!(q.current(), Some(&"first"));
1259 assert!(q.tick(2.5), "a change at expiry");
1261 assert_eq!(q.current(), Some(&"second"));
1262 assert_eq!(q.dismiss(), Some("second"));
1264 assert!(q.is_empty());
1265 assert!(!q.tick(1.0), "an empty queue: nothing changes");
1266 }
1267
1268 #[test]
1269 fn leaving_phase_precedes_dismissal() {
1270 let mut q: SnackBarQueue<&str> = SnackBarQueue::new();
1271 assert!(!q.is_leaving(), "empty queue: nothing leaving");
1272 q.push("hello", 3.0);
1273 assert!(!q.is_leaving(), "on display: not leaving yet");
1274 q.start_leaving();
1276 assert!(q.is_leaving(), "leaving");
1277 assert_eq!(q.current(), Some(&"hello"), "still visible while leaving");
1278 assert_eq!(q.dismiss(), Some("hello"));
1280 assert!(!q.is_leaving());
1281 assert!(q.is_empty());
1282 }
1283
1284 #[test]
1289 fn a_bar_takes_its_own_elevation_and_casts_nothing_at_nought() {
1290 let shadows = |bar: &SnackBar<()>, theme: &Theme| {
1293 let colour = theme.scheme.shadow.with_alpha(0.3);
1294 painted(bar, theme)
1295 .primitives()
1296 .iter()
1297 .filter_map(|p| match p {
1298 Primitive::Rect { color, blur, .. } if *color == colour => Some(*blur),
1299 _ => None,
1300 })
1301 .collect::<Vec<_>>()
1302 };
1303 let theme = Theme::default();
1304 assert_eq!(
1305 shadows(&SnackBar::new("Saved"), &theme),
1306 vec![SNACK_BAR_ELEVATION]
1307 );
1308 assert_eq!(
1309 shadows(&SnackBar::new("Saved").elevation(3.0), &theme),
1310 vec![3.0]
1311 );
1312 assert!(
1313 shadows(&SnackBar::new("Saved").elevation(0.0), &theme).is_empty(),
1314 "a bar on the page casts nothing"
1315 );
1316
1317 let mut flat = Theme::default();
1318 flat.widgets.snack_bar.elevation = Some(0.0);
1319 assert!(
1320 shadows(&SnackBar::new("Saved"), &flat).is_empty(),
1321 "nor one the theme put there"
1322 );
1323 assert_eq!(
1324 shadows(&SnackBar::new("Saved").elevation(2.0), &flat),
1325 vec![2.0],
1326 "the bar's word over the theme's"
1327 );
1328 }
1329}