1use std::cell::RefCell;
163use std::rc::Rc;
164use std::time::Duration;
165
166use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
167use frust::authoring::{
168 Action, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedCallback, EventCtx,
169 EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerEvent, PointerPhase, Role,
170 SemanticsCtx, View, Widget, any, erase_callback,
171};
172use frust::{AnimationController, IconData, Theme};
173use kurbo::{Affine, Point, Rect, Size, Vec2};
174use peniko::{Brush, Color};
175
176use crate::icons;
177use crate::press::presses;
178use crate::tokens::MaterialMotion;
179
180const MIN_HEIGHT: f64 = 48.0;
184const MAX_WIDTH: f64 = 600.0;
186const CONTENT_PAD_H: f64 = 16.0;
188const CONTENT_PAD_V: f64 = 8.0;
190const H_INSET: f64 = 16.0;
193const BOTTOM_INSET: f64 = 16.0;
196const ACTION_PAD_H: f64 = 8.0;
198const ACTION_PAD_V: f64 = 4.0;
199const ACTION_GAP: f64 = 8.0;
201const DEFAULT_DURATION: Duration = Duration::from_millis(4000);
203
204const CLOSE_ICON_SIZE: f64 = 18.0;
209const CLOSE_GAP: f64 = 8.0;
211
212const ENTER_DURATION: Duration = MaterialMotion::MEDIUM_2;
215const EXIT_DURATION: Duration = MaterialMotion::SHORT_4;
217
218const HIDDEN_MARGIN: f64 = 8.0;
225
226const MESSAGE_SIZE: f32 = 14.0;
229const MESSAGE_LINE_HEIGHT: f32 = 20.0;
230const MESSAGE_LETTER_SPACING: f32 = 0.25;
231const ACTION_SIZE: f32 = 14.0;
233const ACTION_LINE_HEIGHT: f32 = 20.0;
234const ACTION_LETTER_SPACING: f32 = 0.1;
235
236const FALLBACK_CONTAINER: Color = Color::from_rgb8(0x32, 0x2F, 0x35);
239const FALLBACK_ON_CONTAINER: Color = Color::from_rgb8(0xF5, 0xEF, 0xF7);
242const FALLBACK_ACTION: Color = Color::from_rgb8(0xD0, 0xBC, 0xFF);
244const FALLBACK_RADIUS: f64 = 4.0;
246const FALLBACK_SHADOW_BLUR: f64 = 6.0;
250const FALLBACK_SHADOW_Y: f64 = 4.0;
251const FALLBACK_SHADOW_ALPHA: f32 = 0.3;
252
253use crate::interaction::PRESSED_OPACITY;
257
258const PROGRESS_EPSILON: f64 = 1e-4;
262const SHADOW_SPILL: f64 = 24.0;
266
267fn with_alpha(color: Color, alpha: f32) -> Color {
270 let c = color.components;
271 Color::new([c[0], c[1], c[2], alpha])
272}
273
274fn message_style(theme: Option<&Theme>) -> TextStyle {
278 let mut style = TextStyle {
279 weight: FontWeight::REGULAR,
280 letter_spacing: MESSAGE_LETTER_SPACING,
281 line_height: LineHeight::Absolute(MESSAGE_LINE_HEIGHT),
282 ..TextStyle::new(MESSAGE_SIZE, Color::BLACK)
283 };
284 if let Some(theme) = theme {
285 style.family = theme.type_scale.body_medium.family.clone();
286 }
287 style
288}
289
290fn action_style(theme: Option<&Theme>) -> TextStyle {
293 let mut style = TextStyle {
294 weight: FontWeight::MEDIUM,
295 letter_spacing: ACTION_LETTER_SPACING,
296 line_height: LineHeight::Absolute(ACTION_LINE_HEIGHT),
297 ..TextStyle::new(ACTION_SIZE, Color::BLACK)
298 };
299 if let Some(theme) = theme {
300 style.family = theme.type_scale.label_large.family.clone();
301 }
302 style
303}
304
305struct TextRun {
313 content: String,
314 style: TextStyle,
316 layout: Option<TextLayout>,
317}
318
319impl TextRun {
320 fn new() -> Self {
321 Self {
322 content: String::new(),
323 style: TextStyle::default(),
324 layout: None,
325 }
326 }
327
328 fn set_content(&mut self, content: &str) {
329 if self.content != content {
330 self.content = content.to_string();
331 self.layout = None;
332 }
333 }
334
335 fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
338 if self.style != *style {
339 self.style = style.clone();
340 self.layout = None;
341 }
342 if let Some(layout) = &self.layout {
343 return layout.size();
344 }
345 let text_ctx = ctx.text_context::<TextContext>();
346 let laid = text_ctx.layout(&self.content, &self.style, None);
347 let size = laid.size();
348 self.layout = Some(laid);
349 size
350 }
351
352 fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
353 let Some(layout) = &self.layout else {
354 return;
355 };
356 for mut run in layout.to_scene_runs(origin) {
357 run.brush = Brush::Solid(color);
358 scene.draw_glyph_run(run);
359 }
360 }
361}
362
363type OnAction<State> = Rc<dyn Fn(&mut State)>;
368
369pub struct SnackbarMessage<State: 'static> {
372 message: String,
373 action_label: Option<String>,
374 on_action: Option<OnAction<State>>,
375 duration: Duration,
376 dismissible: bool,
377}
378
379pub fn snackbar<State: 'static>(message: impl Into<String>) -> SnackbarMessage<State> {
384 SnackbarMessage {
385 message: message.into(),
386 action_label: None,
387 on_action: None,
388 duration: DEFAULT_DURATION,
389 dismissible: false,
390 }
391}
392
393impl<State: 'static> SnackbarMessage<State> {
394 pub fn action<F: Fn(&mut State) + 'static>(
404 mut self,
405 label: impl Into<String>,
406 on_action: F,
407 ) -> Self {
408 self.action_label = Some(label.into());
409 self.on_action = Some(Rc::new(on_action));
410 self
411 }
412
413 pub fn duration(mut self, duration: Duration) -> Self {
416 self.duration = duration;
417 self
418 }
419
420 pub fn dismissible(mut self) -> Self {
424 self.dismissible = true;
425 self
426 }
427}
428
429pub struct SnackbarController<State: 'static> {
437 pending: Rc<RefCell<Option<SnackbarMessage<State>>>>,
438}
439
440impl<State: 'static> Clone for SnackbarController<State> {
441 fn clone(&self) -> Self {
442 Self {
443 pending: Rc::clone(&self.pending),
444 }
445 }
446}
447
448impl<State: 'static> Default for SnackbarController<State> {
449 fn default() -> Self {
450 Self::new()
451 }
452}
453
454impl<State: 'static> SnackbarController<State> {
455 pub fn new() -> Self {
457 Self {
458 pending: Rc::new(RefCell::new(None)),
459 }
460 }
461
462 pub fn show(&self, message: SnackbarMessage<State>) {
466 *self.pending.borrow_mut() = Some(message);
467 }
468
469 fn take_pending(&self) -> Option<SnackbarMessage<State>> {
476 self.pending.borrow_mut().take()
477 }
478}
479
480struct ErasedSnackbar {
487 message: String,
488 action_label: Option<String>,
489 on_action: Option<ErasedCallback>,
490 duration: Duration,
491 dismissible: bool,
492}
493
494fn erase<State: 'static>(msg: SnackbarMessage<State>) -> ErasedSnackbar {
495 ErasedSnackbar {
496 message: msg.message,
497 action_label: msg.action_label,
498 on_action: msg.on_action.map(|f| erase_callback(&f)),
499 duration: msg.duration,
500 dismissible: msg.dismissible,
501 }
502}
503
504pub struct SnackbarHostView<State: 'static> {
508 controller: SnackbarController<State>,
509 app: AnyView<State>,
510}
511
512pub fn snackbar_host<State: 'static, V: View<State>>(
515 controller: &SnackbarController<State>,
516 app: V,
517) -> SnackbarHostView<State> {
518 SnackbarHostView {
519 controller: controller.clone(),
520 app: any(app),
521 }
522}
523
524impl<State: 'static> View<State> for SnackbarHostView<State> {
525 type Element = SnackbarHostWidget;
526
527 fn build(&self, ctx: &mut BuildCtx<'_>) -> SnackbarHostWidget {
528 let mut widget = SnackbarHostWidget {
529 app: frust::authoring::build_child(&self.app, ctx),
530 current: None,
531 };
532 if let Some(msg) = self.controller.take_pending() {
533 widget.begin_show(erase(msg));
534 }
535 widget
536 }
537
538 fn rebuild(
539 &self,
540 prev: &Self,
541 element: &mut SnackbarHostWidget,
542 ctx: &mut BuildCtx<'_>,
543 ) -> ChangeFlags {
544 let mut flags =
545 frust::authoring::rebuild_child(&prev.app, &self.app, &mut element.app, ctx);
546 if element.current.is_none()
547 && let Some(msg) = self.controller.take_pending()
548 {
549 element.begin_show(erase(msg));
550 flags |= ChangeFlags::LAYOUT;
551 }
552 flags
553 }
554
555 fn teardown(&self, element: &mut SnackbarHostWidget, ctx: &mut BuildCtx<'_>) {
556 frust::authoring::teardown_child(&self.app, &mut element.app, ctx);
557 }
558}
559
560#[derive(Clone, Copy, Debug, PartialEq, Eq)]
562enum BarRegion {
563 Action,
564 Close,
565 Background,
569}
570
571struct ActiveSnackbar {
575 message: String,
576 message_run: TextRun,
577 action_label: Option<String>,
578 action_run: Option<TextRun>,
579 on_action: Option<ErasedCallback>,
580 dismissible: bool,
581 hold: AnimationController,
582
583 progress: f64,
588 ramp_from: f64,
589 ramp_to: f64,
590 anim: AnimationController,
593 exiting: bool,
594
595 captured_region: Option<BarRegion>,
596 action_pressed: bool,
597 close_pressed: bool,
598
599 bar_rect: Rect,
601 message_origin: Point,
602 action_rect: Option<Rect>,
604 close_rect: Option<Rect>,
606}
607
608impl ActiveSnackbar {
609 fn new(msg: ErasedSnackbar) -> Self {
610 let mut message_run = TextRun::new();
611 message_run.set_content(&msg.message);
612 let mut action_run = msg.action_label.as_ref().map(|_| TextRun::new());
613 if let (Some(run), Some(label)) = (action_run.as_mut(), msg.action_label.as_ref()) {
614 run.set_content(label);
615 }
616 let mut hold = AnimationController::new(msg.duration);
617 hold.forward();
618 let mut active = Self {
619 message: msg.message,
620 message_run,
621 action_label: msg.action_label,
622 action_run,
623 on_action: msg.on_action,
624 dismissible: msg.dismissible,
625 hold,
626 progress: 0.0,
627 ramp_from: 0.0,
628 ramp_to: 0.0,
629 anim: AnimationController::new(ENTER_DURATION),
630 exiting: false,
631 captured_region: None,
632 action_pressed: false,
633 close_pressed: false,
634 bar_rect: Rect::ZERO,
635 message_origin: Point::ZERO,
636 action_rect: None,
637 close_rect: None,
638 };
639 active.begin_ramp(1.0, false, ENTER_DURATION);
640 active
641 }
642
643 fn begin_ramp(&mut self, to: f64, exiting: bool, duration: Duration) {
648 self.ramp_from = self.progress;
649 self.ramp_to = to;
650 self.exiting = exiting;
651 self.anim = AnimationController::new(duration).with_curve(MaterialMotion::EMPHASIZED);
652 self.anim.forward();
653 }
654
655 fn ramp_value(&self) -> f64 {
656 self.ramp_from + (self.ramp_to - self.ramp_from) * self.anim.value_clamped()
657 }
658
659 fn advance(&mut self, now: frust::FrameTime) -> bool {
664 let mut needs_frame = false;
665 if self.anim.is_animating() {
666 if self.anim.advance(now) {
667 needs_frame = true;
668 }
669 self.progress = self.ramp_value();
670 }
671 if !self.exiting {
672 if self.hold.advance(now) {
673 needs_frame = true;
674 } else {
675 self.begin_ramp(0.0, true, EXIT_DURATION);
676 needs_frame = true;
677 }
678 }
679 needs_frame
680 }
681
682 fn settled_exit(&self) -> bool {
684 self.exiting && !self.anim.is_animating() && self.progress <= PROGRESS_EPSILON
685 }
686
687 fn fire_action(&mut self, ctx: &mut EventCtx) {
691 if let Some(cb) = self.on_action.as_mut() {
692 cb(ctx);
693 }
694 }
695
696 fn visual_dy(&self) -> f64 {
703 let hidden_offset = self.bar_rect.height() + BOTTOM_INSET + HIDDEN_MARGIN;
704 hidden_offset * (1.0 - self.progress.clamp(0.0, 1.0))
705 }
706
707 fn visual_bar_rect(&self) -> Rect {
716 self.bar_rect + Vec2::new(0.0, self.visual_dy())
717 }
718}
719
720pub struct SnackbarHostWidget {
722 app: ChildPod,
723 current: Option<ActiveSnackbar>,
724}
725
726impl SnackbarHostWidget {
727 fn begin_show(&mut self, msg: ErasedSnackbar) {
728 self.current = Some(ActiveSnackbar::new(msg));
729 }
730}
731
732fn finite_or_zero(v: f64) -> f64 {
733 if v.is_finite() { v } else { 0.0 }
734}
735
736impl Widget for SnackbarHostWidget {
737 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
738 let area = Size::new(
739 finite_or_zero(bc.max().width),
740 finite_or_zero(bc.max().height),
741 );
742 self.app.layout_child(ctx, &BoxConstraints::tight(area));
743 self.app.set_origin(Point::ZERO);
744
745 if let Some(active) = &mut self.current {
746 let theme = Theme::from_layout_ctx(ctx);
747 let (message_style, action_style) = (message_style(theme), action_style(theme));
748 let message_size = active.message_run.shape(ctx, &message_style);
749 let action_content = active.action_run.as_mut().map(|run| {
750 let size = run.shape(ctx, &action_style);
751 Size::new(
752 size.width + ACTION_PAD_H * 2.0,
753 size.height + ACTION_PAD_V * 2.0,
754 )
755 });
756 let close_content = active
757 .dismissible
758 .then_some(Size::new(CLOSE_ICON_SIZE, CLOSE_ICON_SIZE));
759
760 let available = (area.width - 2.0 * H_INSET).max(0.0);
761 let bar_width = available.min(MAX_WIDTH);
762
763 let mut content_height = message_size.height;
764 if let Some(a) = action_content {
765 content_height = content_height.max(a.height);
766 }
767 if let Some(c) = close_content {
768 content_height = content_height.max(c.height);
769 }
770 let bar_height = (content_height + CONTENT_PAD_V * 2.0).max(MIN_HEIGHT);
771
772 let mut right_x = bar_width - CONTENT_PAD_H;
773 active.close_rect = close_content.map(|c| {
774 right_x -= c.width;
775 let y = (bar_height - c.height) / 2.0;
776 let rect = Rect::new(right_x, y, right_x + c.width, y + c.height);
777 right_x -= CLOSE_GAP;
778 rect
779 });
780 active.action_rect = action_content.map(|a| {
781 right_x -= a.width;
782 let y = (bar_height - a.height) / 2.0;
783 let rect = Rect::new(right_x, y, right_x + a.width, y + a.height);
784 right_x -= ACTION_GAP;
785 rect
786 });
787
788 let message_y = (bar_height - message_size.height) / 2.0;
789 active.message_origin = Point::new(CONTENT_PAD_H, message_y);
790
791 let bar_size = Size::new(bar_width, bar_height);
792 let bar_x = (area.width - bar_width) / 2.0;
793 let bar_y = area.height - BOTTOM_INSET - bar_height;
794 active.bar_rect = Rect::from_origin_size(Point::new(bar_x, bar_y), bar_size);
795 }
796
797 bc.constrain(area)
798 }
799
800 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
801 self.app.paint_child(ctx, scene);
802
803 let now = ctx.frame_time();
804 let Some(active) = self.current.as_mut() else {
805 return;
806 };
807
808 if active.advance(now) {
809 ctx.request_frame();
810 }
811 if active.settled_exit() {
812 self.current = None;
813 ctx.request_frame();
817 return;
818 }
819
820 let active = self.current.as_mut().expect("checked above");
821 let progress = active.progress.clamp(0.0, 1.0);
822 if progress <= 0.0 {
823 return;
824 }
825
826 let theme = Theme::from_paint_ctx(ctx);
827 let bar_size = active.bar_rect.size();
828 let origin = ctx.origin() + active.visual_bar_rect().origin().to_vec2();
829
830 let radius = theme.map_or(FALLBACK_RADIUS, |t| t.shape.extra_small);
831 let (blur, y_off, shadow_color) = theme.map_or(
832 (
833 FALLBACK_SHADOW_BLUR,
834 FALLBACK_SHADOW_Y,
835 with_alpha(Color::BLACK, FALLBACK_SHADOW_ALPHA),
836 ),
837 |t| {
838 let spec = t.elevation.level3.shadow(t.brightness);
839 (
840 spec.blur_std_dev,
841 spec.y_offset,
842 with_alpha(t.scheme().shadow, spec.color_alpha),
843 )
844 },
845 );
846 let container = theme.map_or(FALLBACK_CONTAINER, |t| t.scheme().inverse_surface);
847 let on_container = theme.map_or(FALLBACK_ON_CONTAINER, |t| t.scheme().inverse_on_surface);
848 let action_ink = theme.map_or(FALLBACK_ACTION, |t| t.scheme().inverse_primary);
849
850 let layer_rect =
851 Rect::from_origin_size(origin, bar_size).inflate(SHADOW_SPILL, SHADOW_SPILL);
852 scene.push_layer(layer_rect.origin(), layer_rect.size(), progress as f32);
853
854 scene.draw_shadow(
855 Point::new(origin.x, origin.y + y_off),
856 bar_size,
857 radius,
858 blur,
859 shadow_color,
860 );
861 scene.fill_rounded_rect(origin, bar_size, radius, container);
862
863 scene.push_clip_rounded(origin, bar_size, radius);
864 active.message_run.paint(
865 origin + active.message_origin.to_vec2(),
866 on_container,
867 scene,
868 );
869
870 if let Some(rect) = active.action_rect {
871 if active.action_pressed {
872 scene.fill_rounded_rect(
873 origin + rect.origin().to_vec2(),
874 rect.size(),
875 radius,
876 with_alpha(action_ink, PRESSED_OPACITY),
877 );
878 }
879 if let Some(run) = &active.action_run {
880 let text_origin =
881 origin + rect.origin().to_vec2() + Vec2::new(ACTION_PAD_H, ACTION_PAD_V);
882 run.paint(text_origin, action_ink, scene);
883 }
884 }
885
886 if let Some(rect) = active.close_rect {
887 if active.close_pressed {
888 scene.fill_rounded_rect(
889 origin + rect.origin().to_vec2(),
890 rect.size(),
891 rect.width() / 2.0,
892 with_alpha(on_container, PRESSED_OPACITY),
893 );
894 }
895 let (base_path, design) = IconData::from(icons::CLOSE).resolve();
896 let scale = if design > 0.0 {
897 CLOSE_ICON_SIZE / design
898 } else {
899 1.0
900 };
901 let scaled = Affine::scale(scale) * base_path;
902 let icon_origin = origin + rect.origin().to_vec2();
903 scene.fill_path(icon_origin, &scaled, &Brush::Solid(on_container));
904 }
905
906 scene.pop_clip();
907 scene.pop_layer();
908 }
909
910 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
911 if event.is_broadcast() || event.is_focus_routed() {
912 return frust::authoring::route_event_single(&mut self.app, ctx, event);
913 }
914 let InputEvent::Pointer(p) = event else {
915 return frust::authoring::route_event_single(&mut self.app, ctx, event);
916 };
917
918 if let Some(active) = self.current.as_mut()
919 && active.captured_region.is_some()
920 {
921 return handle_captured(active, ctx, p);
922 }
923 if self.app.is_active() {
924 return frust::authoring::route_event_single(&mut self.app, ctx, event);
925 }
926 if p.phase == PointerPhase::Down
927 && presses(p)
928 && let Some(active) = self.current.as_mut()
929 && active.visual_bar_rect().contains(p.position)
930 {
931 return begin_press(active, ctx, p.position);
932 }
933 frust::authoring::route_event_single(&mut self.app, ctx, event)
934 }
935
936 fn semantics(&self, ctx: &mut SemanticsCtx) {
937 self.app.semantics_child(ctx);
938 if let Some(active) = &self.current {
939 let message = active.message.clone();
940 let action_label = active.action_label.clone();
941 let dismissible = active.dismissible;
942 ctx.push_container(
943 Role::Alert,
944 move |node| node.set_label(message.as_str()),
945 move |ctx| {
946 if let Some(label) = &action_label {
947 ctx.push_node(Role::Button, |node| {
948 node.set_label(label.as_str());
949 node.add_action(Action::Click);
950 });
951 }
952 if dismissible {
953 ctx.push_node(Role::Button, |node| {
954 node.set_label("Dismiss");
955 node.add_action(Action::Click);
956 });
957 }
958 },
959 );
960 }
961 }
962
963 frust::authoring::visit_children!(app);
964}
965
966fn local_bar_rect(active: &ActiveSnackbar) -> Rect {
969 Rect::from_origin_size(Point::ZERO, active.bar_rect.size())
970}
971
972fn to_local(active: &ActiveSnackbar, position: Point) -> Point {
981 let rect = active.visual_bar_rect();
982 Point::new(position.x - rect.x0, position.y - rect.y0)
983}
984
985fn begin_press(active: &mut ActiveSnackbar, ctx: &mut EventCtx, position: Point) -> EventResult {
988 let local = to_local(active, position);
989 let region = if active.close_rect.is_some_and(|r| r.contains(local)) {
990 BarRegion::Close
991 } else if active.action_rect.is_some_and(|r| r.contains(local)) {
992 BarRegion::Action
993 } else {
994 BarRegion::Background
995 };
996 active.captured_region = Some(region);
997 match region {
998 BarRegion::Action => active.action_pressed = true,
999 BarRegion::Close => active.close_pressed = true,
1000 BarRegion::Background => {}
1001 }
1002 ctx.capture_pointer();
1003 ctx.request_redraw();
1004 EventResult::Handled
1005}
1006
1007fn handle_captured(
1011 active: &mut ActiveSnackbar,
1012 ctx: &mut EventCtx,
1013 p: &PointerEvent,
1014) -> EventResult {
1015 let region = active
1016 .captured_region
1017 .expect("handle_captured only runs while captured");
1018 let local = to_local(active, p.position);
1019 let target = match region {
1020 BarRegion::Action => active.action_rect,
1021 BarRegion::Close => active.close_rect,
1022 BarRegion::Background => Some(local_bar_rect(active)),
1023 };
1024 let over = target.is_some_and(|r| r.contains(local));
1025
1026 match p.phase {
1027 PointerPhase::Move => {
1028 match region {
1029 BarRegion::Action => {
1030 if active.action_pressed != over {
1031 active.action_pressed = over;
1032 ctx.request_redraw();
1033 }
1034 }
1035 BarRegion::Close => {
1036 if active.close_pressed != over {
1037 active.close_pressed = over;
1038 ctx.request_redraw();
1039 }
1040 }
1041 BarRegion::Background => {}
1042 }
1043 EventResult::Handled
1044 }
1045 PointerPhase::Up => {
1046 if over {
1047 match region {
1048 BarRegion::Action => {
1049 active.fire_action(ctx);
1050 active.begin_ramp(0.0, true, EXIT_DURATION);
1051 }
1052 BarRegion::Close => {
1053 active.begin_ramp(0.0, true, EXIT_DURATION);
1054 }
1055 BarRegion::Background => {}
1056 }
1057 }
1058 active.captured_region = None;
1059 active.action_pressed = false;
1060 active.close_pressed = false;
1061 ctx.request_redraw();
1062 EventResult::Handled
1063 }
1064 PointerPhase::Cancel => {
1065 active.captured_region = None;
1066 active.action_pressed = false;
1067 active.close_pressed = false;
1068 ctx.request_redraw();
1069 EventResult::Handled
1070 }
1071 PointerPhase::Down => EventResult::Handled,
1072 }
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077 use std::any::Any;
1078
1079 use frust::FrameTime;
1080 use frust::authoring::text::FontFamily;
1081 use frust::authoring::{Key, NamedKey, PointerButton};
1082 use frust_widgets::test_support::{RecordingScene, probe};
1083
1084 use super::*;
1085
1086 const WINDOW: Size = Size::new(400.0, 800.0);
1087
1088 fn build<S: 'static>(view: &SnackbarHostView<S>) -> SnackbarHostWidget {
1089 let mut counter = 0u64;
1090 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1091 }
1092
1093 fn layout(w: &mut SnackbarHostWidget) -> Size {
1094 let mut tcx = TextContext::new();
1095 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1096 w.layout(&mut lctx, &BoxConstraints::tight(WINDOW))
1097 }
1098
1099 fn ft_ms(ms: f64) -> FrameTime {
1100 FrameTime::from_nanos((ms * 1_000_000.0) as u64)
1101 }
1102
1103 fn frame(w: &mut SnackbarHostWidget, ms: f64) -> RecordingScene {
1104 let mut rec = RecordingScene::default();
1105 let mut ctx = PaintCtx::for_test(Point::ORIGIN, WINDOW, ft_ms(ms));
1106 w.paint(&mut ctx, &mut rec);
1107 rec
1108 }
1109
1110 fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1111 InputEvent::Pointer(PointerEvent {
1112 phase,
1113 position: Point::new(x, y),
1114 button: PointerButton::Primary,
1115 })
1116 }
1117
1118 fn dispatch<S: 'static>(
1119 w: &mut SnackbarHostWidget,
1120 state: &mut S,
1121 event: &InputEvent,
1122 ) -> EventResult {
1123 let state_any: &mut dyn Any = state;
1124 let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
1125 w.event(&mut ctx, event)
1126 }
1127
1128 #[test]
1131 fn show_before_build_is_visible_at_construction() {
1132 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1133 controller.show(snackbar("hi"));
1134 let view = snackbar_host(&controller, probe(0).into_any());
1135 let w = build(&view);
1136 assert!(w.current.is_some());
1137 assert_eq!(w.current.as_ref().unwrap().message, "hi");
1138 assert_eq!(w.current.as_ref().unwrap().progress, 0.0, "not yet ramped");
1139 }
1140
1141 #[test]
1142 fn the_entrance_ramp_reaches_full_progress_within_its_own_duration() {
1143 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1144 controller.show(snackbar("hi"));
1145 let view = snackbar_host(&controller, probe(0).into_any());
1146 let mut w = build(&view);
1147 layout(&mut w);
1148
1149 frame(&mut w, 0.0);
1150 frame(&mut w, 350.0); assert_eq!(w.current.as_ref().unwrap().progress, 1.0);
1153 }
1154
1155 #[test]
1156 fn with_no_interaction_the_message_auto_dismisses_after_its_hold_duration() {
1157 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1158 controller.show(snackbar("hi").duration(Duration::from_millis(1000)));
1159 let view = snackbar_host(&controller, probe(0).into_any());
1160 let mut w = build(&view);
1161 layout(&mut w);
1162
1163 frame(&mut w, 0.0);
1164 frame(&mut w, 350.0);
1165 assert_eq!(w.current.as_ref().unwrap().progress, 1.0, "fully shown");
1166
1167 frame(&mut w, 1100.0);
1169 assert!(w.current.as_ref().unwrap().exiting, "hold expired");
1170
1171 frame(&mut w, 1150.0);
1173 frame(&mut w, 1400.0);
1174
1175 assert!(w.current.is_none(), "settled exit drops the message");
1176 }
1177
1178 #[test]
1179 fn an_action_press_fires_the_callback_and_dismisses_the_message() {
1180 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1181 controller.show(snackbar("Saved").action("Undo", |s: &mut Vec<u32>| s.push(99)));
1182 let view = snackbar_host(&controller, probe(0).into_any());
1183 let mut w = build(&view);
1184 layout(&mut w);
1185 frame(&mut w, 0.0);
1186 frame(&mut w, 350.0); let action_rect = w.current.as_ref().unwrap().action_rect.unwrap();
1189 let bar_origin = w.current.as_ref().unwrap().bar_rect.origin();
1190 let press_at = bar_origin + action_rect.center().to_vec2();
1191
1192 let mut state: Vec<u32> = Vec::new();
1193 dispatch(
1194 &mut w,
1195 &mut state,
1196 &pointer(PointerPhase::Down, press_at.x, press_at.y),
1197 );
1198 assert_eq!(
1199 dispatch(
1200 &mut w,
1201 &mut state,
1202 &pointer(PointerPhase::Up, press_at.x, press_at.y)
1203 ),
1204 EventResult::Handled
1205 );
1206
1207 assert_eq!(state, vec![99], "on_action fired exactly once");
1208 assert!(
1209 w.current.as_ref().unwrap().exiting,
1210 "action press dismisses"
1211 );
1212
1213 frame(&mut w, 380.0); frame(&mut w, 650.0); assert!(w.current.is_none());
1216 }
1217
1218 #[test]
1219 fn a_close_press_dismisses_without_firing_on_action() {
1220 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1221 controller.show(
1222 snackbar("Saved")
1223 .action("Undo", |s: &mut Vec<u32>| s.push(99))
1224 .dismissible(),
1225 );
1226 let view = snackbar_host(&controller, probe(0).into_any());
1227 let mut w = build(&view);
1228 layout(&mut w);
1229 frame(&mut w, 0.0);
1230 frame(&mut w, 350.0);
1231
1232 let close_rect = w.current.as_ref().unwrap().close_rect.unwrap();
1233 let bar_origin = w.current.as_ref().unwrap().bar_rect.origin();
1234 let press_at = bar_origin + close_rect.center().to_vec2();
1235
1236 let mut state: Vec<u32> = Vec::new();
1237 dispatch(
1238 &mut w,
1239 &mut state,
1240 &pointer(PointerPhase::Down, press_at.x, press_at.y),
1241 );
1242 dispatch(
1243 &mut w,
1244 &mut state,
1245 &pointer(PointerPhase::Up, press_at.x, press_at.y),
1246 );
1247
1248 assert!(state.is_empty(), "close never fires on_action");
1249 assert!(w.current.as_ref().unwrap().exiting);
1250 }
1251
1252 #[test]
1253 fn a_cancel_while_captured_clears_press_state_without_touching_state() {
1254 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1255 controller.show(snackbar("Saved").action("Undo", |s: &mut Vec<u32>| s.push(99)));
1256 let view = snackbar_host(&controller, probe(0).into_any());
1257 let mut w = build(&view);
1258 layout(&mut w);
1259 frame(&mut w, 0.0);
1260 frame(&mut w, 350.0);
1261
1262 let action_rect = w.current.as_ref().unwrap().action_rect.unwrap();
1263 let bar_origin = w.current.as_ref().unwrap().bar_rect.origin();
1264 let press_at = bar_origin + action_rect.center().to_vec2();
1265
1266 dispatch(
1270 &mut w,
1271 &mut Vec::<u32>::new(),
1272 &pointer(PointerPhase::Down, press_at.x, press_at.y),
1273 );
1274 let mut unit_state = ();
1275 let state_any: &mut dyn Any = &mut unit_state;
1276 let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
1277 w.event(
1278 &mut ctx,
1279 &pointer(PointerPhase::Cancel, press_at.x, press_at.y),
1280 );
1281
1282 assert!(
1283 !w.current.as_ref().unwrap().exiting,
1284 "a Cancel never dismisses"
1285 );
1286 assert!(w.current.as_ref().unwrap().captured_region.is_none());
1287 }
1288
1289 #[test]
1292 fn a_second_show_before_the_first_drains_replaces_it() {
1293 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1294 controller.show(snackbar("first"));
1295 controller.show(snackbar("second"));
1296 let view = snackbar_host(&controller, probe(0).into_any());
1297 let w = build(&view);
1298 assert_eq!(w.current.as_ref().unwrap().message, "second");
1299 }
1300
1301 #[test]
1302 fn a_show_while_one_is_current_waits_for_it_to_fully_exit() {
1303 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1304 controller.show(snackbar("first").duration(Duration::from_millis(100)));
1305 let view1 = snackbar_host(&controller, probe(0).into_any());
1306 let mut w = build(&view1);
1307 layout(&mut w);
1308 assert_eq!(w.current.as_ref().unwrap().message, "first");
1309
1310 controller.show(snackbar("second"));
1312 let view2 = snackbar_host(&controller, probe(0).into_any());
1313 let mut counter = 0u64;
1314 view2.rebuild(&view1, &mut w, &mut BuildCtx::new(&mut counter));
1315 assert_eq!(
1316 w.current.as_ref().unwrap().message,
1317 "first",
1318 "a pending show must not interrupt the visible message"
1319 );
1320
1321 frame(&mut w, 0.0);
1324 frame(&mut w, 350.0); frame(&mut w, 500.0); assert!(w.current.as_ref().unwrap().exiting);
1327 frame(&mut w, 550.0); frame(&mut w, 800.0); assert!(w.current.is_none());
1330
1331 let view3 = snackbar_host(&controller, probe(0).into_any());
1333 view3.rebuild(&view2, &mut w, &mut BuildCtx::new(&mut counter));
1334 assert_eq!(w.current.as_ref().unwrap().message, "second");
1335 }
1336
1337 #[test]
1340 fn a_press_outside_the_bar_still_reaches_app_content_beneath_it() {
1341 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1342 controller.show(snackbar("hi"));
1343 let view = snackbar_host(&controller, probe(7).into_any());
1344 let mut w = build(&view);
1345 layout(&mut w);
1346
1347 let mut state: Vec<u32> = Vec::new();
1349 let result = dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
1350 assert_eq!(result, EventResult::Handled);
1351 assert_eq!(
1352 state,
1353 vec![7],
1354 "app content beneath the bar still receives input"
1355 );
1356 }
1357
1358 #[test]
1359 fn a_press_on_the_bar_s_own_background_is_swallowed_not_forwarded_to_app() {
1360 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1361 controller.show(snackbar("a message with no action"));
1362 let view = snackbar_host(&controller, probe(7).into_any());
1363 let mut w = build(&view);
1364 layout(&mut w);
1365 frame(&mut w, 0.0);
1371 frame(&mut w, 350.0); let bar_rect = w.current.as_ref().unwrap().bar_rect;
1374 let press_at = Point::new(bar_rect.center().x, bar_rect.center().y);
1375
1376 let mut state: Vec<u32> = Vec::new();
1377 dispatch(
1378 &mut w,
1379 &mut state,
1380 &pointer(PointerPhase::Down, press_at.x, press_at.y),
1381 );
1382 dispatch(
1383 &mut w,
1384 &mut state,
1385 &pointer(PointerPhase::Up, press_at.x, press_at.y),
1386 );
1387
1388 assert!(
1389 state.is_empty(),
1390 "the bar's own background swallows the press"
1391 );
1392 assert!(!w.current.as_ref().unwrap().exiting, "and does not dismiss");
1393 }
1394
1395 #[test]
1396 fn a_focus_routed_key_event_is_routed_by_focus_not_claimed_by_the_bar() {
1397 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1404 controller.show(snackbar("hi"));
1405 let view = snackbar_host(&controller, probe(3).into_any());
1406 let mut w = build(&view);
1407 layout(&mut w);
1408
1409 let mut state: Vec<u32> = Vec::new();
1410 let key = InputEvent::Key(frust::authoring::KeyEvent {
1411 key: Key::Named(NamedKey::Escape),
1412 modifiers: Default::default(),
1413 repeat: false,
1414 });
1415 let result = dispatch(&mut w, &mut state, &key);
1416 assert_eq!(result, EventResult::Ignored);
1417 assert!(state.is_empty(), "nothing was focused, so nothing fired");
1418 }
1419
1420 fn miss_threshold(bar_rect: Rect) -> f64 {
1427 1.0 - (bar_rect.height() / 2.0) / (bar_rect.height() + BOTTOM_INSET + HIDDEN_MARGIN)
1428 }
1429
1430 #[test]
1431 fn a_press_at_the_resting_rect_mid_entrance_is_not_captured_and_reaches_app_content() {
1432 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1433 controller.show(snackbar("hi"));
1434 let view = snackbar_host(&controller, probe(7).into_any());
1435 let mut w = build(&view);
1436 layout(&mut w);
1437
1438 let bar_rect = w.current.as_ref().unwrap().bar_rect;
1439 let threshold = miss_threshold(bar_rect);
1440
1441 frame(&mut w, 0.0);
1445 frame(&mut w, 60.0);
1446 let progress = w.current.as_ref().unwrap().progress;
1447 assert!(
1448 progress < threshold,
1449 "sanity: still well into the entrance ramp (progress={progress}, threshold={threshold})"
1450 );
1451
1452 let press_at = bar_rect.center();
1453 let mut state: Vec<u32> = Vec::new();
1454 let result = dispatch(
1455 &mut w,
1456 &mut state,
1457 &pointer(PointerPhase::Down, press_at.x, press_at.y),
1458 );
1459
1460 assert_eq!(
1461 result,
1462 EventResult::Handled,
1463 "the press falls through to the probe beneath, which claims it"
1464 );
1465 assert_eq!(
1466 state,
1467 vec![7],
1468 "a press at the resting rect mid-entrance reaches app content, not the bar"
1469 );
1470 assert!(
1471 w.current.as_ref().unwrap().captured_region.is_none(),
1472 "the bar must not capture a press landing on its future, not current, position"
1473 );
1474 assert!(
1475 !w.current.as_ref().unwrap().action_pressed,
1476 "no sub-region latched a press either"
1477 );
1478 }
1479
1480 #[test]
1481 fn a_press_at_the_resting_rect_during_the_exit_ramp_is_not_captured_and_reaches_app_content() {
1482 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1483 controller.show(snackbar("hi").dismissible());
1484 let view = snackbar_host(&controller, probe(7).into_any());
1485 let mut w = build(&view);
1486 layout(&mut w);
1487 frame(&mut w, 0.0);
1488 frame(&mut w, 350.0); let bar_rect = w.current.as_ref().unwrap().bar_rect;
1491 let close_rect = w.current.as_ref().unwrap().close_rect.unwrap();
1492 let close_at = bar_rect.origin() + close_rect.center().to_vec2();
1493
1494 let mut state: Vec<u32> = Vec::new();
1495 dispatch(
1496 &mut w,
1497 &mut state,
1498 &pointer(PointerPhase::Down, close_at.x, close_at.y),
1499 );
1500 dispatch(
1501 &mut w,
1502 &mut state,
1503 &pointer(PointerPhase::Up, close_at.x, close_at.y),
1504 );
1505 assert!(
1506 w.current.as_ref().unwrap().exiting,
1507 "the close press starts the exit ramp"
1508 );
1509
1510 frame(&mut w, 380.0);
1513 frame(&mut w, 450.0);
1514 let progress = w.current.as_ref().unwrap().progress;
1515 let threshold = miss_threshold(bar_rect);
1516 assert!(
1517 progress < threshold,
1518 "sanity: still well into the exit ramp (progress={progress}, threshold={threshold})"
1519 );
1520
1521 let press_at = bar_rect.center();
1522 let result = dispatch(
1523 &mut w,
1524 &mut state,
1525 &pointer(PointerPhase::Down, press_at.x, press_at.y),
1526 );
1527
1528 assert_eq!(
1529 result,
1530 EventResult::Handled,
1531 "the press falls through to the probe beneath, which claims it"
1532 );
1533 assert_eq!(
1534 state,
1535 vec![7],
1536 "a press at the resting rect during the exit ramp reaches app content, not the bar"
1537 );
1538 assert!(w.current.as_ref().unwrap().captured_region.is_none());
1539 }
1540
1541 #[test]
1542 fn a_settled_press_still_hits_the_bar_action_and_close_as_before() {
1543 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1550 controller.show(snackbar("hi").dismissible());
1551 let view = snackbar_host(&controller, probe(7).into_any());
1552 let mut w = build(&view);
1553 layout(&mut w);
1554 frame(&mut w, 0.0);
1555 frame(&mut w, 350.0); let active = w.current.as_ref().unwrap();
1558 assert_eq!(active.progress, 1.0);
1559 assert_eq!(
1560 active.visual_bar_rect(),
1561 active.bar_rect,
1562 "at rest, the visual and resting rects coincide"
1563 );
1564 }
1565
1566 #[test]
1569 fn phone_width_is_full_width_minus_margins() {
1570 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1571 controller.show(snackbar("hi"));
1572 let view = snackbar_host(&controller, probe(0).into_any());
1573 let mut w = build(&view);
1574 let mut tcx = TextContext::new();
1575 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1576 w.layout(&mut lctx, &BoxConstraints::tight(Size::new(360.0, 800.0)));
1577 assert_eq!(
1578 w.current.as_ref().unwrap().bar_rect.width(),
1579 360.0 - 2.0 * H_INSET
1580 );
1581 }
1582
1583 fn host_with_action() -> SnackbarHostWidget {
1587 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1588 controller.show(snackbar("Saved").action("Undo", |s: &mut Vec<u32>| s.push(99)));
1589 build(&snackbar_host(&controller, probe(0).into_any()))
1590 }
1591
1592 fn theme_with(message: FontFamily, action: FontFamily) -> Theme {
1595 let mut theme = crate::baseline();
1596 theme.type_scale.body_medium.family = message;
1597 theme.type_scale.label_large.family = action;
1598 theme
1599 }
1600
1601 fn layout_themed(w: &mut SnackbarHostWidget, tcx: &mut TextContext, theme: Option<&Theme>) {
1604 let mut ctx =
1605 LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1606 w.layout(&mut ctx, &BoxConstraints::tight(WINDOW));
1607 }
1608
1609 #[test]
1610 fn layout_takes_the_message_and_action_families_from_their_roles() {
1611 let mut w = host_with_action();
1612 let mut tcx = TextContext::new();
1613 let (message, action) = (
1614 FontFamily::named("Message Role Probe"),
1615 FontFamily::named("Action Role Probe"),
1616 );
1617 layout_themed(
1618 &mut w,
1619 &mut tcx,
1620 Some(&theme_with(message.clone(), action.clone())),
1621 );
1622 let active = w.current.as_ref().expect("a message is showing");
1623 assert_eq!(active.message_run.style.family, message, "bodyMedium");
1624 let action_run = active.action_run.as_ref().expect("an action is set");
1625 assert_eq!(action_run.style.family, action, "labelLarge");
1626 assert_eq!(active.message_run.style.size, MESSAGE_SIZE);
1628 assert_eq!(action_run.style.size, ACTION_SIZE);
1629 }
1630
1631 #[test]
1632 fn without_a_theme_the_runs_keep_the_unthemed_styles() {
1633 assert_eq!(
1634 message_style(None),
1635 TextStyle {
1636 weight: FontWeight::REGULAR,
1637 letter_spacing: MESSAGE_LETTER_SPACING,
1638 line_height: LineHeight::Absolute(MESSAGE_LINE_HEIGHT),
1639 ..TextStyle::new(MESSAGE_SIZE, Color::BLACK)
1640 }
1641 );
1642 assert_eq!(
1643 action_style(None),
1644 TextStyle {
1645 weight: FontWeight::MEDIUM,
1646 letter_spacing: ACTION_LETTER_SPACING,
1647 line_height: LineHeight::Absolute(ACTION_LINE_HEIGHT),
1648 ..TextStyle::new(ACTION_SIZE, Color::BLACK)
1649 }
1650 );
1651
1652 let mut w = host_with_action();
1653 let mut tcx = TextContext::new();
1654 layout_themed(&mut w, &mut tcx, None);
1655 let active = w.current.as_ref().expect("a message is showing");
1656 assert_eq!(active.message_run.style, message_style(None));
1657 let action_run = active.action_run.as_ref().expect("an action is set");
1658 assert_eq!(action_run.style, action_style(None));
1659 }
1660
1661 #[test]
1662 fn a_theme_swap_reshapes_the_cached_runs() {
1663 let mut w = host_with_action();
1664 let mut tcx = TextContext::new();
1665 let probe_a = || FontFamily::named("Snackbar Swap Probe A");
1666 let probe_b = || FontFamily::named("Snackbar Swap Probe B");
1667 let first = theme_with(probe_a(), probe_a());
1668 layout_themed(&mut w, &mut tcx, Some(&first));
1669
1670 let settled = tcx.shape_cache_stats();
1672 layout_themed(&mut w, &mut tcx, Some(&first));
1673 assert_eq!(
1674 tcx.shape_cache_stats(),
1675 settled,
1676 "an unchanged theme reshapes nothing"
1677 );
1678
1679 layout_themed(&mut w, &mut tcx, Some(&theme_with(probe_b(), probe_b())));
1680 assert_eq!(
1681 tcx.shape_cache_stats().shapes,
1682 settled.shapes + 2,
1683 "a family swap must reshape the message and the action"
1684 );
1685 }
1686
1687 #[test]
1688 fn wide_viewport_caps_at_max_width_and_centers() {
1689 let controller: SnackbarController<Vec<u32>> = SnackbarController::new();
1690 controller.show(snackbar("hi"));
1691 let view = snackbar_host(&controller, probe(0).into_any());
1692 let mut w = build(&view);
1693 let mut tcx = TextContext::new();
1694 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1695 w.layout(&mut lctx, &BoxConstraints::tight(Size::new(1200.0, 800.0)));
1696 let bar = w.current.as_ref().unwrap().bar_rect;
1697 assert_eq!(bar.width(), MAX_WIDTH);
1698 assert!(
1699 (bar.center().x - 600.0).abs() < 1e-9,
1700 "centered, not edge-pinned"
1701 );
1702 }
1703}