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