Skip to main content

heatsync_core/
paint.rs

1//! name paints: heatsync's own paint spec and 7TV's paint object, reduced to
2//! one gradient model a terminal can sample per character. pure data in,
3//! rgb out — fetching and caching live in the tui.
4//!
5//! a terminal cell has one color, so only the resting fill survives: motion
6//! effects, glow, shadows and image fills are dropped (an image paint parses
7//! to nothing and the name keeps its plain color).
8
9use serde_json::Value;
10
11/// cells are about twice as tall as wide — the name's box for gradient math.
12const CELL_ASPECT: f32 = 2.0;
13
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub enum Kind {
16    Solid,
17    /// css angle in degrees (0 = up, 90 = right).
18    Linear(f32),
19    Conic,
20    Radial,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct Stop {
25    /// 0..1 along the gradient.
26    pub at: f32,
27    pub rgb: [u8; 3],
28}
29
30#[derive(Clone, Debug, PartialEq)]
31pub struct Paint {
32    pub kind: Kind,
33    /// sorted by `at`, never empty.
34    pub stops: Vec<Stop>,
35    /// the gradient tiles: one period spans `period` name-widths.
36    pub repeat: bool,
37    pub period: f32,
38}
39
40impl Paint {
41    fn new(kind: Kind, mut stops: Vec<Stop>, repeat: bool, period: f32) -> Option<Paint> {
42        if stops.is_empty() {
43            return None;
44        }
45        stops.sort_by(|a, b| a.at.total_cmp(&b.at));
46        let kind = if stops.len() == 1 { Kind::Solid } else { kind };
47        Some(Paint {
48            kind,
49            stops,
50            repeat,
51            period: if period > 0.0 { period } else { 1.0 },
52        })
53    }
54
55    /// heatsync paint spec: the `fill` block's first drawable layer, else
56    /// `base`. `None` for specs with nothing a terminal can show.
57    pub fn from_heatsync(spec: &Value) -> Option<Paint> {
58        if let Some(fill) = spec.get("fill") {
59            let angle = num(fill.get("angle")).unwrap_or(0.0);
60            let layers = fill.get("layers").and_then(Value::as_array);
61            let layer = layers?.iter().find(|l| {
62                l.get("legacy") != Some(&Value::Bool(true))
63                    && l.get("kind") != Some(&Value::from("image"))
64            });
65            if let Some(l) = layer {
66                let stops = stops_of(l.get("stops"), 100.0, hex_rgb)?;
67                let kind = match l.get("kind").and_then(Value::as_str) {
68                    Some("conic") => Kind::Conic,
69                    Some("radial") => Kind::Radial,
70                    _ => Kind::Linear(angle + num(l.get("tilt")).unwrap_or(0.0)),
71                };
72                let repeat = l.get("repeat") == Some(&Value::Bool(true));
73                let tile = l.get("tile");
74                let size = num(tile.and_then(|t| t.get("size"))).unwrap_or(1.0);
75                // a px tile is measured against a ~8px cell
76                let period = match tile.and_then(|t| t.get("unit")).and_then(Value::as_str) {
77                    Some("px") => size / 8.0,
78                    _ => size,
79                };
80                return Paint::new(kind, stops, repeat, period);
81            }
82        }
83        let base = spec.get("base")?;
84        let stops = stops_of(base.get("stops"), 100.0, hex_rgb)?;
85        let angle = num(base.get("angle")).unwrap_or(0.0);
86        let kind = match base.get("type").and_then(Value::as_str) {
87            Some("solid") => Kind::Solid,
88            Some("conic") => Kind::Conic,
89            _ => Kind::Linear(angle),
90        };
91        // a banded base repeats; its tile is pixels, ~8px per cell
92        let banded = base.get("type").and_then(Value::as_str) == Some("repeating-linear");
93        let size = num(base.get("tileWidth")).unwrap_or(8.0);
94        Paint::new(kind, stops, banded, size / 8.0)
95    }
96
97    /// 7TV paint object (`function`, `stops[{at,color}]`, `angle`, `repeat`,
98    /// `shape`, solid `color`). image paints and empty ones → `None`.
99    pub fn from_7tv(p: &Value) -> Option<Paint> {
100        let func = p
101            .get("function")
102            .and_then(Value::as_str)?
103            .to_ascii_lowercase();
104        let rgb = |v: &Value| stv_rgb(v.as_i64()?);
105        let stops = stops_of(p.get("stops"), 1.0, rgb);
106        match func.replace('-', "_").as_str() {
107            "linear_gradient" => Paint::new(
108                Kind::Linear(num(p.get("angle")).unwrap_or(0.0)),
109                stops?,
110                p.get("repeat") == Some(&Value::Bool(true)),
111                1.0,
112            ),
113            "radial_gradient" => Paint::new(
114                Kind::Radial,
115                stops?,
116                p.get("repeat") == Some(&Value::Bool(true)),
117                1.0,
118            ),
119            // a solid `color` paint, or any gradient-less paint that still sets one
120            _ => {
121                let rgb = stv_rgb(p.get("color")?.as_i64()?)?;
122                Paint::new(Kind::Solid, vec![Stop { at: 0.0, rgb }], false, 1.0)
123            }
124        }
125    }
126
127    /// the color at `t` along the gradient (0..1 across one period when
128    /// repeating; clamped otherwise).
129    pub fn sample(&self, t: f32) -> [u8; 3] {
130        let s = &self.stops;
131        let t = if self.repeat {
132            (t / self.period).rem_euclid(1.0)
133        } else {
134            t
135        };
136        if t <= s[0].at {
137            return s[0].rgb;
138        }
139        let last = s[s.len() - 1];
140        if t >= last.at {
141            return last.rgb;
142        }
143        let i = s.windows(2).position(|w| t < w[1].at).unwrap_or(0);
144        let (a, b) = (s[i], s[i + 1]);
145        let f = (t - a.at) / (b.at - a.at);
146        let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * f).round() as u8;
147        [
148            mix(a.rgb[0], b.rgb[0]),
149            mix(a.rgb[1], b.rgb[1]),
150            mix(a.rgb[2], b.rgb[2]),
151        ]
152    }
153
154    /// one color per character of an `n`-cell name. a linear gradient is
155    /// projected the way css does it, over a box `n` wide and one cell tall.
156    pub fn colors(&self, n: usize) -> Vec<[u8; 3]> {
157        (0..n).map(|i| self.sample(self.t_at(i, n))).collect()
158    }
159
160    fn t_at(&self, i: usize, n: usize) -> f32 {
161        let w = n as f32;
162        let x = i as f32 + 0.5;
163        match self.kind {
164            Kind::Solid => 0.0,
165            Kind::Linear(deg) => {
166                let (sin, cos) = deg.to_radians().sin_cos();
167                let len = w * sin.abs() + CELL_ASPECT * cos.abs();
168                0.5 + (x - w / 2.0) * sin / len
169            }
170            // the conic sweep is spread along the name
171            Kind::Conic => x / w,
172            Kind::Radial => (x - w / 2.0).abs() / (w / 2.0),
173        }
174    }
175}
176
177fn num(v: Option<&Value>) -> Option<f32> {
178    v?.as_f64().map(|n| n as f32)
179}
180
181/// `[{pos|at, color}]` → stops, positions divided by `scale` (100 for
182/// percent, 1 for fractions). malformed stops are skipped; none left → `None`.
183fn stops_of(
184    v: Option<&Value>,
185    scale: f32,
186    color: impl Fn(&Value) -> Option<[u8; 3]>,
187) -> Option<Vec<Stop>> {
188    let out: Vec<Stop> = v?
189        .as_array()?
190        .iter()
191        .filter_map(|s| {
192            let at = num(s.get("pos").or_else(|| s.get("at")))? / scale;
193            Some(Stop {
194                at: at.clamp(0.0, 1.0),
195                rgb: color(s.get("color")?)?,
196            })
197        })
198        .collect();
199    (!out.is_empty()).then_some(out)
200}
201
202/// `#rrggbb` or `#rrggbbaa` (alpha dropped).
203fn hex_rgb(v: &Value) -> Option<[u8; 3]> {
204    let h = v.as_str()?.strip_prefix('#')?;
205    if !(h.len() == 6 || h.len() == 8) || !h.is_ascii() {
206        return None;
207    }
208    let b = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
209    Some([b(0)?, b(2)?, b(4)?])
210}
211
212/// 7TV packs rgba into a signed int32: `(r<<24)|(g<<16)|(b<<8)|a`.
213fn stv_rgb(n: i64) -> Option<[u8; 3]> {
214    let v = n as i32 as u32;
215    Some([(v >> 24) as u8, (v >> 16) as u8, (v >> 8) as u8])
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use serde_json::json;
222
223    #[test]
224    fn heatsync_base_linear() {
225        let p = Paint::from_heatsync(&json!({"base": {"type": "linear", "angle": 90,
226            "stops": [{"color": "#ff0000", "pos": 0}, {"color": "#0000ff", "pos": 100}]}}))
227        .unwrap();
228        assert_eq!(p.kind, Kind::Linear(90.0));
229        assert_eq!(p.sample(0.0), [255, 0, 0]);
230        assert_eq!(p.sample(1.0), [0, 0, 255]);
231        assert_eq!(p.sample(0.5), [128, 0, 128]);
232    }
233
234    #[test]
235    fn heatsync_solid_and_single_stop() {
236        let p = Paint::from_heatsync(&json!({"base": {"type": "solid",
237            "stops": [{"color": "#112233", "pos": 0}]}}))
238        .unwrap();
239        assert_eq!(p.kind, Kind::Solid);
240        assert!(p.colors(5).iter().all(|c| *c == [0x11, 0x22, 0x33]));
241    }
242
243    #[test]
244    fn heatsync_fill_wins_over_base_and_skips_image_layers() {
245        let p = Paint::from_heatsync(&json!({
246            "base": {"type": "solid", "stops": [{"color": "#000000", "pos": 0}]},
247            "fill": {"angle": 90, "layers": [
248                {"kind": "image", "src": "x"},
249                {"kind": "linear", "tilt": 0, "stops": [
250                    {"color": "#ff0000ff", "pos": 0}, {"color": "#00ff00", "pos": 100}]}]}}))
251        .unwrap();
252        assert_eq!(p.kind, Kind::Linear(90.0));
253        assert_eq!(p.sample(0.0), [255, 0, 0]);
254    }
255
256    #[test]
257    fn heatsync_junk_is_none() {
258        assert!(Paint::from_heatsync(&json!({})).is_none());
259        assert!(Paint::from_heatsync(&json!({"base": {"type": "linear", "stops": []}})).is_none());
260        assert!(
261            Paint::from_heatsync(&json!({"base": {"stops": [{"color": "red", "pos": 0}]}}))
262                .is_none()
263        );
264    }
265
266    #[test]
267    fn seven_tv_gradient_decodes_packed_rgba() {
268        // 0xff0000ff = opaque red; 0x0000ffff = opaque blue (signed int32 on the wire)
269        let p = Paint::from_7tv(&json!({"function": "LINEAR_GRADIENT", "angle": 90, "repeat": false,
270            "stops": [{"at": 0.0, "color": 0xff0000ffu32 as i32}, {"at": 1.0, "color": 0x0000ffffu32 as i32}]}))
271        .unwrap();
272        assert_eq!(p.sample(0.0), [255, 0, 0]);
273        assert_eq!(p.sample(1.0), [0, 0, 255]);
274    }
275
276    #[test]
277    fn seven_tv_solid_and_image() {
278        let solid =
279            Paint::from_7tv(&json!({"function": "SOLID", "color": 0x336699ffu32 as i32})).unwrap();
280        assert_eq!(solid.sample(0.7), [0x33, 0x66, 0x99]);
281        assert!(Paint::from_7tv(
282            &json!({"function": "URL", "image_url": "https://cdn.7tv.app/paint/x/1x.webp"})
283        )
284        .is_none());
285    }
286
287    #[test]
288    fn hard_stops_and_repeat() {
289        let p = Paint::from_7tv(&json!({"function": "LINEAR_GRADIENT", "angle": 90, "repeat": true,
290            "stops": [{"at": 0.0, "color": 0xff0000ffu32 as i32}, {"at": 0.5, "color": 0xff0000ffu32 as i32},
291                      {"at": 0.5, "color": 0x0000ffffu32 as i32}, {"at": 1.0, "color": 0x0000ffffu32 as i32}]}))
292        .unwrap();
293        assert_eq!(p.sample(0.25), [255, 0, 0]);
294        assert_eq!(p.sample(0.75), [0, 0, 255]);
295        assert_eq!(p.sample(1.25), [255, 0, 0]); // wrapped
296    }
297
298    #[test]
299    fn horizontal_gradient_runs_left_to_right_and_vertical_is_flat() {
300        let stops = |angle| {
301            json!({"base": {"type": "linear", "angle": angle,
302            "stops": [{"color": "#000000", "pos": 0}, {"color": "#ffffff", "pos": 100}]}})
303        };
304        let h = Paint::from_heatsync(&stops(90)).unwrap().colors(10);
305        assert!(h.windows(2).all(|w| w[0][0] < w[1][0]));
306        let v = Paint::from_heatsync(&stops(0)).unwrap().colors(10);
307        assert!(v.iter().all(|c| *c == v[0]));
308    }
309
310    #[test]
311    fn one_cell_name_does_not_divide_by_zero() {
312        let p = Paint::from_heatsync(&json!({"base": {"type": "linear", "angle": 90,
313            "stops": [{"color": "#000000", "pos": 0}, {"color": "#ffffff", "pos": 100}]}}))
314        .unwrap();
315        assert_eq!(p.colors(1).len(), 1);
316        assert!(p.colors(0).is_empty());
317    }
318}