AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/css/src/utilities/typography.rs

Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson

//! Typography: font size/weight/family/style, tracking, leading, alignment,
//! transform, decoration, overflow/wrapping, numerics, smoothing, line clamp,
//! vertical align and list style.
//!
//! `text-{size}` emits `font-size` only (so the legacy `text-sm` ⇒
//! `font-size:0.875rem` is unchanged); pair it with `leading-*`, or use the
//! `text-sm/6` modifier to set both. Sizes are a fixed keyword list, so they
//! never collide with `text-{colour}`.

use super::value::{arbitrary_hinted, integer, looks_like_length, positive};
use super::{keyword, Utility};

const SIZES: &[(&str, &str)] = &[
    ("xs", "0.75rem"),
    ("sm", "0.875rem"),
    ("base", "1rem"),
    ("lg", "1.125rem"),
    ("xl", "1.25rem"),
    ("2xl", "1.5rem"),
    ("3xl", "1.875rem"),
    ("4xl", "2.25rem"),
    ("5xl", "3rem"),
    ("6xl", "3.75rem"),
    ("7xl", "4.5rem"),
    ("8xl", "6rem"),
    ("9xl", "8rem"),
];

const LEADING: &[(&str, &str)] = &[
    ("none", "1"),
    ("tight", "1.25"),
    ("snug", "1.375"),
    ("normal", "1.5"),
    ("relaxed", "1.625"),
    ("loose", "2"),
];

const TRACKING: &[(&str, &str)] = &[
    ("tighter", "-0.05em"),
    ("tight", "-0.025em"),
    ("normal", "0em"),
    ("wide", "0.025em"),
    ("wider", "0.05em"),
    ("widest", "0.1em"),
];

const KEYWORDS: &[(&str, &str)] = &[
    // weight
    ("font-thin", "font-weight:100"),
    ("font-extralight", "font-weight:200"),
    ("font-light", "font-weight:300"),
    ("font-normal", "font-weight:400"),
    ("font-medium", "font-weight:500"),
    ("font-semibold", "font-weight:600"),
    ("font-bold", "font-weight:700"),
    ("font-extrabold", "font-weight:800"),
    ("font-black", "font-weight:900"),
    // family
    (
        "font-sans",
        "font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif)",
    ),
    (
        "font-serif",
        "font-family:var(--font-serif,ui-serif,Georgia,serif)",
    ),
    (
        "font-mono",
        "font-family:var(--font-mono,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace)",
    ),
    // style
    ("italic", "font-style:italic"),
    ("not-italic", "font-style:normal"),
    // alignment
    ("text-left", "text-align:left"),
    ("text-center", "text-align:center"),
    ("text-right", "text-align:right"),
    ("text-justify", "text-align:justify"),
    ("text-start", "text-align:start"),
    ("text-end", "text-align:end"),
    // transform
    ("uppercase", "text-transform:uppercase"),
    ("lowercase", "text-transform:lowercase"),
    ("capitalize", "text-transform:capitalize"),
    ("normal-case", "text-transform:none"),
    // decoration
    ("underline", "text-decoration-line:underline"),
    ("overline", "text-decoration-line:overline"),
    ("line-through", "text-decoration-line:line-through"),
    ("no-underline", "text-decoration-line:none"),
    ("underline-offset-auto", "text-underline-offset:auto"),
    // overflow / wrapping
    (
        "truncate",
        "overflow:hidden;text-overflow:ellipsis;white-space:nowrap",
    ),
    ("text-ellipsis", "text-overflow:ellipsis"),
    ("text-clip", "text-overflow:clip"),
    ("text-wrap", "text-wrap:wrap"),
    ("text-nowrap", "text-wrap:nowrap"),
    ("text-balance", "text-wrap:balance"),
    ("text-pretty", "text-wrap:pretty"),
    ("break-normal", "overflow-wrap:normal;word-break:normal"),
    ("break-words", "overflow-wrap:break-word"),
    ("break-all", "word-break:break-all"),
    ("break-keep", "word-break:keep-all"),
    // numerics / smoothing
    ("normal-nums", "font-variant-numeric:normal"),
    ("tabular-nums", "font-variant-numeric:tabular-nums"),
    (
        "proportional-nums",
        "font-variant-numeric:proportional-nums",
    ),
    ("lining-nums", "font-variant-numeric:lining-nums"),
    (
        "antialiased",
        "-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale",
    ),
    (
        "subpixel-antialiased",
        "-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto",
    ),
    (
        "line-clamp-none",
        "overflow:visible;display:block;-webkit-box-orient:horizontal;-webkit-line-clamp:none",
    ),
    // vertical align
    ("align-baseline", "vertical-align:baseline"),
    ("align-top", "vertical-align:top"),
    ("align-middle", "vertical-align:middle"),
    ("align-bottom", "vertical-align:bottom"),
    ("align-text-top", "vertical-align:text-top"),
    ("align-text-bottom", "vertical-align:text-bottom"),
    ("align-sub", "vertical-align:sub"),
    ("align-super", "vertical-align:super"),
    // lists
    ("list-none", "list-style-type:none"),
    ("list-disc", "list-style-type:disc"),
    ("list-decimal", "list-style-type:decimal"),
    ("list-inside", "list-style-position:inside"),
    ("list-outside", "list-style-position:outside"),
];

