|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import tkinter as tk |
| 4 | +import operator |
| 5 | +from typing import NamedTuple, Callable, TypeVar, List, Generator, Tuple |
| 6 | +from contextlib import suppress |
| 7 | +from more_itertools import chunked |
| 8 | +from enum import Enum |
| 9 | +from dataclasses import dataclass |
| 10 | +from functools import partialmethod, partial |
| 11 | + |
| 12 | + |
| 13 | +class Coord(NamedTuple): |
| 14 | + """ |
| 15 | + Helper class for managing coordinate values. |
| 16 | +
|
| 17 | + Coord overloads many of the numeric operators by mapping |
| 18 | + it to the x and y values individually. |
| 19 | +
|
| 20 | + param: |
| 21 | + x: float -- X position. |
| 22 | + y: float -- Y position. |
| 23 | +
|
| 24 | + example:: |
| 25 | +
|
| 26 | + ``` |
| 27 | + c1 = c2 = Coord(1, 1) |
| 28 | + c1 + c2 |
| 29 | + >>> Coord(2, 2) |
| 30 | + # For convenience, numbers are accepted as well |
| 31 | + c1 = Coord(1, 1) |
| 32 | + c1 + 1 # 1 is cast to Coord(1, 1) |
| 33 | + >>> Coord(2, 2) |
| 34 | + ``` |
| 35 | + """ |
| 36 | + |
| 37 | + x: float |
| 38 | + y: float |
| 39 | + |
| 40 | + Operand = TypeVar('Operand', 'Coord', float) |
| 41 | + |
| 42 | + def __apply(self, op: Callable, other: Coord.Operand) -> Coord: |
| 43 | + if not isinstance(other, self.__class__): |
| 44 | + other = self.__class__(other, other) |
| 45 | + if isinstance(other, Direction): |
| 46 | + other = other.value |
| 47 | + |
| 48 | + x = op(self.x, other.x) |
| 49 | + y = op(self.y, other.y) |
| 50 | + return self.__class__(x, y) |
| 51 | + |
| 52 | + def midpoint(self, other: Coord) -> Coord: |
| 53 | + """ |
| 54 | + The Coord that is equal distance from `self` and `other`. |
| 55 | +
|
| 56 | + param: |
| 57 | + other: Coord -- The point to consider. |
| 58 | +
|
| 59 | + return: |
| 60 | + Coord -- The resulting coordinate. |
| 61 | + """ |
| 62 | + return (self + other) / 2 |
| 63 | + |
| 64 | + def distance(self, other: Coord) -> int: |
| 65 | + """ |
| 66 | + The Manhattan distance between `self` and `other`. |
| 67 | +
|
| 68 | + param: |
| 69 | + other: Coord -- THe point to consider. |
| 70 | +
|
| 71 | + return: |
| 72 | + int -- A numeric representation of the distance between two points. |
| 73 | + """ |
| 74 | + dist = map(abs, other - self) |
| 75 | + return sum(dist) |
| 76 | + |
| 77 | + __add__ = partialmethod(__apply, operator.add) |
| 78 | + __sub__ = partialmethod(__apply, operator.sub) |
| 79 | + __mul__ = partialmethod(__apply, operator.mul) |
| 80 | + __mod__ = partialmethod(__apply, operator.mod) |
| 81 | + __pow__ = partialmethod(__apply, operator.pow) |
| 82 | + __floordiv__ = partialmethod(__apply, operator.floordiv) |
| 83 | + __truediv__ = partialmethod(__apply, operator.truediv) |
| 84 | + |
| 85 | + |
| 86 | +class Direction(Enum): |
| 87 | + """ |
| 88 | + Defines base directions. Can be used to create Coords relative |
| 89 | + to a direction. |
| 90 | +
|
| 91 | + example:: |
| 92 | +
|
| 93 | + ``` |
| 94 | + start = Coord(1, 1) |
| 95 | + end = start + (Direction.LEFT * 20) |
| 96 | + end |
| 97 | + >>> Coord(x=-19, y=1) |
| 98 | + """ |
| 99 | + LEFT = Coord(-1, 0) |
| 100 | + RIGHT = Coord(1, 0) |
| 101 | + UP = Coord(0, -1) |
| 102 | + DOWN = Coord(0, 1) |
| 103 | + |
| 104 | + def __mul__(self, other: int) -> Coord: |
| 105 | + return self.value * other |
| 106 | + |
| 107 | + def __add__(self, other: Direction) -> Coord: |
| 108 | + if isinstance(other, self.__class__): |
| 109 | + return self.value + other.value |
| 110 | + else: |
| 111 | + return self.value + other |
| 112 | + |
| 113 | + |
| 114 | +class Animater(tk.Canvas): |
| 115 | + """ |
| 116 | + Inherits Canvas. Manager for executing animation move commands. |
| 117 | + Currently only event base animations are supported. |
| 118 | +
|
| 119 | + example:: |
| 120 | +
|
| 121 | + ``` |
| 122 | + motion = Motion(...) |
| 123 | + window = Animator(...) |
| 124 | + window.add_motion(motion) |
| 125 | + window.add_event('<Enter>') |
| 126 | + ``` |
| 127 | + """ |
| 128 | + motions = [] |
| 129 | + |
| 130 | + def add_event(self, event: tk.EventType): |
| 131 | + self.bind(event, self.run) |
| 132 | + |
| 133 | + def run(self, event): |
| 134 | + active = [] |
| 135 | + for motion in self.motions: |
| 136 | + with suppress(StopIteration): |
| 137 | + moves = next(motion) |
| 138 | + for move in moves: |
| 139 | + move() |
| 140 | + self.update_idletasks() |
| 141 | + active.append(motion) |
| 142 | + self.motions = active |
| 143 | + |
| 144 | + def add_motion(self, motion: Motion): |
| 145 | + self.motions.append(iter(motion)) |
| 146 | + |
| 147 | + |
| 148 | +@dataclass |
| 149 | +class Motion: |
| 150 | + """ |
| 151 | + Defines the movements derived from a generated vector. |
| 152 | + The result is a two dimensional generator: the first dimension yields |
| 153 | + a "frame" generator, which in turn yields move commands. This structure allows |
| 154 | + for different `speed`s of motion, as the length of the second |
| 155 | + dimension is determined by `speed`. In other words, the `speed` determines |
| 156 | + how many movements occur in one frame. |
| 157 | +
|
| 158 | + param: |
| 159 | + canvas: tk.Canvas -- The parent canvas to issue the move command with. |
| 160 | + id: int -- The id of the widget to be animated. |
| 161 | + endpoints: Tuple[Coord] -- The final position(s) of the widget. Multiple positions allow for |
| 162 | + more intricate pathing. |
| 163 | + speed: int (optional) -- The multipler for move commands per frame. |
| 164 | + Defaults to 1. |
| 165 | +
|
| 166 | + example:: |
| 167 | +
|
| 168 | + ``` |
| 169 | + root = tk.Tk() |
| 170 | +
|
| 171 | + window = Animater(root) |
| 172 | + window.pack() |
| 173 | +
|
| 174 | + c1 = Coord(50, 55) |
| 175 | + c2 = Coord(60, 65) |
| 176 | + rect = window.create_rectangle(c1, c2) |
| 177 | +
|
| 178 | + end = c1 + Direction.RIGHT * 50 |
| 179 | + end2 = end + Direction.DOWN * 50 |
| 180 | + end3 = end2 + (Direction.UP + Direction.LEFT) * 50 |
| 181 | +
|
| 182 | + animation = Motion(window, rect, (end, end2, end3), speed=1) |
| 183 | +
|
| 184 | + window.add_motion(animation) |
| 185 | + window.add_event('<B1-Motion>') |
| 186 | +
|
| 187 | + root.mainloop() |
| 188 | + ``` |
| 189 | + """ |
| 190 | + canvas: tk.Canvas |
| 191 | + id: int |
| 192 | + endpoints: Tuple[Coord] |
| 193 | + |
| 194 | + steps = 60 |
| 195 | + speed: int = 1 |
| 196 | + |
| 197 | + def start(self) -> Generator[Generator[Callable]]: |
| 198 | + """ |
| 199 | + The entry point for generating move commands. |
| 200 | + """ |
| 201 | + move = partial(self.canvas.move, self.id) |
| 202 | + |
| 203 | + def frame(points: List, last: Coord) -> Generator[Callable]: |
| 204 | + for point in points: |
| 205 | + offset = point - last |
| 206 | + yield partial(move, *offset) |
| 207 | + last = point |
| 208 | + |
| 209 | + for end in self.endpoints: |
| 210 | + start = Coord(*self.canvas.coords(self.id)[:2]) |
| 211 | + vec = vector(start, end, self.steps) |
| 212 | + |
| 213 | + last = vec[0] |
| 214 | + for points in chunked(vec, self.speed): |
| 215 | + yield frame(points, last) |
| 216 | + last = points[-1] |
| 217 | + |
| 218 | + def __iter__(self): |
| 219 | + return self.start() |
| 220 | + |
| 221 | + |
| 222 | +def vector(start: Coord, end: Coord, step: int = 60) -> List[Coord]: |
| 223 | + """ |
| 224 | + Creates a list of all the Coords on a straight line from `start` to `end` (inclusive). |
| 225 | +
|
| 226 | + param: |
| 227 | + start: Coord -- The starting point. |
| 228 | + end: Coord -- The end point. |
| 229 | + step: int (optional) -- The desired number of points to include. Defaults to 60. |
| 230 | + Actual resulting length may vary. |
| 231 | +
|
| 232 | + return: |
| 233 | + List[Coord] -- All points that fall on the line from start to end. |
| 234 | +
|
| 235 | + example:: |
| 236 | +
|
| 237 | + ``` |
| 238 | + start = Coord(0, 5) |
| 239 | + end = Coord(5, 0) |
| 240 | + vector(start, end, 5) # ends up being 8 |
| 241 | + >>> [ |
| 242 | + Coord(x=0, y=5), Coord(x=1, y=4), Coord(x=1, y=4), |
| 243 | + Coord(x=2, y=2), Coord(x=2, y=2), Coord(x=4, y=1), |
| 244 | + Coord(x=4, y=1), Coord(x=5, y=0) |
| 245 | + ] |
| 246 | + ``` |
| 247 | +
|
| 248 | + note: |
| 249 | +
|
| 250 | + The current implementation recursively finds midpoints to build the line. |
| 251 | + This means the resulting length may vary, due to its eager nature. |
| 252 | + """ |
| 253 | + mid = start.midpoint(end) |
| 254 | + instep = round(step / 2) |
| 255 | + if instep: |
| 256 | + back = vector(start, mid, step=instep) |
| 257 | + front = vector(mid, end, step=instep) |
| 258 | + return back + front |
| 259 | + else: |
| 260 | + return [start, end] |
0 commit comments