sequoia-elements 0.1.3

Sequoia GUI framework — built-in UI elements
Documentation
use sequoia_core::{Color, Edges, Length, Size};
use sequoia_layout::{LayoutCtx, LayoutStyle};
use sequoia_style::AvatarStyle;
use sequoia_view::{AssetRegistry, FontWeight, PaintCtx, Unit, View, icon_key};

const SIZE: f32 = 32.0;
const FONT_RATIO: f32 = 0.38;
const ICON_RATIO: f32 = 0.55;

pub fn initials_for(name: &str) -> String {
    let mut words = name.split_whitespace();
    let first = words.next().unwrap_or("");
    let last = words.last();
    let mut out = String::new();
    if let Some(c) = first.chars().next() {
        out.extend(c.to_uppercase());
    }
    if let Some(last) = last
        && let Some(c) = last.chars().next()
    {
        out.extend(c.to_uppercase());
    }
    if out.is_empty() { "?".to_string() } else { out }
}

pub fn avatar_color_for(name: &str) -> Color {
    const PALETTE: [Color; 8] = [
        Color::hex(0x356FD1),
        Color::hex(0x7A5ACD),
        Color::hex(0x2E8B57),
        Color::hex(0xB3541E),
        Color::hex(0xA03A5A),
        Color::hex(0x2A9D8F),
        Color::hex(0x8A6D1B),
        Color::hex(0x5A6E7F),
    ];
    let mut hash: u32 = 5381;
    for b in name.bytes() {
        hash = hash.wrapping_mul(33).wrapping_add(b as u32);
    }

    PALETTE[(hash as usize) % PALETTE.len()]
}

pub struct Avatar {
    name: String,
    initials: Option<String>,
    icon: Option<String>,
    size: f32,
    auto_color: bool,
    style: Option<AvatarStyle>,
    background: Option<Color>,
    text_color: Option<Color>,
    font_size: Option<f32>,
    radius: Option<f32>,
    margin: Edges,
}

impl Avatar {
    pub fn named(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            initials: None,
            icon: None,
            size: SIZE,
            auto_color: true,
            style: None,
            background: None,
            text_color: None,
            font_size: None,
            radius: None,
            margin: Edges::ZERO,
        }
    }

    pub fn icon(name: impl Into<String>) -> Self {
        Self {
            name: String::new(),
            initials: None,
            icon: Some(name.into()),
            size: SIZE,
            auto_color: true,
            style: None,
            background: None,
            text_color: None,
            font_size: None,
            radius: None,
            margin: Edges::ZERO,
        }
    }

    pub fn initials(mut self, initials: impl Into<String>) -> Self {
        self.initials = Some(initials.into());
        self
    }

    pub fn size(mut self, px: f32) -> Self {
        self.size = px.max(8.0);
        self
    }

    pub fn auto_color(mut self, on: bool) -> Self {
        self.auto_color = on;
        self
    }

    pub fn style(mut self, style: AvatarStyle) -> Self {
        self.style = Some(style);
        self
    }

    pub fn background(mut self, color: Color) -> Self {
        self.background = Some(color);
        self
    }

    pub fn text_color(mut self, color: Color) -> Self {
        self.text_color = Some(color);
        self
    }

    pub fn font_size(mut self, size: f32) -> Self {
        self.font_size = Some(size);
        self
    }

    pub fn radius(mut self, radius: f32) -> Self {
        self.radius = Some(radius);
        self
    }

    pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
        let m = margin.into();
        self.margin = Edges::only(
            sanitize_margin(m.left),
            sanitize_margin(m.top),
            sanitize_margin(m.right),
            sanitize_margin(m.bottom),
        );
        self
    }

    pub fn margin_xy(self, x: f32, y: f32) -> Self {
        self.margin(Edges::xy(x, y))
    }

    pub fn margin_x(mut self, x: f32) -> Self {
        let x = sanitize_margin(x);
        self.margin.left = x;
        self.margin.right = x;
        self
    }

    pub fn margin_y(mut self, y: f32) -> Self {
        let y = sanitize_margin(y);
        self.margin.top = y;
        self.margin.bottom = y;
        self
    }

    pub fn margin_left(mut self, v: f32) -> Self {
        self.margin.left = sanitize_margin(v);
        self
    }

    pub fn margin_right(mut self, v: f32) -> Self {
        self.margin.right = sanitize_margin(v);
        self
    }

    pub fn margin_top(mut self, v: f32) -> Self {
        self.margin.top = sanitize_margin(v);
        self
    }

    pub fn margin_bottom(mut self, v: f32) -> Self {
        self.margin.bottom = sanitize_margin(v);
        self
    }

    fn label(&self) -> String {
        self.initials
            .clone()
            .unwrap_or_else(|| initials_for(&self.name))
    }
}

