diff --git a/sandbox/local_styles.py b/sandbox/local_styles.py index 8c73aca0f..08eea1cf8 100644 --- a/sandbox/local_styles.py +++ b/sandbox/local_styles.py @@ -11,9 +11,9 @@ class BasicApp(App): """Build layout here.""" self.mount( header=Widget(), - content=Placeholder(), - footer=Widget(), - sidebar=Widget(), + # content=Placeholder(), + # footer=Widget(), + # sidebar=Widget(), ) async def on_key(self, event: events.Key) -> None: diff --git a/src/textual/_animator.py b/src/textual/_animator.py index d31aff219..ada16498b 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -45,6 +45,7 @@ class SimpleAnimation(Animation): duration: float start_value: float | Animatable end_value: float | Animatable + final_value: float | Animatable easing: EasingFunction def __call__(self, time: float) -> bool: @@ -62,7 +63,9 @@ class SimpleAnimation(Animation): factor = min(1.0, (time - self.start_time) / self.duration) eased_factor = self.easing(factor) - if isinstance(self.start_value, Animatable): + if factor == 1.0: + value = self.end_value + elif isinstance(self.start_value, Animatable): assert isinstance( self.end_value, Animatable ), "end_value must be animatable" @@ -148,11 +151,14 @@ class Animator: attribute: str, value: Any, *, + final_value: Any = ..., duration: float | None = None, speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, ) -> None: + if final_value is ...: + final_value = value start_time = time() animation_key = (id(obj), attribute) @@ -190,6 +196,7 @@ class Animator: duration=animation_duration, start_value=start_value, end_value=value, + final_value=final_value, easing=easing_function, ) assert animation is not None, "animation expected to be non-None" diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index c8831e3a3..c8b17ba0c 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -105,8 +105,8 @@ 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) - obj.set_rule(self.name, new_value) - obj.refresh() + if obj.set_rule(self.name, new_value): + obj.refresh() class BoxProperty: @@ -151,7 +151,8 @@ class BoxProperty: StyleSyntaxError: If the string supplied for the color has invalid syntax. """ if border is None: - obj.clear_rule(self.name) + if obj.clear_rule(self.name): + obj.refresh() else: _type, color = border new_value = border @@ -159,8 +160,8 @@ class BoxProperty: new_value = (_type, Color.parse(color)) elif isinstance(color, Color): new_value = (_type, color) - obj.set_rule(self.name, new_value) - obj.refresh() + if obj.set_rule(self.name, new_value): + obj.refresh() @rich.repr.auto @@ -378,11 +379,13 @@ class SpacingProperty: ValueError: When the value is malformed, e.g. a ``tuple`` with a length that is not 1, 2, or 4. """ - obj.refresh(layout=True) + if spacing is None: - obj.clear_rule(self.name) + if obj.clear_rule(self.name): + obj.refresh(layout=True) else: - obj.set_rule(self.name, Spacing.unpack(spacing)) + if obj.set_rule(self.name, Spacing.unpack(spacing)): + obj.refresh(layout=True) class DocksProperty: @@ -411,12 +414,12 @@ class DocksProperty: obj (Styles): The ``Styles`` object. docks (Iterable[DockGroup]): Iterable of DockGroups """ - obj.refresh(layout=True) if docks is None: - obj.clear_rule("docks") - + if obj.clear_rule("docks"): + obj.refresh(layout=True) else: - obj.set_rule("docks", tuple(docks)) + if obj.set_rule("docks", tuple(docks)): + obj.refresh(layout=True) class DockProperty: @@ -445,8 +448,8 @@ class DockProperty: obj (Styles): The ``Styles`` object spacing (str | None): The spacing to use. """ - obj.refresh(layout=True) - obj.set_rule("dock", spacing) + if obj.set_rule("dock", spacing): + obj.refresh(layout=True) class LayoutProperty: @@ -477,14 +480,15 @@ class LayoutProperty: from ..layouts.factory import get_layout, Layout # Prevents circular import - obj.refresh(layout=True) - if layout is None: - obj.clear_rule("layout") + if obj.clear_rule("layout"): + obj.refresh(layout=True) elif isinstance(layout, Layout): - obj.set_rule("layout", layout) + if obj.set_rule("layout", layout): + obj.refresh(layout=True) else: - obj.set_rule("layout", get_layout(layout)) + if obj.set_rule("layout", get_layout(layout)): + obj.refresh(layout=True) class OffsetProperty: @@ -525,11 +529,13 @@ class OffsetProperty: ScalarParseError: If any of the string values supplied in the 2-tuple cannot be parsed into a Scalar. For example, if you specify an non-existent unit. """ - obj.refresh(layout=True) + if offset is None: - obj.clear_rule(self.name) + if obj.clear_rule(self.name): + obj.refresh(layout=True) elif isinstance(offset, ScalarOffset): - obj.set_rule(self.name, offset) + if obj.set_rule(self.name, offset): + obj.refresh(layout=True) else: x, y = offset scalar_x = ( @@ -543,7 +549,8 @@ class OffsetProperty: else Scalar(float(y), Unit.CELLS, Unit.HEIGHT) ) _offset = ScalarOffset(scalar_x, scalar_y) - obj.set_rule(self.name, _offset) + if obj.set_rule(self.name, _offset): + obj.refresh(layout=True) class StringEnumProperty: @@ -580,15 +587,17 @@ class StringEnumProperty: Raises: StyleValueError: If the value is not in the set of valid values. """ - obj.refresh() + if value is None: - obj.clear_rule(self.name) + if obj.clear_rule(self.name): + obj.refresh() else: if value not in self._valid_values: raise StyleValueError( f"{self.name} must be one of {friendly_list(self._valid_values)}" ) - obj.set_rule(self.name, value) + if obj.set_rule(self.name, value): + obj.refresh() class NameProperty: @@ -619,13 +628,15 @@ class NameProperty: Raises: StyleTypeError: If the value is not a ``str``. """ - obj.refresh(layout=True) + if name is None: - obj.clear_rule(self.name) + if obj.clear_rule(self.name): + obj.refresh(layout=True) else: if not isinstance(name, str): raise StyleTypeError(f"{self.name} must be a str") - obj.set_rule(self.name, name) + if obj.set_rule(self.name, name): + obj.refresh(layout=True) class NameListProperty: @@ -640,15 +651,18 @@ class NameListProperty: def __set__( self, obj: Styles, names: str | tuple[str] | None = None ) -> str | tuple[str] | None: - obj.refresh(layout=True) + if names is None: - obj.clear_rule(self.name) + if obj.clear_rule(self.name): + obj.refresh(layout=True) elif isinstance(names, str): - obj.set_rule( + if obj.set_rule( self.name, tuple(name.strip().lower() for name in names.split(" ")) - ) + ): + obj.refresh(layout=True) elif isinstance(names, tuple): - obj.set_rule(self.name, names) + if obj.set_rule(self.name, names): + obj.refresh(layout=True) class ColorProperty: @@ -681,13 +695,16 @@ class ColorProperty: Raises: ColorParseError: When the color string is invalid. """ - obj.refresh() + if color is None: - obj.clear_rule(self.name) + if obj.clear_rule(self.name): + obj.refresh() elif isinstance(color, Color): - obj.set_rule(self.name, color) + if obj.set_rule(self.name, color): + obj.refresh() elif isinstance(color, str): - obj.set_rule(self.name, Color.parse(color)) + if obj.set_rule(self.name, Color.parse(color)): + obj.refresh() class StyleFlagsProperty: @@ -722,7 +739,7 @@ class StyleFlagsProperty: """ return obj.get_rule(self.name, Style.null()) - def __set__(self, obj: Styles, style_flags: str | None): + def __set__(self, obj: Styles, style_flags: Style | str | None): """Set the style using a style flag string Args: @@ -733,9 +750,12 @@ class StyleFlagsProperty: Raises: StyleValueError: If the value is an invalid style flag """ - obj.refresh() if style_flags is None: - obj.clear_rule(self.name) + if obj.clear_rule(self.name): + obj.refresh() + elif isinstance(style_flags, Style): + if obj.set_rule(self.name, style_flags): + obj.refresh() else: words = [word.strip() for word in style_flags.split(" ")] valid_word = self._VALID_PROPERTIES.__contains__ @@ -746,7 +766,8 @@ class StyleFlagsProperty: f"valid values are {friendly_list(self._VALID_PROPERTIES)}" ) style = Style.parse(style_flags) - obj.set_rule(self.name, style) + if obj.set_rule(self.name, style): + obj.refresh() class TransitionsProperty: @@ -806,10 +827,10 @@ class FractionalProperty: value (float|str|None): The value to set as a float between 0 and 1, or as a percentage string such as '10%'. """ - obj.refresh() name = self.name if value is None: - obj.clear_rule(name) + if obj.clear_rule(name): + obj.refresh() return if isinstance(value, float): @@ -820,4 +841,5 @@ class FractionalProperty: raise StyleTypeError( f"{self.name} must be a str (e.g. '10%') or a float (e.g. 0.1)" ) - obj.set_rule(name, clamp(float_value, 0, 1)) + if obj.set_rule(name, clamp(float_value, 0, 1)): + obj.refresh() diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 3a5a3c96a..439c39a6c 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -168,11 +168,14 @@ class StylesBase(ABC): """ @abstractmethod - def clear_rule(self, rule: str) -> None: + def clear_rule(self, rule: str) -> bool: """Removes the rule from the Styles object, as if it had never been set. Args: rule (str): Rule name. + + Returns: + bool: ``True`` if a rules was clearled, or ``False`` if it was previously cleared. """ @abstractmethod @@ -184,12 +187,15 @@ class StylesBase(ABC): """ @abstractmethod - def set_rule(self, rule: str, value: object | None) -> None: - """Set an individual rule. + def set_rule(self, rule: str, value: object | None) -> bool: + """Set a rule. Args: - rule (str): Name of rule. - value (object): Value of rule. + rule (str): Rule name. + value (object | None): New rule value. + + Returns: + bool: ``True`` of the rule changed, otherwise false. """ @abstractmethod @@ -242,6 +248,7 @@ class StylesBase(ABC): def get_render_rules(self) -> RulesMap: """Get rules map with defaults.""" + # Get a dictionary of rules, going through the properties rules = dict(zip(RULE_NAMES, _rule_getter(self))) return cast(RulesMap, rules) @@ -303,24 +310,35 @@ class Styles(StylesBase): def has_rule(self, rule: str) -> bool: return rule in self._rules - def clear_rule(self, rule: str) -> None: - self._rules.pop(rule, None) + def clear_rule(self, rule: str) -> bool: + return self._rules.pop(rule, None) is not None def get_rules(self) -> RulesMap: return self._rules.copy() - def set_rule(self, rule: str, value: object | None) -> None: + def set_rule(self, rule: str, value: object | None) -> bool: + """Set a rule. + + Args: + rule (str): Rule name. + value (object | None): New rule value. + + Returns: + bool: ``True`` of the rule changed, otherwise false. + """ if value is None: - self._rules.pop(rule, None) + return self._rules.pop(rule, None) is not None else: + current = self._rules.get(rule) self._rules[rule] = value + return current != value def get_rule(self, rule: str, default: object = None) -> object: return self._rules.get(rule, default) def refresh(self, *, layout: bool = False) -> None: self._repaint_required = True - self._layout_required = layout + self._layout_required = self._layout_required or layout def check_refresh(self) -> tuple[bool, bool]: """Check if the Styles must be refreshed. @@ -615,9 +633,9 @@ class RenderStyles(StylesBase): return self._inline_styles.get_rule(rule, default) return self._base_styles.get_rule(rule, default) - def clear_rule(self, rule_name: str) -> None: + def clear_rule(self, rule_name: str) -> bool: """Clear a rule (from inline).""" - self._inline_styles.clear_rule(rule_name) + return self._inline_styles.clear_rule(rule_name) def get_rules(self) -> RulesMap: """Get rules as a dictionary""" diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 976519961..780d844f3 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -192,12 +192,38 @@ class Stylesheet: animate (bool, optional): Enable animation. Defaults to False. """ - new_styles = node._default_styles.copy() - new_styles.merge_rules(rules) - node.styles.base.reset() - node.styles.base.merge(new_styles) + styles = node.styles + base_styles = styles.base + + # current_rules = styles.get_render_rules() + base_rules = list(base_styles.get_rules().keys()) + old_rules = {key: None for key in base_rules} + rule_updates = {**old_rules, **rules} + + set_rule = base_styles.set_rule + base_styles.reset() + base_styles.merge(node._default_styles) + # base_styles.merge_rules(rules) + + log("*", rule_updates) + + for key, value in rule_updates.items(): + setattr(base_styles, key, value) + + repaint, layout = styles.check_refresh() + if repaint or layout: + node.refresh(repaint=repaint, layout=layout) + return + # repaint = False + # layout = False + + # for (rule_name, rule1), (_, rule2) in zip(start_rules.items(), new_rules.items()): + # if rule1 != rule2: + + # return + styles = node.styles.base styles = Styles() current_styles = styles.get_render_rules() @@ -260,9 +286,9 @@ class Stylesheet: apply = self.apply for node in root.walk_children(): apply(node, animate=animate) - if hasattr(node, "clear_render_cache"): - # TODO: Not ideal - node.clear_render_cache() + # if hasattr(node, "clear_render_cache"): + # # TODO: Not ideal + # node.clear_render_cache() if __name__ == "__main__":