cranpose-ui 0.0.59

UI primitives for Cranpose
Documentation
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct FontFile {
    pub path: String,
    pub weight: FontWeight,
    pub style: FontStyle,
}

impl FontFile {
    pub fn new(path: impl Into<String>) -> Self {
        Self {
            path: path.into(),
            weight: FontWeight::NORMAL,
            style: FontStyle::Normal,
        }
    }

    pub fn with_weight(mut self, weight: FontWeight) -> Self {
        self.weight = weight;
        self
    }

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

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct FileBackedFontFamily {
    pub fonts: Vec<FontFile>,
}

impl FileBackedFontFamily {
    pub fn new(fonts: Vec<FontFile>) -> Self {
        assert!(
            !fonts.is_empty(),
            "FileBackedFontFamily requires at least one font file"
        );
        Self { fonts }
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct LoadedTypefacePath {
    pub path: String,
}

impl LoadedTypefacePath {
    pub fn new(path: impl Into<String>) -> Self {
        Self { path: path.into() }
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
pub enum FontFamily {
    #[default]
    Default,
    SansSerif,
    Serif,
    Monospace,
    Cursive,
    Fantasy,
    Named(String),
    FileBacked(FileBackedFontFamily),
    LoadedTypeface(LoadedTypefacePath),
}

impl FontFamily {
    pub fn named(name: impl Into<String>) -> Self {
        Self::from_name(&name.into())
    }

    pub fn from_name(name: &str) -> Self {
        match name {
            "" | "Default" | "default" => Self::Default,
            "SansSerif" | "sans-serif" => Self::SansSerif,
            "Serif" | "serif" => Self::Serif,
            "Monospace" | "monospace" => Self::Monospace,
            "Cursive" | "cursive" => Self::Cursive,
            "Fantasy" | "fantasy" => Self::Fantasy,
            value => Self::Named(value.to_string()),
        }
    }

    pub fn file_backed(fonts: Vec<FontFile>) -> Self {
        Self::FileBacked(FileBackedFontFamily::new(fonts))
    }

    pub fn loaded_typeface_path(path: impl Into<String>) -> Self {
        Self::LoadedTypeface(LoadedTypefacePath::new(path))
    }

    pub fn family_name(&self) -> Option<&str> {
        match self {
            Self::Named(name) => Some(name.as_str()),
            _ => None,
        }
    }
}

impl From<&str> for FontFamily {
    fn from(value: &str) -> Self {
        Self::from_name(value)
    }
}

impl From<String> for FontFamily {
    fn from(value: String) -> Self {
        Self::from_name(value.as_str())
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct FontWeight(pub u16);

impl FontWeight {
    pub fn new(weight: u16) -> Self {
        match Self::try_new(weight) {
            Some(value) => value,
            None => panic!("Font weight must be in range [1, 1000], got {weight}"),
        }
    }

    pub const fn try_new(weight: u16) -> Option<Self> {
        if weight >= 1 && weight <= 1000 {
            Some(Self(weight))
        } else {
            None
        }
    }

    pub const fn value(self) -> u16 {
        self.0
    }

    pub const THIN: Self = Self(100);
    pub const EXTRA_LIGHT: Self = Self(200);
    pub const LIGHT: Self = Self(300);
    pub const NORMAL: Self = Self(400);
    pub const MEDIUM: Self = Self(500);
    pub const SEMI_BOLD: Self = Self(600);
    pub const BOLD: Self = Self(700);
    pub const EXTRA_BOLD: Self = Self(800);
    pub const BLACK: Self = Self(900);
    pub const W100: Self = Self(100);
    pub const W200: Self = Self(200);
    pub const W300: Self = Self(300);
    pub const W400: Self = Self(400);
    pub const W500: Self = Self(500);
    pub const W600: Self = Self(600);
    pub const W700: Self = Self(700);
    pub const W800: Self = Self(800);
    pub const W900: Self = Self(900);
}

impl Default for FontWeight {
    fn default() -> Self {
        Self::NORMAL
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub enum FontStyle {
    #[default]
    Normal,
    Italic,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub enum FontSynthesis {
    #[default]
    None,
    All,
    Weight,
    Style,
}

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

    #[test]
    fn font_family_maps_compose_generic_names() {
        assert_eq!(FontFamily::from_name("Default"), FontFamily::Default);
        assert_eq!(FontFamily::from_name("sans-serif"), FontFamily::SansSerif);
        assert_eq!(FontFamily::from_name("serif"), FontFamily::Serif);
        assert_eq!(FontFamily::from_name("monospace"), FontFamily::Monospace);
        assert_eq!(FontFamily::from_name("cursive"), FontFamily::Cursive);
        assert_eq!(FontFamily::from_name("fantasy"), FontFamily::Fantasy);
    }

    #[test]
    fn font_family_preserves_custom_names() {
        let family = FontFamily::named("Fira Sans");
        assert_eq!(family, FontFamily::Named("Fira Sans".to_string()));
        assert_eq!(family.family_name(), Some("Fira Sans"));
    }

    #[test]
    fn font_family_file_backed_preserves_font_entries() {
        let family = FontFamily::file_backed(vec![
            FontFile::new("/tmp/Roboto-Regular.ttf"),
            FontFile::new("/tmp/Roboto-Bold.ttf").with_weight(FontWeight::BOLD),
        ]);
        let FontFamily::FileBacked(file_backed) = family else {
            panic!("expected file-backed family");
        };
        assert_eq!(file_backed.fonts.len(), 2);
        assert_eq!(file_backed.fonts[0].path, "/tmp/Roboto-Regular.ttf");
        assert_eq!(file_backed.fonts[0].weight, FontWeight::NORMAL);
        assert_eq!(file_backed.fonts[1].path, "/tmp/Roboto-Bold.ttf");
        assert_eq!(file_backed.fonts[1].weight, FontWeight::BOLD);
    }

    #[test]
    fn font_file_builder_applies_style_and_weight() {
        let file = FontFile::new("/tmp/Roboto-Italic.ttf")
            .with_weight(FontWeight::MEDIUM)
            .with_style(FontStyle::Italic);
        assert_eq!(file.path, "/tmp/Roboto-Italic.ttf");
        assert_eq!(file.weight, FontWeight::MEDIUM);
        assert_eq!(file.style, FontStyle::Italic);
    }

    #[test]
    #[should_panic(expected = "requires at least one font file")]
    fn file_backed_font_family_rejects_empty_font_list() {
        let _ = FileBackedFontFamily::new(Vec::new());
    }

    #[test]
    fn font_family_loaded_typeface_path_preserves_path() {
        let family = FontFamily::loaded_typeface_path("/tmp/FiraSans-Regular.ttf");
        let FontFamily::LoadedTypeface(typeface) = family else {
            panic!("expected loaded typeface family");
        };
        assert_eq!(typeface.path, "/tmp/FiraSans-Regular.ttf");
    }

    #[test]
    fn font_weight_default_is_normal() {
        assert_eq!(FontWeight::default(), FontWeight::NORMAL);
    }

    #[test]
    fn font_weight_try_new_validates_range() {
        assert_eq!(FontWeight::try_new(0), None);
        assert_eq!(FontWeight::try_new(1), Some(FontWeight(1)));
        assert_eq!(FontWeight::try_new(1000), Some(FontWeight(1000)));
        assert_eq!(FontWeight::try_new(1001), None);
    }
}