diff --git a/sandbox/darren/just_a_box.css b/sandbox/darren/just_a_box.css index 1965ecb95..765fd5651 100644 --- a/sandbox/darren/just_a_box.css +++ b/sandbox/darren/just_a_box.css @@ -1,8 +1,3 @@ -Screen { - layout: dock; - height: 100%; -} - #box { height: 50%; width: 50%; diff --git a/sandbox/darren/just_a_box.py b/sandbox/darren/just_a_box.py index 2541c7b2b..0750afc43 100644 --- a/sandbox/darren/just_a_box.py +++ b/sandbox/darren/just_a_box.py @@ -1,10 +1,27 @@ +from __future__ import annotations + +from rich.console import RenderableType +from rich.panel import Panel + from textual.app import App, ComposeResult -from textual.widgets import Static +from textual.widget import Widget + + +class Box(Widget): + CSS = "#box {background: blue;}" + + def __init__( + self, id: str | None = None, classes: str | None = None, *children: Widget + ): + super().__init__(*children, id=id, classes=classes) + + def render(self) -> RenderableType: + return Panel("Box") class JustABox(App): def compose(self) -> ComposeResult: - yield Static("Hello, World!", id="box") + yield Box(id="box") if __name__ == "__main__": diff --git a/src/textual/css/model.py b/src/textual/css/model.py index 89623ac95..bf4e9e7c8 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -156,6 +156,7 @@ class RuleSet: styles: Styles = field(default_factory=Styles) errors: list[tuple[Token, str]] = field(default_factory=list) classes: set[str] = field(default_factory=set) + is_widget_rule_set: bool = False @classmethod def _selector_to_css(cls, selectors: list[Selector]) -> str: diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 30a2f2234..4530bacd2 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -56,6 +56,7 @@ from .types import ( AlignVertical, Visibility, ScrollbarGutter, + Specificity5, ) if sys.version_info >= (3, 8): @@ -511,20 +512,27 @@ class Styles(StylesBase): self._rules.update(rules) def extract_rules( - self, specificity: Specificity3 - ) -> list[tuple[str, Specificity4, Any]]: - """Extract rules from Styles object, and apply !important css specificity. + self, + specificity: Specificity3, + is_widget_rule: bool = False, + ) -> list[tuple[str, Specificity5, Any]]: + """Extract rules from Styles object, and apply !important css specificity as + well as higher specificity of user CSS vs widget CSS. Args: specificity (Specificity3): A node specificity. Returns: - list[tuple[str, Specificity4, Any]]]: A list containing a tuple of , . + list[tuple[str, Specificity5, Any]]]: A list containing a tuple of , . """ is_important = self.important.__contains__ rules = [ - (rule_name, (int(is_important(rule_name)), *specificity), rule_value) + ( + rule_name, + (int(not is_widget_rule), int(is_important(rule_name)), *specificity), + rule_value, + ) for rule_name, rule_value in self._rules.items() ] diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index b627a6e8f..bc2f6e61c 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -122,6 +122,8 @@ class Stylesheet: self._rules: list[RuleSet] = [] self.variables = variables or {} self.source: dict[str, str] = {} + # Records which of the source keys represent CSS defined at the widget level + self._widget_css_paths: set[str] = set() self._require_parse = False def __rich_repr__(self) -> rich.repr.Result: @@ -147,6 +149,7 @@ class Stylesheet: """ stylesheet = Stylesheet(variables=self.variables.copy()) stylesheet.source = self.source.copy() + stylesheet._widget_css_paths = self._widget_css_paths.copy() return stylesheet def set_variables(self, variables: dict[str, str]) -> None: @@ -160,7 +163,6 @@ class Stylesheet: def _parse_rules(self, css: str, path: str | PurePath) -> list[RuleSet]: """Parse CSS and return rules. - Args: css (str): String containing Textual CSS. path (str | PurePath): Path to CSS or unique identifier @@ -177,6 +179,11 @@ class Stylesheet: raise except Exception as error: raise StylesheetError(f"failed to parse css; {error}") + + if path in self._widget_css_paths: + for rule in rules: + rule.is_widget_rule_set = True + return rules def read(self, filename: str | PurePath) -> None: @@ -199,13 +206,17 @@ class Stylesheet: self.source[str(path)] = css self._require_parse = True - def add_source(self, css: str, path: str | PurePath | None = None) -> None: + def add_source( + self, css: str, path: str | PurePath | None = None, is_widget_css: bool = False + ) -> None: """Parse CSS from a string. Args: css (str): String with CSS source. path (str | PurePath, optional): The path of the source if a file, or some other identifier. Defaults to None. + is_widget_css (bool): True if the CSS is defined in the Widget, False if the CSS is defined + in a user stylesheet. Raises: StylesheetError: If the CSS could not be read. @@ -220,6 +231,11 @@ class Stylesheet: # Path already in source, and CSS is identical return + # Record any CSS defined at the widget level for + # different specificity treatment from user CSS. + if is_widget_css: + self._widget_css_paths.add(path) + self.source[path] = css self._require_parse = True @@ -286,9 +302,10 @@ class Stylesheet: # Collect the rules defined in the stylesheet for rule in reversed(self.rules): - for specificity in _check_rule(rule, node): + is_widget_rule = rule.is_widget_rule_set + for base_specificity in _check_rule(rule, node): for key, rule_specificity, value in rule.styles.extract_rules( - specificity + base_specificity, is_widget_rule ): rule_attributes[key].append((rule_specificity, value)) diff --git a/src/textual/css/types.py b/src/textual/css/types.py index e7fd749b2..1463bf6fa 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -37,5 +37,7 @@ ScrollbarGutter = Literal["auto", "stable"] BoxSizing = Literal["border-box", "content-box"] Overflow = Literal["scroll", "hidden", "auto"] EdgeStyle = Tuple[EdgeType, Color] + Specificity3 = Tuple[int, int, int] Specificity4 = Tuple[int, int, int, int] +Specificity5 = Tuple[int, int, int, int, int] diff --git a/src/textual/widget.py b/src/textual/widget.py index 2100f70fd..11d79780c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -74,12 +74,12 @@ class Widget(DOMNode): CSS = """ Widget{ scrollbar-background: $panel-darken-2; - scrollbar-background-hover: $panel-darken-3; + scrollbar-background-hover: $panel-darken-3; scrollbar-color: $system; - scrollbar-color-active: $secondary-darken-1; + scrollbar-color-active: $secondary-darken-1; scrollbar-size-vertical: 2; scrollbar-size-horizontal: 1; - + } """ @@ -194,7 +194,7 @@ class Widget(DOMNode): """ # Parse the Widget's CSS for path, css in self.css: - self.app.stylesheet.add_source(css, path=path) + self.app.stylesheet.add_source(css, path=path, is_widget_css=True) def get_box_model( self, container: Size, viewport: Size, fraction_unit: Fraction diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index 1727b2911..fd98e8904 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -104,22 +104,25 @@ def test_stylesheet_apply_empty_rulesets(): stylesheet.apply(node) -@pytest.mark.xfail(reason="wip") def test_stylesheet_apply_user_css_over_widget_css(): - user_css = ".a {color: red;}" + user_css = ".a {color: red; tint: yellow;}" class MyWidget(Widget): - CSS = ".a {color: blue;}" + CSS = ".a {color: blue; background: lime;}" node = MyWidget() node.add_class("a") - print(node.styles.color) stylesheet = _make_stylesheet(user_css) + stylesheet.add_source(MyWidget.CSS, "widget.py:MyWidget", is_widget_css=True) stylesheet.apply(node) - assert node.styles.background == Color(0, 0, 255) - # TODO: On Tuesday - writing the tests for prioritising user CSS above widget CSS. + # The node is red because user CSS overrides Widget.CSS + assert node.styles.color == Color(255, 0, 0) + # The background colour defined in the Widget still applies, since user CSS doesn't override it + assert node.styles.background == Color(0, 255, 0) + # As expected, the tint colour is yellow, since there's no competition between user or widget CSS + assert node.styles.tint == Color(255, 255, 0) @pytest.mark.parametrize(