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)
|
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:
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -73,12 +73,12 @@ class Widget(DOMNode):
|
|||||||
CSS = """
|
CSS = """
|
||||||
Widget{
|
Widget{
|
||||||
scrollbar-background: $panel-darken-2;
|
scrollbar-background: $panel-darken-2;
|
||||||
scrollbar-background-hover: $panel-darken-3;
|
scrollbar-background-hover: $panel-darken-3;
|
||||||
scrollbar-color: $system;
|
scrollbar-color: $system;
|
||||||
scrollbar-color-active: $secondary-darken-1;
|
scrollbar-color-active: $secondary-darken-1;
|
||||||
scrollbar-size-vertical: 2;
|
scrollbar-size-vertical: 2;
|
||||||
scrollbar-size-horizontal: 1;
|
scrollbar-size-horizontal: 1;
|
||||||
|
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user