pub(crate) fn sanitize_margin(v: f32) -> f32 {
    if v.is_finite() { v.max(0.0) } else { 0.0 }
}

impl View for Avatar {
    fn body(&self) -> impl View + 'static {
        Unit
    }
    fn is_leaf(&self) -> bool {
        true
    }
    fn hit_testable(&self) -> bool {
        false
    }
    fn layout_style(&self) -> LayoutStyle {
        LayoutStyle {
            width: Length::Px(self.size),
            height: Length::Px(self.size),
            margin: self.margin,
            ..LayoutStyle::default().hit_testable(false)
        }
    }
    fn intrinsic_size(&self, _ctx: &mut LayoutCtx) -> Option<Size> {
        Some(Size::new(self.size, self.size))
    }
    fn paint_self(&self, ctx: &mut PaintCtx) {
        let rect = ctx.rect;
        if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
            return;
        }
        let theme = ctx.theme();
        let style = self.style.unwrap_or_default();
        let side = rect.size.width.min(rect.size.height);
        let radius = self.radius.or(style.radius).unwrap_or(side * 0.5).max(0.0);
        let background = self.background.or(style.background).unwrap_or_else(|| {
            if self.auto_color && self.icon.is_none() {
                avatar_color_for(&self.name)
            } else {
                theme.surface
            }
        });
        ctx.quad(rect, background, radius);

        let text = self.text_color.or(style.text).unwrap_or(theme.text);
        if let Some(name) = &self.icon {
            let px = side * ICON_RATIO;
            if let Some(assets) = ctx.environment.get::<AssetRegistry>() {
                let raster_px = ((px * ctx.scale_factor()).round() as u32).max(1);
                if let Ok(sprite) = assets.rasterize_icon(name, raster_px) {
                    let x = rect.left() + (rect.size.width - px) * 0.5;
                    let y = rect.top() + (rect.size.height - px) * 0.5;
                    ctx.draw_sprite(
                        icon_key(name, raster_px),
                        sprite.width,
                        sprite.height,
                        &sprite.alpha,
                        sequoia_core::Rect::from_xywh(x, y, px, px),
                        text,
                    );
                    return;
                }
            }
        }
        let label = self.label();
        let size = self
            .font_size
            .or(style.font_size)
            .unwrap_or(side * FONT_RATIO);
        let w = ctx.text_width_weighted(&label, size, FontWeight::SEMIBOLD);
        let x = rect.left() + (rect.size.width - w) * 0.5;
        let y = rect.top() + rect.size.height * 0.5 + size * 0.35;
        ctx.draw_text_weighted(&label, (x, y), size, FontWeight::SEMIBOLD, text);
    }
}

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

    #[test]
    fn initials_take_first_and_last() {
        assert_eq!(initials_for("Ada Lovelace"), "AL");
        assert_eq!(initials_for("ada lovelace byron"), "AB");
        assert_eq!(initials_for("root"), "R");
        assert_eq!(initials_for("  "), "?");
        assert_eq!(initials_for(""), "?");
    }

    #[test]
    fn color_is_stable_and_spread() {
        assert_eq!(avatar_color_for("ada"), avatar_color_for("ada"));
        let mut seen = std::collections::HashSet::new();
        for name in [
            "ada", "bob", "ced", "dan", "eli", "fay", "gus", "hal", "ida", "jay",
        ] {
            seen.insert(avatar_color_for(name).to_rgb8());
        }
        assert!(seen.len() > 1, "palette differs: {seen:?}");
    }
}