/// `white-space` values accepted by `whitespace-*`.
const WHITESPACE: &[&str] = &[
    "normal",
    "nowrap",
    "pre",
    "pre-line",
    "pre-wrap",
    "break-spaces",
];

pub fn resolve(u: &str) -> Option<Utility> {
    keyword(KEYWORDS, u)
        .or_else(|| text_size(u))
        .or_else(|| leading(u))
        .or_else(|| tracking(u, false))
        .or_else(|| whitespace(u))
        .or_else(|| underline_offset(u))
        .or_else(|| line_clamp(u))
}

/// `-tracking-wide`.
pub fn negative(u: &str) -> Option<Utility> {
    tracking(u, true)
}

/// `text-sm`, `text-sm/6`, `text-[13px]`, `text-[length:var(--x)]`.
fn text_size(u: &str) -> Option<Utility> {
    let v = u.strip_prefix("text-")?;
    if v.starts_with('[') {
        let (hint, value) = arbitrary_hinted(v)?;
        let is_size = match hint.as_deref() {
            Some("length") | Some("size") => true,
            None => looks_like_length(&value),
            Some(_) => false,
        };
        return is_size.then(|| Utility::decl("font-size", &value));
    }
    let (size, lh) = match v.split_once('/') {
        Some((size, lh)) => (size, Some(leading_value(lh)?)),
        None => (v, None),
    };
    let (_, font_size) = SIZES.iter().find(|(k, _)| *k == size)?;
    let mut decls = vec![format!("font-size:{font_size}")];
    decls.extend(lh.map(|lh| format!("line-height:{lh}")));
    Some(Utility::from_decls(decls))
}

/// `leading-tight` ⇒ `1.25`, `leading-6` ⇒ `1.5rem` (N × 0.25rem).
fn leading(u: &str) -> Option<Utility> {
    let value = leading_value(u.strip_prefix("leading-")?)?;
    Some(Utility::decl("line-height", &value).tier(1))
}

fn leading_value(v: &str) -> Option<String> {
    if let Some((_, lh)) = LEADING.iter().find(|(k, _)| *k == v) {
        return Some((*lh).to_string());
    }
    if let Some(n) = integer(v) {
        return Some(format!(
            "{}rem",
            super::value::trim_float(f64::from(n) * 0.25)
        ));
    }
    super::value::arbitrary(v)
}

fn tracking(u: &str, neg: bool) -> Option<Utility> {
    let v = u.strip_prefix("tracking-")?;
    let (_, value) = TRACKING.iter().find(|(k, _)| *k == v)?;
    let value = match (neg, value.strip_prefix('-')) {
        (false, _) => (*value).to_string(),
        (true, Some(positive)) => positive.to_string(),
        (true, None) => format!("-{value}"),
    };
    Some(Utility::decl("letter-spacing", &value))
}

fn whitespace(u: &str) -> Option<Utility> {
    let v = u.strip_prefix("whitespace-")?;
    WHITESPACE
        .contains(&v)
        .then(|| Utility::decl("white-space", v).tier(1))
}

fn underline_offset(u: &str) -> Option<Utility> {
    let n = integer(u.strip_prefix("underline-offset-")?)?;
    Some(Utility::decl("text-underline-offset", &format!("{n}px")))
}

fn line_clamp(u: &str) -> Option<Utility> {
    let n = positive(u.strip_prefix("line-clamp-")?)?;
    Some(Utility::body(&format!(
        "overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:{n}"
    )))
}

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

    fn decl(u: &str) -> Option<String> {
        resolve(u).map(|x| x.declaration())
    }

    #[test]
    fn sizes_are_keywords_only() {
        assert_eq!(decl("text-accent"), None);
        assert_eq!(decl("text-[#fff]"), None);
        assert_eq!(decl("text-[var(--x)]"), None);
        assert_eq!(
            decl("text-[length:var(--x)]").as_deref(),
            Some("font-size:var(--x)")
        );
        assert_eq!(
            decl("text-xs/[18px]").as_deref(),
            Some("font-size:0.75rem;line-height:18px")
        );
        assert_eq!(decl("text-sm/x"), None);
    }

    #[test]
    fn tracking_negation_flips_sign() {
        let n = |u: &str| negative(u).map(|x| x.declaration());
        assert_eq!(
            n("tracking-wide").as_deref(),
            Some("letter-spacing:-0.025em")
        );
        assert_eq!(
            n("tracking-tight").as_deref(),
            Some("letter-spacing:0.025em")
        );
    }

    #[test]
    fn misc() {
        assert_eq!(decl("leading-3").as_deref(), Some("line-height:0.75rem"));
        assert_eq!(decl("leading-10").as_deref(), Some("line-height:2.5rem"));
        assert_eq!(
            decl("whitespace-pre-line").as_deref(),
            Some("white-space:pre-line")
        );
        assert_eq!(decl("whitespace-wat"), None);
        assert_eq!(decl("line-clamp-0"), None);
    }
}