diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index b62da8cd2..b627a6e8f 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -285,7 +285,7 @@ class Stylesheet: _check_rule = self._check_rule # Collect the rules defined in the stylesheet - for rule in self.rules: + for rule in reversed(self.rules): for specificity in _check_rule(rule, node): for key, rule_specificity, value in rule.styles.extract_rules( specificity @@ -301,6 +301,7 @@ class Stylesheet: for name, specificity_rules in rule_attributes.items() }, ) + self.replace_rules(node, node_rules, animate=animate) node.component_styles.clear() diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 7f7838818..76d786822 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -1,23 +1,42 @@ -from ._datatable import DataTable -from ._footer import Footer -from ._header import Header -from ._button import Button -from ._placeholder import Placeholder -from ._static import Static -from ._tree_control import TreeControl, TreeClick, TreeNode, NodeID -from ._directory_tree import DirectoryTree, FileClick +from __future__ import annotations +from importlib import import_module +import typing +from ..case import camel_to_snake + +if typing.TYPE_CHECKING: + from ..widget import Widget + + +# ⚠️For any new built-in Widget we create, not only we have to add them to the following list, but also to the +# `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't be able to "see" them. __all__ = [ "Button", "DataTable", "DirectoryTree", - "FileClick", "Footer", "Header", "Placeholder", "Static", - "TreeClick", "TreeControl", - "TreeNode", - "NodeID", ] + + +_WIDGETS_LAZY_LOADING_CACHE: dict[str, type[Widget]] = {} + +# Let's decrease startup time by lazy loading our Widgets: +def __getattr__(widget_class: str) -> type[Widget]: + try: + return _WIDGETS_LAZY_LOADING_CACHE[widget_class] + except KeyError: + pass + + if widget_class not in __all__: + raise ImportError(f"Package 'textual.widgets' has no class '{widget_class}'") + + widget_module_path = f"._{camel_to_snake(widget_class)}" + module = import_module(widget_module_path, package="textual.widgets") + class_ = getattr(module, widget_class) + + _WIDGETS_LAZY_LOADING_CACHE[widget_class] = class_ + return class_ diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi new file mode 100644 index 000000000..b93fb3ae5 --- /dev/null +++ b/src/textual/widgets/__init__.pyi @@ -0,0 +1,8 @@ +# This stub file must re-export every classes exposed in the __init__.py's `__all__` list: +from ._button import Button as Button +from ._directory_tree import DirectoryTree as DirectoryTree +from ._footer import Footer as Footer +from ._header import Header as Header +from ._placeholder import Placeholder as Placeholder +from ._static import Static as Static +from ._tree_control import TreeControl as TreeControl diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index 6cb2f01ac..6d4425b42 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -7,6 +7,98 @@ from textual.color import Color from textual.css._help_renderables import HelpText from textual.css.stylesheet import Stylesheet, StylesheetParseError from textual.css.tokenizer import TokenizeError +from textual.dom import DOMNode +from textual.geometry import Spacing + + +def _make_stylesheet(css: str) -> Stylesheet: + stylesheet = Stylesheet() + stylesheet.source["test.css"] = css + stylesheet.parse() + return stylesheet + + +def test_stylesheet_apply_highest_specificity_wins(): + """#ids have higher specificity than .classes""" + css = "#id {color: red;} .class {color: blue;}" + stylesheet = _make_stylesheet(css) + node = DOMNode(classes="class", id="id") + stylesheet.apply(node) + + assert node.styles.color == Color(255, 0, 0) + + +def test_stylesheet_apply_doesnt_override_defaults(): + css = "#id {color: red;}" + stylesheet = _make_stylesheet(css) + node = DOMNode(id="id") + stylesheet.apply(node) + + assert node.styles.margin == Spacing.all(0) + assert node.styles.box_sizing == "border-box" + + +def test_stylesheet_apply_highest_specificity_wins_multiple_classes(): + """When we use two selectors containing only classes, then the selector + `.b.c` has greater specificity than the selector `.a`""" + css = ".b.c {background: blue;} .a {background: red; color: lime;}" + stylesheet = _make_stylesheet(css) + node = DOMNode(classes="a b c") + stylesheet.apply(node) + + assert node.styles.background == Color(0, 0, 255) + assert node.styles.color == Color(0, 255, 0) + + +def test_stylesheet_many_classes_dont_overrule_id(): + """#id is further to the left in the specificity tuple than class, and + a selector containing multiple classes cannot take priority over even a + single class.""" + css = "#id {color: red;} .a.b.c.d {color: blue;}" + stylesheet = _make_stylesheet(css) + node = DOMNode(classes="a b c d", id="id") + stylesheet.apply(node) + + assert node.styles.color == Color(255, 0, 0) + + +def test_stylesheet_last_rule_wins_when_same_rule_twice_in_one_ruleset(): + css = "#id {color: red; color: blue;}" + stylesheet = _make_stylesheet(css) + node = DOMNode(id="id") + stylesheet.apply(node) + + assert node.styles.color == Color(0, 0, 255) + + +def test_stylesheet_rulesets_merged_for_duplicate_selectors(): + css = "#id {color: red; background: lime;} #id {color:blue;}" + stylesheet = _make_stylesheet(css) + node = DOMNode(id="id") + stylesheet.apply(node) + + assert node.styles.color == Color(0, 0, 255) + assert node.styles.background == Color(0, 255, 0) + + +def test_stylesheet_apply_takes_final_rule_in_specificity_clash(): + """.a and .b both contain background and have same specificity, so .b wins + since it was declared last - the background should be blue.""" + css = ".a {background: red; color: lime;} .b {background: blue;}" + stylesheet = _make_stylesheet(css) + node = DOMNode(classes="a b", id="c") + stylesheet.apply(node) + + assert node.styles.color == Color(0, 255, 0) # color: lime + assert node.styles.background == Color(0, 0, 255) # background: blue + + +def test_stylesheet_apply_empty_rulesets(): + """Ensure that we don't crash when working with empty rulesets""" + css = ".a {} .b {}" + stylesheet = _make_stylesheet(css) + node = DOMNode(classes="a b") + stylesheet.apply(node) @pytest.mark.parametrize(