CSS tie breaker

This commit is contained in:
Will McGugan
2022-08-11 10:20:50 +01:00
parent 0f3919d73a
commit 2fb5a1dde7
12 changed files with 162 additions and 45 deletions

View File

@@ -0,0 +1,24 @@
Screen {
background: lightcoral;
}
#left_pane {
background: red;
width: 30;
height: auto;
}
#middle_pane {
background: green;
width: 140;
}
#right_pane {
background: blue;
width: 30;
}
.box {
height: 5;
width: 15;
}

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from rich.console import RenderableType
from rich.panel import Panel
from textual import events
from textual.app import App, ComposeResult
from textual.layout import Container, Horizontal, Vertical
from textual.widget import Widget
class Box(Widget, can_focus=True):
CSS = "#box {background: blue;}"
def render(self) -> RenderableType:
return Panel("Box")
class JustABox(App):
def compose(self) -> ComposeResult:
yield Horizontal(
Vertical(
Box(id="box1", classes="box"),
Box(id="box2", classes="box"),
id="left_pane",
),
id="horizontal",
)
def key_p(self):
for k, v in self.app.stylesheet.source.items():
print(k)
print(self.query_one("#horizontal").styles.layout)
async def on_key(self, event: events.Key) -> None:
await self.dispatch_key(event)
app = JustABox(css_path="just_a_box.css", watch_css=True)
if __name__ == "__main__":
app.run()

View File

