frus-widgets 0.5.0

frus — declarative widget tree
Documentation
//! [`Breadcrumb`]: a trail of clickable segments, the last of which is the current
//! page.

use frus_core::{Point, Rect, ResolvedTextStyle, Scene, TextStyle};
use frus_layout::{Align, Dimension, FlexDirection, Style};

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

/// The style the segments are drawn in: what the caller said, else what the theme says,
/// else `bodyMedium`.
///
/// The reference has no breadcrumb, so this one is argued rather than read: a trail is a
/// **secondary** line of navigation above the page's own content, and it should not read
/// louder than what it leads to.
///
/// **Resolved once**, so that the number the box is measured with is the number the glyphs
/// are drawn at. Resolving is the single place the reader's font setting is applied
/// (milestone 403); a size that never passes through it is a size the reader cannot change.
fn label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
    over.or(theme.and_then(|t| t.widgets.breadcrumb.text_style))
        .unwrap_or_else(|| crate::theme::type_scale(theme).body_medium)
        .resolved()
}

/// One segment: a clickable link, the current page, or a separator.
struct Crumb<Msg> {
    label: String,
    /// `Some` is a clickable link; `None` is the current page or a separator.
    message: Option<Msg>,
    separator: bool,
    text_style: Option<TextStyle>,
}

impl<Msg> Crumb<Msg> {
    fn sizing(&self, theme: Option<&Theme>) -> Style {
        let measured =
            frus_text::measure_resolved(&self.label, &label_style(self.text_style, theme));
        Style {
            width: Dimension::Length(measured.width.ceil()),
            height: Dimension::Length(measured.height.ceil()),
            ..Default::default()
        }
    }
}

impl<Msg: Clone> Widget<Msg> for Crumb<Msg> {
    fn style(&self) -> Style {
        self.sizing(None)
    }

    fn style_themed(&self, theme: &Theme) -> Style {
        self.sizing(Some(theme))
    }

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

    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
        let o = status.opacity;
        let color = if self.separator {
            theme.muted
        } else if self.message.is_some() {
            // A link: discreet, lightening on hover.
            theme.muted.lerp(theme.on_surface, status.hover_progress)
        } else {
            // The current page.
            theme.on_surface
        };
        scene.text(
            Point::new(bounds.x, bounds.y),
            self.label.clone(),
            &label_style(self.text_style, Some(theme)),
            color.fade(o),
        );
    }

    fn on_click(&self) -> Option<Msg> {
        self.message.clone()
    }

    fn focusable(&self) -> bool {
        self.message.is_some()
    }
}

/// A controlled breadcrumb trail.
pub struct Breadcrumb<Msg = crate::callback::Callback> {
    on_select: Box<dyn Fn(usize) -> Msg>,
    labels: Vec<String>,
    text_style: Option<TextStyle>,
    children: Vec<Box<dyn Widget<Msg>>>,
}

impl<Msg: Clone + 'static> Breadcrumb<Msg> {
    /// Creates a breadcrumb; `on_select(i)` is emitted when the i-th segment is clicked.
    pub fn new<R: crate::callback::IntoMsg<Msg>>(on_select: impl Fn(usize) -> R + 'static) -> Self {
        Self {
            on_select: Box::new(crate::callback::handler1(on_select)),
            labels: Vec::new(),
            text_style: None,
            children: Vec::new(),
        }
    }

    /// The segments' type, over the theme's and the framework's.
    #[must_use]
    pub fn text_style(mut self, style: TextStyle) -> Self {
        self.text_style = Some(style);
        self.rebuild();
        self
    }

    /// Adds a segment (at the end of the trail).
    pub fn crumb(mut self, label: impl Into<String>) -> Self {
        self.labels.push(label.into());
        self.rebuild();
        self
    }

    fn rebuild(&mut self) {
        let last = self.labels.len().saturating_sub(1);
        let mut children: Vec<Box<dyn Widget<Msg>>> = Vec::new();
        for (i, label) in self.labels.iter().enumerate() {
            if i > 0 {
                children.push(Box::new(Crumb {
                    label: "›".to_string(),
                    message: None,
                    separator: true,
                    text_style: self.text_style,
                }));
            }
            children.push(Box::new(Crumb {
                label: label.clone(),
                message: if i == last {
                    None
                } else {
                    Some((self.on_select)(i))
                },
                separator: false,
                text_style: self.text_style,
            }));
        }
        self.children = children;
    }
}

impl<Msg: Clone> Widget<Msg> for Breadcrumb<Msg> {
    fn style(&self) -> Style {
        Style {
            flex_direction: FlexDirection::Row,
            align: Align::Center,
            gap: 6.0,
            ..Default::default()
        }
    }

    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::*;

    #[derive(Clone, Debug, PartialEq)]
    enum Msg {
        Go(usize),
    }

    #[test]
    fn links_click_but_current_does_not() {
        let bc = Breadcrumb::new(Msg::Go)
            .crumb("Home")
            .crumb("Settings")
            .crumb("Advanced");
        let children = Widget::<Msg>::children(&bc);
        // 3 segments plus 2 separators makes 5 children.
        assert_eq!(children.len(), 5);
        // Child 0 = "Home" (a link) → Go(0).
        assert_eq!(children[0].on_click(), Some(Msg::Go(0)));
        // The last one, "Advanced", is current and so not clickable.
        assert_eq!(children[4].on_click(), None);
    }
}