frus-widgets 0.5.0

frus — declarative widget tree
Documentation
//! [`ScaffoldMessenger`]: the **notification layer** — places and stacks [`crate::SnackBar`]s
//! in a corner of the screen, with an optional **entry transition**.
//!
//! Put it as the last layer of a [`crate::Stack`], above the interface. The widget fills
//! the available surface and aligns its toasts in the chosen corner ([`SnackBarPosition`]);
//! several toasts **stack** in a column. `fade_in` wraps each toast in an animated
//! opacity (the existing animation layer, [`crate::AnimatedOpacity`]) for a fade-in.
//!
//! The **content** (which toast(s) to show, their queue and auto-dismiss) stays driven
//! by the application through [`crate::SnackBarQueue`]: `ScaffoldMessenger` only places them.

use frus_core::{Curve, Insets, Rect, Scene};
use frus_layout::{Align, FlexDirection, Justify, Style};

use crate::animated::AnimatedOpacity;
use crate::interaction::Status;
use crate::theme::Theme;
use crate::widget::Widget;

/// Default margin between the toasts and the edges: **none**.
///
/// What a notification keeps clear of the page belongs to the notification, which is
/// where the reference keeps it (`snack_bar.dart:823`, `:989`) — a fixed bar keeps
/// nothing clear and a floating one keeps 15 and 10, and neither number is the host's to
/// decide. This used to impose 16 on everything it held, which a floating bar then added
/// its own margin to. [`ScaffoldMessenger::padding`] is still there for a caller who
/// wants a layer of their own arrangement.
const HOST_PAD: f32 = 0.0;
/// Vertical gap between stacked toasts.
const STACK_GAP: f32 = 8.0;

/// The corner the notifications are anchored to.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SnackBarPosition {
    /// The top corner at the start of the line: top left in a left-to-right layout.
    TopStart,
    /// The middle of the top edge.
    TopCenter,
    /// The top corner at the end of the line: top right in a left-to-right layout.
    TopEnd,
    /// The bottom corner at the start of the line: bottom left in a left-to-right layout.
    BottomStart,
    /// The middle of the bottom edge.
    BottomCenter,
    /// The bottom corner at the end of the line: bottom right in a left-to-right layout.
    BottomEnd,
}

impl SnackBarPosition {
    /// Vertical alignment (the column's main axis): top vs bottom.
    fn justify(self) -> Justify {
        match self {
            SnackBarPosition::TopStart | SnackBarPosition::TopCenter | SnackBarPosition::TopEnd => {
                Justify::Start
            }
            _ => Justify::End,
        }
    }

    /// Horizontal alignment (the cross axis): left / centre / right.
    fn align(self) -> Align {
        match self {
            SnackBarPosition::TopStart | SnackBarPosition::BottomStart => Align::Start,
            SnackBarPosition::TopCenter | SnackBarPosition::BottomCenter => Align::Center,
            SnackBarPosition::TopEnd | SnackBarPosition::BottomEnd => Align::End,
        }
    }
}

/// A notification layer anchored in a corner.
pub struct ScaffoldMessenger<Msg = crate::callback::Callback> {
    position: SnackBarPosition,
    padding: f32,
    children: Vec<Box<dyn Widget<Msg>>>,
}

impl<Msg: Clone + 'static> ScaffoldMessenger<Msg> {
    /// An empty layer anchored at `position`.
    pub fn new(position: SnackBarPosition) -> Self {
        Self {
            position,
            padding: HOST_PAD,
            children: Vec::new(),
        }
    }

    /// Margin between the toasts and the edges. **None by default**: what a notification
    /// keeps clear of the page is the notification's own business — see
    /// [`SnackBar::margin`](crate::SnackBar::margin).
    pub fn padding(mut self, padding: f32) -> Self {
        self.padding = padding;
        self
    }

    /// Adds a toast to the stack (call it several times to stack several).
    pub fn toast(mut self, widget: impl Widget<Msg> + 'static) -> Self {
        self.children.push(Box::new(widget));
        self
    }

    /// Wraps **every** toast in an animated opacity (`duration` seconds): a fade-in
    /// built on the existing animation layer. Call this after the `toast`s.
    pub fn fade_in(self, duration: f32) -> Self {
        self.wrap_opacity(1.0, duration)
    }

    /// The mirror of [`fade_in`](Self::fade_in): animates the opacity toward **0** — the
    /// **exit** transition (the toast fades out before it is removed, Material style).
    /// The application plays it when the notification is leaving (see
    /// [`crate::SnackBarQueue::is_leaving`]).
    pub fn fade_out(self, duration: f32) -> Self {
        self.wrap_opacity(0.0, duration)
    }

    /// Wraps each toast in an opacity animated toward `target`.
    fn wrap_opacity(mut self, target: f32, duration: f32) -> Self {
        self.children = self
            .children
            .into_iter()
            .map(|child| {
                Box::new(AnimatedOpacity::new(
                    target,
                    duration,
                    Curve::ease_in_out(),
                    child,
                )) as Box<dyn Widget<Msg>>
            })
            .collect();
        self
    }
}

impl<Msg: Clone> Widget<Msg> for ScaffoldMessenger<Msg> {
    /// It asks to fill **both** axes rather than declaring `100%` on either — see
    /// [`FillAxes`](crate::widget::FillAxes). A percentage resolves against the parent's
    /// *resolved* size, which a parent that shrink-wraps has not got yet, so a shell
    /// nested in one vanished entirely (milestone 404).
    fn style(&self) -> Style {
        Style {
            flex_direction: FlexDirection::Column,
            justify: self.position.justify(),
            align: self.position.align(),
            gap: STACK_GAP,
            padding: Insets::new(self.padding, self.padding, self.padding, self.padding),
            ..Default::default()
        }
    }

    /// A shell takes everything it is offered, on both axes.
    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
        crate::widget::FillAxes::BOTH
    }

    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
        &self.children
    }

    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}

    fn on_click(&self) -> Option<Msg> {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::text::Text;

    #[test]
    fn empty_host_has_no_children() {
        let host = ScaffoldMessenger::<()>::new(SnackBarPosition::TopEnd);
        assert!(Widget::<()>::children(&host).is_empty());
    }

    #[test]
    fn position_maps_to_justify_and_align() {
        let host = ScaffoldMessenger::<()>::new(SnackBarPosition::BottomEnd).toast(Text::new("x"));
        let style = Widget::<()>::style(&host);
        assert!(
            matches!(style.justify, Justify::End),
            "bottom → justify End"
        );
        assert!(matches!(style.align, Align::End), "right → align End");
        assert_eq!(Widget::<()>::children(&host).len(), 1);

        let top_center =
            Widget::<()>::style(&ScaffoldMessenger::<()>::new(SnackBarPosition::TopCenter));
        assert!(matches!(top_center.justify, Justify::Start));
        assert!(matches!(top_center.align, Align::Center));
    }

    #[test]
    fn stacks_multiple_and_fade_in_preserves_count() {
        let host = ScaffoldMessenger::<()>::new(SnackBarPosition::BottomCenter)
            .toast(Text::new("a"))
            .toast(Text::new("b"))
            .fade_in(0.2);
        assert_eq!(
            Widget::<()>::children(&host).len(),
            2,
            "two toasts, wrapped in a fade"
        );
    }

    #[test]
    fn fade_out_wraps_children() {
        let host = ScaffoldMessenger::<()>::new(SnackBarPosition::BottomCenter)
            .toast(Text::new("bye"))
            .fade_out(0.3);
        assert_eq!(
            Widget::<()>::children(&host).len(),
            1,
            "toast wrapped in a fade-out"
        );
    }
}