Repository: Textualize/textual

Issue:
`display: none` is being ignored in vertical and horizontal layouts
Example app:

```python
from textual.app import App
from textual.widget import Widget

class ButtonsApp(App):

    def on_load(self) -> None:
        self.bind("q", "quit")

    def on_mount(self):
        self.mount(
            Widget(classes={"no-display"})
        )


ButtonsApp.run(css_file="buttons.scss", log="textual.log")
```

Example CSS:
```scss
Widget.no-display {
  display: none;
}
```

The widget is unexpectedly displayed.

Comments:

Patch:
diff --git a/src/textual/app.py b/src/textual/app.py
index 6a6e80272..2acc04ffe 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -521,6 +521,7 @@ class App(DOMNode):
                 mount_event = events.Mount(sender=self)
                 await self.dispatch_message(mount_event)
 
+                # TODO: don't override `self.console` here
                 self.console = Console(file=sys.__stdout__)
                 self.title = self._title
                 self.refresh()
diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py
index d1ecbdd91..43c724479 100644
--- a/src/textual/css/styles.py
+++ b/src/textual/css/styles.py
@@ -70,7 +70,7 @@ if TYPE_CHECKING:
 class RulesMap(TypedDict, total=False):
     """A typed dict for CSS rules.
 
-    Any key may be absent, indiciating that rule has not been set.
+    Any key may be absent, indicating that rule has not been set.
 
     Does not define composite rules, that is a rule that is made of a combination of other rules.
 
diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py
index 3ccc8166f..7d5dbe3a0 100644
--- a/src/textual/css/tokenize.py
+++ b/src/textual/css/tokenize.py
@@ -11,7 +11,7 @@ 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*\)"
 KEY_VALUE = r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+"
-TOKEN = "[a-zA-Z_-]+"
+TOKEN = "[a-zA-Z][a-zA-Z0-9_-]*"
 STRING = r"\".*?\""
 VARIABLE_REF = r"\$[a-zA-Z0-9_\-]+"
 
diff --git a/src/textual/dom.py b/src/textual/dom.py
index 7c0c0ba54..79f2cc796 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -278,6 +278,10 @@ class DOMNode(MessagePump):
         add_children(tree, self)
         return tree
 
+    @property
+    def displayed_children(self) -> list[DOMNode]:
+        return [child for child in self.children if child.display]
+
     def get_pseudo_classes(self) -> Iterable[str]:
         """Get any pseudo classes applicable to this Node, e.g. hover, focus.
 
diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py
index b507bbc67..f85e6de02 100644
--- a/src/textual/layouts/dock.py
+++ b/src/textual/layouts/dock.py
@@ -50,10 +50,9 @@ class DockLayout(Layout):
 
     def get_docks(self, parent: Widget) -> list[Dock]:
         groups: dict[str, list[Widget]] = defaultdict(list)
-        for child in parent.children:
+        for child in parent.displayed_children:
             assert isinstance(child, Widget)
-            if child.display:
-                groups[child.styles.dock].append(child)
+            groups[child.styles.dock].append(child)
         docks: list[Dock] = []
         append_dock = docks.append
         for name, edge, z in parent.styles.docks:
diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py
index 36fef9bd7..ba428c968 100644
--- a/src/textual/layouts/horizontal.py
+++ b/src/textual/layouts/horizontal.py
@@ -39,7 +39,9 @@ class HorizontalLayout(Layout):
 
         x = box_models[0].margin.left if box_models else 0
 
-        for widget, box_model, margin in zip(parent.children, box_models, margins):
+        displayed_children = parent.displayed_children
+
+        for widget, box_model, margin in zip(displayed_children, box_models, margins):
             content_width, content_height = box_model.size
             offset_y = widget.styles.align_height(content_height, parent_size.height)
             region = Region(x, offset_y, content_width, content_height)
@@ -53,4 +55,4 @@ class HorizontalLayout(Layout):
         total_region = Region(0, 0, max_width, max_height)
         add_placement(WidgetPlacement(total_region, None, 0))
 
