Skip to main content

css_variable_lsp/
color.rs

1use csscolorparser::Color as CssColor;
2use ls_types::{Color, ColorPresentation, Range, TextEdit};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
5pub struct NormalizedColorKey {
6    pub red: u8,
7    pub green: u8,
8    pub blue: u8,
9    pub alpha: u8,
10}
11
12/// Parse a CSS color value and return an LSP Color
13pub fn parse_color(value: &str) -> Option<Color> {
14    parse_csscolorparser(value.trim())
15}
16
17pub fn normalized_color_key(value: &str) -> Option<NormalizedColorKey> {
18    parse_color(value).map(normalize_color)
19}
20
21pub fn normalize_color(color: Color) -> NormalizedColorKey {
22    NormalizedColorKey {
23        red: color_channel_to_u8(color.red),
24        green: color_channel_to_u8(color.green),
25        blue: color_channel_to_u8(color.blue),
26        alpha: color_channel_to_u8(color.alpha),
27    }
28}
29
30pub fn color_from_key(key: NormalizedColorKey) -> Color {
31    Color {
32        red: key.red as f32 / 255.0,
33        green: key.green as f32 / 255.0,
34        blue: key.blue as f32 / 255.0,
35        alpha: key.alpha as f32 / 255.0,
36    }
37}
38
39fn parse_csscolorparser(value: &str) -> Option<Color> {
40    let parsed: CssColor = value.parse().ok()?;
41    Some(Color {
42        red: parsed.r as f32,
43        green: parsed.g as f32,
44        blue: parsed.b as f32,
45        alpha: parsed.a as f32,
46    })
47}
48
49/// Generate color presentations for color picker
50pub fn generate_color_presentations(color: Color, range: Range) -> Vec<ColorPresentation> {
51    let mut presentations = Vec::new();
52
53    let hex_str = format_color_as_hex(color);
54    presentations.push(ColorPresentation {
55        label: hex_str.clone(),
56        text_edit: Some(TextEdit {
57            range,
58            new_text: hex_str,
59        }),
60        additional_text_edits: None,
61    });
62
63    let rgb_str = format_color_as_rgb(color);
64    presentations.push(ColorPresentation {
65        label: rgb_str.clone(),
66        text_edit: Some(TextEdit {
67            range,
68            new_text: rgb_str,
69        }),
70        additional_text_edits: None,
71    });
72
73    let hsl_str = format_color_as_hsl(color);
74    presentations.push(ColorPresentation {
75        label: hsl_str.clone(),
76        text_edit: Some(TextEdit {
77            range,
78            new_text: hsl_str,
79        }),
80        additional_text_edits: None,
81    });
82
83    presentations
84}
85
86pub fn format_color_as_hex(color: Color) -> String {
87    let r = (color.red.clamp(0.0, 1.0) * 255.0).round() as u8;
88    let g = (color.green.clamp(0.0, 1.0) * 255.0).round() as u8;
89    let b = (color.blue.clamp(0.0, 1.0) * 255.0).round() as u8;
90    let a = (color.alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
91
92    if a == 255 {
93        format!("#{:02x}{:02x}{:02x}", r, g, b)
94    } else {
95        format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)
96    }
97}
98
99pub fn format_color_as_rgb(color: Color) -> String {
100    let r = (color.red.clamp(0.0, 1.0) * 255.0).round() as u8;
101    let g = (color.green.clamp(0.0, 1.0) * 255.0).round() as u8;
102    let b = (color.blue.clamp(0.0, 1.0) * 255.0).round() as u8;
103    let a = color.alpha.clamp(0.0, 1.0);
104
105    if a >= 1.0 {
106        format!("rgb({}, {}, {})", r, g, b)
107    } else {
108        format!("rgba({}, {}, {}, {:.2})", r, g, b, a)
109    }
110}
111
112pub fn format_color_as_hsl(color: Color) -> String {
113    let (h, s, l) = rgb_to_hsl(color.red, color.green, color.blue);
114    let a = color.alpha.clamp(0.0, 1.0);
115
116    let h_deg = (h * 360.0).round();
117    let s_pct = (s * 100.0).round();
118    let l_pct = (l * 100.0).round();
119
120    if a >= 1.0 {
121        format!("hsl({}, {}%, {}%)", h_deg, s_pct, l_pct)
122    } else {
123        format!("hsla({}, {}%, {}%, {:.2})", h_deg, s_pct, l_pct, a)
124    }
125}
126
127fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
128    let max = r.max(g).max(b);
129    let min = r.min(g).min(b);
130    let l = (max + min) / 2.0;
131
132    if max == min {
133        return (0.0, 0.0, l);
134    }
135
136    let d = max - min;
137    let s = if l > 0.5 {
138        d / (2.0 - max - min)
139    } else {
140        d / (max + min)
141    };
142
143    let h = if max == r {
144        ((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0
145    } else if max == g {
146        ((b - r) / d + 2.0) / 6.0
147    } else {
148        ((r - g) / d + 4.0) / 6.0
149    };
150
151    (h, s, l)
152}
153
154fn color_channel_to_u8(channel: f32) -> u8 {
155    (channel.clamp(0.0, 1.0) * 255.0).round() as u8
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use ls_types::Position;
162
163    fn approx_eq(a: f32, b: f32) -> bool {
164        (a - b).abs() < 0.01
165    }
166
167    #[test]
168    fn parse_color_hex_and_named() {
169        let color = parse_color("#abc").expect("hex");
170        assert!(approx_eq(color.red, 0xAA as f32 / 255.0));
171        assert!(approx_eq(color.green, 0xBB as f32 / 255.0));
172        assert!(approx_eq(color.blue, 0xCC as f32 / 255.0));
173        assert!(approx_eq(color.alpha, 1.0));
174
175        let color = parse_color("#abcd").expect("hex with alpha");
176        assert!(approx_eq(color.red, 0xAA as f32 / 255.0));
177        assert!(approx_eq(color.alpha, 0xDD as f32 / 255.0));
178
179        let color = parse_color("blue").expect("named");
180        assert!(approx_eq(color.blue, 1.0));
181        assert!(approx_eq(color.red, 0.0));
182    }
183
184    #[test]
185    fn parse_color_rgb_variants() {
186        let color = parse_color("rgb(255, 0, 128)").expect("rgb");
187        assert!(approx_eq(color.red, 1.0));
188        assert!(approx_eq(color.green, 0.0));
189        assert!(approx_eq(color.blue, 128.0 / 255.0));
190
191        let color = parse_color("rgba(255, 0, 0, 0.5)").expect("rgba");
192        assert!(approx_eq(color.red, 1.0));
193        assert!(approx_eq(color.alpha, 0.5));
194
195        let color = parse_color("rgb(100%, 0%, 50%)").expect("rgb percent");
196        assert!(approx_eq(color.red, 1.0));
197        assert!(approx_eq(color.blue, 0.5));
198
199        let color = parse_color("rgba(255, 0, 0, 50%)").expect("rgba percent");
200        assert!(approx_eq(color.alpha, 0.5));
201    }
202
203    #[test]
204    fn normalized_color_key_matches_equivalent_inputs() {
205        let white = normalized_color_key("white").expect("white");
206        assert_eq!(white, normalized_color_key("#fff").expect("#fff"));
207        assert_eq!(white, normalized_color_key("#ffffff").expect("#ffffff"));
208        assert_eq!(
209            white,
210            normalized_color_key("rgb(255 255 255)").expect("rgb")
211        );
212        assert_eq!(white, normalized_color_key("hsl(0 0% 100%)").expect("hsl"));
213    }
214
215    #[test]
216    fn normalized_color_key_preserves_alpha() {
217        let translucent = normalized_color_key("rgba(255, 255, 255, 0.5)").expect("rgba");
218        assert_eq!(
219            translucent,
220            normalized_color_key("#ffffff80").expect("hex alpha")
221        );
222        assert_ne!(translucent, normalized_color_key("white").expect("white"));
223    }
224
225    #[test]
226    fn generate_color_presentations_formats_output() {
227        let range = Range::new(Position::new(0, 0), Position::new(0, 4));
228        let color = Color {
229            red: 1.0,
230            green: 0.0,
231            blue: 0.5,
232            alpha: 1.0,
233        };
234        let presentations = generate_color_presentations(color, range);
235        assert_eq!(presentations.len(), 3);
236        assert!(presentations[0].label.starts_with('#'));
237        assert!(presentations[1].label.starts_with("rgb("));
238        assert!(presentations[2].label.starts_with("hsl("));
239
240        let color = Color {
241            red: 1.0,
242            green: 0.0,
243            blue: 0.0,
244            alpha: 0.5,
245        };
246        let presentations = generate_color_presentations(color, range);
247        assert_eq!(presentations.len(), 3);
248        assert!(presentations[0].label.starts_with('#'));
249        assert!(presentations[1].label.starts_with("rgba("));
250        assert!(presentations[2].label.starts_with("hsla("));
251    }
252
253    #[test]
254    fn format_color_hex_opaque_and_transparent() {
255        // Opaque color (alpha = 255)
256        let color = Color {
257            red: 0.0,
258            green: 0.5,
259            blue: 1.0,
260            alpha: 1.0,
261        };
262        let hex = format_color_as_hex(color);
263        assert_eq!(hex, "#0080ff");
264
265        // Transparent color (alpha < 255)
266        let color = Color {
267            red: 1.0,
268            green: 0.0,
269            blue: 0.0,
270            alpha: 0.5,
271        };
272        let hex = format_color_as_hex(color);
273        assert_eq!(hex, "#ff000080");
274    }
275
276    #[test]
277    fn format_color_rgb_with_alpha() {
278        let color = Color {
279            red: 0.5,
280            green: 0.5,
281            blue: 0.5,
282            alpha: 1.0,
283        };
284        let rgb = format_color_as_rgb(color);
285        assert_eq!(rgb, "rgb(128, 128, 128)");
286
287        let color = Color {
288            red: 1.0,
289            green: 0.0,
290            blue: 0.0,
291            alpha: 0.75,
292        };
293        let rgba = format_color_as_rgb(color);
294        assert_eq!(rgba, "rgba(255, 0, 0, 0.75)");
295    }
296
297    #[test]
298    fn format_color_hsl_with_alpha() {
299        let color = Color {
300            red: 1.0,
301            green: 0.0,
302            blue: 0.0,
303            alpha: 1.0,
304        };
305        let hsl = format_color_as_hsl(color);
306        assert!(hsl.starts_with("hsl("));
307        assert!(hsl.contains("0,") || hsl.contains("360,")); // Red hue
308
309        let color = Color {
310            red: 0.0,
311            green: 0.5,
312            blue: 1.0,
313            alpha: 0.5,
314        };
315        let hsla = format_color_as_hsl(color);
316        assert!(hsla.starts_with("hsla("));
317        assert!(hsla.contains("0.50"));
318    }
319
320    #[test]
321    fn rgb_to_hsl_conversion() {
322        // Pure red
323        let (h, s, l) = rgb_to_hsl(1.0, 0.0, 0.0);
324        assert!(approx_eq(h, 0.0));
325        assert!(approx_eq(s, 1.0));
326        assert!(approx_eq(l, 0.5));
327
328        // Pure green
329        let (h, s, l) = rgb_to_hsl(0.0, 1.0, 0.0);
330        assert!(approx_eq(h, 1.0 / 3.0));
331        assert!(approx_eq(s, 1.0));
332        assert!(approx_eq(l, 0.5));
333
334        // Gray (no saturation)
335        let (_h, s, l) = rgb_to_hsl(0.5, 0.5, 0.5);
336        assert!(approx_eq(s, 0.0));
337        assert!(approx_eq(l, 0.5));
338    }
339
340    #[test]
341    fn parse_color_edge_cases() {
342        // Invalid colors should return None
343        assert!(parse_color("not-a-color").is_none());
344        assert!(parse_color("").is_none());
345        assert!(parse_color("rgb(999, 999, 999)").is_some()); // Clamped by parser
346
347        // Named colors
348        assert!(parse_color("rebeccapurple").is_some());
349        assert!(parse_color("aliceblue").is_some());
350
351        // Transparent keyword
352        let color = parse_color("transparent").expect("transparent");
353        assert!(approx_eq(color.alpha, 0.0));
354    }
355
356    #[test]
357    fn color_clamping() {
358        // Test that colors are properly clamped to [0, 1]
359        let color = Color {
360            red: 1.5,
361            green: -0.5,
362            blue: 0.5,
363            alpha: 2.0,
364        };
365
366        let hex = format_color_as_hex(color);
367        assert!(hex.starts_with('#'));
368
369        let rgb = format_color_as_rgb(color);
370        assert!(rgb.contains("255")); // Red clamped to max
371        assert!(rgb.contains("0")); // Green clamped to min
372    }
373}