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.

Comments:

Patch:
diff --git a/poetry.lock b/poetry.lock
index 3182e7961..98961e8e7 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -205,7 +205,7 @@ python-versions = ">=3.7"
 
 [[package]]
 name = "ghp-import"
-version = "2.0.2"
+version = "2.1.0"
 description = "Copy your docs directly to the gh-pages branch."
 category = "dev"
 optional = false
@@ -219,7 +219,7 @@ dev = ["twine", "markdown", "flake8", "wheel"]
 
 [[package]]
 name = "identify"
-version = "2.4.12"
+version = "2.5.0"
 description = "File identification library for Python"
 category = "dev"
 optional = false
@@ -263,7 +263,7 @@ python-versions = "*"
 
 [[package]]
 name = "jinja2"
-version = "3.1.1"
+version = "3.1.2"
 description = "A very fast and expressive template engine."
 category = "dev"
 optional = false
@@ -496,22 +496,22 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [[package]]
 name = "pygments"
-version = "2.11.2"
+version = "2.12.0"
 description = "Pygments is a syntax highlighting package written in Python."
 category = "main"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.6"
 
 [[package]]
 name = "pymdown-extensions"
-version = "9.3"
+version = "9.4"
 description = "Extension pack for Python Markdown."
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 
 [package.dependencies]
-Markdown = ">=3.2"
+markdown = ">=3.2"
 
 [[package]]
 name = "pyparsing"
@@ -641,16 +641,16 @@ pyyaml = "*"
 
 [[package]]
 name = "rich"
-version = "12.1.0"
+version = "12.3.0"
 description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
 category = "main"
 optional = false
-python-versions = ">=3.6.2,<4.0.0"
+python-versions = ">=3.6.3,<4.0.0"
 
 [package.dependencies]
 commonmark = ">=0.9.0,<0.10.0"
 pygments = ">=2.6.0,<3.0.0"
-typing-extensions = {version = ">=3.7.4,<5.0", markers = "python_version < \"3.9\""}
+typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""}
 
 [package.extras]
 jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
@@ -700,11 +700,11 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "typing-extensions"
-version = "3.10.0.2"
-description = "Backported and Experimental Type Hints for Python 3.5+"
+version = "4.2.0"
+description = "Backported and Experimental Type Hints for Python 3.7+"
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.7"
 
 [[package]]
 name = "virtualenv"