-        return placements, set(parent.children)
+        return placements, set(displayed_children)
diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py
index 2752c4445..196f7eb52 100644
--- a/src/textual/layouts/vertical.py
+++ b/src/textual/layouts/vertical.py
@@ -40,10 +40,13 @@ class VerticalLayout(Layout):
 
         y = box_models[0].margin.top if box_models else 0
 
-        for widget, box_model, margin in zip(parent.children, box_models, margins):
+        displayed_children = parent.displayed_children
+
+        for widget, box_model, margin in zip(displayed_children, box_models, margins):
             content_width, content_height = box_model.size
             offset_x = widget.styles.align_width(content_width, parent_size.width)
             region = Region(offset_x, y, content_width, content_height)
+            # TODO: it seems that `max_height` is not used?
             max_height = max(max_height, content_height)
             add_placement(WidgetPlacement(region, widget, 0))
             y += region.height + margin
@@ -54,4 +57,4 @@ class VerticalLayout(Layout):
         total_region = Region(0, 0, max_width, max_height)
         add_placement(WidgetPlacement(total_region, None, 0))
 
-        return placements, set(parent.children)
+        return placements, set(displayed_children)


Test patch:
diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py
new file mode 100644
index 000000000..87f27845d
--- /dev/null
+++ b/tests/css/test_stylesheet.py
@@ -0,0 +1,49 @@
+from contextlib import nullcontext as does_not_raise
+import pytest
+
+from textual.color import Color
+from textual.css.stylesheet import Stylesheet, StylesheetParseError
+from textual.css.tokenizer import TokenizeError
+
+
+@pytest.mark.parametrize(
+    "css_value,expectation,expected_color",
+    [
+        # Valid values:
+        ["red", does_not_raise(), Color(128, 0, 0)],
+        ["dark_cyan", does_not_raise(), Color(0, 175, 135)],
+        ["medium_turquoise", does_not_raise(), Color(95, 215, 215)],
+        ["turquoise4", does_not_raise(), Color(0, 135, 135)],
+        ["#ffcc00", does_not_raise(), Color(255, 204, 0)],
+        ["#ffcc0033", does_not_raise(), Color(255, 204, 0, 0.2)],
+        ["rgb(200,90,30)", does_not_raise(), Color(200, 90, 30)],
+        ["rgba(200,90,30,0.3)", does_not_raise(), Color(200, 90, 30, 0.3)],
+        # Some invalid ones:
+        ["coffee", pytest.raises(StylesheetParseError), None],  # invalid color name
+        ["turquoise10", pytest.raises(StylesheetParseError), None],
+        ["turquoise 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],
+    ],
+)
+def test_color_property_parsing(css_value, expectation, expected_color):
+    stylesheet = Stylesheet()
+    css = """
+    * {
+      background: ${COLOR};
+    }
+    """.replace(
+        "${COLOR}", css_value
+    )
+
+    with expectation:
+        stylesheet.parse(css)
+
+    if expected_color:
+        css_rule = stylesheet.rules[0]
+        assert css_rule.styles.background == expected_color
diff --git a/tests/layouts/test_common_layout_features.py b/tests/layouts/test_common_layout_features.py
new file mode 100644
index 000000000..e8cc5ee19
--- /dev/null
+++ b/tests/layouts/test_common_layout_features.py
@@ -0,0 +1,30 @@
+import pytest
+
+from textual.screen import Screen
+from textual.widget import Widget
+
+
+@pytest.mark.parametrize(
+    "layout,display,expected_in_displayed_children",
+    [
+        ("dock", "block", True),
+        ("horizontal", "block", True),
+        ("vertical", "block", True),
+        ("dock", "none", False),
+        ("horizontal", "none", False),
+        ("vertical", "none", False),
+    ],
+)
+def test_nodes_take_display_property_into_account_when_they_display_their_children(
+    layout: str, display: str, expected_in_displayed_children: bool
+):
+    widget = Widget(name="widget that might not be visible 🥷 ")
+    widget.styles.display = display
+
+    screen = Screen()
+    screen.styles.layout = layout
+    screen.add_child(widget)
+
+    displayed_children = screen.displayed_children
+    assert isinstance(displayed_children, list)
+    assert (widget in screen.displayed_children) is expected_in_displayed_children