mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
scalar animation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import sys
|
||||
from time import time
|
||||
@@ -30,15 +31,21 @@ class Animatable(Protocol):
|
||||
...
|
||||
|
||||
|
||||
class Animation(ABC):
|
||||
@abstractmethod
|
||||
def __call__(self, time: float) -> bool:
|
||||
raise NotImplementedError("")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Animation:
|
||||
class SimpleAnimation(Animation):
|
||||
obj: object
|
||||
attribute: str
|
||||
start_time: float
|
||||
duration: float
|
||||
start_value: float | Animatable
|
||||
end_value: float | Animatable
|
||||
easing_function: EasingFunction
|
||||
easing: EasingFunction
|
||||
|
||||
def __call__(self, time: float) -> bool:
|
||||
def blend_float(start: float, end: float, factor: float) -> float:
|
||||
@@ -53,22 +60,27 @@ class Animation:
|
||||
value = self.end_value
|
||||
else:
|
||||
factor = min(1.0, (time - self.start_time) / self.duration)
|
||||
eased_factor = self.easing_function(factor)
|
||||
eased_factor = self.easing(factor)
|
||||
|
||||
log("ANIMATE", self.start_value, self.end_value)
|
||||
if isinstance(self.start_value, Animatable):
|
||||
assert isinstance(self.end_value, Animatable)
|
||||
assert isinstance(
|
||||
self.end_value, Animatable, "end_value must be animatable"
|
||||
)
|
||||
value = self.start_value.blend(self.end_value, eased_factor)
|
||||
else:
|
||||
assert isinstance(self.start_value, float)
|
||||
assert isinstance(self.end_value, float)
|
||||
assert isinstance(
|
||||
self.start_value, float
|
||||
), "`start_value` must be float"
|
||||
assert isinstance(self.end_value, float), "`end_value` must be float"
|
||||
if self.end_value > self.start_value:
|
||||
eased_factor = self.easing_function(factor)
|
||||
eased_factor = self.easing(factor)
|
||||
value = (
|
||||
self.start_value
|
||||
+ (self.end_value - self.start_value) * eased_factor
|
||||
)
|
||||
else:
|
||||
eased_factor = 1 - self.easing_function(factor)
|
||||
eased_factor = 1 - self.easing(factor)
|
||||
value = (
|
||||
self.end_value
|
||||
+ (self.start_value - self.end_value) * eased_factor
|
||||
@@ -104,7 +116,7 @@ class BoundAnimator:
|
||||
|
||||
class Animator:
|
||||
def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None:
|
||||
self._animations: dict[tuple[object, str], Animation] = {}
|
||||
self._animations: dict[tuple[object, str], SimpleAnimation] = {}
|
||||
self._timer = Timer(
|
||||
target,
|
||||
1 / frames_per_second,
|
||||
@@ -141,30 +153,46 @@ class Animator:
|
||||
log("animate", obj, attribute, value)
|
||||
start_time = time()
|
||||
|
||||
animation_key = (obj, attribute)
|
||||
animation_key = (id(obj), attribute)
|
||||
if animation_key in self._animations:
|
||||
self._animations[animation_key](start_time)
|
||||
|
||||
start_value = getattr(obj, attribute)
|
||||
|
||||
if start_value == value:
|
||||
self._animations.pop(animation_key, None)
|
||||
return
|
||||
|
||||
if duration is not None:
|
||||
animation_duration = duration
|
||||
else:
|
||||
animation_duration = abs(value - start_value) / (speed or 50)
|
||||
easing_function = EASING[easing] if isinstance(easing, str) else easing
|
||||
animation = Animation(
|
||||
obj,
|
||||
attribute=attribute,
|
||||
start_time=start_time,
|
||||
duration=animation_duration,
|
||||
start_value=start_value,
|
||||
end_value=value,
|
||||
easing_function=easing_function,
|
||||
)
|
||||
|
||||
animation: Animation | None = None
|
||||
if hasattr(obj, "__textual_animation__"):
|
||||
animation = getattr(obj, "__textual_animation__")(
|
||||
attribute,
|
||||
value,
|
||||
start_time,
|
||||
duration=duration,
|
||||
speed=speed,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
if animation is None:
|
||||
|
||||
start_value = getattr(obj, attribute)
|
||||
|
||||
if start_value == value:
|
||||
self._animations.pop(animation_key, None)
|
||||
return
|
||||
|
||||
if duration is not None:
|
||||
animation_duration = duration
|
||||
else:
|
||||
animation_duration = abs(value - start_value) / (speed or 50)
|
||||
|
||||
animation = SimpleAnimation(
|
||||
obj,
|
||||
attribute=attribute,
|
||||
start_time=start_time,
|
||||
duration=animation_duration,
|
||||
start_value=start_value,
|
||||
end_value=value,
|
||||
easing=easing_function,
|
||||
)
|
||||
assert animation is not None, "animation expected to be non-None"
|
||||
self._animations[animation_key] = animation
|
||||
self._timer.resume()
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from . import actions
|
||||
from .dom import DOMNode
|
||||
from ._animator import Animator
|
||||
from .binding import Bindings, NoBinding
|
||||
from .geometry import Offset, Region
|
||||
from .geometry import Offset, Region, Size
|
||||
from . import log
|
||||
from ._callback import invoke
|
||||
from ._context import active_app
|
||||
@@ -149,13 +149,18 @@ class App(DOMNode):
|
||||
return self._animator
|
||||
|
||||
@property
|
||||
def view(self) -> DockView:
|
||||
def view(self) -> View:
|
||||
return self._view_stack[-1]
|
||||
|
||||
@property
|
||||
def css_type(self) -> str:
|
||||
return "app"
|
||||
|
||||
@property
|
||||
def size(self) -> Size:
|
||||
width, height = self.console.size
|
||||
return Size(*self.console.size)
|
||||
|
||||
def log(self, *args: Any, verbosity: int = 1, **kwargs) -> None:
|
||||
"""Write to logs.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from enum import Enum, unique
|
||||
import re
|
||||
from typing import Iterable, NamedTuple
|
||||
from typing import Iterable, NamedTuple, TYPE_CHECKING
|
||||
|
||||
import rich.repr
|
||||
|
||||
@@ -60,6 +60,12 @@ RESOLVE_MAP = {
|
||||
}
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..widget import Widget
|
||||
from .styles import Styles
|
||||
from .._animator import EasingFunction
|
||||
|
||||
|
||||
def get_symbols(units: Iterable[Unit]) -> list[str]:
|
||||
"""Get symbols for an iterable of units.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
import sys
|
||||
from typing import Any, Iterable, NamedTuple
|
||||
from typing import Any, Iterable, NamedTuple, TYPE_CHECKING
|
||||
|
||||
from rich import print
|
||||
from rich.color import Color
|
||||
@@ -11,7 +11,10 @@ import rich.repr
|
||||
from rich.style import Style
|
||||
|
||||
from .. import log
|
||||
from .._animator import SimpleAnimation, Animation, EasingFunction
|
||||
from .._types import MessageTarget
|
||||
from .errors import StyleValueError
|
||||
from .. import events
|
||||
from ._error_tools import friendly_list
|
||||
from .constants import (
|
||||
VALID_DISPLAY,
|
||||
@@ -19,6 +22,7 @@ from .constants import (
|
||||
VALID_LAYOUT,
|
||||
NULL_SPACING,
|
||||
)
|
||||
from .scalar_animation import ScalarAnimation
|
||||
from ..geometry import NULL_OFFSET, Offset, Spacing
|
||||
from .scalar import Scalar, ScalarOffset, Unit
|
||||
from .transition import Transition
|
||||
@@ -47,6 +51,10 @@ else:
|
||||
from typing_extensions import Literal
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..dom import DOMNode
|
||||
|
||||
|
||||
class DockGroup(NamedTuple):
|
||||
name: str
|
||||
edge: Edge
|
||||
@@ -57,7 +65,8 @@ class DockGroup(NamedTuple):
|
||||
@dataclass
|
||||
class Styles:
|
||||
|
||||
_changed: float = 0
|
||||
node: DOMNode | None = None
|
||||
|
||||
_rule_display: Display | None = None
|
||||
_rule_visibility: Visibility | None = None
|
||||
_rule_layout: str | None = None
|
||||
@@ -155,6 +164,31 @@ class Styles:
|
||||
styles = parse_declarations(css, path)
|
||||
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
|
||||
@@ -207,9 +241,36 @@ class Styles:
|
||||
]
|
||||
return rules
|
||||
|
||||
def apply_rules(self, rules: Iterable[tuple[str, object]]):
|
||||
for key, value in rules:
|
||||
setattr(self, f"_rule_{key}", value)
|
||||
def apply_rules(self, rules: Iterable[tuple[str, object]], animate: bool = False):
|
||||
if animate or self.node is None:
|
||||
for key, value in rules:
|
||||
setattr(self, f"_rule_{key}", value)
|
||||
else:
|
||||
styles = self
|
||||
is_animatable = styles.ANIMATABLE.__contains__
|
||||
for key, value in rules:
|
||||
current = getattr(styles, f"_rule_{key}")
|
||||
if current == value:
|
||||
continue
|
||||
log(key, "=", value)
|
||||
if is_animatable(key):
|
||||
log("animatable", key)
|
||||
transition = styles.get_transition(key)
|
||||
log("transition", transition)
|
||||
if transition is None:
|
||||
setattr(styles, f"_rule_{key}", value)
|
||||
else:
|
||||
duration, easing, delay = transition
|
||||
log("ANIMATING")
|
||||
self.node.app.animator.animate(
|
||||
styles, key, value, duration=duration, easing=easing
|
||||
)
|
||||
else:
|
||||
log("not animatable")
|
||||
setattr(styles, f"_rule_{key}", value)
|
||||
|
||||
if self.node is not None:
|
||||
self.node.post_message_no_wait(events.Null(self.node))
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
for rule_name, internal_rule_name in zip(RULE_NAMES, INTERNAL_RULE_NAMES):
|
||||
|
||||
@@ -129,7 +129,7 @@ class Stylesheet:
|
||||
for name, specificity_rules in rule_attributes.items()
|
||||
]
|
||||
|
||||
node.apply_style_rules(node_rules)
|
||||
node.styles.apply_rules(node_rules)
|
||||
|
||||
def update(self, root: DOMNode) -> None:
|
||||
"""Update a node and its children."""
|
||||
|
||||
@@ -36,7 +36,7 @@ class DOMNode(MessagePump):
|
||||
self._id = id
|
||||
self._classes: set[str] = set()
|
||||
self.children = NodeList()
|
||||
self.styles: Styles = Styles()
|
||||
self.styles: Styles = Styles(self)
|
||||
super().__init__()
|
||||
self.default_styles = Styles.parse(self.STYLES, repr(self))
|
||||
self._default_rules = self.default_styles.extract_rules((0, 0, 0))
|
||||
@@ -135,9 +135,6 @@ class DOMNode(MessagePump):
|
||||
add_children(tree, self)
|
||||
return tree
|
||||
|
||||
def apply_style_rules(self, rules: Iterable[tuple[str, Any]]) -> None:
|
||||
self.styles.apply_rules(rules)
|
||||
|
||||
def reset_styles(self) -> None:
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import sqrt
|
||||
from typing import Any, cast, NamedTuple, Tuple, Union, TypeVar
|
||||
|
||||
|
||||
@@ -58,6 +59,12 @@ class Offset(NamedTuple):
|
||||
return Offset(_x - x, _y - y)
|
||||
return NotImplemented
|
||||
|
||||
def __mul__(self, other: object) -> Offset:
|
||||
if isinstance(other, (float, int)):
|
||||
x, y = self
|
||||
return Offset(int(x * other), int(y * other))
|
||||
return NotImplemented
|
||||
|
||||
def blend(self, destination: Offset, factor: float) -> Offset:
|
||||
"""Blend (interpolate) to a new point.
|
||||
|
||||
@@ -72,6 +79,20 @@ class Offset(NamedTuple):
|
||||
x2, y2 = destination
|
||||
return Offset(int(x1 + (x2 - x1) * factor), int((y1 + (y2 - y1) * factor)))
|
||||
|
||||
def get_distance_to(self, other: Offset) -> float:
|
||||
"""Get the distance to another offset.
|
||||
|
||||
Args:
|
||||
other (Offset): An offset
|
||||
|
||||
Returns:
|
||||
float: Distance to other offset
|
||||
"""
|
||||
x1, y1 = self
|
||||
x2, y2 = other
|
||||
distance = sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1))
|
||||
return distance
|
||||
|
||||
|
||||
class Size(NamedTuple):
|
||||
"""An area defined by its width and height."""
|
||||
@@ -80,7 +101,7 @@ class Size(NamedTuple):
|
||||
height: int = 0
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""A Size is Falsey if it has area 0."""
|
||||
"""A Size is Falsy if it has area 0."""
|
||||
return self.width * self.height != 0
|
||||
|
||||
@property
|
||||
|
||||
@@ -237,30 +237,6 @@ class Widget(DOMNode):
|
||||
offset_x, offset_y = self.root_view.get_offset(self)
|
||||
return self.root_view.get_style_at(x + offset_x, y + offset_y)
|
||||
|
||||
def apply_style_rules(self, rules: Iterable[tuple[str, Any]]) -> None:
|
||||
styles = self.styles
|
||||
is_animatable = styles.ANIMATABLE.__contains__
|
||||
for key, value in rules:
|
||||
current = getattr(styles, f"_rule_{key}")
|
||||
if current == value:
|
||||
continue
|
||||
self.log(key, "=", value)
|
||||
if is_animatable(key):
|
||||
self.log("animatable", key)
|
||||
transition = styles.get_transition(key)
|
||||
self.log("transition", transition)
|
||||
if transition is None:
|
||||
setattr(styles, f"_rule_{key}", value)
|
||||
else:
|
||||
duration, easing, delay = transition
|
||||
self.log("ANIMATING")
|
||||
self.app.animator.animate(
|
||||
styles, key, value, duration=duration, easing=easing
|
||||
)
|
||||
else:
|
||||
self.log("not animatable")
|
||||
setattr(styles, f"_rule_{key}", value)
|
||||
|
||||
async def call_later(self, callback: Callable, *args, **kwargs) -> None:
|
||||
await self.app.call_later(callback, *args, **kwargs)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user