1use 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#[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
41fn 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
111pub 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}