refactor of styles

This commit is contained in:
Will McGugan
2022-02-08 15:23:40 +00:00
parent 309130bd77
commit fd4215b160
5 changed files with 158 additions and 184 deletions

View File

@@ -173,6 +173,10 @@ class Edges(NamedTuple):
bottom: tuple[BoxType, Color]
left: tuple[BoxType, Color]
def __bool__(self) -> bool:
(top, _), (right, _), (bottom, _), (left, _) = self
return bool(top or right or bottom or left)
def __rich_repr__(self) -> rich.repr.Result:
top, right, bottom, left = self
if top[0]:
@@ -749,9 +753,6 @@ class StyleFlagsProperty:
class TransitionsProperty:
"""Descriptor for getting transitions properties"""
def __set_name__(self, owner: Styles, name: str) -> None:
self.name = name
def __get__(
self, obj: Styles, objtype: type[Styles] | None = None
) -> dict[str, Transition]:
@@ -766,4 +767,10 @@ class TransitionsProperty:
e.g. ``{"offset": Transition(...), ...}``. If no transitions have been set, an empty ``dict``
is returned.
"""
return obj.get_rule(self.name, {})
return obj.get_rule("transitions", {})
def __set__(self, obj: Styles, transitions: dict[str, Transition] | None) -> None:
if transitions is None:
obj.clear_rule("transitions")
else:
obj.set_rule("transitions", transitions.copy())

View File

@@ -119,6 +119,43 @@ class StylesBase(ABC):
"min_height",
}
display = StringEnumProperty(VALID_DISPLAY, "block")
visibility = StringEnumProperty(VALID_VISIBILITY, "visible")
layout = LayoutProperty()
text = StyleProperty()
text_color = ColorProperty()
text_background = ColorProperty()
text_style = StyleFlagsProperty()
padding = SpacingProperty()
margin = SpacingProperty()
offset = OffsetProperty()
border = BorderProperty()
border_top = BoxProperty()
border_right = BoxProperty()
border_bottom = BoxProperty()
border_left = BoxProperty()
outline = BorderProperty()
outline_top = BoxProperty()
outline_right = BoxProperty()
outline_bottom = BoxProperty()
outline_left = BoxProperty()
width = ScalarProperty(percent_unit=Unit.WIDTH)
height = ScalarProperty(percent_unit=Unit.HEIGHT)
min_width = ScalarProperty(percent_unit=Unit.WIDTH)
min_height = ScalarProperty(percent_unit=Unit.HEIGHT)
dock = DockProperty()
docks = DocksProperty()
layer = NameProperty()
layers = NameListProperty()
transitions = TransitionsProperty()
@abstractmethod
def has_rule(self, rule: str) -> bool:
...
@@ -164,10 +201,6 @@ class StylesBase(ABC):
tuple[bool, bool]: (repaint required, layout_required)
"""
@abstractmethod
def get_transition(self, key: str) -> Transition | None:
"""Get a transition for a given key (or ``None`` if it doesn't exist)"""
@abstractmethod
def reset(self) -> None:
"""Reset the rules to initial state."""
@@ -180,9 +213,25 @@ class StylesBase(ABC):
other (Styles): A Styles object.
"""
@abstractmethod
def merge_rules(self, rules: RulesMap) -> None:
"""Merge rules in to Styles.
Args:
rules (RulesMap): A mapping of rules.
"""
@classmethod
def is_animatable(cls, rule_name: str) -> bool:
return rule_name in cls.ANIMATABLE
def is_animatable(cls, rule: str) -> bool:
"""Check if a given rule may be animated.
Args:
rule (str): Name of the rule.
Returns:
bool: ``True`` if the rule may be animated, otherwise ``False``.
"""
return rule in cls.ANIMATABLE
@classmethod
@lru_cache(maxsize=1024)
@@ -203,42 +252,11 @@ class StylesBase(ABC):
styles.node = node
return styles
display = StringEnumProperty(VALID_DISPLAY, "block")
visibility = StringEnumProperty(VALID_VISIBILITY, "visible")
layout = LayoutProperty()
text = StyleProperty()
text_color = ColorProperty()
text_background = ColorProperty()
text_style = StyleFlagsProperty()
padding = SpacingProperty()
margin = SpacingProperty()
offset = OffsetProperty()
border = BorderProperty()
border_top = BoxProperty()
border_right = BoxProperty()
border_bottom = BoxProperty()
border_left = BoxProperty()
outline = BorderProperty()
outline_top = BoxProperty()
outline_right = BoxProperty()
outline_bottom = BoxProperty()
outline_left = BoxProperty()
width = ScalarProperty(percent_unit=Unit.WIDTH)
height = ScalarProperty(percent_unit=Unit.HEIGHT)
min_width = ScalarProperty(percent_unit=Unit.WIDTH)
min_height = ScalarProperty(percent_unit=Unit.HEIGHT)
dock = DockProperty()
docks = DocksProperty()
layer = NameProperty()
layers = NameListProperty()
transitions = TransitionsProperty()
def get_transition(self, key: str) -> Transition | None:
if key in self.ANIMATABLE:
return self.transitions.get(key, None)
else:
return None
@rich.repr.auto
@@ -286,12 +304,6 @@ class Styles(StylesBase):
self._repaint_required = self._layout_required = False
return result
def get_transition(self, key: str) -> Transition | None:
if key in self.ANIMATABLE:
return self.transitions.get(key, None)
else:
return None
def reset(self) -> None:
"""
Reset internal style rules to ``None``, reverting to default styles.
@@ -307,6 +319,9 @@ class Styles(StylesBase):
self._rules.update(other._rules)
def merge_rules(self, rules: RulesMap) -> None:
self._rules.update(rules)
def extract_rules(
self, specificity: Specificity3
) -> list[tuple[str, Specificity4, Any]]:
@@ -319,46 +334,39 @@ class Styles(StylesBase):
return rules
def apply_rules(self, rules: RulesMap, animate: bool = False):
"""Apply rules to this Styles object, animating as required.
Args:
rules (RulesMap): A map containing rules to apply.
animate (bool, optional): ``True`` if the rules should animate, or ``False``
to set rules without any animation. Defaults to ``False``.
"""
if animate and self.node is not None:
styles = self
is_animatable = styles.ANIMATABLE.__contains__
_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:
_rules[key] = value
else:
duration, easing, delay = transition
self.node.app.animator.animate(
styles, key, value, duration=duration, easing=easing
)
else:
rules[key] = value
else:
self._rules.update(rules)
if self.node is not None:
self.node.on_style_change()
def __rich_repr__(self) -> rich.repr.Result:
for name, value in self._rules.items():
yield name, value
has_rule = self.has_rule
for name in RULE_NAMES:
if has_rule(name):
yield name, getattr(self, name)
if self.important:
yield "important", self.important
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_border_css_lines(
self, rules: RulesMap, name: str
) -> Iterable[tuple[str, str]]:
@@ -393,26 +401,26 @@ class Styles(StylesBase):
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}"
border_type, border_color = rules[f"{name}_top"]
yield name, f"{border_type} {border_color.name}"
return
# Check for edges
if has_top:
border_type, border_style = rules[f"{name}_top"]
yield f"{name}-top", f"{border_type} {border_style}"
border_type, border_color = rules[f"{name}_top"]
yield f"{name}-top", f"{border_type} {border_color.name}"
if has_right:
border_type, border_style = rules[f"{name}_right"]
yield f"{name}-right", f"{border_type} {border_style}"
border_type, border_color = rules[f"{name}_right"]
yield f"{name}-right", f"{border_type} {border_color.name}"
if has_bottom:
border_type, border_style = rules[f"{name}_bottom"]
yield f"{name}-bottom", f"{border_type} {border_style}"
border_type, border_color = rules[f"{name}_bottom"]
yield f"{name}-bottom", f"{border_type} {border_color.name}"
if has_left:
border_type, border_style = rules[f"{name}_left"]
yield f"{name}-left", f"{border_type} {border_style}"
border_type, border_color = rules[f"{name}_left"]
yield f"{name}-left", f"{border_type} {border_color.name}"
@property
def css_lines(self) -> list[str]:
@@ -473,9 +481,9 @@ class Styles(StylesBase):
append_declaration("text", str(self.text))
else:
if has_rule("text_color"):
append_declaration("text-color", get_rule("text_color").name)
if has_rule("text_bgcolor"):
append_declaration("text-bgcolor", str(get_rule("text_bgcolor").name))
append_declaration("text-color", self.text_color.name)
if has_rule("text_background"):
append_declaration("text-background", self.text_background.name)
if has_rule("text_style"):
append_declaration("text-style", str(get_rule("text_style")))
@@ -504,29 +512,6 @@ class Styles(StylesBase):
return "\n".join(self.css_lines)
from typing import Generic, TypeVar
GetType = TypeVar("GetType")
SetType = TypeVar("SetType")
class StyleViewProperty(Generic[GetType, SetType]):
"""Presents a view of a base Styles object, plus inline styles."""
def __set_name__(self, owner: RenderStyles, name: str) -> None:
self.name = name
def __set__(self, obj: RenderStyles, value: SetType) -> None:
setattr(obj._inline_styles, self.name, value)
def __get__(
self, obj: RenderStyles, objtype: type[RenderStyles] | 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)
@rich.repr.auto
class RenderStyles(StylesBase):
"""Presents a combined view of two Styles object: a base Styles and inline Styles."""
@@ -538,10 +523,12 @@ class RenderStyles(StylesBase):
@property
def base(self) -> Styles:
"""Quick access to base (css) style."""
return self._base_styles
@property
def inline(self) -> Styles:
"""Quick access to the inline styles."""
return self._inline_styles
def __rich_repr__(self) -> rich.repr.Result:
@@ -564,6 +551,9 @@ class RenderStyles(StylesBase):
"""
self._inline_styles.merge(other)
def merge_rules(self, rules: RulesMap) -> None:
self._inline_styles.merge_rules(rules)
def check_refresh(self) -> tuple[bool, bool]:
"""Check if the Styles must be refreshed.
@@ -596,37 +586,6 @@ class RenderStyles(StylesBase):
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."""

View File

@@ -23,6 +23,7 @@ from .parse import parse
from .styles import RulesMap
from .types import Specificity3, Specificity4
from ..dom import DOMNode
from .. import log
class StylesheetParseError(Exception):
@@ -135,7 +136,7 @@ class Stylesheet:
yield selector_set.specificity
def apply(self, node: DOMNode, animate: bool = False) -> None:
"""pply the stylesheet to a DOM node.
"""Apply the stylesheet to a DOM node.
Args:
node (DOMNode): The ``DOMNode`` to apply the stylesheet to.
@@ -143,9 +144,6 @@ class Stylesheet:
If the same rule is defined multiple times for the node (e.g. multiple
classes modifying the same CSS property), then only the most specific
rule will be applied.
Returns:
None
"""
# Dictionary of rule attribute names e.g. "text_background" to list of tuples.
@@ -159,7 +157,7 @@ class Stylesheet:
_check_rule = self._check_rule
# TODO: The line below breaks inline styles and animations
node._css_styles.reset()
# node._css_styles.reset()
# Collect default node CSS rules
for key, default_specificity, value in node._default_rules:
@@ -182,37 +180,48 @@ class Stylesheet:
for name, specificity_rules in rule_attributes.items()
},
)
node._css_styles.apply_rules(node_rules, animate=animate)
self.apply_rules(node, node_rules, animate=animate)
@classmethod
def apply_rules(
cls, node: DOMNode | None, rules: RulesMap, animate: bool = False
) -> None:
def apply_rules(cls, node: DOMNode, rules: RulesMap, animate: bool = False) -> None:
"""Apply style rules to a node, animating as required.
if animate and node is not None:
Args:
node (DOMNode): A DOM node.
rules (RulesMap): Mapping of rules.
animate (bool, optional): Enable animation. Defaults to False.
"""
styles = node.styles
if animate:
is_animatable = styles.is_animatable
current_rules = styles.get_rules()
set_rule = styles.base.set_rule
is_animatable = node._css_styles.ANIMATABLE.__contains__
_rules = node.styles.get_rules()
for key, value in rules.items():
current = _rules.get(key)
current = current_rules.get(key)
if current == value:
continue
if is_animatable(key):
transition = styles.get_transition(key)
if transition is None:
_rules[key] = value
styles.base.set_rule(key, value)
else:
duration, easing, delay = transition
self.node.app.animator.animate(
styles, key, value, duration=duration, easing=easing
node.app.animator.animate(
node.styles.base,
key,
value,
duration=duration,
easing=easing,
)
else:
rules[key] = value
set_rule(key, value)
else:
self._rules.update(rules)
styles.base.merge_rules(rules)
if self.node is not None:
self.node.on_style_change()
node.on_style_change()
def update(self, root: DOMNode, animate: bool = False) -> None:
"""Update a node and its children."""

View File

@@ -213,7 +213,6 @@ class Widget(DOMNode):
return gutter
def on_style_change(self) -> None:
self.log("style_change", self)
self.clear_render_cache()
def _update_size(self, size: Size) -> None:

View File

@@ -1,7 +1,7 @@
from rich.color import Color
from rich.style import Style
from textual.css.styles import Styles, StylesView
from textual.css.styles import Styles, RenderStyles
def test_styles_reset():
@@ -16,7 +16,7 @@ def test_styles_view_text():
"""Test inline styles override base styles"""
base = Styles()
inline = Styles()
styles_view = StylesView(None, base, inline)
styles_view = RenderStyles(None, base, inline)
# Both styles are empty
assert styles_view.text == Style()
@@ -43,19 +43,19 @@ def test_styles_view_border():
base = Styles()
inline = Styles()
styles_view = StylesView(None, base, inline)
styles_view = RenderStyles(None, base, inline)
base.border_top = ("heavy", "red")
# Base has border-top: heavy red
assert styles_view.border_top == ("heavy", Style.parse("red"))
assert styles_view.border_top == ("heavy", Color.parse("red"))
inline.border_left = ("rounded", "green")
# Base has border-top heavy red, inline has border-left: rounded green
assert styles_view.border_top == ("heavy", Style.parse("red"))
assert styles_view.border_left == ("rounded", Style.parse("green"))
assert styles_view.border_top == ("heavy", Color.parse("red"))
assert styles_view.border_left == ("rounded", Color.parse("green"))
assert styles_view.border == (
("heavy", Color.parse("red")),
("", Color()),
("", Color()),
("", Color.default()),
("", Color.default()),
("rounded", Color.parse("green")),
)