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)
errors: list[tuple[Token, str]] = field(default_factory=list)
classes: set[str] = field(default_factory=set)
is_default_rules: bool = False
@classmethod
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 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)),
@@ -79,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] = []
@@ -148,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
@@ -306,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.
@@ -314,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))
@@ -322,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__":

View File

@@ -56,6 +56,7 @@ from .types import (
AlignVertical,
Visibility,
ScrollbarGutter,
Specificity5,
)
if sys.version_info >= (3, 8):
@@ -511,20 +512,33 @@ 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_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, 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__
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()
]

View File

@@ -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,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)
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] = {}
self.source: dict[str, CssSource] = {}
self._require_parse = False
def __rich_repr__(self) -> rich.repr.Result:
@@ -157,13 +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.
@@ -172,11 +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}")
return rules
def read(self, filename: str | PurePath) -> None:
@@ -196,16 +222,20 @@ 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) -> None:
def add_source(
self, css: str, path: str | PurePath | None = None, is_default_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_default_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.
@@ -216,11 +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
self.source[path] = css
self.source[path] = CssSource(content=css, is_defaults=is_default_css)
self._require_parse = True
def parse(self) -> None:
@@ -231,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)
@@ -250,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
@@ -286,9 +316,10 @@ class Stylesheet:
# Collect the rules defined in the stylesheet
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(
specificity
base_specificity, is_default_rules
):
rule_attributes[key].append((rule_specificity, value))

View File

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

View File

@@ -73,12 +73,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;
}
"""
@@ -193,7 +193,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_default_css=True)
def get_box_model(
self, container: Size, viewport: Size, fraction_unit: Fraction

View File

@@ -5,15 +5,16 @@ import pytest
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
@@ -21,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)
@@ -30,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)
@@ -42,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)
@@ -55,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)
@@ -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():
css = "#id {color: red; color: blue;}"
stylesheet = _make_stylesheet(css)
stylesheet = _make_user_stylesheet(css)
node = DOMNode(id="id")
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():
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)
@@ -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
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)
@@ -96,11 +97,32 @@ 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)
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(
"css_value,expectation,expected_color",
[