Repository: Textualize/textual

Issue:
Add 3 and 8 digit hex colors to Color and CSS parser
For 3 digits colors, each digit is repeat. Thus #17a is the same as #1177aa

For 8 digit colors, the last two hex digits translate to the alpha component in a color, which should be 0 - 1, for hex 0 - FF.


Discussion:

Resolving patch:
diff --git a/src/textual/color.py b/src/textual/color.py
--- a/src/textual/color.py
+++ b/src/textual/color.py
@@ -1,7 +1,7 @@
 """
 Manages Color in Textual.
 
-All instances where the developer is presented with a color should use this class. The only 
+All instances where the developer is presented with a color should use this class. The only
 exception should be when passing things to a Rich renderable, which will need to use the
 `rich_color` attribute to perform a conversion.
 
@@ -54,6 +54,8 @@ class Lab(NamedTuple):
 
 RE_COLOR = re.compile(
     r"""^
+\#([0-9a-fA-F]{3})$|
+\#([0-9a-fA-F]{4})$|
 \#([0-9a-fA-F]{6})$|
 \#([0-9a-fA-F]{8})$|
 rgb\((\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*)\)$|
@@ -62,7 +64,7 @@ class Lab(NamedTuple):
     re.VERBOSE,
 )
 
-# Fast way to split a string of 8 characters in to 3 pairs of 2 characters
+# Fast way to split a string of 6 characters in to 3 pairs of 2 characters
 split_pairs3: Callable[[str], tuple[str, str, str]] = itemgetter(
     slice(0, 2), slice(2, 4), slice(4, 6)
 )
@@ -264,9 +266,27 @@ def parse(cls, color_text: str | Color) -> Color:
         color_match = RE_COLOR.match(color_text)
         if color_match is None:
             raise ColorParseError(f"failed to parse {color_text!r} as a color")
-        rgb_hex, rgba_hex, rgb, rgba = color_match.groups()
-
-        if rgb_hex is not None:
+        (
+            rgb_hex_triple,
+            rgb_hex_quad,
+            rgb_hex,
+            rgba_hex,
+            rgb,
+            rgba,
+        ) = color_match.groups()
+
+        if rgb_hex_triple is not None:
+            r, g, b = rgb_hex_triple
+            color = cls(int(f"{r}{r}", 16), int(f"{g}{g}", 16), int(f"{b}{b}", 16))
+        elif rgb_hex_quad is not None:
+            r, g, b, a = rgb_hex_quad
+            color = cls(
+                int(f"{r}{r}", 16),
+                int(f"{g}{g}", 16),
+                int(f"{b}{b}", 16),
+                int(f"{a}{a}", 16) / 255.0,
+            )
+        elif rgb_hex is not None:
             r, g, b = [int(pair, 16) for pair in split_pairs3(rgb_hex)]
             color = cls(r, g, b, 1.0)
         elif rgba_hex is not None:
diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py
--- a/src/textual/css/tokenize.py
+++ b/src/textual/css/tokenize.py
@@ -9,7 +9,7 @@
 SCALAR = r"\-?\d+\.?\d*(?:fr|%|w|h|vw|vh)"
 DURATION = r"\d+\.?\d*(?:ms|s)"
 NUMBER = r"\-?\d+\.?\d*"
-COLOR = r"\#[0-9a-fA-F]{8}|\#[0-9a-fA-F]{6}|rgb\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)|rgba\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)"
+COLOR = r"\#[0-9a-fA-F]{8}|\#[0-9a-fA-F]{6}|\#[0-9a-fA-F]{4}|\#[0-9a-fA-F]{3}|rgb\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)|rgba\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)"
 KEY_VALUE = r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+"
 TOKEN = "[a-zA-Z][a-zA-Z0-9_-]*"
 STRING = r"\".*?\""


Test patch:
diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py
--- a/tests/css/test_stylesheet.py
+++ b/tests/css/test_stylesheet.py
@@ -29,8 +29,6 @@
         ["red 4", pytest.raises(StylesheetParseError), None],  # space in it
         ["1", pytest.raises(StylesheetParseError), None],  # invalid value
         ["()", pytest.raises(TokenizeError), None],  # invalid tokens
-        # TODO: implement hex colors with 3 chars? @link https://devdocs.io/css/color_value
-        ["#09f", pytest.raises(TokenizeError), None],
         # TODO: allow spaces in rgb/rgba expressions?
         ["rgb(200, 90, 30)", pytest.raises(TokenizeError), None],
         ["rgba(200,90,30, 0.4)", pytest.raises(TokenizeError), None],
diff --git a/tests/test_color.py b/tests/test_color.py
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -93,6 +93,8 @@ def test_color_blend():
         ("#000000", Color(0, 0, 0, 1.0)),
         ("#ffffff", Color(255, 255, 255, 1.0)),
         ("#FFFFFF", Color(255, 255, 255, 1.0)),
+        ("#fab", Color(255, 170, 187, 1.0)),  # #ffaabb
+        ("#fab0", Color(255, 170, 187, .0)),  # #ffaabb00
         ("#020304ff", Color(2, 3, 4, 1.0)),
         ("#02030400", Color(2, 3, 4, 0.0)),
         ("#0203040f", Color(2, 3, 4, 0.058823529411764705)),