Skip to main content

textual_rs/css/
property.rs

1//! TCSS property value parser: converts raw CSS tokens into typed TcssValue variants.
2
3use cssparser::{ParseError, Parser, Token};
4use cssparser_color::Color as ParsedColor;
5
6use crate::css::types::{
7    BorderStyle, Declaration, DockEdge, HatchStyle, LayoutDirection, Overflow, Sides, TcssColor,
8    TcssDimension, TcssDisplay, TcssValue, TextAlign, Visibility,
9};
10
11/// Try to parse a `$variable` token sequence from the CSS parser.
12///
13/// Detects `Token::Delim('$')` followed by an ident (the base name), then optionally
14/// a `-lighten-N` or `-darken-N` suffix. Returns the full variable name string
15/// (e.g. "primary", "accent-darken-1") or None if the next token is not `$`.
16fn try_parse_variable<'i>(input: &mut Parser<'i, '_>) -> Option<String> {
17    let state = input.state();
18    match input.next() {
19        Ok(&Token::Delim('$')) => {
20            // Read the base ident (e.g. "primary")
21            let base = match input.expect_ident_cloned() {
22                Ok(ident) => ident.to_string(),
23                Err(_) => {
24                    input.reset(&state);
25                    return None;
26                }
27            };
28
29            // Try to read -lighten-N or -darken-N suffix
30            let suffix_state = input.state();
31            if let Ok(&Token::Delim('-')) = input.next() {
32                if let Ok(modifier) = input.expect_ident_cloned() {
33                    if modifier == "lighten" || modifier == "darken" {
34                        let dash_state = input.state();
35                        if let Ok(&Token::Delim('-')) = input.next() {
36                            if let Ok(&Token::Number {
37                                int_value: Some(n), ..
38                            }) = input.next()
39                            {
40                                let mut name = base;
41                                name.push('-');
42                                name.push_str(&modifier);
43                                name.push('-');
44                                name.push_str(&n.to_string());
45                                return Some(name);
46                            }
47                        }
48                        // Failed to read the number part, reset to after modifier
49                        input.reset(&dash_state);
50                        // Actually we consumed "lighten"/"darken" but no -N, reset fully
51                        input.reset(&suffix_state);
52                        return Some(base);
53                    }
54                }
55                // Not a lighten/darken modifier, reset
56                input.reset(&suffix_state);
57            } else {
58                input.reset(&suffix_state);
59            }
60
61            Some(base)
62        }
63        _ => {
64            input.reset(&state);
65            None
66        }
67    }
68}
69
70/// Error type for property parsing.
71#[derive(Debug, Clone)]
72pub enum PropertyParseError {
73    /// The property name is not recognized by the TCSS engine.
74    UnknownProperty(String),
75    /// The property value could not be parsed into the expected type.
76    InvalidValue(String),
77}
78
79/// Parse a color token sequence into TcssColor.
80fn parse_color<'i>(
81    input: &mut Parser<'i, '_>,
82) -> Result<TcssColor, ParseError<'i, PropertyParseError>> {
83    let location = input.current_source_location();
84    let color = ParsedColor::parse(input).map_err(|e| {
85        location.new_custom_error(PropertyParseError::InvalidValue(format!(
86            "invalid color: {:?}",
87            e
88        )))
89    })?;
90
91    match color {
92        ParsedColor::Rgba(rgba) => {
93            if rgba.alpha >= 1.0 - f32::EPSILON {
94                Ok(TcssColor::Rgb(rgba.red, rgba.green, rgba.blue))
95            } else {
96                // Convert 0.0-1.0 alpha to 0-255 range
97                let alpha_u8 = (rgba.alpha * 255.0).round() as u8;
98                Ok(TcssColor::Rgba(rgba.red, rgba.green, rgba.blue, alpha_u8))
99            }
100        }
101        ParsedColor::CurrentColor => Ok(TcssColor::Reset),
102        _ => Err(location.new_custom_error(PropertyParseError::InvalidValue(
103            "unsupported color format".to_string(),
104        ))),
105    }
106}
107
108/// Parse a dimension value: number (Length), number% (Percent), number fr (Fraction), "auto" (Auto).
109fn parse_dimension<'i>(
110    input: &mut Parser<'i, '_>,
111) -> Result<TcssDimension, ParseError<'i, PropertyParseError>> {
112    let location = input.current_source_location();
113    match input.next()? {
114        Token::Ident(name) if name.eq_ignore_ascii_case("auto") => Ok(TcssDimension::Auto),
115        Token::Number { value, .. } => Ok(TcssDimension::Length(*value)),
116        Token::Percentage { unit_value, .. } => Ok(TcssDimension::Percent(*unit_value * 100.0)),
117        Token::Dimension { value, unit, .. } if unit.eq_ignore_ascii_case("fr") => {
118            Ok(TcssDimension::Fraction(*value))
119        }
120        other => Err(
121            location.new_custom_error(PropertyParseError::InvalidValue(format!(
122                "expected dimension value, got {:?}",
123                other
124            ))),
125        ),
126    }
127}
128
129/// Parse a non-negative float/number for padding/margin cell values.
130fn parse_cells<'i>(input: &mut Parser<'i, '_>) -> Result<f32, ParseError<'i, PropertyParseError>> {
131    let location = input.current_source_location();
132    match input.next()? {
133        Token::Number { value, .. } => Ok(*value),
134        other => Err(
135            location.new_custom_error(PropertyParseError::InvalidValue(format!(
136                "expected number, got {:?}",
137                other
138            ))),
139        ),
140    }
141}
142
143/// Parse a declaration block (the part between `{` and `}`).
144/// Returns a list of parsed declarations and skips unknown/invalid properties with collected errors.
145pub fn parse_declaration_block<'i>(
146    input: &mut Parser<'i, '_>,
147) -> Result<Vec<Declaration>, ParseError<'i, PropertyParseError>> {
148    let mut declarations = Vec::new();
149
150    loop {
151        input.skip_whitespace();
152        if input.is_exhausted() {
153            break;
154        }
155
156        // Parse property name
157        let location = input.current_source_location();
158        let property_name = match input.next() {
159            Ok(Token::Ident(name)) => name.to_string(),
160            Ok(_) | Err(_) => break,
161        };
162
163        input.skip_whitespace();
164
165        // Expect colon
166        match input.next() {
167            Ok(Token::Colon) => {}
168            _ => {
169                // Skip to next semicolon and continue
170                let _ = input.parse_until_after(cssparser::Delimiter::Semicolon, |_| {
171                    Ok::<(), ParseError<'i, PropertyParseError>>(())
172                });
173                continue;
174            }
175        }
176
177        input.skip_whitespace();
178
179        // Parse value based on property name
180        let result = parse_property_value(input, &property_name, location);
181
182        match result {
183            Ok(Some(value)) => {
184                declarations.push(Declaration {
185                    property: property_name,
186                    value,
187                });
188            }
189            Ok(None) | Err(_) => {
190                // Unknown property or parse error — consume everything up to the
191                // next semicolon so we don't eat the following property's tokens.
192                let _ = input.parse_until_after(cssparser::Delimiter::Semicolon, |_| {
193                    Ok::<(), ParseError<'i, PropertyParseError>>(())
194                });
195                continue;
196            }
197        }
198
199        // Skip to semicolon or end
200        input.skip_whitespace();
201        let state = input.state();
202        match input.next() {
203            Ok(Token::Semicolon) => {}
204            Ok(_) => {
205                input.reset(&state);
206            }
207            Err(_) => break,
208        }
209    }
210
211    Ok(declarations)
212}
213
214fn parse_property_value<'i>(
215    input: &mut Parser<'i, '_>,
216    property_name: &str,
217    location: cssparser::SourceLocation,
218) -> Result<Option<TcssValue>, ParseError<'i, PropertyParseError>> {
219    match property_name {
220        "color" | "background" => {
221            if let Some(var_name) = try_parse_variable(input) {
222                Ok(Some(TcssValue::Variable(var_name)))
223            } else {
224                Ok(Some(TcssValue::Color(parse_color(input)?)))
225            }
226        }
227        "border" => {
228            let name = input.expect_ident_cloned().map_err(|e| {
229                location.new_custom_error(PropertyParseError::InvalidValue(format!("{:?}", e)))
230            })?;
231            let style = match name.as_ref() {
232                "none" => BorderStyle::None,
233                "solid" => BorderStyle::Solid,
234                "rounded" => BorderStyle::Rounded,
235                "heavy" => BorderStyle::Heavy,
236                "double" => BorderStyle::Double,
237                "ascii" => BorderStyle::Ascii,
238                "tall" => BorderStyle::Tall,
239                "inner" | "mcgugan" => BorderStyle::McguganBox,
240                other => {
241                    return Err(location.new_custom_error(PropertyParseError::InvalidValue(
242                        format!("unknown border style: {}", other),
243                    )));
244                }
245            };
246            // Try variable first (e.g. "border: tall $primary")
247            if let Some(var_name) = try_parse_variable(input) {
248                return Ok(Some(TcssValue::BorderWithVariable(style, var_name)));
249            }
250            // Then try literal color (e.g. "border: solid #4a4a5a")
251            let color = parse_color(input).ok();
252            if let Some(c) = color {
253                return Ok(Some(TcssValue::BorderWithColor(style, c)));
254            }
255            Ok(Some(TcssValue::Border(style)))
256        }
257        "border-title" => {
258            let s = input.expect_string_cloned().map_err(|e| {
259                location.new_custom_error(PropertyParseError::InvalidValue(format!("{:?}", e)))
260            })?;
261            Ok(Some(TcssValue::String(s.to_string())))
262        }
263        "width" | "height" | "min-width" | "min-height" | "max-width" | "max-height" => {
264            Ok(Some(TcssValue::Dimension(parse_dimension(input)?)))
265        }
266        "padding" | "margin" => {
267            // Parse 1-4 values
268            let first = parse_cells(input)?;
269            input.skip_whitespace();
270
271            // Try to peek for more values
272            let state = input.state();
273            match input.next_including_whitespace() {
274                Ok(Token::Number { value, .. }) => {
275                    let second = *value;
276                    input.skip_whitespace();
277                    let state2 = input.state();
278                    match input.next_including_whitespace() {
279                        Ok(Token::Number { value, .. }) => {
280                            let third = *value;
281                            input.skip_whitespace();
282                            let state3 = input.state();
283                            match input.next_including_whitespace() {
284                                Ok(Token::Number { value, .. }) => {
285                                    let fourth = *value;
286                                    // 4 values: top right bottom left
287                                    Ok(Some(TcssValue::Sides(Sides {
288                                        top: first,
289                                        right: second,
290                                        bottom: third,
291                                        left: fourth,
292                                    })))
293                                }
294                                _ => {
295                                    input.reset(&state3);
296                                    // 3 values: top, left/right, bottom
297                                    Ok(Some(TcssValue::Sides(Sides {
298                                        top: first,
299                                        right: second,
300                                        bottom: third,
301                                        left: second,
302                                    })))
303                                }
304                            }
305                        }
306                        _ => {
307                            input.reset(&state2);
308                            // 2 values: top/bottom + left/right
309                            Ok(Some(TcssValue::Sides(Sides {
310                                top: first,
311                                right: second,
312                                bottom: first,
313                                left: second,
314                            })))
315                        }
316                    }
317                }
318                _ => {
319                    input.reset(&state);
320                    // 1 value: all sides
321                    Ok(Some(TcssValue::Float(first)))
322                }
323            }
324        }
325        "display" => {
326            let name = input.expect_ident_cloned().map_err(|e| {
327                location.new_custom_error(PropertyParseError::InvalidValue(format!("{:?}", e)))
328            })?;
329            let d = match name.as_ref() {
330                "flex" => TcssDisplay::Flex,
331                "grid" => TcssDisplay::Grid,
332                "block" => TcssDisplay::Block,
333                "none" => TcssDisplay::None,
334                other => {
335                    return Err(location.new_custom_error(PropertyParseError::InvalidValue(
336                        format!("unknown display value: {}", other),
337                    )));
338                }
339            };
340            Ok(Some(TcssValue::Display(d)))
341        }
342        "visibility" => {
343            let name = input.expect_ident_cloned().map_err(|e| {
344                location.new_custom_error(PropertyParseError::InvalidValue(format!("{:?}", e)))
345            })?;
346            let v = match name.as_ref() {
347                "visible" => Visibility::Visible,
348                "hidden" => Visibility::Hidden,
349                other => {
350                    return Err(location.new_custom_error(PropertyParseError::InvalidValue(
351                        format!("unknown visibility value: {}", other),
352                    )));
353                }
354            };
355            Ok(Some(TcssValue::Visibility(v)))
356        }
357        "opacity" | "flex-grow" => {
358            let v = input.expect_number().map_err(|e| {
359                location.new_custom_error(PropertyParseError::InvalidValue(format!("{:?}", e)))
360            })?;
361            Ok(Some(TcssValue::Float(v)))
362        }
363        "text-align" => {
364            let name = input.expect_ident_cloned().map_err(|e| {
365                location.new_custom_error(PropertyParseError::InvalidValue(format!("{:?}", e)))
366            })?;
367            let a = match name.as_ref() {
368                "left" => TextAlign::Left,
369                "center" => TextAlign::Center,
370                "right" => TextAlign::Right,
371                other => {
372                    return Err(location.new_custom_error(PropertyParseError::InvalidValue(
373                        format!("unknown text-align value: {}", other),
374                    )));
375                }
376            };
377            Ok(Some(TcssValue::TextAlign(a)))
378        }
379        "overflow" => {
380            let name = input.expect_ident_cloned().map_err(|e| {
381                location.new_custom_error(PropertyParseError::InvalidValue(format!("{:?}", e)))
382            })?;
383            let o = match name.as_ref() {
384                "visible" => Overflow::Visible,
385                "hidden" => Overflow::Hidden,
386                "scroll" => Overflow::Scroll,
387                "auto" => Overflow::Auto,
388                other => {
389                    return Err(location.new_custom_error(PropertyParseError::InvalidValue(
390                        format!("unknown overflow value: {}", other),
391                    )));
392                }
393            };
394            Ok(Some(TcssValue::Overflow(o)))
395        }
396        "scrollbar-gutter" => {
397            let name = input.expect_ident_cloned().map_err(|e| {
398                location.new_custom_error(PropertyParseError::InvalidValue(format!("{:?}", e)))
399            })?;
400            let b = match name.as_ref() {
401                "stable" => true,
402                "auto" => false,
403                other => {
404                    return Err(location.new_custom_error(PropertyParseError::InvalidValue(
405                        format!("unknown scrollbar-gutter value: {}", other),
406                    )));
407                }
408            };
409            Ok(Some(TcssValue::Bool(b)))
410        }
411        "dock" => {
412            let name = input.expect_ident_cloned().map_err(|e| {
413                location.new_custom_error(PropertyParseError::InvalidValue(format!("{:?}", e)))
414            })?;
415            let d = match name.as_ref() {
416                "top" => DockEdge::Top,
417                "bottom" => DockEdge::Bottom,
418                "left" => DockEdge::Left,
419                "right" => DockEdge::Right,
420                other => {
421                    return Err(location.new_custom_error(PropertyParseError::InvalidValue(
422                        format!("unknown dock value: {}", other),
423                    )));
424                }
425            };
426            Ok(Some(TcssValue::DockEdge(d)))
427        }
428        "grid-template-columns" | "grid-template-rows" => {
429            // Parse space-separated list of dimensions
430            let mut dims = Vec::new();
431            loop {
432                input.skip_whitespace();
433                let state = input.state();
434                match parse_dimension(input) {
435                    Ok(d) => dims.push(d),
436                    Err(_) => {
437                        input.reset(&state);
438                        break;
439                    }
440                }
441            }
442            if dims.is_empty() {
443                Ok(None)
444            } else {
445                Ok(Some(TcssValue::Dimensions(dims)))
446            }
447        }
448        "layout-direction" => {
449            let name = input.expect_ident_cloned().map_err(|e| {
450                location.new_custom_error(PropertyParseError::InvalidValue(format!("{:?}", e)))
451            })?;
452            let d = match name.as_ref() {
453                "vertical" => LayoutDirection::Vertical,
454                "horizontal" => LayoutDirection::Horizontal,
455                other => {
456                    return Err(location.new_custom_error(PropertyParseError::InvalidValue(
457                        format!("unknown layout-direction value: {}", other),
458                    )));
459                }
460            };
461            Ok(Some(TcssValue::LayoutDirection(d)))
462        }
463        "hatch" => {
464            let name = input.expect_ident_cloned().map_err(|e| {
465                location.new_custom_error(PropertyParseError::InvalidValue(format!("{:?}", e)))
466            })?;
467            let style = match name.as_ref() {
468                "cross" => HatchStyle::Cross,
469                "horizontal" => HatchStyle::Horizontal,
470                "vertical" => HatchStyle::Vertical,
471                "left" => HatchStyle::Left,
472                "right" => HatchStyle::Right,
473                other => {
474                    return Err(location.new_custom_error(PropertyParseError::InvalidValue(
475                        format!("unknown hatch style: {}", other),
476                    )));
477                }
478            };
479            Ok(Some(TcssValue::Hatch(style)))
480        }
481        "keyline" => {
482            if let Some(var_name) = try_parse_variable(input) {
483                Ok(Some(TcssValue::KeylineVariable(var_name)))
484            } else {
485                Ok(Some(TcssValue::Keyline(parse_color(input)?)))
486            }
487        }
488        // Unknown property — skip
489        _other => Ok(None),
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    fn parse_decl(css: &str) -> Declaration {
498        let input_str = format!("{};", css);
499        let mut input = cssparser::ParserInput::new(&input_str);
500        let mut parser = cssparser::Parser::new(&mut input);
501        let decls = parse_declaration_block(&mut parser).expect("parse failed");
502        assert!(!decls.is_empty(), "no declaration parsed from: {}", css);
503        decls.into_iter().next().unwrap()
504    }
505
506    fn parse_decl_value(css: &str) -> TcssValue {
507        parse_decl(css).value
508    }
509
510    #[test]
511    fn parse_color_named() {
512        let val = parse_decl_value("color: red");
513        // "red" -> Rgb(255, 0, 0)
514        assert!(matches!(val, TcssValue::Color(TcssColor::Rgb(255, 0, 0))));
515    }
516
517    #[test]
518    fn parse_color_hex_6() {
519        let val = parse_decl_value("color: #ff0000");
520        assert!(matches!(val, TcssValue::Color(TcssColor::Rgb(255, 0, 0))));
521    }
522
523    #[test]
524    fn parse_color_rgb_function() {
525        let val = parse_decl_value("color: rgb(255, 0, 0)");
526        assert!(matches!(val, TcssValue::Color(TcssColor::Rgb(255, 0, 0))));
527    }
528
529    #[test]
530    fn parse_color_rgba_function() {
531        // CSS rgba uses 0-1 alpha; 0.5 = ~50% opacity, stored as alpha_u8 ~128
532        let val = parse_decl_value("color: rgba(255, 0, 0, 0.5)");
533        assert!(matches!(
534            val,
535            TcssValue::Color(TcssColor::Rgba(255, 0, 0, _))
536        ));
537    }
538
539    #[test]
540    fn parse_color_hex_3() {
541        let val = parse_decl_value("color: #f00");
542        assert!(matches!(val, TcssValue::Color(TcssColor::Rgb(255, 0, 0))));
543    }
544
545    #[test]
546    fn parse_width_number() {
547        let val = parse_decl_value("width: 20");
548        assert_eq!(val, TcssValue::Dimension(TcssDimension::Length(20.0)));
549    }
550
551    #[test]
552    fn parse_width_percent() {
553        let val = parse_decl_value("width: 50%");
554        assert_eq!(val, TcssValue::Dimension(TcssDimension::Percent(50.0)));
555    }
556
557    #[test]
558    fn parse_width_fraction() {
559        let val = parse_decl_value("width: 1fr");
560        assert_eq!(val, TcssValue::Dimension(TcssDimension::Fraction(1.0)));
561    }
562
563    #[test]
564    fn parse_width_auto() {
565        let val = parse_decl_value("width: auto");
566        assert_eq!(val, TcssValue::Dimension(TcssDimension::Auto));
567    }
568
569    #[test]
570    fn parse_border_solid() {
571        let val = parse_decl_value("border: solid");
572        assert_eq!(val, TcssValue::Border(BorderStyle::Solid));
573    }
574
575    #[test]
576    fn parse_border_rounded() {
577        let val = parse_decl_value("border: rounded");
578        assert_eq!(val, TcssValue::Border(BorderStyle::Rounded));
579    }
580
581    #[test]
582    fn parse_display_none() {
583        let val = parse_decl_value("display: none");
584        assert_eq!(val, TcssValue::Display(TcssDisplay::None));
585    }
586
587    #[test]
588    fn parse_opacity() {
589        let val = parse_decl_value("opacity: 0.5");
590        assert_eq!(val, TcssValue::Float(0.5));
591    }
592
593    #[test]
594    fn parse_dock_top() {
595        let val = parse_decl_value("dock: top");
596        assert_eq!(val, TcssValue::DockEdge(DockEdge::Top));
597    }
598
599    // --- Theme variable parsing tests ---
600
601    #[test]
602    fn parse_color_variable_primary() {
603        let val = parse_decl_value("color: $primary");
604        assert_eq!(val, TcssValue::Variable("primary".to_string()));
605    }
606
607    #[test]
608    fn parse_background_variable() {
609        let val = parse_decl_value("background: $surface");
610        assert_eq!(val, TcssValue::Variable("surface".to_string()));
611    }
612
613    #[test]
614    fn parse_variable_lighten_suffix() {
615        let val = parse_decl_value("color: $primary-lighten-2");
616        assert_eq!(val, TcssValue::Variable("primary-lighten-2".to_string()));
617    }
618
619    #[test]
620    fn parse_variable_darken_suffix() {
621        let val = parse_decl_value("color: $accent-darken-1");
622        assert_eq!(val, TcssValue::Variable("accent-darken-1".to_string()));
623    }
624
625    #[test]
626    fn parse_variable_darken_3() {
627        let val = parse_decl_value("background: $error-darken-3");
628        assert_eq!(val, TcssValue::Variable("error-darken-3".to_string()));
629    }
630
631    #[test]
632    fn parse_regular_color_still_works_after_variable_support() {
633        // Ensure $ variable support doesn't break normal color parsing
634        let val = parse_decl_value("color: #ff0000");
635        assert!(matches!(val, TcssValue::Color(TcssColor::Rgb(255, 0, 0))));
636
637        let val2 = parse_decl_value("color: red");
638        assert!(matches!(val2, TcssValue::Color(TcssColor::Rgb(255, 0, 0))));
639
640        let val3 = parse_decl_value("background: rgb(0, 255, 0)");
641        assert!(matches!(val3, TcssValue::Color(TcssColor::Rgb(0, 255, 0))));
642    }
643
644    // --- Border + variable tests ---
645
646    #[test]
647    fn parse_border_tall_variable() {
648        let val = parse_decl_value("border: tall $primary");
649        assert_eq!(
650            val,
651            TcssValue::BorderWithVariable(BorderStyle::Tall, "primary".to_string())
652        );
653    }
654
655    #[test]
656    fn parse_border_rounded_variable_with_suffix() {
657        let val = parse_decl_value("border: rounded $accent-lighten-2");
658        assert_eq!(
659            val,
660            TcssValue::BorderWithVariable(BorderStyle::Rounded, "accent-lighten-2".to_string())
661        );
662    }
663
664    #[test]
665    fn parse_border_solid_hex_still_works() {
666        let val = parse_decl_value("border: solid #ff0000");
667        match val {
668            TcssValue::BorderWithColor(BorderStyle::Solid, TcssColor::Rgb(255, 0, 0)) => {}
669            other => panic!(
670                "expected BorderWithColor(Solid, Rgb(255,0,0)), got {:?}",
671                other
672            ),
673        }
674    }
675
676    #[test]
677    fn parse_border_heavy_no_color() {
678        let val = parse_decl_value("border: heavy");
679        assert_eq!(val, TcssValue::Border(BorderStyle::Heavy));
680    }
681
682    #[test]
683    fn parse_unknown_variable_produces_variable_variant() {
684        // Unknown variables are stored as Variable; resolution happens at cascade time
685        let val = parse_decl_value("color: $nonexistent");
686        assert_eq!(val, TcssValue::Variable("nonexistent".to_string()));
687    }
688
689    // --- Hatch property tests ---
690
691    #[test]
692    fn parse_hatch_cross() {
693        let val = parse_decl_value("hatch: cross");
694        assert_eq!(val, TcssValue::Hatch(HatchStyle::Cross));
695    }
696
697    #[test]
698    fn parse_hatch_horizontal() {
699        let val = parse_decl_value("hatch: horizontal");
700        assert_eq!(val, TcssValue::Hatch(HatchStyle::Horizontal));
701    }
702
703    #[test]
704    fn parse_hatch_vertical() {
705        let val = parse_decl_value("hatch: vertical");
706        assert_eq!(val, TcssValue::Hatch(HatchStyle::Vertical));
707    }
708
709    #[test]
710    fn parse_hatch_left() {
711        let val = parse_decl_value("hatch: left");
712        assert_eq!(val, TcssValue::Hatch(HatchStyle::Left));
713    }
714
715    #[test]
716    fn parse_hatch_right() {
717        let val = parse_decl_value("hatch: right");
718        assert_eq!(val, TcssValue::Hatch(HatchStyle::Right));
719    }
720
721    // --- Keyline property tests ---
722
723    #[test]
724    fn parse_keyline_color() {
725        let val = parse_decl_value("keyline: #ff0000");
726        assert_eq!(val, TcssValue::Keyline(TcssColor::Rgb(255, 0, 0)));
727    }
728
729    #[test]
730    fn parse_keyline_variable() {
731        let val = parse_decl_value("keyline: $primary");
732        assert_eq!(val, TcssValue::KeylineVariable("primary".to_string()));
733    }
734}