hydrolysis-m3 0.3.0

Material 3 widget theme for WaterUI self-drawn backends
use waterui::reactive::{Computed, Signal};
use waterui::text::font::{
    Body, Caption, Font, FontWeight, Footnote, Headline, ResolvedFont, Subheadline, Title,
};
use waterui::theme::install_font_signal;
use waterui_core::{Environment, resolve::Resolvable};

const MATERIAL_TYPEFACE: &str = "Roboto, sans-serif";

const fn font(
    size: f32,
    weight: FontWeight,
    line_height: f32,
    letter_spacing: f32,
) -> ResolvedFont {
    ResolvedFont::with_static_family(size, weight, MATERIAL_TYPEFACE)
        .with_typography_metrics(line_height, letter_spacing)
}

fn default<T: 'static>(env: &mut Environment, value: ResolvedFont) {
    if env.query::<T, Computed<ResolvedFont>>().is_none() {
        install_font_signal::<T>(env, Computed::constant(value));
    }
}

pub fn defaults(env: &mut Environment) {
    default::<Body>(env, font(16.0, FontWeight::Normal, 24.0, 0.5));
    default::<Title>(env, font(22.0, FontWeight::Normal, 28.0, 0.0));
    default::<Headline>(env, font(24.0, FontWeight::Normal, 32.0, 0.0));
    default::<Subheadline>(env, font(16.0, FontWeight::Medium, 24.0, 0.15));
    default::<Caption>(env, font(12.0, FontWeight::Normal, 16.0, 0.4));
    default::<Footnote>(env, font(11.0, FontWeight::Medium, 16.0, 0.5));
}

#[derive(Debug, Clone, Copy)]
pub struct LabelLarge;

impl Resolvable for LabelLarge {
    type Resolved = ResolvedFont;

    fn resolve(&self, _env: &Environment) -> impl Signal<Output = Self::Resolved> {
        Computed::constant(font(14.0, FontWeight::Medium, 20.0, 0.1))
    }
}

pub fn label_large() -> Font {
    Font::new(LabelLarge)
}

#[derive(Debug, Clone, Copy)]
pub struct LabelMedium;

impl Resolvable for LabelMedium {
    type Resolved = ResolvedFont;

    fn resolve(&self, _env: &Environment) -> impl Signal<Output = Self::Resolved> {
        Computed::constant(font(12.0, FontWeight::Medium, 16.0, 0.5))
    }
}

pub fn label_medium() -> Font {
    Font::new(LabelMedium)
}

#[derive(Debug, Clone, Copy)]
pub struct LabelSmall;

impl Resolvable for LabelSmall {
    type Resolved = ResolvedFont;

    fn resolve(&self, _env: &Environment) -> impl Signal<Output = Self::Resolved> {
        Computed::constant(font(11.0, FontWeight::Medium, 16.0, 0.5))
    }
}

pub fn label_small() -> Font {
    Font::new(LabelSmall)
}

#[derive(Debug, Clone, Copy)]
pub struct BodyLarge;

impl Resolvable for BodyLarge {
    type Resolved = ResolvedFont;

    fn resolve(&self, _env: &Environment) -> impl Signal<Output = Self::Resolved> {
        Computed::constant(font(16.0, FontWeight::Normal, 24.0, 0.5))
    }
}

pub fn body_large() -> Font {
    Font::new(BodyLarge)
}

#[derive(Debug, Clone, Copy)]
pub struct BodyMedium;

impl Resolvable for BodyMedium {
    type Resolved = ResolvedFont;

    fn resolve(&self, _env: &Environment) -> impl Signal<Output = Self::Resolved> {
        Computed::constant(font(14.0, FontWeight::Normal, 20.0, 0.25))
    }
}

pub fn body_medium() -> Font {
    Font::new(BodyMedium)
}

#[derive(Debug, Clone, Copy)]
pub struct BodySmall;

impl Resolvable for BodySmall {
    type Resolved = ResolvedFont;

    fn resolve(&self, _env: &Environment) -> impl Signal<Output = Self::Resolved> {
        Computed::constant(font(12.0, FontWeight::Normal, 16.0, 0.4))
    }
}

pub fn body_small() -> Font {
    Font::new(BodySmall)
}

#[derive(Debug, Clone, Copy)]
pub struct TitleSmall;

impl Resolvable for TitleSmall {
    type Resolved = ResolvedFont;

    fn resolve(&self, _env: &Environment) -> impl Signal<Output = Self::Resolved> {
        Computed::constant(font(14.0, FontWeight::Medium, 20.0, 0.1))
    }
}

pub fn title_small() -> Font {
    Font::new(TitleSmall)
}

#[derive(Debug, Clone, Copy)]
pub struct HeadlineSmall;

impl Resolvable for HeadlineSmall {
    type Resolved = ResolvedFont;

    fn resolve(&self, _env: &Environment) -> impl Signal<Output = Self::Resolved> {
        Computed::constant(font(24.0, FontWeight::Normal, 32.0, 0.0))
    }
}

pub fn headline_small() -> Font {
    Font::new(HeadlineSmall)
}

