Merge pull request #595 from Textualize/user-css-over-widget-css

User CSS should always take precedence over Widget CSS
This commit is contained in:
Will McGugan
2022-06-29 14:03:14 +01:00
committed by GitHub
19 changed files with 192 additions and 56 deletions

View File

@@ -0,0 +1,4 @@
Button {
padding-left: 1;
padding-right: 1;
}

31
sandbox/darren/buttons.py Normal file
View File

@@ -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))

View File

@@ -0,0 +1,6 @@
#box {
height: 50%;
width: 50%;
align: center middle;
background: green;
}

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from rich.console import RenderableType
from rich.panel import Panel
from textual.app import App, ComposeResult
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 Box(id="box")
if __name__ == "__main__":
app = JustABox(css_path="just_a_box.css", watch_css=True)
app.run()

View File

@@ -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()

View File

@@ -1,5 +0,0 @@
.box {
height: 1fr;
/*background: rgb(180,50, 50);*/
background: hsl(180,50%, 50%);
}

View File

@@ -156,6 +156,7 @@ class RuleSet:
styles: Styles = field(default_factory=Styles) styles: Styles = field(default_factory=Styles)
errors: list[tuple[Token, str]] = field(default_factory=list) errors: list[tuple[Token, str]] = field(default_factory=list)
classes: set[str] = field(default_factory=set) classes: set[str] = field(default_factory=set)
is_default_rules: bool = False
@classmethod @classmethod
def _selector_to_css(cls, selectors: list[Selector]) -> str: def _selector_to_css(cls, selectors: list[Selector]) -> str:

View File