@@ -573,6 +573,20 @@ class App(Generic[ReturnType], DOMNode):
return DOMQuery(self.screen, selector)
def query_one(self, selector: str) -> Widget:
"""Get the first Widget matching the given selector.
Args:
selector (str | None, optional): A selector.
Returns:
Widget: _description_
"""
from .css.query import DOMQuery
query = DOMQuery(self.screen, selector)
return query.first()
def get_child(self, id: str) -> DOMNode:
"""Shorthand for self.screen.get_child(id: str)
Returns the first child (immediate descendent) of this DOMNode
@@ -763,8 +777,10 @@ class App(Generic[ReturnType], DOMNode):
try:
if self.css_path is not None:
self.stylesheet.read(self.css_path)
for path, css in self.css:
self.stylesheet.add_source(css, path=path)
for path, css, tie_breaker in self.get_default_css():
self.stylesheet.add_source(
css, path=path, is_default_css=True, tie_breaker=tie_breaker
)
except Exception as error:
self.on_exception(error)
self._print_error_renderables()

View File

@@ -725,7 +725,7 @@ class StringEnumProperty:
else:
if value not in self._valid_values:
raise StyleValueError(
f"{self.name} must be one of {friendly_list(self._valid_values)}",
f"{self.name} must be one of {friendly_list(self._valid_values)} (received {value!r})",
help_text=string_enum_help_text(
self.name,
valid_values=list(self._valid_values),

View File

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

View File

@@ -48,22 +48,23 @@ def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
token = next(tokens)
except EOFError:
break
if token.name == "pseudo_class":
token_name = token.name
if token_name == "pseudo_class":
selectors[-1]._add_pseudo_class(token.value.lstrip(":"))
elif token.name == "whitespace":
elif token_name == "whitespace":
if combinator is None or combinator == CombinatorType.SAME:
combinator = CombinatorType.DESCENDENT
elif token.name == "new_selector":
elif token_name == "new_selector":
rule_selectors.append(selectors[:])
selectors.clear()
combinator = None
elif token.name == "declaration_set_start":
elif token_name == "declaration_set_start":
break
elif token.name == "combinator_child":
elif token_name == "combinator_child":
combinator = CombinatorType.CHILD
else:
_selector, specificity = get_selector(
token.name, (SelectorType.TYPE, (0, 0, 0))
token_name, (SelectorType.TYPE, (0, 0, 0))
)
selectors.append(
Selector(
@@ -82,7 +83,10 @@ def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
def parse_rule_set(
tokens: Iterator[Token], token: Token, is_default_rules: bool = False
tokens: Iterator[Token],
token: Token,
is_default_rules: bool = False,
tie_breaker: int = 0,
) -> Iterable[RuleSet]:
get_selector = SELECTOR_MAP.get
combinator: CombinatorType | None = CombinatorType.DESCENDENT
@@ -156,6 +160,7 @@ def parse_rule_set(
styles_builder.styles,
errors,
is_default_rules=is_default_rules,
tie_breaker=tie_breaker,
)
rule_set._post_parse()
yield rule_set
@@ -332,6 +337,7 @@ def parse(
path: str | PurePath,
variables: dict[str, str] | None = None,
is_default_rules: bool = False,
tie_breaker: int = 0,
) -> Iterable[RuleSet]:
"""Parse CSS by tokenizing it, performing variable substitution,
and generating rule sets from it.
@@ -350,7 +356,12 @@ def parse(
if token is None:
break
if token.name.startswith("selector_start"):
yield from parse_rule_set(tokens, token, is_default_rules=is_default_rules)
yield from parse_rule_set(
tokens,
token,
is_default_rules=is_default_rules,
tie_breaker=tie_breaker,
)
if __name__ == "__main__":

View File

@@ -12,7 +12,7 @@ from rich.style import Style
from .._animator import Animation, EasingFunction
from ..color import Color
from ..geometry import Size, Offset, Spacing
from ..geometry import Offset, Size, Spacing
from ._style_properties import (
AlignProperty,
BorderProperty,
@@ -20,6 +20,7 @@ from ._style_properties import (
ColorProperty,
DockProperty,
DocksProperty,
FractionalProperty,
IntegerProperty,
LayoutProperty,
NameListProperty,
@@ -31,32 +32,30 @@ from ._style_properties import (
StyleFlagsProperty,
StyleProperty,
TransitionsProperty,
FractionalProperty,
)
from .constants import (
VALID_ALIGN_HORIZONTAL,
VALID_ALIGN_VERTICAL,
VALID_BOX_SIZING,
VALID_DISPLAY,
VALID_VISIBILITY,
VALID_OVERFLOW,
VALID_SCROLLBAR_GUTTER,
VALID_VISIBILITY,
)
from .scalar import Scalar, ScalarOffset, Unit
from .scalar_animation import ScalarAnimation
from .transition import Transition
from .types import (
AlignHorizontal,
AlignVertical,
BoxSizing,
Display,
Edge,
AlignHorizontal,
Overflow,
Specificity3,
Specificity4,
AlignVertical,
Visibility,
ScrollbarGutter,
Specificity5,
Specificity3,
Specificity6,
Visibility,
)
if sys.version_info >= (3, 8):
@@ -65,8 +64,8 @@ else:
from typing_extensions import TypedDict
if TYPE_CHECKING:
from ..dom import DOMNode
from .._layout import Layout
from ..dom import DOMNode
class RulesMap(TypedDict, total=False):
@@ -532,7 +531,8 @@ class Styles(StylesBase):
self,
specificity: Specificity3,
is_default_rules: bool = False,
) -> list[tuple[str, Specificity5, Any]]:
tie_breaker: int = 0,
) -> list[tuple[str, Specificity6, Any]]:
"""Extract rules from Styles object, and apply !important css specificity as
well as higher specificity of user CSS vs widget CSS.
@@ -553,12 +553,12 @@ class Styles(StylesBase):
0 if is_default_rules else 1,
1 if is_important(rule_name) else 0,
*specificity,
tie_breaker,
),
rule_value,
)
for rule_name, rule_value in self._rules.items()
]
return rules
def __rich_repr__(self) -> rich.repr.Result:

View File

@@ -23,7 +23,7 @@ from .parse import parse
from .styles import RulesMap, Styles
from .tokenize import tokenize_values, Token
from .tokenizer import TokenError
from .types import Specificity3, Specificity4
from .types import Specificity3, Specificity5
from ..dom import DOMNode
from .. import messages
@@ -128,6 +128,7 @@ class CssSource(NamedTuple):
content: str
is_defaults: bool
tie_breaker: int
@rich.repr.auto(angular=True)
@@ -136,6 +137,7 @@ class Stylesheet:
self._rules: list[RuleSet] = []
self.variables = variables or {}
self.source: dict[str, CssSource] = {}
self._source_visited: set[str] = set()
self._require_parse = False
def __rich_repr__(self) -> rich.repr.Result:
@@ -172,7 +174,11 @@ class Stylesheet:
self.variables = variables
def _parse_rules(
self, css: str, path: str | PurePath, is_default_rules: bool = False
self,
css: str,
path: str | PurePath,
is_default_rules: bool = False,
tie_breaker: int = 0,
) -> list[RuleSet]:
"""Parse CSS and return rules.
@@ -196,6 +202,7 @@ class Stylesheet:
path,
variables=self.variables,
is_default_rules=is_default_rules,
tie_breaker=tie_breaker,
)
)
except TokenError:
@@ -222,11 +229,15 @@ class Stylesheet:
path = os.path.abspath(filename)
except Exception as error:
raise StylesheetError(f"unable to read {filename!r}; {error}")
self.source[str(path)] = CssSource(content=css, is_defaults=False)
self.source[str(path)] = CssSource(css, False, 0)
self._require_parse = True
def add_source(
self, css: str, path: str | PurePath | None = None, is_default_css: bool = False
self,
css: str,
path: str | PurePath | None = None,
is_default_css: bool = False,
tie_breaker: int = 0,
) -> None:
"""Parse CSS from a string.
@@ -248,9 +259,11 @@ class Stylesheet:
path = str(css)
if path in self.source and self.source[path].content == css:
# Path already in source, and CSS is identical
content, is_defaults, source_tie_breaker = self.source[path]
if source_tie_breaker > tie_breaker:
self.source[path] = CssSource(content, is_defaults, tie_breaker)
return
self.source[path] = CssSource(content=css, is_defaults=is_default_css)
self.source[path] = CssSource(css, is_default_css, tie_breaker)
self._require_parse = True
def parse(self) -> None:
@@ -261,8 +274,10 @@ class Stylesheet:
"""
rules: list[RuleSet] = []
add_rules = rules.extend
for path, (css, is_default_rules) in self.source.items():
css_rules = self._parse_rules(css, path, is_default_rules=is_default_rules)
for path, (css, is_default_rules, tie_breaker) in self.source.items():
css_rules = self._parse_rules(
css, path, is_default_rules=is_default_rules, tie_breaker=tie_breaker
)
if any(rule.errors for rule in css_rules):
error_renderable = StylesheetErrors(css_rules)
raise StylesheetParseError(error_renderable)
@@ -280,8 +295,10 @@ 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, is_defaults) in self.source.items():
stylesheet.add_source(css, path, is_default_css=is_defaults)
for path, (css, is_defaults, tie_breaker) in self.source.items():
stylesheet.add_source(
css, path, is_default_css=is_defaults, tie_breaker=tie_breaker
)
stylesheet.parse()
self._rules = stylesheet.rules
self.source = stylesheet.source
@@ -309,7 +326,7 @@ class Stylesheet:
# We can use this to determine, for a given rule, whether we should apply it
# or not by examining the specificity. If we have two rules for the
# same attribute, then we can choose the most specific rule and use that.
rule_attributes: dict[str, list[tuple[Specificity4, object]]]
rule_attributes: dict[str, list[tuple[Specificity5, object]]]
rule_attributes = defaultdict(list)
_check_rule = self._check_rule
@@ -317,9 +334,10 @@ class Stylesheet:
# Collect the rules defined in the stylesheet
for rule in reversed(self.rules):
is_default_rules = rule.is_default_rules
tie_breaker = rule.tie_breaker
for base_specificity in _check_rule(rule, node):
for key, rule_specificity, value in rule.styles.extract_rules(
base_specificity, is_default_rules
base_specificity, is_default_rules, tie_breaker
):
rule_attributes[key].append((rule_specificity, value))