#[cfg(test)]
mod tests {
    use super::{
        MATERIAL_TYPEFACE, body_large, body_medium, body_small, defaults, headline_small,
        label_large, label_medium, label_small, title_small,
    };
    use waterui::Plugin as _;
    use waterui::env::Environment;
    use waterui::reactive::Signal as _;
    use waterui::text::font::{
        Body, Caption, FontWeight, Footnote, Headline, ResolvedFont, Subheadline, Title,
    };
    use waterui_core::resolve::Resolvable as _;

    #[allow(
        clippy::needless_pass_by_value,
        reason = "test helper; passing assertion inputs by value reads clearest"
    )]
    fn assert_material_font(
        font: waterui::text::font::ResolvedFont,
        expected_size: f32,
        expected_weight: FontWeight,
        expected_line_height: f32,
        expected_letter_spacing: f32,
    ) {
        assert_eq!(font.size, expected_size);
        assert_eq!(font.weight, expected_weight);
        assert_eq!(font.line_height, Some(expected_line_height));
        assert_eq!(font.letter_spacing, expected_letter_spacing);
        assert_eq!(font.family.as_deref(), Some(MATERIAL_TYPEFACE));
    }

    #[test]
    fn material_typeface_uses_roboto_then_script_aware_sans_fallbacks() {
        assert_eq!(MATERIAL_TYPEFACE, "Roboto, sans-serif");
    }

    #[test]
    fn font_slots_match_compose_type_scale_tokens() {
        let mut env = Environment::new();
        defaults(&mut env);

        assert_material_font(
            Body.resolve(&env).get(),
            16.0,
            FontWeight::Normal,
            24.0,
            0.5,
        );
        assert_material_font(
            Title.resolve(&env).get(),
            22.0,
            FontWeight::Normal,
            28.0,
            0.0,
        );
        assert_material_font(
            Headline.resolve(&env).get(),
            24.0,
            FontWeight::Normal,
            32.0,
            0.0,
        );
        assert_material_font(
            Subheadline.resolve(&env).get(),
            16.0,
            FontWeight::Medium,
            24.0,
            0.15,
        );
        assert_material_font(
            Caption.resolve(&env).get(),
            12.0,
            FontWeight::Normal,
            16.0,
            0.4,
        );
        assert_material_font(
            Footnote.resolve(&env).get(),
            11.0,
            FontWeight::Medium,
            16.0,
            0.5,
        );
    }

    #[test]
    fn material_defaults_preserve_app_font_overrides() {
        let app_body = ResolvedFont::new(27.0, FontWeight::Bold);
        let mut env = Environment::new();
        waterui::theme::Theme::new()
            .fonts(waterui::theme::FontSettings::new().body(app_body.clone()))
            .install(&mut env);

        defaults(&mut env);

        let resolved_body = Body.resolve(&env).get();
        assert_eq!(resolved_body.size, app_body.size);
        assert_eq!(resolved_body.weight, app_body.weight);
        assert_eq!(resolved_body.family, app_body.family);
        assert_material_font(
            Title.resolve(&env).get(),
            22.0,
            FontWeight::Normal,
            28.0,
            0.0,
        );
    }

    #[test]
    fn label_large_matches_compose_type_scale() {
        let env = Environment::new();

        assert_material_font(
            label_large().resolve(&env).get(),
            14.0,
            FontWeight::Medium,
            20.0,
            0.1,
        );
    }

    #[test]
    fn label_medium_matches_compose_type_scale() {
        let env = Environment::new();

        assert_material_font(
            label_medium().resolve(&env).get(),
            12.0,
            FontWeight::Medium,
            16.0,
            0.5,
        );
    }

    #[test]
    fn label_small_matches_compose_type_scale() {
        let env = Environment::new();

        assert_material_font(
            label_small().resolve(&env).get(),
            11.0,
            FontWeight::Medium,
            16.0,
            0.5,
        );
    }

    #[test]
    fn body_medium_matches_compose_type_scale() {
        let env = Environment::new();

        assert_material_font(
            body_medium().resolve(&env).get(),
            14.0,
            FontWeight::Normal,
            20.0,
            0.25,
        );
    }

    #[test]
    fn body_large_matches_compose_type_scale() {
        let env = Environment::new();

        assert_material_font(
            body_large().resolve(&env).get(),
            16.0,
            FontWeight::Normal,
            24.0,
            0.5,
        );
    }

    #[test]
    fn body_small_matches_compose_type_scale() {
        let env = Environment::new();

        assert_material_font(
            body_small().resolve(&env).get(),
            12.0,
            FontWeight::Normal,
            16.0,
            0.4,
        );
    }

    #[test]
    fn title_small_matches_compose_type_scale() {
        let env = Environment::new();

        assert_material_font(
            title_small().resolve(&env).get(),
            14.0,
            FontWeight::Medium,
            20.0,
            0.1,
        );
    }

    #[test]
    fn headline_small_matches_m3_reference_headline_small() {
        let env = Environment::new();

        assert_material_font(
            headline_small().resolve(&env).get(),
            24.0,
            FontWeight::Normal,
            32.0,
            0.0,
        );
    }
}