mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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:
4
sandbox/darren/buttons.css
Normal file
4
sandbox/darren/buttons.css
Normal file
@@ -0,0 +1,4 @@
|
||||
Button {
|
||||
padding-left: 1;
|
||||
padding-right: 1;
|
||||
}
|
||||
31
sandbox/darren/buttons.py
Normal file
31
sandbox/darren/buttons.py
Normal 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))
|
||||
6
sandbox/darren/just_a_box.css
Normal file
6
sandbox/darren/just_a_box.css
Normal file
@@ -0,0 +1,6 @@
|
||||
#box {
|
||||
height: 50%;
|
||||
width: 50%;
|
||||
align: center middle;
|
||||
background: green;
|
||||
}
|
||||
29
sandbox/darren/just_a_box.py
Normal file
29
sandbox/darren/just_a_box.py
Normal 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()
|
||||
@@ -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()
|
||||
@@ -1,5 +0,0 @@
|
||||
.box {
|
||||
height: 1fr;
|
||||
/*background: rgb(180,50, 50);*/
|
||||
background: hsl(180,50%, 50%);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user