scalar animation

This commit is contained in:
Will McGugan
2021-12-23 21:32:27 +00:00
parent 129015dea6
commit a034c76405
8 changed files with 161 additions and 67 deletions

View File

@@ -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()

View File

@@ -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.

View File

@@ -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.

View File

@@ -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):

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

View File

@@ -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)