diff --git a/sandbox/will/just_a_box.css b/sandbox/will/just_a_box.css new file mode 100644 index 000000000..617fbaf59 --- /dev/null +++ b/sandbox/will/just_a_box.css @@ -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; +} diff --git a/sandbox/will/just_a_box.py b/sandbox/will/just_a_box.py new file mode 100644 index 000000000..c01b8ec1b --- /dev/null +++ b/sandbox/will/just_a_box.py @@ -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() diff --git a/src/textual/app.py b/src/textual/app.py index bb01179bb..27c72e8c5 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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() diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 0f38ae03e..027a88441 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -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), diff --git a/src/textual/css/model.py b/src/textual/css/model.py index d5d2eb726..64bf2bd9b 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -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: diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index 5183f5572..284bd20d8 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -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__": diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 311903a0a..8485e650b 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -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: diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index ffc77557c..cf67da4f5 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -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)) diff --git a/src/textual/css/types.py b/src/textual/css/types.py index d21fb0cb9..d969397f0 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -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] diff --git a/src/textual/dom.py b/src/textual/dom.py index cc614c263..54be79b41 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -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 diff --git a/src/textual/layout.py b/src/textual/layout.py index ac5db6963..b1cc7a008 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -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.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index 1aefcae1f..e8d4fc825 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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