From 76b39747cafa4aa1910e3c35bdb801c87ee10619 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 22 Jun 2022 14:02:24 +0100 Subject: [PATCH 1/7] Move files into darren sandbox --- sandbox/{ => darren}/file_search.py | 0 sandbox/{ => darren}/file_search.scss | 0 sandbox/{ => darren}/focus_keybindings.py | 0 sandbox/{ => darren}/focus_keybindings.scss | 0 sandbox/{ => darren}/tabs.py | 0 sandbox/{ => darren}/tabs.scss | 0 sandbox/hsl.py | 11 ----------- sandbox/hsl.scss | 5 ----- 8 files changed, 16 deletions(-) rename sandbox/{ => darren}/file_search.py (100%) rename sandbox/{ => darren}/file_search.scss (100%) rename sandbox/{ => darren}/focus_keybindings.py (100%) rename sandbox/{ => darren}/focus_keybindings.scss (100%) rename sandbox/{ => darren}/tabs.py (100%) rename sandbox/{ => darren}/tabs.scss (100%) delete mode 100644 sandbox/hsl.py delete mode 100644 sandbox/hsl.scss diff --git a/sandbox/file_search.py b/sandbox/darren/file_search.py similarity index 100% rename from sandbox/file_search.py rename to sandbox/darren/file_search.py diff --git a/sandbox/file_search.scss b/sandbox/darren/file_search.scss similarity index 100% rename from sandbox/file_search.scss rename to sandbox/darren/file_search.scss diff --git a/sandbox/focus_keybindings.py b/sandbox/darren/focus_keybindings.py similarity index 100% rename from sandbox/focus_keybindings.py rename to sandbox/darren/focus_keybindings.py diff --git a/sandbox/focus_keybindings.scss b/sandbox/darren/focus_keybindings.scss similarity index 100% rename from sandbox/focus_keybindings.scss rename to sandbox/darren/focus_keybindings.scss diff --git a/sandbox/tabs.py b/sandbox/darren/tabs.py similarity index 100% rename from sandbox/tabs.py rename to sandbox/darren/tabs.py diff --git a/sandbox/tabs.scss b/sandbox/darren/tabs.scss similarity index 100% rename from sandbox/tabs.scss rename to sandbox/darren/tabs.scss diff --git a/sandbox/hsl.py b/sandbox/hsl.py deleted file mode 100644 index 17c0d4f49..000000000 --- a/sandbox/hsl.py +++ /dev/null @@ -1,11 +0,0 @@ -from textual.app import App, ComposeResult -from textual.widgets import Static - - -class HSLApp(App): - def compose(self) -> ComposeResult: - yield Static(classes="box") - - -app = HSLApp(css_path="hsl.scss", watch_css=True) -app.run() diff --git a/sandbox/hsl.scss b/sandbox/hsl.scss deleted file mode 100644 index 78c0a495f..000000000 --- a/sandbox/hsl.scss +++ /dev/null @@ -1,5 +0,0 @@ -.box { - height: 1fr; - /*background: rgb(180,50, 50);*/ - background: hsl(180,50%, 50%); -} From 71536364a4f0a6ff7202c7d81d763ec151641465 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 22 Jun 2022 14:03:24 +0100 Subject: [PATCH 2/7] Copy buttons example into sandbox --- sandbox/darren/buttons.css | 4 ++++ sandbox/darren/buttons.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 sandbox/darren/buttons.css create mode 100644 sandbox/darren/buttons.py diff --git a/sandbox/darren/buttons.css b/sandbox/darren/buttons.css new file mode 100644 index 000000000..cedf20ded --- /dev/null +++ b/sandbox/darren/buttons.css @@ -0,0 +1,4 @@ +Button { + padding-left: 1; + padding-right: 1; +} diff --git a/sandbox/darren/buttons.py b/sandbox/darren/buttons.py new file mode 100644 index 000000000..daf1047a1 --- /dev/null +++ b/sandbox/darren/buttons.py @@ -0,0 +1,31 @@ +from textual import layout, events +from textual.app import App, ComposeResult +from textual.widgets import Button + + +class ButtonsApp(App[str]): + def compose(self) -> ComposeResult: + yield layout.Vertical( + Button("default", id="foo"), + Button.success("success", id="bar"), + Button.warning("warning", id="baz"), + Button.error("error", id="baz"), + ) + + def handle_pressed(self, event: Button.Pressed) -> None: + self.app.bell() + + async def on_key(self, event: events.Key) -> None: + await self.dispatch_key(event) + + def key_d(self): + self.dark = not self.dark + + +app = ButtonsApp( + log_path="textual.log", css_path="buttons.css", watch_css=True, log_verbosity=2 +) + +if __name__ == "__main__": + result = app.run() + print(repr(result)) From 7ebac6b860049547b729c18f273b42cfc0472ae2 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 28 Jun 2022 13:52:58 +0100 Subject: [PATCH 3/7] Add wip test for user over widget css, use type alias --- sandbox/darren/just_a_box.css | 11 +++++++++++ sandbox/darren/just_a_box.py | 12 ++++++++++++ src/textual/css/parse.py | 3 ++- tests/css/test_stylesheet.py | 21 +++++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 sandbox/darren/just_a_box.css create mode 100644 sandbox/darren/just_a_box.py diff --git a/sandbox/darren/just_a_box.css b/sandbox/darren/just_a_box.css new file mode 100644 index 000000000..1965ecb95 --- /dev/null +++ b/sandbox/darren/just_a_box.css @@ -0,0 +1,11 @@ +Screen { + layout: dock; + height: 100%; +} + +#box { + height: 50%; + width: 50%; + align: center middle; + background: green; +} diff --git a/sandbox/darren/just_a_box.py b/sandbox/darren/just_a_box.py new file mode 100644 index 000000000..2541c7b2b --- /dev/null +++ b/sandbox/darren/just_a_box.py @@ -0,0 +1,12 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class JustABox(App): + def compose(self) -> ComposeResult: + yield Static("Hello, World!", id="box") + + +if __name__ == "__main__": + app = JustABox(css_path="just_a_box.css", watch_css=True) + app.run() diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index f3ce8d3f7..9052c3b1d 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -7,6 +7,7 @@ from typing import Iterator, Iterable from rich import print from textual.css.errors import UnresolvedVariableError +from textual.css.types import Specificity3 from ._styles_builder import StylesBuilder, DeclarationError from .model import ( Declaration, @@ -20,7 +21,7 @@ from .styles import Styles from .tokenize import tokenize, tokenize_declarations, Token, tokenize_values from .tokenizer import EOFError, ReferencedBy -SELECTOR_MAP: dict[str, tuple[SelectorType, tuple[int, int, int]]] = { +SELECTOR_MAP: dict[str, tuple[SelectorType, Specificity3]] = { "selector": (SelectorType.TYPE, (0, 0, 1)), "selector_start": (SelectorType.TYPE, (0, 0, 1)), "selector_class": (SelectorType.CLASS, (0, 1, 0)), diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index 6d4425b42..1727b2911 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -3,12 +3,15 @@ from typing import Any import pytest +from tests.utilities.test_app import AppTest +from textual.app import App, ComposeResult 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 +from textual.widget import Widget def _make_stylesheet(css: str) -> Stylesheet: @@ -101,6 +104,24 @@ 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;}" + + class MyWidget(Widget): + CSS = ".a {color: blue;}" + + node = MyWidget() + node.add_class("a") + + print(node.styles.color) + stylesheet = _make_stylesheet(user_css) + stylesheet.apply(node) + + assert node.styles.background == Color(0, 0, 255) + # TODO: On Tuesday - writing the tests for prioritising user CSS above widget CSS. + + @pytest.mark.parametrize( "css_value,expectation,expected_color", [ From 972aeece649bc2f1eaa74378c1c43e06b3ffad74 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 28 Jun 2022 17:26:18 +0100 Subject: [PATCH 4/7] Add Specificity5 for user defined CSS --- sandbox/darren/just_a_box.css | 5 ----- sandbox/darren/just_a_box.py | 21 +++++++++++++++++++-- src/textual/css/model.py | 1 + src/textual/css/styles.py | 18 +++++++++++++----- src/textual/css/stylesheet.py | 25 +++++++++++++++++++++---- src/textual/css/types.py | 2 ++ src/textual/widget.py | 8 ++++---- tests/css/test_stylesheet.py | 15 +++++++++------ 8 files changed, 69 insertions(+), 26 deletions(-) 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( From 8617cbdc70cb49e7d54936a13bd362c9bc93a362 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 28 Jun 2022 18:39:39 +0100 Subject: [PATCH 5/7] Update test over user css over widget css to show important widget css can be overridden by users too --- sandbox/darren/just_a_box.css | 2 +- tests/css/test_stylesheet.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sandbox/darren/just_a_box.css b/sandbox/darren/just_a_box.css index 765fd5651..9182f42c7 100644 --- a/sandbox/darren/just_a_box.css +++ b/sandbox/darren/just_a_box.css @@ -2,5 +2,5 @@ height: 50%; width: 50%; align: center middle; - background: green; + background: blue; } diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index fd98e8904..b489298e6 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -108,7 +108,7 @@ def test_stylesheet_apply_user_css_over_widget_css(): user_css = ".a {color: red; tint: yellow;}" class MyWidget(Widget): - CSS = ".a {color: blue; background: lime;}" + CSS = ".a {color: blue !important; background: lime;}" node = MyWidget() node.add_class("a") From c00de10262cc70d1300e1a15f736ea64bb3d3fd8 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 28 Jun 2022 18:52:47 +0100 Subject: [PATCH 6/7] Use more efficient bool to int conversion --- sandbox/darren/just_a_box.css | 2 +- src/textual/css/styles.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/sandbox/darren/just_a_box.css b/sandbox/darren/just_a_box.css index 9182f42c7..765fd5651 100644 --- a/sandbox/darren/just_a_box.css +++ b/sandbox/darren/just_a_box.css @@ -2,5 +2,5 @@ height: 50%; width: 50%; align: center middle; - background: blue; + background: green; } diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 4530bacd2..6a8a8b6e4 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -530,7 +530,11 @@ class Styles(StylesBase): rules = [ ( rule_name, - (int(not is_widget_rule), int(is_important(rule_name)), *specificity), + ( + 0 if is_widget_rule else 1, + 1 if is_important(rule_name) else 0, + *specificity, + ), rule_value, ) for rule_name, rule_value in self._rules.items() From 908e2e940ac19f3bde1896bd6cde7b9eeda0fcf8 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 29 Jun 2022 13:30:21 +0100 Subject: [PATCH 7/7] Feedback from code review --- src/textual/css/model.py | 2 +- src/textual/css/parse.py | 19 +++++++--- src/textual/css/styles.py | 6 ++-- src/textual/css/stylesheet.py | 68 +++++++++++++++++++++-------------- src/textual/widget.py | 2 +- tests/css/test_stylesheet.py | 28 +++++++-------- 6 files changed, 75 insertions(+), 50 deletions(-) diff --git a/src/textual/css/model.py b/src/textual/css/model.py index bf4e9e7c8..d5d2eb726 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -156,7 +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 + is_default_rules: bool = False @classmethod def _selector_to_css(cls, selectors: list[Selector]) -> str: diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index 9052c3b1d..6e3ddb4cd 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -80,7 +80,9 @@ def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]: return selector_set -def parse_rule_set(tokens: Iterator[Token], token: Token) -> Iterable[RuleSet]: +def parse_rule_set( + tokens: Iterator[Token], token: Token, is_default_rules: bool = False +) -> Iterable[RuleSet]: get_selector = SELECTOR_MAP.get combinator: CombinatorType | None = CombinatorType.DESCENDENT selectors: list[Selector] = [] @@ -149,7 +151,10 @@ def parse_rule_set(tokens: Iterator[Token], token: Token) -> Iterable[RuleSet]: errors.append((error.token, error.message)) rule_set = RuleSet( - list(SelectorSet.from_selectors(rule_selectors)), styles_builder.styles, errors + list(SelectorSet.from_selectors(rule_selectors)), + styles_builder.styles, + errors, + is_default_rules=is_default_rules, ) rule_set._post_parse() yield rule_set @@ -307,7 +312,10 @@ def substitute_references( def parse( - css: str, path: str | PurePath, variables: dict[str, str] | None = None + css: str, + path: str | PurePath, + variables: dict[str, str] | None = None, + is_default_rules: bool = False, ) -> Iterable[RuleSet]: """Parse CSS by tokenizing it, performing variable substitution, and generating rule sets from it. @@ -315,6 +323,9 @@ def parse( Args: css (str): The input CSS path (str): Path to the CSS + variables (dict[str, str]): Substitution variables to substitute tokens for. + is_default_rules (bool): True if the rules we're extracting are + default (i.e. in Widget.CSS) rules. False if they're from user defined CSS. """ variable_tokens = tokenize_values(variables or {}) tokens = iter(substitute_references(tokenize(css, path), variable_tokens)) @@ -323,7 +334,7 @@ def parse( if token is None: break if token.name.startswith("selector_start"): - yield from parse_rule_set(tokens, token) + yield from parse_rule_set(tokens, token, is_default_rules=is_default_rules) if __name__ == "__main__": diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 6a8a8b6e4..d2e0a042d 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -514,13 +514,15 @@ class Styles(StylesBase): def extract_rules( self, specificity: Specificity3, - is_widget_rule: bool = False, + is_default_rules: 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. + is_default_rules (bool): True if the rules we're extracting are + default (i.e. in Widget.CSS) rules. False if they're from user defined CSS. Returns: list[tuple[str, Specificity5, Any]]]: A list containing a tuple of , . @@ -531,7 +533,7 @@ class Styles(StylesBase): ( rule_name, ( - 0 if is_widget_rule else 1, + 0 if is_default_rules else 1, 1 if is_important(rule_name) else 0, *specificity, ), diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index bc2f6e61c..02a641168 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -4,7 +4,7 @@ import os from collections import defaultdict from operator import itemgetter from pathlib import Path, PurePath -from typing import cast, Iterable +from typing import cast, Iterable, NamedTuple import rich.repr from rich.console import RenderableType, RenderResult, Console, ConsoleOptions @@ -116,14 +116,26 @@ class StylesheetErrors: ) +class CssSource(NamedTuple): + """Contains the CSS content and whether or not the CSS comes from user defined stylesheets + vs widget-level stylesheets. + + Args: + content (str): The CSS as a string. + is_defaults (bool): True if the CSS is default (i.e. that defined at the widget level). + False if it's user CSS (which will override the defaults). + """ + + content: str + is_defaults: bool + + @rich.repr.auto(angular=True) class Stylesheet: def __init__(self, *, variables: dict[str, str] | None = None) -> None: 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.source: dict[str, CssSource] = {} self._require_parse = False def __rich_repr__(self) -> rich.repr.Result: @@ -149,7 +161,6 @@ 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,12 +171,17 @@ class Stylesheet: """ self.variables = variables - def _parse_rules(self, css: str, path: str | PurePath) -> list[RuleSet]: + def _parse_rules( + self, css: str, path: str | PurePath, is_default_rules: bool = False + ) -> list[RuleSet]: """Parse CSS and return rules. Args: + is_default_rules: css (str): String containing Textual CSS. path (str | PurePath): Path to CSS or unique identifier + is_default_rules (bool): True if the rules we're extracting are + default (i.e. in Widget.CSS) rules. False if they're from user defined CSS. Raises: StylesheetError: If the CSS is invalid. @@ -174,16 +190,19 @@ class Stylesheet: list[RuleSet]: List of RuleSets. """ try: - rules = list(parse(css, path, variables=self.variables)) + rules = list( + parse( + css, + path, + variables=self.variables, + is_default_rules=is_default_rules, + ) + ) except TokenizeError: 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: @@ -203,11 +222,11 @@ class Stylesheet: path = os.path.abspath(filename) except Exception as error: raise StylesheetError(f"unable to read {filename!r}; {error}") - self.source[str(path)] = css + self.source[str(path)] = CssSource(content=css, is_defaults=False) self._require_parse = True def add_source( - self, css: str, path: str | PurePath | None = None, is_widget_css: bool = False + self, css: str, path: str | PurePath | None = None, is_default_css: bool = False ) -> None: """Parse CSS from a string. @@ -215,7 +234,7 @@ class Stylesheet: 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 + is_default_css (bool): True if the CSS is defined in the Widget, False if the CSS is defined in a user stylesheet. Raises: @@ -227,16 +246,11 @@ class Stylesheet: path = str(hash(css)) elif isinstance(path, PurePath): path = str(css) - if path in self.source and self.source[path] == css: + if path in self.source and self.source[path].content == css: # 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.source[path] = CssSource(content=css, is_defaults=is_default_css) self._require_parse = True def parse(self) -> None: @@ -247,8 +261,8 @@ class Stylesheet: """ rules: list[RuleSet] = [] add_rules = rules.extend - for path, css in self.source.items(): - css_rules = self._parse_rules(css, path) + for path, (css, is_default_rules) in self.source.items(): + css_rules = self._parse_rules(css, path, is_default_rules=is_default_rules) if any(rule.errors for rule in css_rules): error_renderable = StylesheetErrors(css_rules) raise StylesheetParseError(error_renderable) @@ -266,8 +280,8 @@ class Stylesheet: """ # Do this in a fresh Stylesheet so if there are errors we don't break self. stylesheet = Stylesheet(variables=self.variables) - for path, css in self.source.items(): - stylesheet.add_source(css, path) + for path, (css, is_defaults) in self.source.items(): + stylesheet.add_source(css, path, is_default_css=is_defaults) stylesheet.parse() self._rules = stylesheet.rules self.source = stylesheet.source @@ -302,10 +316,10 @@ class Stylesheet: # Collect the rules defined in the stylesheet for rule in reversed(self.rules): - is_widget_rule = rule.is_widget_rule_set + is_default_rules = rule.is_default_rules for base_specificity in _check_rule(rule, node): for key, rule_specificity, value in rule.styles.extract_rules( - base_specificity, is_widget_rule + base_specificity, is_default_rules ): rule_attributes[key].append((rule_specificity, value)) diff --git a/src/textual/widget.py b/src/textual/widget.py index 11d79780c..91c2ae9bc 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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, is_widget_css=True) + self.app.stylesheet.add_source(css, path=path, is_default_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 b489298e6..14b912f00 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -3,20 +3,18 @@ from typing import Any import pytest -from tests.utilities.test_app import AppTest -from textual.app import App, ComposeResult from textual.color import Color from textual.css._help_renderables import HelpText -from textual.css.stylesheet import Stylesheet, StylesheetParseError +from textual.css.stylesheet import Stylesheet, StylesheetParseError, CssSource from textual.css.tokenizer import TokenizeError from textual.dom import DOMNode from textual.geometry import Spacing from textual.widget import Widget -def _make_stylesheet(css: str) -> Stylesheet: +def _make_user_stylesheet(css: str) -> Stylesheet: stylesheet = Stylesheet() - stylesheet.source["test.css"] = css + stylesheet.source["test.css"] = CssSource(css, is_defaults=False) stylesheet.parse() return stylesheet @@ -24,7 +22,7 @@ def _make_stylesheet(css: str) -> 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) + stylesheet = _make_user_stylesheet(css) node = DOMNode(classes="class", id="id") stylesheet.apply(node) @@ -33,7 +31,7 @@ def test_stylesheet_apply_highest_specificity_wins(): def test_stylesheet_apply_doesnt_override_defaults(): css = "#id {color: red;}" - stylesheet = _make_stylesheet(css) + stylesheet = _make_user_stylesheet(css) node = DOMNode(id="id") stylesheet.apply(node) @@ -45,7 +43,7 @@ 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) + stylesheet = _make_user_stylesheet(css) node = DOMNode(classes="a b c") stylesheet.apply(node) @@ -58,7 +56,7 @@ def test_stylesheet_many_classes_dont_overrule_id(): 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) + stylesheet = _make_user_stylesheet(css) node = DOMNode(classes="a b c d", id="id") stylesheet.apply(node) @@ -67,7 +65,7 @@ def test_stylesheet_many_classes_dont_overrule_id(): def test_stylesheet_last_rule_wins_when_same_rule_twice_in_one_ruleset(): css = "#id {color: red; color: blue;}" - stylesheet = _make_stylesheet(css) + stylesheet = _make_user_stylesheet(css) node = DOMNode(id="id") stylesheet.apply(node) @@ -76,7 +74,7 @@ def test_stylesheet_last_rule_wins_when_same_rule_twice_in_one_ruleset(): def test_stylesheet_rulesets_merged_for_duplicate_selectors(): css = "#id {color: red; background: lime;} #id {color:blue;}" - stylesheet = _make_stylesheet(css) + stylesheet = _make_user_stylesheet(css) node = DOMNode(id="id") stylesheet.apply(node) @@ -88,7 +86,7 @@ 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) + stylesheet = _make_user_stylesheet(css) node = DOMNode(classes="a b", id="c") stylesheet.apply(node) @@ -99,7 +97,7 @@ def test_stylesheet_apply_takes_final_rule_in_specificity_clash(): def test_stylesheet_apply_empty_rulesets(): """Ensure that we don't crash when working with empty rulesets""" css = ".a {} .b {}" - stylesheet = _make_stylesheet(css) + stylesheet = _make_user_stylesheet(css) node = DOMNode(classes="a b") stylesheet.apply(node) @@ -113,8 +111,8 @@ def test_stylesheet_apply_user_css_over_widget_css(): node = MyWidget() node.add_class("a") - stylesheet = _make_stylesheet(user_css) - stylesheet.add_source(MyWidget.CSS, "widget.py:MyWidget", is_widget_css=True) + stylesheet = _make_user_stylesheet(user_css) + stylesheet.add_source(MyWidget.CSS, "widget.py:MyWidget", is_default_css=True) stylesheet.apply(node) # The node is red because user CSS overrides Widget.CSS