@@ -764,7 +764,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.7"
-content-hash = "256c1d6571a11bf4b80d0eba16d9e39bf2965c4436281c3ec40033cca54aa098"
+content-hash = "2f50b8219bfdf683dabf54b0a33636f27712a6ecccc1f8ce2695e1f7793f9969"
 
 [metadata.files]
 aiohttp = [
@@ -1027,12 +1027,12 @@ frozenlist = [
     {file = "frozenlist-1.3.0.tar.gz", hash = "sha256:ce6f2ba0edb7b0c1d8976565298ad2deba6f8064d2bebb6ffce2ca896eb35b0b"},
 ]
 ghp-import = [
-    {file = "ghp-import-2.0.2.tar.gz", hash = "sha256:947b3771f11be850c852c64b561c600fdddf794bab363060854c1ee7ad05e071"},
-    {file = "ghp_import-2.0.2-py3-none-any.whl", hash = "sha256:5f8962b30b20652cdffa9c5a9812f7de6bcb56ec475acac579807719bf242c46"},
+    {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"},
+    {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"},
 ]
 identify = [
-    {file = "identify-2.4.12-py2.py3-none-any.whl", hash = "sha256:5f06b14366bd1facb88b00540a1de05b69b310cbc2654db3c7e07fa3a4339323"},
-    {file = "identify-2.4.12.tar.gz", hash = "sha256:3f3244a559290e7d3deb9e9adc7b33594c1bc85a9dd82e0f1be519bf12a1ec17"},
+    {file = "identify-2.5.0-py2.py3-none-any.whl", hash = "sha256:3acfe15a96e4272b4ec5662ee3e231ceba976ef63fd9980ed2ce9cc415df393f"},
+    {file = "identify-2.5.0.tar.gz", hash = "sha256:c83af514ea50bf2be2c4a3f2fb349442b59dc87284558ae9ff54191bff3541d2"},
 ]
 idna = [
     {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
@@ -1047,8 +1047,8 @@ iniconfig = [
     {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
 ]
 jinja2 = [
-    {file = "Jinja2-3.1.1-py3-none-any.whl", hash = "sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119"},
-    {file = "Jinja2-3.1.1.tar.gz", hash = "sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9"},
+    {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
+    {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
 ]
 markdown = [
     {file = "Markdown-3.3.6-py3-none-any.whl", hash = "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"},
@@ -1236,12 +1236,12 @@ py = [
     {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
 ]
 pygments = [
-    {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"},
-    {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"},
+    {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"},
+    {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"},
 ]
 pymdown-extensions = [
-    {file = "pymdown-extensions-9.3.tar.gz", hash = "sha256:a80553b243d3ed2d6c27723bcd64ca9887e560e6f4808baa96f36e93061eaf90"},
-    {file = "pymdown_extensions-9.3-py3-none-any.whl", hash = "sha256:b37461a181c1c8103cfe1660081726a0361a8294cbfda88e5b02cefe976f0546"},
+    {file = "pymdown_extensions-9.4-py3-none-any.whl", hash = "sha256:5b7432456bf555ce2b0ab3c2439401084cda8110f24f6b3ecef952b8313dfa1b"},
+    {file = "pymdown_extensions-9.4.tar.gz", hash = "sha256:1baa22a60550f731630474cad28feb0405c8101f1a7ddc3ec0ed86ee510bcc43"},
 ]
 pyparsing = [
     {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"},
@@ -1312,8 +1312,8 @@ pyyaml-env-tag = [
     {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"},
 ]
 rich = [
-    {file = "rich-12.1.0-py3-none-any.whl", hash = "sha256:b60ff99f4ff7e3d1d37444dee2b22fdd941c622dbc37841823ec1ce7f058b263"},
-    {file = "rich-12.1.0.tar.gz", hash = "sha256:198ae15807a7c1bf84ceabf662e902731bf8f874f9e775e2289cab02bb6a4e30"},
+    {file = "rich-12.3.0-py3-none-any.whl", hash = "sha256:0eb63013630c6ee1237e0e395d51cb23513de6b5531235e33889e8842bdf3a6f"},
+    {file = "rich-12.3.0.tar.gz", hash = "sha256:7e8700cda776337036a712ff0495b04052fb5f957c7dfb8df997f88350044b64"},
 ]
 six = [
     {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
@@ -1396,9 +1396,8 @@ typed-ast = [
     {file = "typed_ast-1.5.3.tar.gz", hash = "sha256:27f25232e2dd0edfe1f019d6bfaaf11e86e657d9bdb7b0956db95f560cceb2b3"},
 ]
 typing-extensions = [
-    {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"},
-    {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"},
-    {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"},
+    {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"},
+    {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"},
 ]
 virtualenv = [
     {file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"},
diff --git a/pyproject.toml b/pyproject.toml
index 3b110a12d..388724741 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,12 +22,12 @@ textual = "textual.cli.cli:run"
 
 [tool.poetry.dependencies]
 python = "^3.7"
-rich = "^12.0.0"
+rich = "^12.3.0"
 
 #rich = {git = "git@github.com:willmcgugan/rich", rev = "link-id"}
-typing-extensions = { version = "^3.10.0", python = "<3.8" }
 click = "8.1.2"
 importlib-metadata = "^4.11.3"
+typing-extensions = { version = "^4.0.0", python = "<3.8" }
 
 [tool.poetry.dev-dependencies]
 pytest = "^6.2.3"
diff --git a/sandbox/uber.css b/sandbox/uber.css
index 195c06593..517aec8d2 100644
--- a/sandbox/uber.css
+++ b/sandbox/uber.css
@@ -7,5 +7,6 @@
 
 .list-item {
   height: 8;
-  background: darkblue;
+  color: #12a0;
+  background: #ffffff00;
 }
diff --git a/sandbox/uber.py b/sandbox/uber.py
index 91835343b..0eb90cdbd 100644
--- a/sandbox/uber.py
+++ b/sandbox/uber.py
@@ -80,7 +80,7 @@ class BasicApp(App):
         self.focused.display = not self.focused.display
 
     def action_toggle_border(self):
-        self.focused.styles.border = ("solid", "red")
+        self.focused.styles.border_top = ("solid", "invalid-color")
 
 
 app = BasicApp(css_file="uber.css", log="textual.log", log_verbosity=1)
diff --git a/src/textual/color.py b/src/textual/color.py
index f6e5be1c3..73adbabf3 100644
--- 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 @@ rgba\((\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*)\)$
     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 @@ class Color(NamedTuple):
         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/_style_properties.py b/src/textual/css/_style_properties.py
index ecafefa86..19f402bb4 100644
--- a/src/textual/css/_style_properties.py
+++ b/src/textual/css/_style_properties.py
@@ -103,6 +103,7 @@ class ScalarProperty:
             StyleValueError: If the value is of an invalid type, uses an invalid unit, or
                 cannot be parsed for any other reason.
         """
+        _rich_traceback_omit = True
         if value is None:
             obj.clear_rule(self.name)
             obj.refresh(layout=True)
@@ -186,6 +187,7 @@ class BoxProperty:
         Raises:
             StyleSyntaxError: If the string supplied for the color has invalid syntax.
         """
+        _rich_traceback_omit = True
         if border is None:
             if obj.clear_rule(self.name):
                 obj.refresh()
@@ -310,6 +312,7 @@ class BorderProperty:
         Raises:
             StyleValueError: When the supplied ``tuple`` is not of valid length (1, 2, or 4).
         """
+        _rich_traceback_omit = True
         top, right, bottom, left = self._properties
         if border is None:
             clear_rule = obj.clear_rule
@@ -405,7 +408,7 @@ class SpacingProperty:
             ValueError: When the value is malformed, e.g. a ``tuple`` with a length that is
                 not 1, 2, or 4.
         """
-
+        _rich_traceback_omit = True
         if spacing is None:
             if obj.clear_rule(self.name):
                 obj.refresh(layout=True)
@@ -455,6 +458,7 @@ class DocksProperty:
             obj (Styles): The ``Styles`` object.
             docks (Iterable[DockGroup]): Iterable of DockGroups
         """
+        _rich_traceback_omit = True
         if docks is None:
             if obj.clear_rule("docks"):
                 obj.refresh(layout=True)
@@ -489,6 +493,7 @@ class DockProperty:
             obj (Styles): The ``Styles`` object
             dock_name (str | None): The name of the dock to attach this widget to
         """
+        _rich_traceback_omit = True
         if obj.set_rule("dock", dock_name):
             obj.refresh(layout=True)
 
@@ -525,6 +530,7 @@ class LayoutProperty:
             MissingLayout,
         )  # Prevents circular import
 
+        _rich_traceback_omit = True
         if layout is None:
             if obj.clear_rule("layout"):
                 obj.refresh(layout=True)
@@ -583,6 +589,7 @@ class OffsetProperty:
             ScalarParseError: If any of the string values supplied in the 2-tuple cannot
                 be parsed into a Scalar. For example, if you specify a non-existent unit.
         """
+        _rich_traceback_omit = True
         if offset is None:
             if obj.clear_rule(self.name):
                 obj.refresh(layout=True)
@@ -649,7 +656,7 @@ class StringEnumProperty:
         Raises:
             StyleValueError: If the value is not in the set of valid values.
         """
-
+        _rich_traceback_omit = True
         if value is None:
             if obj.clear_rule(self.name):
                 obj.refresh(layout=self._layout)
@@ -695,7 +702,7 @@ class NameProperty:
         Raises:
             StyleTypeError: If the value is not a ``str``.
         """
-
+        _rich_traceback_omit = True
         if name is None:
             if obj.clear_rule(self.name):
                 obj.refresh(layout=True)
@@ -716,7 +723,7 @@ class NameListProperty:
         return cast("tuple[str, ...]", obj.get_rule(self.name, ()))
 
     def __set__(self, obj: StylesBase, names: str | tuple[str] | None = None):
-
+        _rich_traceback_omit = True
         if names is None:
             if obj.clear_rule(self.name):
                 obj.refresh(layout=True)
@@ -765,7 +772,7 @@ class ColorProperty:
         Raises:
             ColorParseError: When the color string is invalid.
         """
-
+        _rich_traceback_omit = True
         if color is None:
             if obj.clear_rule(self.name):
                 obj.refresh()
@@ -817,6 +824,7 @@ class StyleFlagsProperty:
         Raises:
             StyleValueError: If the value is an invalid style flag
         """
+        _rich_traceback_omit = True
         if style_flags is None:
             if obj.clear_rule(self.name):
                 obj.refresh()
@@ -859,6 +867,7 @@ class TransitionsProperty:
         return obj.get_rule("transitions", {})
 
     def __set__(self, obj: Styles, transitions: dict[str, Transition] | None) -> None:
+        _rich_traceback_omit = True
         if transitions is None:
             obj.clear_rule("transitions")
         else:
@@ -896,6 +905,7 @@ class FractionalProperty:
             value (float|str|None): The value to set as a float between 0 and 1, or
                 as a percentage string such as '10%'.
         """
+        _rich_traceback_omit = True
         name = self.name
         if value is None:
             if obj.clear_rule(name):
diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py
index 7d5dbe3a0..7076d390a 100644
--- a/src/textual/css/tokenize.py
+++ b/src/textual/css/tokenize.py
@@ -9,7 +9,7 @@ COMMENT_START = r"\/\*"
 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
index 202935a3d..e9042675b 100644
--- a/tests/css/test_stylesheet.py
+++ b/tests/css/test_stylesheet.py
@@ -32,8 +32,6 @@ from textual.css.tokenizer import TokenizeError
         ["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
index 26f65f724..e1db3922b 100644
--- 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)),