View File

@@ -43,3 +43,4 @@ EdgeStyle = Tuple[EdgeType, Color]
Specificity3 = Tuple[int, int, int]
Specificity4 = Tuple[int, int, int, int]
Specificity5 = Tuple[int, int, int, int, int]
Specificity6 = Tuple[int, int, int, int, int, int]

View File

@@ -88,7 +88,7 @@ class DOMNode(MessagePump):
Iterator[Type[DOMNode]]: An iterable of DOMNode classes.
"""
# Node bases are in reversed order so that the base class is lower priority
return reversed(list(self._css_bases(self.__class__)))
return self._css_bases(self.__class__)
@classmethod
def _css_bases(cls, base: Type[DOMNode]) -> Iterator[Type[DOMNode]]:
@@ -125,8 +125,7 @@ class DOMNode(MessagePump):
if self._classes:
yield "classes", " ".join(self._classes)
@property
def css(self) -> list[tuple[str, str]]:
def get_default_css(self) -> list[tuple[str, str, int]]:
"""Gets the CSS for this class and inherited from bases.
Returns:
@@ -134,7 +133,7 @@ class DOMNode(MessagePump):
and inherited from base classes.
"""
css_stack: list[tuple[str, str]] = []
css_stack: list[tuple[str, str, int]] = []
def get_path(base: Type[DOMNode]) -> str:
"""Get a path to the DOM Node"""
@@ -143,10 +142,10 @@ class DOMNode(MessagePump):
except TypeError:
return f"{base.__name__}"
for base in self._node_bases:
for tie_breaker, base in enumerate(self._node_bases):
css = base.CSS.strip()
if css:
css_stack.append((get_path(base), css))
css_stack.append((get_path(base), css, -tie_breaker))
return css_stack

View File

@@ -15,6 +15,9 @@ class Container(Widget):
class Vertical(Container):
"""A container widget to align children vertically."""
# Blank CSS is important, otherwise you get a clone of Container
CSS = ""
class Horizontal(Container):
"""A container widget to align children horizontally."""

View File

@@ -27,8 +27,7 @@ from . import errors, events, messages
from ._animator import BoundAnimator
from ._arrange import arrange, DockArrangeResult
from ._context import active_app
from ._layout import ArrangeResult, Layout
from ._segment_tools import line_crop
from ._layout import Layout
from ._styles_cache import StylesCache
from ._types import Lines
from .box_model import BoxModel, get_box_model
@@ -206,8 +205,10 @@ class Widget(DOMNode):
app (App): App instance.
"""
# Parse the Widget's CSS
for path, css in self.css:
self.app.stylesheet.add_source(css, path=path, is_default_css=True)
for path, css, tie_breaker in self.get_default_css():
self.app.stylesheet.add_source(
css, path=path, is_default_css=True, tie_breaker=tie_breaker
)
def get_box_model(
self, container: Size, viewport: Size, fraction_unit: Fraction