Skip to main content

eui_core/
text_shimmer.rs

1//! EXP-1324: the Text `shimmer` colours (catalog/style.json `textShimmer`),
2//! a port of `src/text-shimmer.ts`; `fixtures/text-shimmer.json` locks it.
3//! A painter fills a shimmering text's glyphs with the gradient the style's
4//! `$comment` describes, from these colours; reduced motion = plain text.
5
6use std::sync::LazyLock;
7
8use serde::{Deserialize, Serialize};
9
10use crate::generated::catalog as g;
11
12#[derive(Debug, Clone, Copy, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct ShimmerSpec {
15    pub duration_ms: u32,
16    pub angle_deg: f64,
17    pub spread_px: f64,
18    pub highlight_lightness_min: f64,
19    pub highlight_lightness_boost: f64,
20    pub highlight_alpha_boost: f64,
21    pub light_alpha_factor: f64,
22}
23
24pub static SHIMMER: LazyLock<ShimmerSpec> = LazyLock::new(|| {
25    let style: serde_json::Value = serde_json::from_str(g::STYLE_JSON).expect("style.json");
26    serde_json::from_value(style["textShimmer"].clone()).expect("style.json textShimmer")
27});
28
29/// The colours (`#rrggbb[aa]`) and numbers of one shimmering text.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct TextShimmer {
33    pub base: String,
34    pub highlight: String,
35    pub mid: String,
36    pub duration_ms: u32,
37    pub angle_deg: f64,
38    pub spread_px: f64,
39}
40
41/// `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` → (r, g, b 0–255, a 0–1).
42fn parse_hex(color: &str) -> Option<(f64, f64, f64, f64)> {
43    let h = color.strip_prefix('#')?;
44    if !h.chars().all(|c| c.is_ascii_hexdigit()) {
45        return None;
46    }
47    let full: String = match h.len() {
48        3 | 4 => h.chars().flat_map(|c| [c, c]).collect(),
49        6 | 8 => h.to_string(),
50        _ => return None,
51    };
52    let byte = |i: usize| u8::from_str_radix(&full[i..i + 2], 16).ok().map(f64::from);
53    let a = if full.len() == 8 { byte(6)? / 255.0 } else { 1.0 };
54    Some((byte(0)?, byte(2)?, byte(4)?, a))
55}
56
57fn byte(n: f64) -> String {
58    format!("{:02x}", n.round().clamp(0.0, 255.0) as u8)
59}
60
61fn to_hex(r: f64, g: f64, b: f64, a: f64) -> String {
62    let base = format!("#{}{}{}", byte(r), byte(g), byte(b));
63    if a >= 1.0 {
64        base
65    } else {
66        format!("{base}{}", byte(a * 255.0))
67    }
68}
69
70fn to_linear(c: f64) -> f64 {
71    if c <= 0.04045 {
72        c / 12.92
73    } else {
74        ((c + 0.055) / 1.055).powf(2.4)
75    }
76}
77
78fn to_gamma(c: f64) -> f64 {
79    let x = c.clamp(0.0, 1.0);
80    if x <= 0.0031308 {
81        12.92 * x
82    } else {
83        1.055 * x.powf(1.0 / 2.4) - 0.055
84    }
85}
86
87fn rgb_to_oklab(r: f64, g: f64, b: f64) -> [f64; 3] {
88    let (lr, lg, lb) = (to_linear(r / 255.0), to_linear(g / 255.0), to_linear(b / 255.0));
89    let l = (0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb).cbrt();
90    let m = (0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb).cbrt();
91    let s = (0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb).cbrt();
92    [
93        0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
94        1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
95        0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
96    ]
97}
98
99fn oklab_hex([lightness, a, b]: [f64; 3], alpha: f64) -> String {
100    let l = (lightness + 0.3963377774 * a + 0.2158037573 * b).powi(3);
101    let m = (lightness - 0.1055613458 * a - 0.0638541728 * b).powi(3);
102    let s = (lightness - 0.0894841775 * a - 1.291485548 * b).powi(3);
103    to_hex(
104        to_gamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s) * 255.0,
105        to_gamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s) * 255.0,
106        to_gamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s) * 255.0,
107        alpha,
108    )
109}
110
111/// The shimmer colours for a text colour; `dark` = the surface's dark mode.
112/// An unparsable colour shimmers from black.
113pub fn text_shimmer(color: &str, dark: bool) -> TextShimmer {
114    let t = *SHIMMER;
115    let (r, g, b, a) = parse_hex(color).unwrap_or((0.0, 0.0, 0.0, 1.0));
116    let base = rgb_to_oklab(r, g, b);
117    let (high, high_alpha) = if dark {
118        ([(base[0] + t.highlight_lightness_boost).max(t.highlight_lightness_min).min(1.0), base[1], base[2]], (a + t.highlight_alpha_boost).min(1.0))
119    } else {
120        (base, a * t.light_alpha_factor)
121    };
122    let sum = a + high_alpha;
123    let mid = if sum > 0.0 { [0, 1, 2].map(|i| (base[i] * a + high[i] * high_alpha) / sum) } else { base };
124    TextShimmer {
125        base: to_hex(r, g, b, a),
126        highlight: oklab_hex(high, high_alpha),
127        mid: oklab_hex(mid, sum / 2.0),
128        duration_ms: t.duration_ms,
129        angle_deg: t.angle_deg,
130        spread_px: t.spread_px,
131    }
132}