@@ -7,6 +7,7 @@ from typing import Iterator, Iterable
from rich import print from rich import print
from textual.css.errors import UnresolvedVariableError from textual.css.errors import UnresolvedVariableError
from textual.css.types import Specificity3
from ._styles_builder import StylesBuilder, DeclarationError from ._styles_builder import StylesBuilder, DeclarationError
from .model import ( from .model import (
Declaration, Declaration,
@@ -20,7 +21,7 @@ from .styles import Styles
from .tokenize import tokenize, tokenize_declarations, Token, tokenize_values from .tokenize import tokenize, tokenize_declarations, Token, tokenize_values
from .tokenizer import EOFError, ReferencedBy 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": (SelectorType.TYPE, (0, 0, 1)),
"selector_start": (SelectorType.TYPE, (0, 0, 1)), "selector_start": (SelectorType.TYPE, (0, 0, 1)),
"selector_class": (SelectorType.CLASS, (0, 1, 0)), "selector_class": (SelectorType.CLASS, (0, 1, 0)),
@@ -79,7 +80,9 @@ def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
return selector_set 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 get_selector = SELECTOR_MAP.get
combinator: CombinatorType | None = CombinatorType.DESCENDENT combinator: CombinatorType | None = CombinatorType.DESCENDENT
selectors: list[Selector] = [] selectors: list[Selector] = []
@@ -148,7 +151,10 @@ def parse_rule_set(tokens: Iterator[Token], token: Token) -> Iterable[RuleSet]:
errors.append((error.token, error.message)) errors.append((error.token, error.message))
rule_set = RuleSet( 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() rule_set._post_parse()
yield rule_set yield rule_set
@@ -306,7 +312,10 @@ def substitute_references(
def parse( 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]: ) -> Iterable[RuleSet]:
"""Parse CSS by tokenizing it, performing variable substitution, """Parse CSS by tokenizing it, performing variable substitution,
and generating rule sets from it. and generating rule sets from it.
@@ -314,6 +323,9 @@ def parse(
Args: Args:
css (str): The input CSS css (str): The input CSS
path (str): Path to the 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 {}) variable_tokens = tokenize_values(variables or {})
tokens = iter(substitute_references(tokenize(css, path), variable_tokens)) tokens = iter(substitute_references(tokenize(css, path), variable_tokens))
@@ -322,7 +334,7 @@ def parse(
if token is None: if token is None:
break break
if token.name.startswith("selector_start"): 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__": if __name__ == "__main__":

View File

@@ -56,6 +56,7 @@ from .types import (
AlignVertical, AlignVertical,
Visibility, Visibility,
ScrollbarGutter, ScrollbarGutter,
Specificity5,
) )
if sys.version_info >= (3, 8): if sys.version_info >= (3, 8):
@@ -511,20 +512,33 @@ class Styles(StylesBase):
self._rules.update(rules) self._rules.update(rules)
def extract_rules( def extract_rules(
self, specificity: Specificity3 self,
) -> list[tuple[str, Specificity4, Any]]: specificity: Specificity3,
"""Extract rules from Styles object, and apply !important css specificity. 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: Args:
specificity (Specificity3): A node specificity. 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: Returns:
list[tuple[str, Specificity4, Any]]]: A list containing a tuple of <RULE NAME>, <SPECIFICITY> <RULE VALUE>. list[tuple[str, Specificity5, Any]]]: A list containing a tuple of <RULE NAME>, <SPECIFICITY> <RULE VALUE>.
""" """
is_important = self.important.__contains__ is_important = self.important.__contains__
rules = [ rules = [
(rule_name, (int(is_important(rule_name)), *specificity), rule_value) (
rule_name,
(
0 if is_default_rules else 1,
1 if is_important(rule_name) else 0,
*specificity,
),
rule_value,
)
for rule_name, rule_value in self._rules.items() for rule_name, rule_value in self._rules.items()
] ]

View File

@@ -4,7 +4,7 @@ import os
from collections import defaultdict from collections import defaultdict
from operator import itemgetter from operator import itemgetter
from pathlib import Path, PurePath from pathlib import Path, PurePath
from typing import cast, Iterable from typing import cast, Iterable, NamedTuple
import rich.repr import rich.repr
from rich.console import RenderableType, RenderResult, Console, ConsoleOptions from rich.console import RenderableType, RenderResult, Console, ConsoleOptions
@@ -116,12 +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) @rich.repr.auto(angular=True)
class Stylesheet: class Stylesheet:
def __init__(self, *, variables: dict[str, str] | None = None) -> None: def __init__(self, *, variables: dict[str, str] | None = None) -> None:
self._rules: list[RuleSet] = [] self._rules: list[RuleSet] = []
self.variables = variables or {} self.variables = variables or {}
self.source: dict[str, str] = {} self.source: dict[str, CssSource] = {}
self._require_parse = False self._require_parse = False
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
@@ -157,13 +171,17 @@ class Stylesheet:
""" """
self.variables = variables 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. """Parse CSS and return rules.
Args: Args:
is_default_rules:
css (str): String containing Textual CSS. css (str): String containing Textual CSS.
path (str | PurePath): Path to CSS or unique identifier 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: Raises:
StylesheetError: If the CSS is invalid. StylesheetError: If the CSS is invalid.
@@ -172,11 +190,19 @@ class Stylesheet:
list[RuleSet]: List of RuleSets. list[RuleSet]: List of RuleSets.
""" """
try: 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: except TokenizeError:
raise raise
except Exception as error: except Exception as error:
raise StylesheetError(f"failed to parse css; {error}") raise StylesheetError(f"failed to parse css; {error}")
return rules return rules
def read(self, filename: str | PurePath) -> None: def read(self, filename: str | PurePath) -> None:
@@ -196,16 +222,20 @@ class Stylesheet:
path = os.path.abspath(filename) path = os.path.abspath(filename)
except Exception as error: except Exception as error:
raise StylesheetError(f"unable to read {filename!r}; {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 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_default_css: bool = False
) -> None:
"""Parse CSS from a string. """Parse CSS from a string.
Args: Args:
css (str): String with CSS source. css (str): String with CSS source.
path (str | PurePath, optional): The path of the source if a file, or some other identifier. path (str | PurePath, optional): The path of the source if a file, or some other identifier.
Defaults to None. Defaults to None.
is_default_css (bool): True if the CSS is defined in the Widget, False if the CSS is defined
in a user stylesheet.
Raises: Raises:
StylesheetError: If the CSS could not be read. StylesheetError: If the CSS could not be read.
@@ -216,11 +246,11 @@ class Stylesheet:
path = str(hash(css)) path = str(hash(css))
elif isinstance(path, PurePath): elif isinstance(path, PurePath):
path = str(css) 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 # Path already in source, and CSS is identical
return return
self.source[path] = css self.source[path] = CssSource(content=css, is_defaults=is_default_css)
self._require_parse = True self._require_parse = True
def parse(self) -> None: def parse(self) -> None:
@@ -231,8 +261,8 @@ class Stylesheet:
""" """
rules: list[RuleSet] = [] rules: list[RuleSet] = []
add_rules = rules.extend add_rules = rules.extend
for path, css in self.source.items(): for path, (css, is_default_rules) in self.source.items():
css_rules = self._parse_rules(css, path) css_rules = self._parse_rules(css, path, is_default_rules=is_default_rules)
if any(rule.errors for rule in css_rules): if any(rule.errors for rule in css_rules):
error_renderable = StylesheetErrors(css_rules) error_renderable = StylesheetErrors(css_rules)
raise StylesheetParseError(error_renderable) raise StylesheetParseError(error_renderable)
@@ -250,8 +280,8 @@ class Stylesheet:
""" """
# Do this in a fresh Stylesheet so if there are errors we don't break self. # Do this in a fresh Stylesheet so if there are errors we don't break self.
stylesheet = Stylesheet(variables=self.variables) stylesheet = Stylesheet(variables=self.variables)
for path, css in self.source.items(): for path, (css, is_defaults) in self.source.items():
stylesheet.add_source(css, path) stylesheet.add_source(css, path, is_default_css=is_defaults)
stylesheet.parse() stylesheet.parse()
self._rules = stylesheet.rules self._rules = stylesheet.rules
self.source = stylesheet.source self.source = stylesheet.source
@@ -286,9 +316,10 @@ class Stylesheet:
# Collect the rules defined in the stylesheet # Collect the rules defined in the stylesheet
for rule in reversed(self.rules): for rule in reversed(self.rules):
for specificity in _check_rule(rule, node): 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( for key, rule_specificity, value in rule.styles.extract_rules(
specificity base_specificity, is_default_rules
): ):
rule_attributes[key].append((rule_specificity, value)) rule_attributes[key].append((rule_specificity, value))

View File

@@ -37,5 +37,7 @@ ScrollbarGutter = Literal["auto", "stable"]
BoxSizing = Literal["border-box", "content-box"] BoxSizing = Literal["border-box", "content-box"]
Overflow = Literal["scroll", "hidden", "auto"] Overflow = Literal["scroll", "hidden", "auto"]
EdgeStyle = Tuple[EdgeType, Color] EdgeStyle = Tuple[EdgeType, Color]
Specificity3 = Tuple[int, int, int] Specificity3 = Tuple[int, int, int]
Specificity4 = Tuple[int, int, int, int] Specificity4 = Tuple[int, int, int, int]
Specificity5 = Tuple[int, int, int, int, int]

View File

@@ -193,7 +193,7 @@ class Widget(DOMNode):
""" """
# Parse the Widget's CSS # Parse the Widget's CSS
for path, css in self.css: for path, css in self.css:
self.app.stylesheet.add_source(css, path=path) self.app.stylesheet.add_source(css, path=path, is_default_css=True)
def get_box_model( def get_box_model(
self, container: Size, viewport: Size, fraction_unit: Fraction self, container: Size, viewport: Size, fraction_unit: Fraction

View File

@@ -5,15 +5,16 @@ import pytest
from textual.color import Color from textual.color import Color
from textual.css._help_renderables import HelpText 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.css.tokenizer import TokenizeError
from textual.dom import DOMNode from textual.dom import DOMNode
from textual.geometry import Spacing 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 = Stylesheet()
stylesheet.source["test.css"] = css stylesheet.source["test.css"] = CssSource(css, is_defaults=False)
stylesheet.parse() stylesheet.parse()
return stylesheet return stylesheet
@@ -21,7 +22,7 @@ def _make_stylesheet(css: str) -> Stylesheet:
def test_stylesheet_apply_highest_specificity_wins(): def test_stylesheet_apply_highest_specificity_wins():
"""#ids have higher specificity than .classes""" """#ids have higher specificity than .classes"""
css = "#id {color: red;} .class {color: blue;}" css = "#id {color: red;} .class {color: blue;}"
stylesheet = _make_stylesheet(css) stylesheet = _make_user_stylesheet(css)
node = DOMNode(classes="class", id="id") node = DOMNode(classes="class", id="id")
stylesheet.apply(node) stylesheet.apply(node)
@@ -30,7 +31,7 @@ def test_stylesheet_apply_highest_specificity_wins():
def test_stylesheet_apply_doesnt_override_defaults(): def test_stylesheet_apply_doesnt_override_defaults():
css = "#id {color: red;}" css = "#id {color: red;}"
stylesheet = _make_stylesheet(css) stylesheet = _make_user_stylesheet(css)
node = DOMNode(id="id") node = DOMNode(id="id")
stylesheet.apply(node) stylesheet.apply(node)
@@ -42,7 +43,7 @@ def test_stylesheet_apply_highest_specificity_wins_multiple_classes():
"""When we use two selectors containing only classes, then the selector """When we use two selectors containing only classes, then the selector
`.b.c` has greater specificity than the selector `.a`""" `.b.c` has greater specificity than the selector `.a`"""
css = ".b.c {background: blue;} .a {background: red; color: lime;}" 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") node = DOMNode(classes="a b c")
stylesheet.apply(node) stylesheet.apply(node)
@@ -55,7 +56,7 @@ def test_stylesheet_many_classes_dont_overrule_id():
a selector containing multiple classes cannot take priority over even a a selector containing multiple classes cannot take priority over even a
single class.""" single class."""
css = "#id {color: red;} .a.b.c.d {color: blue;}" 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") node = DOMNode(classes="a b c d", id="id")
stylesheet.apply(node) stylesheet.apply(node)
@@ -64,7 +65,7 @@ def test_stylesheet_many_classes_dont_overrule_id():
def test_stylesheet_last_rule_wins_when_same_rule_twice_in_one_ruleset(): def test_stylesheet_last_rule_wins_when_same_rule_twice_in_one_ruleset():
css = "#id {color: red; color: blue;}" css = "#id {color: red; color: blue;}"
stylesheet = _make_stylesheet(css) stylesheet = _make_user_stylesheet(css)
node = DOMNode(id="id") node = DOMNode(id="id")
stylesheet.apply(node) stylesheet.apply(node)
@@ -73,7 +74,7 @@ def test_stylesheet_last_rule_wins_when_same_rule_twice_in_one_ruleset():
def test_stylesheet_rulesets_merged_for_duplicate_selectors(): def test_stylesheet_rulesets_merged_for_duplicate_selectors():
css = "#id {color: red; background: lime;} #id {color:blue;}" css = "#id {color: red; background: lime;} #id {color:blue;}"
stylesheet = _make_stylesheet(css) stylesheet = _make_user_stylesheet(css)
node = DOMNode(id="id") node = DOMNode(id="id")
stylesheet.apply(node) stylesheet.apply(node)
@@ -85,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 """.a and .b both contain background and have same specificity, so .b wins
since it was declared last - the background should be blue.""" since it was declared last - the background should be blue."""
css = ".a {background: red; color: lime;} .b {background: 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") node = DOMNode(classes="a b", id="c")
stylesheet.apply(node) stylesheet.apply(node)
@@ -96,11 +97,32 @@ def test_stylesheet_apply_takes_final_rule_in_specificity_clash():
def test_stylesheet_apply_empty_rulesets(): def test_stylesheet_apply_empty_rulesets():
"""Ensure that we don't crash when working with empty rulesets""" """Ensure that we don't crash when working with empty rulesets"""
css = ".a {} .b {}" css = ".a {} .b {}"
stylesheet = _make_stylesheet(css) stylesheet = _make_user_stylesheet(css)
node = DOMNode(classes="a b") node = DOMNode(classes="a b")
stylesheet.apply(node) stylesheet.apply(node)
def test_stylesheet_apply_user_css_over_widget_css():
user_css = ".a {color: red; tint: yellow;}"
class MyWidget(Widget):
CSS = ".a {color: blue !important; background: lime;}"
node = MyWidget()
node.add_class("a")
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
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( @pytest.mark.parametrize(
"css_value,expectation,expected_color", "css_value,expectation,expected_color",
[ [