Skip to main content

perspective_viewer/config/
css_scale.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use std::collections::BTreeMap;
14
15use serde_json::Value;
16
17use super::{GradientStopSpec, canonicalize_gradient_stops};
18
19pub const USER_VAR_PREFIX: &str = "--psp-user--";
20
21#[derive(Clone, Debug, PartialEq)]
22pub struct NamedValue {
23    pub name: String,
24    pub value: String,
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
28pub enum CssKind {
29    Gradient,
30    Palette,
31    Color,
32}
33
34impl CssKind {
35    pub const ALL: [CssKind; 3] = [CssKind::Gradient, CssKind::Palette, CssKind::Color];
36
37    pub fn var_prefix(self) -> &'static str {
38        match self {
39            CssKind::Gradient => "--psp-user--gradient-",
40            CssKind::Palette => "--psp-user--palette-",
41            CssKind::Color => "--psp-user--color-",
42        }
43    }
44
45    pub fn of_var(name: &str) -> Option<CssKind> {
46        CssKind::ALL
47            .into_iter()
48            .find(|kind| name.starts_with(kind.var_prefix()))
49    }
50
51    pub fn canonicalize(self, src: &str) -> Result<String, String> {
52        match self {
53            CssKind::Gradient => CssGradient::parse(src).map(|x| x.to_css()),
54            CssKind::Palette => CssPalette::parse(src).map(|x| x.to_css()),
55            CssKind::Color => CssColor::parse(src).map(|x| x.to_css()),
56        }
57    }
58
59    pub fn short_name(self, var_name: &str) -> String {
60        var_name
61            .strip_prefix(self.var_prefix())
62            .unwrap_or(var_name)
63            .to_owned()
64    }
65}
66
67/// The property name a whole-value `var(--psp-user--…)` reference
68/// names, or `None` when `src` is not one.
69pub fn parse_var_ref(src: &str) -> Option<&str> {
70    let inner = src.trim().strip_prefix("var(")?.strip_suffix(')')?.trim();
71    if !inner.starts_with(USER_VAR_PREFIX)
72        || inner.len() == USER_VAR_PREFIX.len()
73        || inner.contains(|c: char| c.is_whitespace() || c == ',' || c == ')')
74    {
75        return None;
76    }
77
78    Some(inner)
79}
80
81/// `var(name)`.
82pub fn format_var_ref(name: &str) -> String {
83    format!("var({name})")
84}
85
86fn parse_kind_var(src: &str, kind: CssKind) -> Result<Option<String>, String> {
87    let Some(name) = parse_var_ref(src) else {
88        return Ok(None);
89    };
90
91    if name.starts_with(kind.var_prefix()) {
92        Ok(Some(name.to_owned()))
93    } else {
94        Err(format!(
95            "`{src}` is not a {} reference (expected `var({}…)`)",
96            kind_label(kind),
97            kind.var_prefix()
98        ))
99    }
100}
101
102fn kind_label(kind: CssKind) -> &'static str {
103    match kind {
104        CssKind::Gradient => "gradient",
105        CssKind::Palette => "palette",
106        CssKind::Color => "color",
107    }
108}
109
110fn hex_channel(src: &str) -> Result<u8, String> {
111    u8::from_str_radix(src, 16).map_err(|_| format!("invalid hex color `#{src}`"))
112}
113
114fn channel_from_token(token: &str) -> Result<u8, String> {
115    let token = token.trim();
116    let value = match token.strip_suffix('%') {
117        Some(pct) => {
118            pct.trim()
119                .parse::<f64>()
120                .map_err(|_| format!("invalid color channel `{token}`"))?
121                / 100.0
122                * 255.0
123        },
124        None => token
125            .parse::<f64>()
126            .map_err(|_| format!("invalid color channel `{token}`"))?,
127    };
128
129    Ok(value.round().clamp(0.0, 255.0) as u8)
130}
131
132/// A color literal — `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`
133/// or `rgba()` — normalized to lowercase `#rrggbb`.
134pub fn normalize_css_color(src: &str) -> Result<String, String> {
135    let src = src.trim();
136    if let Some(hex) = src.strip_prefix('#') {
137        let (r, g, b) = match hex.len() {
138            3 | 4 => {
139                let d = |i: usize| hex_channel(&hex[i..i + 1]).map(|x| x * 17);
140                (d(0)?, d(1)?, d(2)?)
141            },
142            6 | 8 => (
143                hex_channel(&hex[0..2])?,
144                hex_channel(&hex[2..4])?,
145                hex_channel(&hex[4..6])?,
146            ),
147            _ => return Err(format!("invalid hex color `{src}`")),
148        };
149
150        return Ok(format!("#{r:02x}{g:02x}{b:02x}"));
151    }
152
153    let lower = src.to_ascii_lowercase();
154    let body = lower
155        .strip_prefix("rgba(")
156        .or_else(|| lower.strip_prefix("rgb("))
157        .and_then(|x| x.strip_suffix(')'))
158        .ok_or_else(|| format!("unsupported color `{src}`"))?;
159
160    let body = body.split('/').next().unwrap_or("");
161    let tokens: Vec<&str> = body
162        .split(|c: char| c == ',' || c.is_whitespace())
163        .filter(|x| !x.is_empty())
164        .collect();
165
166    if tokens.len() < 3 || tokens.len() > 4 {
167        return Err(format!("unsupported color `{src}`"));
168    }
169
170    Ok(format!(
171        "#{:02x}{:02x}{:02x}",
172        channel_from_token(tokens[0])?,
173        channel_from_token(tokens[1])?,
174        channel_from_token(tokens[2])?
175    ))
176}
177
178fn split_entries(body: &str) -> Vec<&str> {
179    let mut parts = vec![];
180    let mut depth = 0i32;
181    let mut start = 0;
182    for (i, ch) in body.char_indices() {
183        match ch {
184            '(' => depth += 1,
185            ')' => depth -= 1,
186            ',' if depth == 0 => {
187                parts.push(&body[start..i]);
188                start = i + 1;
189            },
190            _ => {},
191        }
192    }
193
194    parts.push(&body[start..]);
195    parts
196}
197
198fn is_direction_token(entry: &str) -> bool {
199    let entry = entry.trim().to_ascii_lowercase();
200    if entry.starts_with("to ") {
201        return true;
202    }
203
204    ["deg", "rad", "grad", "turn"].iter().any(|unit| {
205        entry
206            .strip_suffix(unit)
207            .map(|n| n.parse::<f64>().is_ok())
208            .unwrap_or(false)
209    })
210}
211
212fn tokenize_linear_gradient(src: &str) -> Result<Vec<(String, Option<f64>)>, String> {
213    let src = src.trim();
214    let lower = src.to_ascii_lowercase();
215    let body = lower
216        .strip_prefix("linear-gradient(")
217        .and_then(|x| x.strip_suffix(')'))
218        .ok_or_else(|| format!("expected `linear-gradient(…)`, got `{src}`"))?;
219
220    let entries = split_entries(body);
221    let mut out = vec![];
222    for (index, entry) in entries.iter().enumerate() {
223        let entry = entry.trim();
224        if entry.is_empty() {
225            return Err(format!("empty entry in `{src}`"));
226        }
227
228        if index == 0 && is_direction_token(entry) {
229            continue;
230        }
231
232        let (color, position) = match entry.rfind(|c: char| c.is_whitespace()) {
233            Some(at) if !entry[at..].contains(')') => {
234                let tail = entry[at..].trim();
235                if let Some(pct) = tail.strip_suffix('%') {
236                    let value = pct
237                        .parse::<f64>()
238                        .map_err(|_| format!("invalid stop position `{tail}`"))?;
239                    (entry[..at].trim(), Some(value / 100.0))
240                } else if tail.starts_with(|c: char| c.is_ascii_digit() || c == '.' || c == '-') {
241                    return Err(format!(
242                        "unsupported stop position `{tail}` (only `%` is accepted)"
243                    ));
244                } else {
245                    (entry, None)
246                }
247            },
248            _ => (entry, None),
249        };
250
251        out.push((normalize_css_color(color)?, position));
252    }
253
254    Ok(out)
255}
256
257fn format_percent(offset: f64) -> String {
258    let text = format!("{:.1}", offset * 100.0);
259    let text = text.strip_suffix(".0").unwrap_or(&text).to_owned();
260    format!("{text}%")
261}
262
263/// The canonical gradient string for `stops`: `linear-gradient(to
264/// right, #rrggbb P%, …)` with every position explicit.
265pub fn gradient_to_css(stops: &[GradientStopSpec]) -> String {
266    let body = canonicalize_gradient_stops(stops.to_vec())
267        .iter()
268        .map(|stop| format!("{} {}", stop.color, format_percent(stop.offset)))
269        .collect::<Vec<_>>()
270        .join(", ");
271
272    format!("linear-gradient(to right, {body})")
273}
274
275/// The canonical palette string for `colors`: `linear-gradient(to
276/// right, #rrggbb, …)` with no positions.
277pub fn palette_to_css(colors: &[String]) -> String {
278    format!("linear-gradient(to right, {})", colors.join(", "))
279}
280
281/// A serialized [`ControlSpec::GradientStops`] value.
282///
283/// [`ControlSpec::GradientStops`]: super::ControlSpec::GradientStops
284#[derive(Clone, Debug, PartialEq)]
285pub enum CssGradient {
286    /// Canonical stops (sorted, rounded), length ≥ 2.
287    Literal(Vec<GradientStopSpec>),
288    /// A `--psp-user--gradient-…` property name.
289    Var(String),
290}
291
292impl CssGradient {
293    /// Parse a gradient string of at least 2 stops, with optional
294    /// positions filled by the CSS implicit rules.
295    pub fn parse(src: &str) -> Result<Self, String> {
296        if let Some(name) = parse_kind_var(src, CssKind::Gradient)? {
297            return Ok(CssGradient::Var(name));
298        }
299
300        let entries = tokenize_linear_gradient(src)?;
301        if entries.len() < 2 {
302            return Err(format!("a gradient needs at least 2 stops: `{src}`"));
303        }
304
305        let mut offsets: Vec<Option<f64>> = entries.iter().map(|(_, p)| *p).collect();
306        let last = offsets.len() - 1;
307        offsets[0].get_or_insert(0.0);
308        offsets[last].get_or_insert(1.0);
309        let mut i = 1;
310        while i < last {
311            if offsets[i].is_some() {
312                i += 1;
313                continue;
314            }
315
316            let mut j = i + 1;
317            while offsets[j].is_none() {
318                j += 1;
319            }
320
321            let before = offsets[i - 1].unwrap();
322            let after = offsets[j].unwrap();
323            let span = (j - (i - 1)) as f64;
324            for (k, slot) in offsets.iter_mut().enumerate().take(j).skip(i) {
325                *slot = Some(before + ((k - (i - 1)) as f64 / span) * (after - before));
326            }
327
328            i = j;
329        }
330
331        let stops = entries
332            .into_iter()
333            .zip(offsets)
334            .map(|((color, _), offset)| GradientStopSpec {
335                color,
336                offset: offset.unwrap(),
337            })
338            .collect();
339
340        Ok(CssGradient::Literal(canonicalize_gradient_stops(stops)))
341    }
342
343    pub fn to_css(&self) -> String {
344        match self {
345            CssGradient::Literal(stops) => gradient_to_css(stops),
346            CssGradient::Var(name) => format_var_ref(name),
347        }
348    }
349}
350
351/// A serialized [`ControlSpec::Palette`] value.
352///
353/// [`ControlSpec::Palette`]: super::ControlSpec::Palette
354#[derive(Clone, Debug, PartialEq)]
355pub enum CssPalette {
356    /// Ordered `#rrggbb` entries, length ≥ 1.
357    Literal(Vec<String>),
358    /// A `--psp-user--palette-…` property name.
359    Var(String),
360}
361
362impl CssPalette {
363    /// Parse a palette string of at least 1 entry, with positions
364    /// forbidden.
365    pub fn parse(src: &str) -> Result<Self, String> {
366        if let Some(name) = parse_kind_var(src, CssKind::Palette)? {
367            return Ok(CssPalette::Var(name));
368        }
369
370        let entries = tokenize_linear_gradient(src)?;
371        if entries.is_empty() {
372            return Err(format!("a palette needs at least 1 color: `{src}`"));
373        }
374
375        let mut colors = Vec::with_capacity(entries.len());
376        for (color, position) in entries {
377            if position.is_some() {
378                return Err(format!(
379                    "palette entries must not carry positions (a positioned `linear-gradient()` \
380                     is a gradient, not a palette): `{src}`"
381                ));
382            }
383
384            colors.push(color);
385        }
386
387        Ok(CssPalette::Literal(colors))
388    }
389
390    pub fn to_css(&self) -> String {
391        match self {
392            CssPalette::Literal(colors) => palette_to_css(colors),
393            CssPalette::Var(name) => format_var_ref(name),
394        }
395    }
396}
397
398/// A serialized [`ControlSpec::Color`] value.
399///
400/// [`ControlSpec::Color`]: super::ControlSpec::Color
401#[derive(Clone, Debug, PartialEq)]
402pub enum CssColor {
403    /// Lowercase `#rrggbb`.
404    Literal(String),
405    /// A `--psp-user--color-…` property name.
406    Var(String),
407}
408
409impl CssColor {
410    pub fn parse(src: &str) -> Result<Self, String> {
411        if let Some(name) = parse_kind_var(src, CssKind::Color)? {
412            return Ok(CssColor::Var(name));
413        }
414
415        normalize_css_color(src).map(CssColor::Literal)
416    }
417
418    pub fn to_css(&self) -> String {
419        match self {
420            CssColor::Literal(color) => color.clone(),
421            CssColor::Var(name) => format_var_ref(name),
422        }
423    }
424}
425
426pub fn canonicalize_css_gradient(src: &str) -> Result<String, String> {
427    CssKind::Gradient.canonicalize(src)
428}
429
430pub fn canonicalize_css_palette(src: &str) -> Result<String, String> {
431    CssKind::Palette.canonicalize(src)
432}
433
434pub fn canonicalize_css_color(src: &str) -> Result<String, String> {
435    CssKind::Color.canonicalize(src)
436}
437
438/// One CSS literal stored in a panel's `columns_config`: its column,
439/// key, schema kind and canonical value — the unit of "in use".
440#[derive(Clone, Debug, PartialEq)]
441pub struct CssLiteralUse {
442    pub column: String,
443    pub key: String,
444    pub kind: CssKind,
445    pub literal: String,
446}
447
448/// The workspace palette: `restored` unioned with every in-use
449/// literal, each named deterministically.
450pub fn assign_palette_names(
451    restored: &BTreeMap<String, String>,
452    host: &[NamedValue],
453    in_use: &[(CssKind, String)],
454    is_taken: &dyn Fn(&str) -> bool,
455) -> BTreeMap<String, String> {
456    let mut set = restored.clone();
457    for (kind, literal) in in_use {
458        if palette_name_for(&set, *kind, literal).is_some() {
459            continue;
460        }
461
462        let name = host
463            .iter()
464            .find(|entry| entry.name.starts_with(kind.var_prefix()) && entry.value == *literal)
465            .map(|entry| entry.name.clone())
466            .unwrap_or_else(|| {
467                (1..)
468                    .map(|n| format!("{}{n}", kind.var_prefix()))
469                    .find(|name| !set.contains_key(name) && !is_taken(name))
470                    .unwrap()
471            });
472
473        set.insert(name, literal.clone());
474    }
475
476    set
477}
478
479/// The name under which `set` holds `literal` as a value of `kind`.
480pub fn palette_name_for(
481    set: &BTreeMap<String, String>,
482    kind: CssKind,
483    literal: &str,
484) -> Option<String> {
485    set.iter()
486        .find(|(name, value)| name.starts_with(kind.var_prefix()) && *value == literal)
487        .map(|(name, _)| name.clone())
488}
489
490/// Replace every whole-value `var(--psp-user--…)` string in one
491/// column's config entry with the literal `lookup` resolves it to,
492/// removing and reporting keys that do not resolve.
493pub fn resolve_css_refs(
494    entry: &mut serde_json::Map<String, Value>,
495    lookup: &dyn Fn(&str) -> Option<String>,
496) -> Vec<(String, String)> {
497    let mut dropped = vec![];
498    let refs: Vec<(String, String)> = entry
499        .iter()
500        .filter_map(|(key, value)| {
501            let name = parse_var_ref(value.as_str()?)?;
502            Some((key.clone(), name.to_owned()))
503        })
504        .collect();
505
506    for (key, name) in refs {
507        let resolved = CssKind::of_var(&name)
508            .and_then(|kind| lookup(&name).and_then(|raw| kind.canonicalize(&raw).ok()));
509
510        match resolved {
511            Some(literal) => {
512                entry.insert(key, Value::String(literal));
513            },
514            None => {
515                entry.remove(&key);
516                dropped.push((key, name));
517            },
518        }
519    }
520
521    dropped
522}
523
524#[cfg(test)]
525mod tests {
526    use serde_json::json;
527
528    use super::*;
529
530    fn stop(color: &str, offset: f64) -> GradientStopSpec {
531        GradientStopSpec {
532            color: color.to_owned(),
533            offset,
534        }
535    }
536
537    #[test]
538    fn colors_normalize_to_lowercase_hex() {
539        assert_eq!(normalize_css_color("#ABC").unwrap(), "#aabbcc");
540        assert_eq!(normalize_css_color("#abcd").unwrap(), "#aabbcc");
541        assert_eq!(normalize_css_color(" #FF7F0E ").unwrap(), "#ff7f0e");
542        assert_eq!(normalize_css_color("#ff7f0e80").unwrap(), "#ff7f0e");
543        assert_eq!(normalize_css_color("rgb(255, 127, 14)").unwrap(), "#ff7f0e");
544        assert_eq!(
545            normalize_css_color("rgba(255,127,14,0.5)").unwrap(),
546            "#ff7f0e"
547        );
548        assert_eq!(
549            normalize_css_color("rgb(255 127 14 / 50%)").unwrap(),
550            "#ff7f0e"
551        );
552        assert_eq!(
553            normalize_css_color("rgb(100%, 0%, 50%)").unwrap(),
554            "#ff0080"
555        );
556        assert_eq!(normalize_css_color("rgb(300, -5, 14)").unwrap(), "#ff000e");
557        assert!(normalize_css_color("red").is_err());
558        assert!(normalize_css_color("#12345").is_err());
559        assert!(normalize_css_color("hsl(0, 100%, 50%)").is_err());
560        assert!(normalize_css_color("#gggggg").is_err());
561        assert!(normalize_css_color("").is_err());
562    }
563
564    #[test]
565    fn var_refs_are_whole_value_and_namespaced() {
566        assert_eq!(
567            parse_var_ref("var(--psp-user--gradient-1)"),
568            Some("--psp-user--gradient-1")
569        );
570        assert_eq!(
571            parse_var_ref("  var( --psp-user--color-hot )  "),
572            Some("--psp-user--color-hot")
573        );
574        assert_eq!(parse_var_ref("var(--psp-charts--gradient)"), None);
575        assert_eq!(parse_var_ref("var(--psp-user--)"), None);
576        assert_eq!(parse_var_ref("var(--psp-user--gradient-1, red)"), None);
577        assert_eq!(parse_var_ref("#ff0000"), None);
578        assert_eq!(
579            parse_var_ref("linear-gradient(var(--psp-user--color-1), #fff)"),
580            None
581        );
582    }
583
584    #[test]
585    fn kind_is_carried_by_the_reference_prefix() {
586        assert_eq!(
587            CssKind::of_var("--psp-user--gradient-1"),
588            Some(CssKind::Gradient)
589        );
590        assert_eq!(
591            CssKind::of_var("--psp-user--palette-warm"),
592            Some(CssKind::Palette)
593        );
594        assert_eq!(CssKind::of_var("--psp-user--color-1"), Some(CssKind::Color));
595        assert_eq!(CssKind::of_var("--psp-user--other-1"), None);
596        assert_eq!(
597            CssKind::Palette.short_name("--psp-user--palette-warm"),
598            "warm"
599        );
600
601        assert_eq!(
602            CssGradient::parse("var(--psp-user--gradient-1)").unwrap(),
603            CssGradient::Var("--psp-user--gradient-1".to_owned())
604        );
605        assert!(CssGradient::parse("var(--psp-user--palette-1)").is_err());
606        assert!(CssPalette::parse("var(--psp-user--gradient-1)").is_err());
607        assert!(CssColor::parse("var(--psp-user--palette-1)").is_err());
608        assert_eq!(
609            CssColor::parse("var(--psp-user--color-1)")
610                .unwrap()
611                .to_css(),
612            "var(--psp-user--color-1)"
613        );
614
615        assert_eq!(
616            canonicalize_css_palette("var(--psp-user--palette-1)").unwrap(),
617            "var(--psp-user--palette-1)"
618        );
619    }
620
621    #[test]
622    fn gradient_round_trip_is_canonical_and_idempotent() {
623        let src = "linear-gradient(to right, #0366d6 0%, #ffffff 33.3%, #ff7f0e 100%)";
624        let canon = canonicalize_css_gradient(src).unwrap();
625        assert_eq!(canon, src);
626        assert_eq!(canonicalize_css_gradient(&canon).unwrap(), canon);
627
628        let CssGradient::Literal(stops) = CssGradient::parse(src).unwrap() else {
629            panic!("expected literal");
630        };
631
632        assert_eq!(stops, vec![
633            stop("#0366d6", 0.0),
634            stop("#ffffff", 0.333),
635            stop("#ff7f0e", 1.0),
636        ]);
637
638        let css = gradient_to_css(&[
639            stop("#222222", 1.7),
640            stop("#111111", 1.0 / 3.0),
641            stop("#000000", -0.5),
642        ]);
643
644        assert_eq!(
645            css,
646            "linear-gradient(to right, #000000 0%, #111111 33.3%, #222222 100%)"
647        );
648
649        assert_eq!(format_percent(0.375), "37.5%");
650        assert_eq!(format_percent(0.1), "10%");
651        assert_eq!(format_percent(1.0), "100%");
652    }
653
654    #[test]
655    fn gradient_accepts_loose_input() {
656        assert_eq!(
657            canonicalize_css_gradient("linear-gradient(#0366d6 0%, #ff7f0e 100%)").unwrap(),
658            "linear-gradient(to right, #0366d6 0%, #ff7f0e 100%)"
659        );
660
661        assert_eq!(
662            canonicalize_css_gradient("LINEAR-GRADIENT(90deg, RGB(3, 102, 214), #FFF, #ff7f0e)")
663                .unwrap(),
664            "linear-gradient(to right, #0366d6 0%, #ffffff 50%, #ff7f0e 100%)"
665        );
666
667        assert_eq!(
668            canonicalize_css_gradient(
669                "linear-gradient(to bottom, #000 10%, #111, #222, #333 70%, #444)"
670            )
671            .unwrap(),
672            "linear-gradient(to right, #000000 10%, #111111 30%, #222222 50%, #333333 70%, \
673             #444444 100%)"
674        );
675
676        assert_eq!(
677            canonicalize_css_gradient("linear-gradient(#fff 120%, #000 -5%)").unwrap(),
678            "linear-gradient(to right, #000000 0%, #ffffff 100%)"
679        );
680    }
681
682    #[test]
683    fn gradient_rejects_malformed_input() {
684        assert!(canonicalize_css_gradient("#ff0000").is_err());
685        assert!(canonicalize_css_gradient("linear-gradient(#ff0000)").is_err());
686        assert!(canonicalize_css_gradient("linear-gradient(#ff0000 0px, #fff 10px)").is_err());
687        assert!(canonicalize_css_gradient("linear-gradient(red, blue)").is_err());
688        assert!(canonicalize_css_gradient("radial-gradient(#000, #fff)").is_err());
689        assert!(canonicalize_css_gradient("linear-gradient(#000, , #fff)").is_err());
690        assert!(canonicalize_css_gradient("").is_err());
691    }
692
693    #[test]
694    fn palette_round_trip_and_rules() {
695        let src = "linear-gradient(to right, #0366d6, #ff7f0e, #2ca02c)";
696        assert_eq!(canonicalize_css_palette(src).unwrap(), src);
697        assert_eq!(
698            CssPalette::parse(src).unwrap(),
699            CssPalette::Literal(vec![
700                "#0366d6".to_owned(),
701                "#ff7f0e".to_owned(),
702                "#2ca02c".to_owned()
703            ])
704        );
705
706        assert_eq!(
707            canonicalize_css_palette("linear-gradient(90deg, rgb(3,102,214), #FFF)").unwrap(),
708            "linear-gradient(to right, #0366d6, #ffffff)"
709        );
710
711        assert_eq!(
712            canonicalize_css_palette("linear-gradient(#abc)").unwrap(),
713            "linear-gradient(to right, #aabbcc)"
714        );
715
716        assert_eq!(
717            palette_to_css(&["#000000".to_owned(), "#ffffff".to_owned()]),
718            "linear-gradient(to right, #000000, #ffffff)"
719        );
720    }
721
722    #[test]
723    fn palette_rejects_positions() {
724        let gradient = gradient_to_css(&[stop("#000000", 0.0), stop("#ffffff", 1.0)]);
725        assert!(CssPalette::parse(&gradient).is_err());
726        assert!(canonicalize_css_palette("linear-gradient(#000, #fff 50%)").is_err());
727        assert!(canonicalize_css_palette("linear-gradient()").is_err());
728        assert!(canonicalize_css_palette("#000000").is_err());
729
730        let palette = palette_to_css(&["#000000".to_owned(), "#ffffff".to_owned()]);
731        assert_eq!(
732            canonicalize_css_gradient(&palette).unwrap(),
733            "linear-gradient(to right, #000000 0%, #ffffff 100%)"
734        );
735    }
736
737    #[test]
738    fn color_canonicalizes_literals_and_passes_refs() {
739        assert_eq!(canonicalize_css_color("#ABC").unwrap(), "#aabbcc");
740        assert_eq!(
741            canonicalize_css_color("var(--psp-user--color-hot)").unwrap(),
742            "var(--psp-user--color-hot)"
743        );
744        assert!(canonicalize_css_color("var(--psp-user--gradient-1)").is_err());
745        assert!(canonicalize_css_color("linear-gradient(#000, #fff)").is_err());
746    }
747
748    #[test]
749    fn palette_set_unions_restored_and_in_use_with_stable_names() {
750        let mut restored = BTreeMap::new();
751        restored.insert(
752            "--psp-user--gradient-heat".to_owned(),
753            "linear-gradient(to right, #000000 0%, #ffffff 100%)".to_owned(),
754        );
755        restored.insert("--psp-user--color-unused".to_owned(), "#123456".to_owned());
756        let host = vec![NamedValue {
757            name: "--psp-user--palette-1".to_owned(),
758            value: "linear-gradient(to right, #ff0000, #00ff00)".to_owned(),
759        }];
760        let taken =
761            |name: &str| name == "--psp-user--gradient-1" || name == "--psp-user--palette-1";
762        let in_use = vec![
763            (
764                CssKind::Gradient,
765                "linear-gradient(to right, #000000 0%, #ffffff 100%)".to_owned(),
766            ),
767            (
768                CssKind::Gradient,
769                "linear-gradient(to right, #111111 0%, #222222 100%)".to_owned(),
770            ),
771            (
772                CssKind::Gradient,
773                "linear-gradient(to right, #111111 0%, #222222 100%)".to_owned(),
774            ),
775            (
776                CssKind::Palette,
777                "linear-gradient(to right, #ff0000, #00ff00)".to_owned(),
778            ),
779            (CssKind::Color, "#abcdef".to_owned()),
780        ];
781
782        let set = assign_palette_names(&restored, &host, &in_use, &taken);
783        assert_eq!(
784            set.iter()
785                .map(|(k, v)| (k.as_str(), v.as_str()))
786                .collect::<Vec<_>>(),
787            vec![
788                ("--psp-user--color-1", "#abcdef"),
789                ("--psp-user--color-unused", "#123456"),
790                (
791                    "--psp-user--gradient-2",
792                    "linear-gradient(to right, #111111 0%, #222222 100%)"
793                ),
794                (
795                    "--psp-user--gradient-heat",
796                    "linear-gradient(to right, #000000 0%, #ffffff 100%)"
797                ),
798                (
799                    "--psp-user--palette-1",
800                    "linear-gradient(to right, #ff0000, #00ff00)"
801                ),
802            ]
803        );
804
805        assert_eq!(
806            palette_name_for(
807                &set,
808                CssKind::Gradient,
809                "linear-gradient(to right, #111111 0%, #222222 100%)"
810            )
811            .as_deref(),
812            Some("--psp-user--gradient-2")
813        );
814        assert_eq!(palette_name_for(&set, CssKind::Gradient, "#abcdef"), None);
815
816        assert_eq!(assign_palette_names(&set, &host, &in_use, &taken), set);
817    }
818
819    #[test]
820    fn resolver_inlines_known_refs_and_drops_the_rest() {
821        let host = |name: &str| -> Option<String> {
822            match name {
823                "--psp-user--gradient-1" => Some("linear-gradient(#000, #fff)".to_owned()),
824                "--psp-user--palette-warm" => Some("linear-gradient(#f00, #ff0)".to_owned()),
825                "--psp-user--color-hot" => Some("rgb(255, 0, 0)".to_owned()),
826                "--psp-user--palette-bad" => Some("linear-gradient(#000 0%, #fff 100%)".to_owned()),
827                "--psp-user--color-empty" => Some("   ".to_owned()),
828                _ => None,
829            }
830        };
831
832        let mut entry = json!({
833            "gradient": "var(--psp-user--gradient-1)",
834            "palette": "var(--psp-user--palette-warm)",
835            "color": "var(--psp-user--color-hot)",
836            "bad_palette": "var(--psp-user--palette-bad)",
837            "missing": "var(--psp-user--gradient-missing)",
838            "empty": "var(--psp-user--color-empty)",
839            "unknown_kind": "var(--psp-user--other-1)",
840            "literal": "#123456",
841            "number": 3,
842        })
843        .as_object()
844        .unwrap()
845        .clone();
846
847        let mut dropped = resolve_css_refs(&mut entry, &host);
848        dropped.sort();
849        assert_eq!(dropped, vec![
850            (
851                "bad_palette".to_owned(),
852                "--psp-user--palette-bad".to_owned()
853            ),
854            ("empty".to_owned(), "--psp-user--color-empty".to_owned()),
855            (
856                "missing".to_owned(),
857                "--psp-user--gradient-missing".to_owned()
858            ),
859            ("unknown_kind".to_owned(), "--psp-user--other-1".to_owned()),
860        ]);
861
862        assert_eq!(
863            entry,
864            json!({
865                "gradient": "linear-gradient(to right, #000000 0%, #ffffff 100%)",
866                "palette": "linear-gradient(to right, #ff0000, #ffff00)",
867                "color": "#ff0000",
868                "literal": "#123456",
869                "number": 3,
870            })
871            .as_object()
872            .unwrap()
873            .clone()
874        );
875
876        assert!(entry.values().all(|v| {
877            v.as_str()
878                .map(|s| parse_var_ref(s).is_none())
879                .unwrap_or(true)
880        }));
881    }
882}