diff --git a/examples/basic.py b/examples/basic.py index 72743ab93..514cf8409 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -17,6 +17,7 @@ class BasicApp(App): footer=Widget(), sidebar=Widget(), ) + self.panic(self.stylesheet.css) BasicApp.run(css_file="basic.css", watch_css=True, log="textual.log") diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 94dac6973..469482925 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -9,7 +9,8 @@ when setting and getting. from __future__ import annotations -from typing import Iterable, NamedTuple, Sequence, TYPE_CHECKING + +from typing import Iterable, Iterator, NamedTuple, Sequence, TYPE_CHECKING import rich.repr from rich.color import Color @@ -54,7 +55,6 @@ class ScalarProperty: def __set_name__(self, owner: Styles, name: str) -> None: self.name = name - self.internal_name = f"_rule_{name}" def __get__( self, obj: Styles, objtype: type[Styles] | None = None @@ -68,7 +68,7 @@ class ScalarProperty: Returns: The Scalar object or ``None`` if it's not set. """ - value = getattr(obj, self.internal_name) + value = obj._rules.get(self.name) return value def __set__(self, obj: Styles, value: float | Scalar | str | None) -> None: @@ -88,8 +88,9 @@ class ScalarProperty: cannot be parsed for any other reason. """ if value is None: - new_value = None - elif isinstance(value, float): + obj._rules.pop(self.name, None) + return + if isinstance(value, float): new_value = Scalar(float(value), Unit.CELLS, Unit.WIDTH) elif isinstance(value, Scalar): new_value = value @@ -106,7 +107,7 @@ class ScalarProperty: ) if new_value is not None and new_value.is_percent: new_value = Scalar(float(new_value.value), self.percent_unit, Unit.WIDTH) - setattr(obj, self.internal_name, new_value) + obj._rules[self.name] = new_value obj.refresh() @@ -118,7 +119,7 @@ class BoxProperty: DEFAULT = ("", Style()) def __set_name__(self, owner: Styles, name: str) -> None: - self.internal_name = f"_rule_{name}" + self.name = name _type, edge = name.split("_") self._type = _type self.edge = edge @@ -136,8 +137,8 @@ class BoxProperty: A ``tuple[BoxType, Style]`` containing the string type of the box and it's style. Example types are "rounded", "solid", and "dashed". """ - value = getattr(obj, self.internal_name) - return value or self.DEFAULT + value = obj._rules.get(self.name) or self.DEFAULT + return value def __set__(self, obj: Styles, border: tuple[BoxType, str | Color | Style] | None): """Set the box property @@ -152,7 +153,7 @@ class BoxProperty: StyleSyntaxError: If the string supplied for the color has invalid syntax. """ if border is None: - new_value = None + obj._rules.pop(self.name, None) else: _type, color = border if isinstance(color, str): @@ -161,7 +162,7 @@ class BoxProperty: new_value = (_type, Style.from_color(color)) else: new_value = (_type, Style.from_color(Color.parse(color))) - setattr(obj, self.internal_name, new_value) + obj._rules[self.name] = new_value obj.refresh() @@ -169,13 +170,14 @@ class BoxProperty: class Edges(NamedTuple): """Stores edges for border / outline.""" + css_name: str top: tuple[BoxType, Style] right: tuple[BoxType, Style] bottom: tuple[BoxType, Style] left: tuple[BoxType, Style] def __rich_repr__(self) -> rich.repr.Result: - top, right, bottom, left = self + _, top, right, bottom, left = self if top[0]: yield "top", top if right[0]: @@ -191,7 +193,7 @@ class Edges(NamedTuple): Returns: tuple[int, int, int, int]: Spacing for top, right, bottom, and left. """ - top, right, bottom, left = self + _, top, right, bottom, left = self return ( 1 if top[0] else 0, 1 if right[0] else 0, @@ -204,6 +206,7 @@ class BorderProperty: """Descriptor for getting and setting full borders and outlines.""" def __set_name__(self, owner: Styles, name: str) -> None: + self.name = name self._properties = ( f"{name}_top", f"{name}_right", @@ -222,7 +225,9 @@ class BorderProperty: An ``Edges`` object describing the type and style of each edge. """ top, right, bottom, left = self._properties + border = Edges( + self.name, getattr(obj, top), getattr(obj, right), getattr(obj, bottom), @@ -292,11 +297,6 @@ class StyleProperty: DEFAULT_STYLE = Style() - def __set_name__(self, owner: Styles, name: str) -> None: - self._color_name = f"_rule_{name}_color" - self._bgcolor_name = f"_rule_{name}_background" - self._style_name = f"_rule_{name}_style" - def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Style: """Get the Style @@ -307,12 +307,11 @@ class StyleProperty: Returns: A ``Style`` object. """ - color = getattr(obj, self._color_name) - bgcolor = getattr(obj, self._bgcolor_name) + rules_get = obj._rules.get + color = rules_get("text_color") or Color.default() + bgcolor = rules_get("text_background") or Color.default() style = Style.from_color(color, bgcolor) - style_flags = getattr(obj, self._style_name) - if style_flags: - style += style_flags + style += rules_get("text_style") or Style() return style def __set__(self, obj: Styles, style: Style | str | None): @@ -327,26 +326,28 @@ class StyleProperty: StyleSyntaxError: When the supplied style string has invalid syntax. """ obj.refresh() + rules_set = obj._rules.__setitem__ if style is None: - setattr(obj, self._color_name, None) - setattr(obj, self._bgcolor_name, None) - setattr(obj, self._style_name, None) + rules = obj._rules + rules.pop("text_color") + rules.pop("text_background") + rules.pop("text_style") elif isinstance(style, Style): - setattr(obj, self._color_name, style.color) - setattr(obj, self._bgcolor_name, style.bgcolor) - setattr(obj, self._style_name, style.without_color) + rules_set("text_color", style.color) + rules_set("text_background", style.bgcolor) + rules_set("text_style", style.without_color) elif isinstance(style, str): new_style = Style.parse(style) - setattr(obj, self._color_name, new_style.color) - setattr(obj, self._bgcolor_name, new_style.bgcolor) - setattr(obj, self._style_name, new_style.without_color) + rules_set("text_color", new_style.color) + rules_set("text_background", new_style.bgcolor) + rules_set("text_style", new_style.without_color) class SpacingProperty: """Descriptor for getting and setting spacing properties (e.g. padding and margin).""" def __set_name__(self, owner: Styles, name: str) -> None: - self._internal_name = f"_rule_{name}" + self.name = name def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Spacing: """Get the Spacing @@ -358,9 +359,9 @@ class SpacingProperty: Returns: Spacing: The Spacing. If unset, returns the null spacing ``(0, 0, 0, 0)``. """ - return getattr(obj, self._internal_name) or NULL_SPACING + return obj._rules.get(self.name, NULL_SPACING) - def __set__(self, obj: Styles, spacing: SpacingDimensions): + def __set__(self, obj: Styles, spacing: SpacingDimensions | None): """Set the Spacing Args: @@ -373,8 +374,10 @@ class SpacingProperty: not 1, 2, or 4. """ obj.refresh(layout=True) - spacing = Spacing.unpack(spacing) - setattr(obj, self._internal_name, spacing) + if spacing is None: + obj._rules.pop(self.name) + else: + obj._rules[self.name] = Spacing.unpack(spacing) class DocksProperty: @@ -394,7 +397,7 @@ class DocksProperty: Returns: tuple[DockGroup, ...]: A ``tuple`` containing the defined docks. """ - return obj._rule_docks or () + return obj._rules.get("docks") or () def __set__(self, obj: Styles, docks: Iterable[DockGroup] | None): """Set the Docks property @@ -405,9 +408,9 @@ class DocksProperty: """ obj.refresh(layout=True) if docks is None: - obj._rule_docks = None + obj._rules.pop("docks") else: - obj._rule_docks = tuple(docks) + obj._rules["docks"] = tuple(docks) class DockProperty: @@ -427,7 +430,7 @@ class DockProperty: Returns: str: The dock name as a string, or "" if the rule is not set. """ - return obj._rule_dock or "" + return obj._rules.get("dock") or "" def __set__(self, obj: Styles, spacing: str | None): """Set the Dock property @@ -437,14 +440,17 @@ class DockProperty: spacing (str | None): The spacing to use. """ obj.refresh(layout=True) - obj._rule_dock = spacing + if spacing is None: + obj._rules.pop("dock") + else: + obj._rules["dock"] = spacing class LayoutProperty: """Descriptor for getting and setting layout.""" def __set_name__(self, owner: Styles, name: str) -> None: - self._internal_name = f"_rule_{name}" + self.name = name def __get__( self, obj: Styles, objtype: type[Styles] | None = None @@ -456,9 +462,9 @@ class LayoutProperty: Returns: The ``Layout`` object. """ - return getattr(obj, self._internal_name) + return obj._rules.get(self.name) - def __set__(self, obj: Styles, layout: str | Layout): + def __set__(self, obj: Styles, layout: str | Layout | None): """ Args: obj (Styles): The Styles object. @@ -469,11 +475,13 @@ class LayoutProperty: from ..layouts.factory import get_layout, Layout # Prevents circular import obj.refresh(layout=True) - if isinstance(layout, Layout): - new_layout = layout + + if layout is None: + obj._rules.pop("layout") + elif isinstance(layout, Layout): + obj._rules["layout"] = layout else: - new_layout = get_layout(layout) - setattr(obj, self._internal_name, new_layout) + obj._rules["layout"] = get_layout(layout) class OffsetProperty: @@ -483,7 +491,7 @@ class OffsetProperty: """ def __set_name__(self, owner: Styles, name: str) -> None: - self._internal_name = f"_rule_{name}" + self.name = name def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> ScalarOffset: """Get the offset @@ -496,11 +504,11 @@ class OffsetProperty: ScalarOffset: The ``ScalarOffset`` indicating the adjustment that will be made to widget position prior to it being rendered. """ - return getattr(obj, self._internal_name) or ScalarOffset( - Scalar.from_number(0), Scalar.from_number(0) - ) + return obj._rules.get(self.name, ScalarOffset.null()) - def __set__(self, obj: Styles, offset: tuple[int | str, int | str] | ScalarOffset): + def __set__( + self, obj: Styles, offset: tuple[int | str, int | str] | ScalarOffset | None + ): """Set the offset Args: @@ -515,57 +523,24 @@ class OffsetProperty: be parsed into a Scalar. For example, if you specify an non-existent unit. """ obj.refresh(layout=True) - if isinstance(offset, ScalarOffset): - setattr(obj, self._internal_name, offset) - return offset - x, y = offset - scalar_x = ( - Scalar.parse(x, Unit.WIDTH) - if isinstance(x, str) - else Scalar(float(x), Unit.CELLS, Unit.WIDTH) - ) - scalar_y = ( - Scalar.parse(y, Unit.HEIGHT) - if isinstance(y, str) - else Scalar(float(y), Unit.CELLS, Unit.HEIGHT) - ) - _offset = ScalarOffset(scalar_x, scalar_y) - setattr(obj, self._internal_name, _offset) - - -class IntegerProperty: - """Descriptor for getting and setting integer properties""" - - def __set_name__(self, owner: Styles, name: str) -> None: - self._name = name - self._internal_name = f"_{name}" - - def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> int: - """Get the integer property, or the default ``0`` if not set. - - Args: - obj (Styles): The ``Styles`` object. - objtype (type[Styles]): The ``Styles`` class. - - Returns: - int: The integer property value - """ - return getattr(obj, self._internal_name, 0) - - def __set__(self, obj: Styles, value: int): - """Set the integer property - - Args: - obj: The ``Styles`` object - value: The value to set the integer to - - Raises: - StyleTypeError: If the supplied value is not an integer. - """ - obj.refresh() - if not isinstance(value, int): - raise StyleTypeError(f"{self._name} must be an integer") - setattr(obj, self._internal_name, value) + if offset is None: + obj._rules.pop(self.name, None) + elif isinstance(offset, ScalarOffset): + obj._rules[self.name] = offset + else: + x, y = offset + scalar_x = ( + Scalar.parse(x, Unit.WIDTH) + if isinstance(x, str) + else Scalar(float(x), Unit.CELLS, Unit.WIDTH) + ) + scalar_y = ( + Scalar.parse(y, Unit.HEIGHT) + if isinstance(y, str) + else Scalar(float(y), Unit.CELLS, Unit.HEIGHT) + ) + _offset = ScalarOffset(scalar_x, scalar_y) + obj._rules[self.name] = _offset class StringEnumProperty: @@ -578,8 +553,7 @@ class StringEnumProperty: self._default = default def __set_name__(self, owner: Styles, name: str) -> None: - self._name = name - self._internal_name = f"_rule_{name}" + self.name = name def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> str: """Get the string property, or the default value if it's not set @@ -591,7 +565,7 @@ class StringEnumProperty: Returns: str: The string property value """ - return getattr(obj, self._internal_name, None) or self._default + return obj._rules.get(self.name, self._default) def __set__(self, obj: Styles, value: str | None = None): """Set the string property and ensure it is in the set of allowed values. @@ -604,12 +578,14 @@ class StringEnumProperty: StyleValueError: If the value is not in the set of valid values. """ obj.refresh() - if value is not None: + if value is None: + obj._rules.pop(self.name, None) + 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)}" ) - setattr(obj, self._internal_name, value) + obj._rules[self.name] = value class NameProperty: @@ -617,7 +593,6 @@ class NameProperty: def __set_name__(self, owner: Styles, name: str) -> None: self._name = name - self._internal_name = f"_rule_{name}" def __get__(self, obj: Styles, objtype: type[Styles] | None) -> str: """Get the name property @@ -629,7 +604,7 @@ class NameProperty: Returns: str: The name """ - return getattr(obj, self._internal_name) or "" + return obj.get(self.name, "") def __set__(self, obj: Styles, name: str | None): """Set the name property @@ -642,42 +617,42 @@ class NameProperty: StyleTypeError: If the value is not a ``str``. """ obj.refresh(layout=True) - if not isinstance(name, str): - raise StyleTypeError(f"{self._name} must be a str") - setattr(obj, self._internal_name, name) + if name is None: + obj._rules.pop(self.name, None) + else: + if not isinstance(name, str): + raise StyleTypeError(f"{self._name} must be a str") + obj._rules[self.name] = name class NameListProperty: def __set_name__(self, owner: Styles, name: str) -> None: self._name = name - self._internal_name = f"_rule_{name}" def __get__( self, obj: Styles, objtype: type[Styles] | None = None ) -> tuple[str, ...]: - return getattr(obj, self._internal_name, None) or () + return obj._rules.get(self.name, ()) def __set__( self, obj: Styles, names: str | tuple[str] | None = None ) -> str | tuple[str] | None: obj.refresh(layout=True) - names_value: tuple[str, ...] | None = None - if isinstance(names, str): - names_value = tuple(name.strip().lower() for name in names.split(" ")) + if names is None: + obj._rules.pop(self.name, None) + elif isinstance(names, str): + obj._rules[self.name] = tuple( + name.strip().lower() for name in names.split(" ") + ) elif isinstance(names, tuple): - names_value = names - elif names is None: - names_value = None - setattr(obj, self._internal_name, names_value) - return names + obj._rules[self.name] = names class ColorProperty: """Descriptor for getting and setting color properties.""" def __set_name__(self, owner: Styles, name: str) -> None: - self._name = name - self._internal_name = f"_rule_{name}" + self.name = name def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Color: """Get the ``Color``, or ``Color.default()`` if no color is set. @@ -689,7 +664,7 @@ class ColorProperty: Returns: Color: The Color """ - return getattr(obj, self._internal_name, None) or Color.default() + return obj._rules.get(self.name) or Color.default() def __set__(self, obj: Styles, color: Color | str | None): """Set the Color @@ -705,13 +680,11 @@ class ColorProperty: """ obj.refresh() if color is None: - setattr(self, self._internal_name, None) - else: - if isinstance(color, Color): - setattr(self, self._internal_name, color) - elif isinstance(color, str): - new_color = Color.parse(color) - setattr(self, self._internal_name, new_color) + obj._rules.pop(self.name, None) + elif isinstance(color, Color): + obj._rules[self.name] = color + elif isinstance(color, str): + obj._rules[self.name] = Color.parse(color) class StyleFlagsProperty: @@ -731,8 +704,7 @@ class StyleFlagsProperty: } def __set_name__(self, owner: Styles, name: str) -> None: - self._name = name - self._internal_name = f"_rule_{name}" + self.name = name def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Style: """Get the ``Style`` @@ -744,7 +716,7 @@ class StyleFlagsProperty: Returns: Style: The ``Style`` object """ - return getattr(obj, self._internal_name, None) or Style.null() + return obj._rules.get(self.name, Style.null()) def __set__(self, obj: Styles, style_flags: str | None): """Set the style using a style flag string @@ -759,7 +731,7 @@ class StyleFlagsProperty: """ obj.refresh() if style_flags is None: - setattr(self, self._internal_name, None) + obj._styles.pop(self.name, None) else: words = [word.strip() for word in style_flags.split(" ")] valid_word = self._VALID_PROPERTIES.__contains__ @@ -770,15 +742,14 @@ class StyleFlagsProperty: f"valid values are {friendly_list(self._VALID_PROPERTIES)}" ) style = Style.parse(style_flags) - setattr(obj, self._internal_name, style) + obj._rules[self.name] = style class TransitionsProperty: """Descriptor for getting transitions properties""" def __set_name__(self, owner: Styles, name: str) -> None: - self._name = name - self._internal_name = f"_rule_{name}" + self.name = name def __get__( self, obj: Styles, objtype: type[Styles] | None = None @@ -794,4 +765,4 @@ class TransitionsProperty: e.g. ``{"offset": Transition(...), ...}``. If no transitions have been set, an empty ``dict`` is returned. """ - return getattr(obj, self._internal_name, None) or {} + return obj._rules.get(self.name, {}) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 616ae4854..2b1cc2456 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -71,7 +71,7 @@ class StylesBuilder: if name == "token": value = value.lower() if value in VALID_DISPLAY: - self.styles._rule_display = cast(Display, value) + self.styles._rules["display"] = cast(Display, value) else: self.error( name, @@ -85,7 +85,7 @@ class StylesBuilder: if not tokens: return if len(tokens) == 1: - setattr(self.styles, name, Scalar.parse(tokens[0].value)) + self.styles._rules[name] = Scalar.parse(tokens[0].value) else: self.error(name, tokens[0], "a single scalar is expected") @@ -113,7 +113,7 @@ class StylesBuilder: if name == "token": value = value.lower() if value in VALID_VISIBILITY: - self.styles._rule_visibility = cast(Visibility, value) + self.styles._rules["visibility"] = cast(Visibility, value) else: self.error( name, @@ -139,11 +139,7 @@ class StylesBuilder: self.error( name, tokens[0], f"1, 2, or 4 values expected; received {len(space)}" ) - setattr( - self.styles, - f"_rule_{name}", - Spacing.unpack(cast(SpacingDimensions, tuple(space))), - ) + self.styles._rules[name] = Spacing.unpack(cast(SpacingDimensions, tuple(space))) def process_padding(self, name: str, tokens: list[Token], important: bool) -> None: self._process_space(name, tokens) @@ -176,13 +172,13 @@ class StylesBuilder: def _process_border(self, edge: str, name: str, tokens: list[Token]) -> None: border = self._parse_border("border", tokens) - setattr(self.styles, f"_rule_border_{edge}", border) + self.styles._rules[f"border_{edge}"] = border def process_border(self, name: str, tokens: list[Token], important: bool) -> None: border = self._parse_border("border", tokens) - styles = self.styles - styles._rule_border_top = styles._rule_border_right = border - styles._rule_border_bottom = styles._rule_border_left = border + rules = self.styles._rules + rules["border_top"] = rules["border_right"] = border + rules["border_bottom"] = rules["border_left"] = border def process_border_top( self, name: str, tokens: list[Token], important: bool @@ -206,13 +202,13 @@ class StylesBuilder: def _process_outline(self, edge: str, name: str, tokens: list[Token]) -> None: border = self._parse_border("outline", tokens) - setattr(self.styles, f"_rule_outline_{edge}", border) + self.styles._rules[f"outline_{edge}"] = border def process_outline(self, name: str, tokens: list[Token], important: bool) -> None: border = self._parse_border("outline", tokens) - styles = self.styles - styles._rule_outline_top = styles._rule_outline_right = border - styles._rule_outline_bottom = styles._rule_outline_left = border + rules = self.styles._rules + rules["outline_top"] = rules["outline_right"] = border + rules["outline_bottom"] = rules["outline_left"] = border def process_outline_top( self, name: str, tokens: list[Token], important: bool @@ -255,7 +251,7 @@ class StylesBuilder: scalar_x = Scalar.parse(token1.value, Unit.WIDTH) scalar_y = Scalar.parse(token2.value, Unit.HEIGHT) - self.styles._rule_offset = ScalarOffset(scalar_x, scalar_y) + self.styles._rules["offset"] = ScalarOffset(scalar_x, scalar_y) def process_offset_x(self, name: str, tokens: list[Token], important: bool) -> None: if not tokens: @@ -268,7 +264,7 @@ class StylesBuilder: self.error(name, token, f"expected a scalar; found {token.value!r}") x = Scalar.parse(token.value, Unit.WIDTH) y = self.styles.offset.y - self.styles._rule_offset = ScalarOffset(x, y) + self.styles._rules["offset"] = ScalarOffset(x, y) def process_offset_y(self, name: str, tokens: list[Token], important: bool) -> None: if not tokens: @@ -281,7 +277,7 @@ class StylesBuilder: self.error(name, token, f"expected a scalar; found {token.value!r}") y = Scalar.parse(token.value, Unit.HEIGHT) x = self.styles.offset.x - self.styles._rule_offset = ScalarOffset(x, y) + self.styles._rules["offset"] = ScalarOffset(x, y) def process_layout(self, name: str, tokens: list[Token], important: bool) -> None: from ..layouts.factory import get_layout, MissingLayout, LAYOUT_MAP @@ -293,7 +289,7 @@ class StylesBuilder: value = tokens[0].value layout_name = value try: - self.styles._rule_layout = get_layout(layout_name) + self.styles._rules["layout"] = get_layout(layout_name) except MissingLayout: self.error( name, @@ -311,7 +307,7 @@ class StylesBuilder: self.styles.important.update( {"text_style", "text_background", "text_color"} ) - self.styles.text = style + self.styles._rules["text"] = style def process_text_color( self, name: str, tokens: list[Token], important: bool @@ -319,7 +315,7 @@ class StylesBuilder: for token in tokens: if token.name in ("color", "token"): try: - self.styles._rule_text_color = Color.parse(token.value) + self.styles._rules["text_color"] = Color.parse(token.value) except Exception as error: self.error( name, token, f"failed to parse color {token.value!r}; {error}" @@ -335,7 +331,7 @@ class StylesBuilder: for token in tokens: if token.name in ("color", "token"): try: - self.styles._rule_text_background = Color.parse(token.value) + self.styles._rules["text_background"] = Color.parse(token.value) except Exception as error: self.error( name, token, f"failed to parse color {token.value!r}; {error}" @@ -359,7 +355,7 @@ class StylesBuilder: tokens[1], f"unexpected tokens in dock declaration", ) - self.styles._rule_dock = tokens[0].value if tokens else "" + self.styles._rules["dock"] = tokens[0].value if tokens else "" def process_docks(self, name: str, tokens: list[Token], important: bool) -> None: docks: list[DockGroup] = [] @@ -390,12 +386,12 @@ class StylesBuilder: token, f"unexpected token {token.value!r} in docks declaration", ) - self.styles._rule_docks = tuple(docks + [DockGroup("_default", "top", 0)]) + self.styles._rules["docks"] = tuple(docks + [DockGroup("_default", "top", 0)]) def process_layer(self, name: str, tokens: list[Token], important: bool) -> None: if len(tokens) > 1: self.error(name, tokens[1], f"unexpected tokens in dock-edge declaration") - self.styles._rule_layer = tokens[0].value + self.styles._rules["layer"] = tokens[0].value def process_layers(self, name: str, tokens: list[Token], important: bool) -> None: layers: list[str] = [] @@ -403,7 +399,7 @@ class StylesBuilder: if token.name != "token": self.error(name, token, "{token.name} not expected here") layers.append(token.value) - self.styles._rule_layers = tuple(layers) + self.styles._rules["layers"] = tuple(layers) def process_transition( self, name: str, tokens: list[Token], important: bool @@ -468,4 +464,4 @@ class StylesBuilder: pass transitions[css_property] = Transition(duration, easing, delay) - self.styles._rule_transitions = transitions + self.styles._rules["transitions"] = transitions diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 42482efad..5d5501ba1 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -142,7 +142,7 @@ class DOMQuery: layout (bool): Layout node(s). Defaults to False. Returns: - [type]: [description] + DOMQuery: Query for chaining. """ for node in self._nodes: node.refresh(repaint=repaint, layout=layout) diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index bf68dce19..7be2058d9 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -145,6 +145,10 @@ class ScalarOffset(NamedTuple): x: Scalar y: Scalar + @classmethod + def null(cls) -> ScalarOffset: + return cls(Scalar.from_number(0), Scalar.from_number(0)) + def __bool__(self) -> bool: x, y = self return bool(x.value or y.value) diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index c39ec2234..8d797eb6c 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -60,7 +60,7 @@ class ScalarAnimation(Animation): return True offset = self.start + (self.destination - self.start) * eased_factor - current = getattr(self.styles, f"_rule_{self.attribute}") + current = self.styles._rules[self.attribute] if current != offset: setattr(self.styles, f"{self.attribute}", offset) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 43a854113..8261b49e8 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -2,8 +2,8 @@ from __future__ import annotations from dataclasses import dataclass, field from functools import lru_cache -from operator import attrgetter -from typing import Any, Iterable, NamedTuple, TYPE_CHECKING +import sys +from typing import Any, cast, Iterable, NamedTuple, TYPE_CHECKING import rich.repr from rich.color import Color @@ -45,25 +45,56 @@ from ..geometry import Spacing, SpacingDimensions from .._box import BoxType +if sys.version_info >= (3, 8): + from typing import TypedDict +else: + from typing_extensions import TypedDict + + if TYPE_CHECKING: from ..layout import Layout from ..dom import DOMNode -_text_getter = attrgetter( - "_rule_text_color", "_rule_text_background", "_rule_text_style" -) +class RulesMap(TypedDict): -_border_getter = attrgetter( - "_rule_border_top", "_rule_border_right", "_rule_border_bottom", "_rule_border_left" -) + display: Display + visibility: Visibility + layout: "Layout" -_outline_getter = attrgetter( - "_rule_outline_top", - "_rule_outline_right", - "_rule_outline_bottom", - "_rule_outline_left", -) + text_color: Color + text_background: Color + text_style: Style + + padding: Spacing + margin: Spacing + offset: ScalarOffset + + border_top: tuple[str, Style] + border_right: tuple[str, Style] + border_bottom: tuple[str, Style] + border_left: tuple[str, Style] + + outline_top: tuple[str, Style] + outline_right: tuple[str, Style] + outline_bottom: tuple[str, Style] + outline_left: tuple[str, Style] + + width: Scalar + height: Scalar + min_width: Scalar + min_height: Scalar + + dock: str + docks: tuple[DockGroup, ...] + + layers: tuple[str, ...] + layer: str + + transitions: dict[str, Transition] + + +RULE_NAMES = list(RulesMap.__annotations__.keys()) class DockGroup(NamedTuple): @@ -78,40 +109,7 @@ class Styles: node: DOMNode | None = None - _rule_display: Display | None = None - _rule_visibility: Visibility | None = None - _rule_layout: "Layout" | None = None - - _rule_text_color: Color | None = None - _rule_text_background: Color | None = None - _rule_text_style: Style | None = None - - _rule_padding: Spacing | None = None - _rule_margin: Spacing | None = None - _rule_offset: ScalarOffset | None = None - - _rule_border_top: tuple[str, Style] | None = None - _rule_border_right: tuple[str, Style] | None = None - _rule_border_bottom: tuple[str, Style] | None = None - _rule_border_left: tuple[str, Style] | None = None - - _rule_outline_top: tuple[str, Style] | None = None - _rule_outline_right: tuple[str, Style] | None = None - _rule_outline_bottom: tuple[str, Style] | None = None - _rule_outline_left: tuple[str, Style] | None = None - - _rule_width: Scalar | None = None - _rule_height: Scalar | None = None - _rule_min_width: Scalar | None = None - _rule_min_height: Scalar | None = None - - _rule_dock: str | None = None - _rule_docks: tuple[DockGroup, ...] | None = None - - _rule_layers: tuple[str, ...] | None = None - _rule_layer: str | None = None - - _rule_transitions: dict[str, Transition] | None = None + _rules: RulesMap = field(default_factory=dict) _layout_required: bool = False _repaint_required: bool = False @@ -120,16 +118,26 @@ class Styles: def has_rule(self, rule: str) -> bool: """Check if a rule has been set.""" - if rule in RULE_NAMES and getattr(self, f"_rule_{rule}") is not None: + if rule in RULE_NAMES and rule in self._rules: return True + has_rule = self._rules.__contains__ if rule == "text": - return not all(rule is None for rule in _text_getter(self)) + return ( + has_rule("text_color") + or has_rule("text_bgcolor") + or has_rule("text_style") + ) if rule == "border" and any(self.border): - return not all(rule is None for rule in _border_getter(self)) + return True if rule == "outline" and any(self.outline): - return not all(rule is None for rule in _outline_getter(self)) + return True return False + def get_rules(self) -> RulesMap: + """Get rules as a dictionary.""" + rules = self._rules.copy() + return rules + display = StringEnumProperty(VALID_DISPLAY, "block") visibility = StringEnumProperty(VALID_VISIBILITY, "visible") layout = LayoutProperty() @@ -196,31 +204,6 @@ class Styles: styles.node = node return styles - def __textual_animation__( - self, - attribute: str, - value: Any, - start_time: float, - duration: float | None, - speed: float | None, - easing: EasingFunction, - ) -> Animation | None: - from ..widget import Widget - - assert isinstance(self.node, Widget) - if isinstance(value, ScalarOffset): - return ScalarAnimation( - self.node, - self, - start_time, - attribute, - value, - duration=duration, - speed=speed, - easing=easing, - ) - return None - def refresh(self, layout: bool = False) -> None: self._repaint_required = True self._layout_required = layout @@ -245,54 +228,49 @@ class Styles: """ Reset internal style rules to ``None``, reverting to default styles. """ - for rule_name in INTERNAL_RULE_NAMES: - setattr(self, rule_name, None) + self._rules.clear() def extract_rules( self, specificity: Specificity3 ) -> list[tuple[str, Specificity4, Any]]: is_important = self.important.__contains__ + rules = [ - ( - rule_name, - (int(is_important(rule_name)), *specificity), - getattr(self, f"_rule_{rule_name}"), - ) - for rule_name in RULE_NAMES - if getattr(self, f"_rule_{rule_name}") is not None + (rule_name, (int(is_important(rule_name)), *specificity), rule_value) + for rule_name, rule_value in self._rules.items() ] + return rules - def apply_rules(self, rules: Iterable[tuple[str, object]], animate: bool = False): + def apply_rules(self, rules: RulesMap, animate: bool = False): if animate or self.node is None: - for key, value in rules: - setattr(self, f"_rule_{key}", value) + self._rules.update(rules) else: styles = self is_animatable = styles.ANIMATABLE.__contains__ - for key, value in rules: - current = getattr(styles, f"_rule_{key}") + _rules = self._rules + for key, value in rules.items(): + current = _rules.get(key) if current == value: continue if is_animatable(key): transition = styles.get_transition(key) if transition is None: - setattr(styles, f"_rule_{key}", value) + _rules[key] = value else: duration, easing, delay = transition self.node.app.animator.animate( styles, key, value, duration=duration, easing=easing ) else: - setattr(styles, f"_rule_{key}", value) + rules[key] = value if self.node is not None: self.node.on_style_change() def __rich_repr__(self) -> rich.repr.Result: - for rule_name, internal_rule_name in zip(RULE_NAMES, INTERNAL_RULE_NAMES): - if getattr(self, internal_rule_name) is not None: - yield rule_name, getattr(self, rule_name) + for name, value in self._rules.items(): + yield name, value if self.important: yield "important", self.important @@ -302,10 +280,63 @@ class Styles: Args: other (Styles): A Styles object. """ - for name in INTERNAL_RULE_NAMES: - value = getattr(other, name) - if value is not None: - setattr(self, name, value) + + self._rules.update(other._rules) + + def _get_border_css_lines( + self, rules: RulesMap, name: str + ) -> Iterable[tuple[str, str]]: + """Get CSS lines for border / outline + + Args: + rules (RulesMap): A rules map. + name (str): Name of rules (border or outline) + + Returns: + Iterable[tuple[str, str]]: An iterable of CSS declarations. + + """ + + has_rule = rules.__contains__ + get_rule = rules.__getitem__ + + has_top = has_rule(f"{name}_top") + has_right = has_rule(f"{name}_right") + has_bottom = has_rule(f"{name}_bottom") + has_left = has_rule(f"{name}_left") + if not any((has_top, has_right, has_bottom, has_left)): + # No border related rules + return + + if all((has_top, has_right, has_bottom, has_left)): + # All rules are set + # See if we can set them with a single border: declaration + top = get_rule(f"{name}_top") + right = get_rule(f"{name}_right") + bottom = get_rule(f"{name}_bottom") + left = get_rule(f"{name}_left") + + if top == right and right == bottom and bottom == left: + border_type, border_style = rules[f"{name}_top"] + yield name, f"{border_type} {border_style}" + return + + # Check for edges + if has_top: + border_type, border_style = rules[f"{name}_top"] + yield f"{name}-top", f"{border_type} {border_style}" + + if has_right: + border_type, border_style = rules[f"{name}_right"] + yield f"{name}-right", f"{border_type} {border_style}" + + if has_bottom: + border_type, border_style = rules[f"{name}_bottom"] + yield f"{name}-bottom", f"{border_type} {border_style}" + + if has_left: + border_type, border_style = rules[f"{name}_left"] + yield f"{name}-left", f"{border_type} {border_style}" @property def css_lines(self) -> list[str]: @@ -318,91 +349,69 @@ class Styles: else: append(f"{name}: {value};") - if self._rule_display is not None: - append_declaration("display", self._rule_display) - if self._rule_visibility is not None: - append_declaration("visibility", self._rule_visibility) - if self._rule_padding is not None: - append_declaration("padding", self._rule_padding.packed) - if self._rule_margin is not None: - append_declaration("margin", self._rule_margin.packed) + rules = self.get_rules() + get_rule = rules.get + has_rule = rules.__contains__ - if ( - self._rule_border_top is not None - and self._rule_border_top == self._rule_border_right - and self._rule_border_right == self._rule_border_bottom - and self._rule_border_bottom == self._rule_border_left - ): - _type, style = self._rule_border_top - append_declaration("border", f"{_type} {style}") - else: - if self._rule_border_top is not None: - _type, style = self._rule_border_top - append_declaration("border-top", f"{_type} {style}") - if self._rule_border_right is not None: - _type, style = self._rule_border_right - append_declaration("border-right", f"{_type} {style}") - if self._rule_border_bottom is not None: - _type, style = self._rule_border_bottom - append_declaration("border-bottom", f"{_type} {style}") - if self._rule_border_left is not None: - _type, style = self._rule_border_left - append_declaration("border-left", f"{_type} {style}") + if has_rule("display"): + append_declaration("display", rules["display"]) + if has_rule("visibility"): + append_declaration("visibility", rules["visibility"]) + if has_rule("padding"): + append_declaration("padding", rules["padding"].css) + if has_rule("margin"): + append_declaration("margin", rules["margin"].css) - if ( - self._rule_outline_top is not None - and self._rule_outline_top == self._rule_outline_right - and self._rule_outline_right == self._rule_outline_bottom - and self._rule_outline_bottom == self._rule_outline_left - ): - _type, style = self._rule_outline_top - append_declaration("outline", f"{_type} {style}") - else: - if self._rule_outline_top is not None: - _type, style = self._rule_outline_top - append_declaration("outline-top", f"{_type} {style}") - if self._rule_outline_right is not None: - _type, style = self._rule_outline_right - append_declaration("outline-right", f"{_type} {style}") - if self._rule_outline_bottom is not None: - _type, style = self._rule_outline_bottom - append_declaration("outline-bottom", f"{_type} {style}") - if self._rule_outline_left is not None: - _type, style = self._rule_outline_left - append_declaration("outline-left", f"{_type} {style}") + for name, rule in self._get_border_css_lines(rules, "border"): + append_declaration(name, rule) - if self._rule_offset is not None: + for name, rule in self._get_border_css_lines(rules, "outline"): + append_declaration(name, rule) + + if has_rule("offset"): x, y = self.offset append_declaration("offset", f"{x} {y}") - if self._rule_dock: - append_declaration("dock", self._rule_dock) - if self._rule_docks: + if has_rule("dock"): + append_declaration("dock", rules["dock"]) + if has_rule("docks"): append_declaration( "docks", " ".join( (f"{name}={edge}/{z}" if z else f"{name}={edge}") - for name, edge, z in self._rule_docks + for name, edge, z in rules["docks"] ), ) - if self._rule_layers is not None: + if has_rule("layers"): append_declaration("layers", " ".join(self.layers)) - if self._rule_layer is not None: + if has_rule("layer"): append_declaration("layer", self.layer) - if self._rule_layout is not None: + if has_rule("layout"): assert self.layout is not None append_declaration("layout", self.layout.name) - if self._rule_text_color or self._rule_text_background or self._rule_text_style: - append_declaration("text", str(self.text)) - if self._rule_width is not None: + if ( + has_rule("text_color") + and has_rule("text_bgcolor") + and has_rule("text_style") + ): + append_declaration("text", str(self.text)) + else: + if has_rule("text_color"): + append_declaration("text-color", str(get_rule("text_color"))) + if has_rule("text_bgcolor"): + append_declaration("text-bgcolor", str(get_rule("text_bgcolor"))) + if has_rule("text_style"): + append_declaration("text-style", str(get_rule("text_style"))) + + if has_rule("width"): append_declaration("width", str(self.width)) - if self._rule_height is not None: + if has_rule("height"): append_declaration("height", str(self.height)) - if self._rule_min_width is not None: + if has_rule("min-width"): append_declaration("min-width", str(self.min_width)) - if self._rule_min_height is not None: + if has_rule("min_height"): append_declaration("min-height", str(self.min_height)) - if self._rule_transitions is not None: + if has_rule("transitions"): append_declaration( "transition", ", ".join( @@ -419,10 +428,6 @@ class Styles: return "\n".join(self.css_lines) -RULE_NAMES = [name[6:] for name in dir(Styles) if name.startswith("_rule_")] -INTERNAL_RULE_NAMES = [f"_rule_{name}" for name in RULE_NAMES] - - from typing import Generic, TypeVar GetType = TypeVar("GetType") @@ -433,25 +438,35 @@ class StyleViewProperty(Generic[GetType, SetType]): """Presents a view of a base Styles object, plus inline styles.""" def __set_name__(self, owner: StylesView, name: str) -> None: - self._name = name - self._internal_name = f"_rule_{name}" + self.name = name def __set__(self, obj: StylesView, value: SetType) -> None: - setattr(obj._inline_styles, self._name, value) + setattr(obj._inline_styles, self.name, value) def __get__( self, obj: StylesView, objtype: type[StylesView] | None = None ) -> GetType: - if obj._inline_styles.has_rule(self._name): - return getattr(obj._inline_styles, self._name) - return getattr(obj._base_styles, self._name) + if obj._inline_styles.has_rule(self.name): + return getattr(obj._inline_styles, self.name) + return getattr(obj._base_styles, self.name) @rich.repr.auto class StylesView: """Presents a combined view of two Styles object: a base Styles and inline Styles.""" - def __init__(self, base: Styles, inline_styles: Styles) -> None: + ANIMATABLE = { + "offset", + "padding", + "margin", + "width", + "height", + "min_width", + "min_height", + } + + def __init__(self, node: DOMNode, base: Styles, inline_styles: Styles) -> None: + self.node = node self._base_styles = base self._inline_styles = inline_styles @@ -500,6 +515,42 @@ class StylesView: """Check if a rule has been set.""" return self._inline_styles.has_rule(rule) or self._base_styles.has_rule(rule) + def get_rules(self) -> RulesMap: + """Get rules as a dictionary""" + rules = {**self._base_styles._rules, **self._inline_styles._rules} + return cast(RulesMap, rules) + + def __textual_animation__( + self, + attribute: str, + value: Any, + start_time: float, + duration: float | None, + speed: float | None, + easing: EasingFunction, + ) -> Animation | None: + from ..widget import Widget + + assert isinstance(self.node, Widget) + if isinstance(value, ScalarOffset): + return ScalarAnimation( + self.node, + self, + start_time, + attribute, + value, + duration=duration, + speed=speed, + easing=easing, + ) + return None + + def get_transition(self, key: str) -> Transition | None: + if key in self.ANIMATABLE: + return self.transitions.get(key, None) + else: + return None + @property def css(self) -> str: """Get the CSS for the combined styles.""" @@ -511,7 +562,7 @@ class StylesView: display: StyleViewProperty[str, str | None] = StyleViewProperty() visibility: StyleViewProperty[str, str | None] = StyleViewProperty() - layout: StyleViewProperty[Layout | None, str | Layout] = StyleViewProperty() + layout: StyleViewProperty[Layout | None, str | Layout | None] = StyleViewProperty() text: StyleViewProperty[Style, Style | str | None] = StyleViewProperty() text_color: StyleViewProperty[Color, Color | str | None] = StyleViewProperty() diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 711761cb2..e6fe3f969 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -148,10 +148,10 @@ class Stylesheet: # For each rule declared for this node, keep only the most specific one get_first_item = itemgetter(0) - node_rules = [ - (name, max(specificity_rules, key=get_first_item)[1]) + node_rules = { + name: max(specificity_rules, key=get_first_item)[1] for name, specificity_rules in rule_attributes.items() - ] + } node._css_styles.apply_rules(node_rules) diff --git a/src/textual/dom.py b/src/textual/dom.py index c58855d66..c6fa68560 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -42,7 +42,7 @@ class DOMNode(MessagePump): self.children = NodeList() self._css_styles: Styles = Styles(self) self._inline_styles: Styles = Styles.parse(self.STYLES, repr(self), node=self) - self.styles = StylesView(self._css_styles, self._inline_styles) + self.styles = StylesView(self, self._css_styles, self._inline_styles) super().__init__() self.default_styles = Styles.parse(self.DEFAULT_STYLES, repr(self)) self._default_rules = self.default_styles.extract_rules((0, 0, 0)) @@ -53,10 +53,6 @@ class DOMNode(MessagePump): if self._classes: yield "classes", self._classes - @property - def inline_styles(self) -> Styles: - return self._inline_styles - @property def parent(self) -> DOMNode: """Get the parent node. @@ -310,18 +306,41 @@ class DOMNode(MessagePump): self.refresh() def has_class(self, *class_names: str) -> bool: + """Check if the Node has all the given class names. + + Args: + *class_names (str): CSS class names to check. + + Returns: + bool: ``True`` if the node has all the given class names, otherwise ``False``. + """ return self._classes.issuperset(class_names) def add_class(self, *class_names: str) -> None: - """Add class names.""" + """Add class names to this Node. + + Args: + *class_names (str): CSS class names to add. + + """ self._classes.update(class_names) def remove_class(self, *class_names: str) -> None: - """Remove class names""" + """Remove class names from this Node. + + Args: + *class_names (str): CSS class names to remove. + + """ self._classes.difference_update(class_names) def toggle_class(self, *class_names: str) -> None: - """Toggle class names""" + """Toggle class names on this Node. + + Args: + *class_names (str): CSS class names to toggle. + + """ self._classes.symmetric_difference_update(class_names) self.app.stylesheet.update(self.app) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 004964ba9..e902ebfdd 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -514,6 +514,16 @@ class Spacing(NamedTuple): else: return f"{top}, {right}, {bottom}, {left}" + @property + def css(self) -> str: + top, right, bottom, left = self + if top == right == bottom == left: + return f"{top}" + if (top, right) == (bottom, left): + return f"{top} {right}" + else: + return f"{top} {right} {bottom} {left}" + @classmethod def unpack(cls, pad: SpacingDimensions) -> Spacing: """Unpack padding specified in CSS style.""" diff --git a/src/textual/view.py b/src/textual/view.py index ebcc93047..dc1b0f618 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -32,13 +32,6 @@ class View(Widget): ) super().__init__(name=name, id=id) - # def __init_subclass__( - # cls, layout: Callable[[], Layout] | None = None, **kwargs - # ) -> None: - # if layout is not None: - # cls.layout_factory = layout - # super().__init_subclass__(**kwargs) - background: Reactive[str] = Reactive("") scroll_x: Reactive[int] = Reactive(0) scroll_y: Reactive[int] = Reactive(0)