mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #665 from Textualize/css-tie-breaker
CSS tie breaker
This commit is contained in:
24
sandbox/will/just_a_box.css
Normal file
24
sandbox/will/just_a_box.css
Normal 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;
|
||||
}
|
||||
43
sandbox/will/just_a_box.py
Normal file
43
sandbox/will/just_a_box.py
Normal 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 Container(Box(classes="box"))
|
||||
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()
|
||||
@@ -560,19 +560,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
def render(self) -> RenderableType:
|
||||
return Blank()
|
||||
|
||||
def query(self, selector: str | None = None) -> DOMQuery:
|
||||
"""Get a DOM query in the current screen.
|
||||
|
||||
Args:
|
||||
selector (str, optional): A CSS selector or `None` for all nodes. Defaults to None.
|
||||
|
||||
Returns:
|
||||
DOMQuery: A query object.
|
||||
"""
|
||||
from .css.query import DOMQuery
|
||||
|
||||
return DOMQuery(self.screen, selector)
|
||||
|
||||
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 +750,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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = 0
|
||||
|
||||
|
||||
@rich.repr.auto(angular=True)
|
||||
@@ -172,7 +173,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 +201,7 @@ class Stylesheet:
|
||||
path,
|
||||
variables=self.variables,
|
||||
is_default_rules=is_default_rules,
|
||||
tie_breaker=tie_breaker,
|
||||
)
|
||||
)
|
||||
except TokenError:
|
||||
@@ -222,11 +228,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 +258,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 +273,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 +294,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 +325,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 +333,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))
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -525,6 +524,20 @@ class DOMNode(MessagePump):
|
||||
|
||||
return DOMQuery(self, 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: A widget matching the selector.
|
||||
"""
|
||||
from .css.query import DOMQuery
|
||||
|
||||
query = DOMQuery(self.screen, selector)
|
||||
return query.first()
|
||||
|
||||
def set_styles(self, css: str | None = None, **styles) -> None:
|
||||
"""Set custom styles on this object."""
|
||||
# TODO: This can be done more efficiently
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user