staticfx 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of staticfx might be problematic. Click here for more details.
- staticfx/__init__.py +3 -0
- staticfx/__main__.py +5 -0
- staticfx/app/__init__.py +1 -0
- staticfx/app/app.py +335 -0
- staticfx/app/keymap.py +163 -0
- staticfx/app/player.py +143 -0
- staticfx/app/session.py +97 -0
- staticfx/cli/__init__.py +20 -0
- staticfx/cli/commands.py +293 -0
- staticfx/cli/parser.py +185 -0
- staticfx/core/__init__.py +1 -0
- staticfx/core/clock.py +30 -0
- staticfx/core/color.py +89 -0
- staticfx/core/frame.py +85 -0
- staticfx/core/glyphs.py +15 -0
- staticfx/core/keys.py +116 -0
- staticfx/core/renderer.py +84 -0
- staticfx/core/terminal.py +148 -0
- staticfx/effects/__init__.py +1 -0
- staticfx/effects/base.py +88 -0
- staticfx/effects/builtin/__init__.py +203 -0
- staticfx/effects/builtin/fields.py +404 -0
- staticfx/effects/builtin/geometry.py +77 -0
- staticfx/effects/builtin/particles.py +623 -0
- staticfx/effects/builtin/shapes.py +255 -0
- staticfx/effects/builtin/signals.py +298 -0
- staticfx/effects/builtin/simulations.py +439 -0
- staticfx/effects/builtin/terminal.py +362 -0
- staticfx/effects/curated.py +16 -0
- staticfx/effects/engine/__init__.py +11 -0
- staticfx/effects/engine/effect.py +267 -0
- staticfx/effects/engine/filters.py +115 -0
- staticfx/effects/engine/generate.py +203 -0
- staticfx/effects/engine/layers.py +396 -0
- staticfx/effects/engine/recipe.py +215 -0
- staticfx/effects/engine/schema.py +87 -0
- staticfx/effects/engine/warps.py +203 -0
- staticfx/effects/registry.py +103 -0
- staticfx/effects/spec.py +51 -0
- staticfx/integrations.py +48 -0
- staticfx/library/__init__.py +1 -0
- staticfx/library/config.py +122 -0
- staticfx/library/effects.py +159 -0
- staticfx/library/files.py +44 -0
- staticfx/library/legacy.py +180 -0
- staticfx/library/looks.py +134 -0
- staticfx/postfx.py +78 -0
- staticfx/py.typed +0 -0
- staticfx/themes.py +162 -0
- staticfx/ui/__init__.py +1 -0
- staticfx/ui/browser.py +269 -0
- staticfx/ui/dialogs.py +95 -0
- staticfx/ui/help.py +99 -0
- staticfx/ui/pickers.py +221 -0
- staticfx/ui/play.py +213 -0
- staticfx/ui/screen.py +39 -0
- staticfx/ui/settings.py +262 -0
- staticfx/ui/studio.py +433 -0
- staticfx/ui/widgets.py +196 -0
- staticfx-2.0.0.dist-info/METADATA +164 -0
- staticfx-2.0.0.dist-info/RECORD +65 -0
- staticfx-2.0.0.dist-info/WHEEL +5 -0
- staticfx-2.0.0.dist-info/entry_points.txt +2 -0
- staticfx-2.0.0.dist-info/licenses/LICENSE +21 -0
- staticfx-2.0.0.dist-info/top_level.txt +1 -0
staticfx/core/color.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""24-bit colour value and the handful of operations effects actually use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import colorsys
|
|
6
|
+
from itertools import pairwise
|
|
7
|
+
from typing import NamedTuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RGB(NamedTuple):
|
|
11
|
+
"""An sRGB colour with 0-255 channels.
|
|
12
|
+
|
|
13
|
+
A NamedTuple rather than a dataclass: colours are created per cell per
|
|
14
|
+
frame, and tuples are the cheapest immutable, hashable value in CPython.
|
|
15
|
+
Callers are expected to pass in-range ints; use :func:`rgb` to clamp.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
r: int
|
|
19
|
+
g: int
|
|
20
|
+
b: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
BLACK = RGB(0, 0, 0)
|
|
24
|
+
WHITE = RGB(255, 255, 255)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def clamp(value: float, low: float = 0.0, high: float = 1.0) -> float:
|
|
28
|
+
return low if value < low else high if value > high else value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def rgb(r: float, g: float, b: float) -> RGB:
|
|
32
|
+
"""Build a colour from arbitrary numbers, rounding and clamping to 0-255."""
|
|
33
|
+
return RGB(
|
|
34
|
+
int(clamp(round(r), 0, 255)), int(clamp(round(g), 0, 255)), int(clamp(round(b), 0, 255))
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def lerp(a: RGB, b: RGB, t: float) -> RGB:
|
|
39
|
+
t = clamp(t)
|
|
40
|
+
return RGB(
|
|
41
|
+
int(a.r + (b.r - a.r) * t + 0.5),
|
|
42
|
+
int(a.g + (b.g - a.g) * t + 0.5),
|
|
43
|
+
int(a.b + (b.b - a.b) * t + 0.5),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def scale(color: RGB, factor: float) -> RGB:
|
|
48
|
+
"""Darken (factor < 1) or brighten (factor > 1) by multiplying each channel."""
|
|
49
|
+
return rgb(color.r * factor, color.g * factor, color.b * factor)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def tint(color: RGB, amount: float) -> RGB:
|
|
53
|
+
"""Mix towards white by ``amount`` (0 = unchanged, 1 = white)."""
|
|
54
|
+
return lerp(color, WHITE, amount)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def hsv(h: float, s: float = 1.0, v: float = 1.0) -> RGB:
|
|
58
|
+
r, g, b = colorsys.hsv_to_rgb(h % 1.0, clamp(s), clamp(v))
|
|
59
|
+
return RGB(int(r * 255 + 0.5), int(g * 255 + 0.5), int(b * 255 + 0.5))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def hue_of(color: RGB) -> float:
|
|
63
|
+
return colorsys.rgb_to_hsv(color.r / 255, color.g / 255, color.b / 255)[0]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def gradient(stops: list[tuple[float, RGB]], t: float) -> RGB:
|
|
67
|
+
"""Sample a piecewise-linear gradient given ``(position, colour)`` stops."""
|
|
68
|
+
t = clamp(t)
|
|
69
|
+
for (p0, c0), (p1, c1) in pairwise(stops):
|
|
70
|
+
if t <= p1:
|
|
71
|
+
return c0 if p1 == p0 else lerp(c0, c1, (t - p0) / (p1 - p0))
|
|
72
|
+
return stops[-1][1]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_hex(text: str) -> RGB:
|
|
76
|
+
"""Parse ``#rgb`` or ``#rrggbb`` (the ``#`` is optional)."""
|
|
77
|
+
value = text.strip().lstrip("#")
|
|
78
|
+
if len(value) == 3:
|
|
79
|
+
value = "".join(ch * 2 for ch in value)
|
|
80
|
+
if len(value) != 6:
|
|
81
|
+
raise ValueError(f"not a hex colour: {text!r}")
|
|
82
|
+
try:
|
|
83
|
+
return RGB(int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16))
|
|
84
|
+
except ValueError:
|
|
85
|
+
raise ValueError(f"not a hex colour: {text!r}") from None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def to_hex(color: RGB) -> str:
|
|
89
|
+
return f"#{color.r:02x}{color.g:02x}{color.b:02x}"
|
staticfx/core/frame.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The drawing surface: a grid of coloured characters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from staticfx.core.color import BLACK, RGB
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Cell:
|
|
9
|
+
__slots__ = ("char", "color")
|
|
10
|
+
|
|
11
|
+
def __init__(self, char: str = " ", color: RGB = BLACK) -> None:
|
|
12
|
+
self.char = char
|
|
13
|
+
self.color = color
|
|
14
|
+
|
|
15
|
+
def __repr__(self) -> str:
|
|
16
|
+
return f"Cell({self.char!r}, {self.color})"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Frame:
|
|
20
|
+
"""A ``width`` x ``height`` grid of :class:`Cell`.
|
|
21
|
+
|
|
22
|
+
A space is "empty": the renderer skips its colour, and effects treat it as
|
|
23
|
+
transparent. Effects write to ``cells`` directly in hot loops; everything
|
|
24
|
+
else should use :meth:`put` and :meth:`text`, which clip to the frame.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, width: int, height: int) -> None:
|
|
28
|
+
self.width = max(1, int(width))
|
|
29
|
+
self.height = max(1, int(height))
|
|
30
|
+
self.cells = self._blank()
|
|
31
|
+
|
|
32
|
+
def _blank(self) -> list[list[Cell]]:
|
|
33
|
+
return [[Cell() for _ in range(self.width)] for _ in range(self.height)]
|
|
34
|
+
|
|
35
|
+
def resize(self, width: int, height: int) -> None:
|
|
36
|
+
width, height = max(1, int(width)), max(1, int(height))
|
|
37
|
+
if (width, height) != (self.width, self.height):
|
|
38
|
+
self.width, self.height = width, height
|
|
39
|
+
self.cells = self._blank()
|
|
40
|
+
|
|
41
|
+
def clear(self) -> None:
|
|
42
|
+
for row in self.cells:
|
|
43
|
+
for cell in row:
|
|
44
|
+
cell.char = " "
|
|
45
|
+
cell.color = BLACK
|
|
46
|
+
|
|
47
|
+
def put(self, x: int, y: int, char: str, color: RGB) -> None:
|
|
48
|
+
if 0 <= x < self.width and 0 <= y < self.height:
|
|
49
|
+
cell = self.cells[y][x]
|
|
50
|
+
cell.char = char
|
|
51
|
+
cell.color = color
|
|
52
|
+
|
|
53
|
+
def char_at(self, x: int, y: int) -> str:
|
|
54
|
+
if 0 <= x < self.width and 0 <= y < self.height:
|
|
55
|
+
return self.cells[y][x].char
|
|
56
|
+
return " "
|
|
57
|
+
|
|
58
|
+
def text(self, x: int, y: int, text: str, color: RGB) -> None:
|
|
59
|
+
"""Write ``text`` starting at ``(x, y)``, clipped to the frame."""
|
|
60
|
+
if not 0 <= y < self.height or x >= self.width:
|
|
61
|
+
return
|
|
62
|
+
row = self.cells[y]
|
|
63
|
+
start = max(0, -x)
|
|
64
|
+
for offset, char in enumerate(text[start : self.width - x], start):
|
|
65
|
+
cell = row[x + offset]
|
|
66
|
+
cell.char = char
|
|
67
|
+
cell.color = color
|
|
68
|
+
|
|
69
|
+
def fill(self, x: int, y: int, width: int, height: int, char: str = " ") -> None:
|
|
70
|
+
"""Fill a rectangle (clipped) with ``char``; used to blank panel backgrounds."""
|
|
71
|
+
for row in self.cells[max(0, y) : max(0, y + height)]:
|
|
72
|
+
for cell in row[max(0, x) : max(0, x + width)]:
|
|
73
|
+
cell.char = char
|
|
74
|
+
|
|
75
|
+
def dim(self, factor: float) -> None:
|
|
76
|
+
"""Darken everything already drawn; used behind modal panels."""
|
|
77
|
+
for row in self.cells:
|
|
78
|
+
for cell in row:
|
|
79
|
+
if cell.char != " ":
|
|
80
|
+
c = cell.color
|
|
81
|
+
cell.color = RGB(int(c.r * factor), int(c.g * factor), int(c.b * factor))
|
|
82
|
+
|
|
83
|
+
def lines(self) -> list[str]:
|
|
84
|
+
"""Plain text of each row, for tests and ``staticfx render``."""
|
|
85
|
+
return ["".join(cell.char for cell in row) for row in self.cells]
|
staticfx/core/glyphs.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Character pools shared by several effects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import string
|
|
6
|
+
|
|
7
|
+
TECH = string.ascii_uppercase + string.digits + "!@#$%^&*<>/\\|=+-_~"
|
|
8
|
+
KATAKANA = "".join(chr(code) for code in range(0xFF66, 0xFF9E)) + string.digits
|
|
9
|
+
BINARY = "01"
|
|
10
|
+
HEX = "0123456789ABCDEF"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def tech_glyphs(unicode: bool) -> str:
|
|
14
|
+
"""The "terminal noise" pool: half-width katakana when Unicode is enabled."""
|
|
15
|
+
return KATAKANA if unicode else TECH
|
staticfx/core/keys.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Keyboard input: raw bytes from the terminal -> key names.
|
|
2
|
+
|
|
3
|
+
Printable characters come back as themselves (``"a"``, ``"?"``). Everything
|
|
4
|
+
else gets a name: ``up``, ``down``, ``left``, ``right``, ``home``, ``end``,
|
|
5
|
+
``pageup``, ``pagedown``, ``delete``, ``enter``, ``tab``, ``shift-tab``,
|
|
6
|
+
``backspace``, ``escape``, ``space`` and ``ctrl-a`` .. ``ctrl-z``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import select
|
|
13
|
+
|
|
14
|
+
_CSI_LETTER = {
|
|
15
|
+
"A": "up",
|
|
16
|
+
"B": "down",
|
|
17
|
+
"C": "right",
|
|
18
|
+
"D": "left",
|
|
19
|
+
"H": "home",
|
|
20
|
+
"F": "end",
|
|
21
|
+
"Z": "shift-tab",
|
|
22
|
+
}
|
|
23
|
+
_CSI_TILDE = {
|
|
24
|
+
"1": "home",
|
|
25
|
+
"2": "insert",
|
|
26
|
+
"3": "delete",
|
|
27
|
+
"4": "end",
|
|
28
|
+
"5": "pageup",
|
|
29
|
+
"6": "pagedown",
|
|
30
|
+
"7": "home",
|
|
31
|
+
"8": "end",
|
|
32
|
+
}
|
|
33
|
+
_CONTROL = {"\r": "enter", "\n": "enter", "\t": "tab", "\x7f": "backspace", "\x08": "backspace"}
|
|
34
|
+
|
|
35
|
+
# How long to wait for the rest of an escape sequence before deciding the
|
|
36
|
+
# user pressed Esc on its own. Terminals send sequences in one write, so this
|
|
37
|
+
# only matters on very slow links.
|
|
38
|
+
ESCAPE_TIMEOUT = 0.03
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def decode(data: str) -> tuple[list[str], str]:
|
|
42
|
+
"""Split ``data`` into key names.
|
|
43
|
+
|
|
44
|
+
Returns ``(keys, rest)`` where ``rest`` is an incomplete escape sequence
|
|
45
|
+
at the end of ``data`` that should be prepended to the next read.
|
|
46
|
+
"""
|
|
47
|
+
keys: list[str] = []
|
|
48
|
+
i, size = 0, len(data)
|
|
49
|
+
while i < size:
|
|
50
|
+
char = data[i]
|
|
51
|
+
if char == "\x1b":
|
|
52
|
+
if i + 1 == size:
|
|
53
|
+
return keys, data[i:]
|
|
54
|
+
if data[i + 1] in "[O":
|
|
55
|
+
end = i + 2
|
|
56
|
+
while end < size and (data[end].isdigit() or data[end] == ";"):
|
|
57
|
+
end += 1
|
|
58
|
+
if end == size:
|
|
59
|
+
return keys, data[i:]
|
|
60
|
+
final, params = data[end], data[i + 2 : end].split(";", maxsplit=1)[0]
|
|
61
|
+
if final == "~":
|
|
62
|
+
keys.append(_CSI_TILDE.get(params, "unknown"))
|
|
63
|
+
else:
|
|
64
|
+
keys.append(_CSI_LETTER.get(final, "unknown"))
|
|
65
|
+
i = end + 1
|
|
66
|
+
continue
|
|
67
|
+
keys.append("escape") # Esc, or Alt+key: the key follows separately
|
|
68
|
+
elif char in _CONTROL:
|
|
69
|
+
keys.append(_CONTROL[char])
|
|
70
|
+
elif char == " ":
|
|
71
|
+
keys.append("space")
|
|
72
|
+
elif "\x01" <= char <= "\x1a":
|
|
73
|
+
keys.append("ctrl-" + chr(ord(char) + 96))
|
|
74
|
+
elif char.isprintable():
|
|
75
|
+
keys.append(char)
|
|
76
|
+
i += 1
|
|
77
|
+
return keys, ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class KeyReader:
|
|
81
|
+
"""Non-blocking reads from a terminal file descriptor."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, fd: int) -> None:
|
|
84
|
+
self.fd = fd
|
|
85
|
+
|
|
86
|
+
def _ready(self, timeout: float) -> bool:
|
|
87
|
+
try:
|
|
88
|
+
return bool(select.select([self.fd], [], [], timeout)[0])
|
|
89
|
+
except (OSError, ValueError):
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def _read(self) -> str:
|
|
93
|
+
try:
|
|
94
|
+
return os.read(self.fd, 1024).decode("utf-8", errors="ignore")
|
|
95
|
+
except OSError:
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
def read(self) -> list[str]:
|
|
99
|
+
"""All keys pressed since the last call (possibly none)."""
|
|
100
|
+
data = ""
|
|
101
|
+
while self._ready(0):
|
|
102
|
+
chunk = self._read()
|
|
103
|
+
if not chunk:
|
|
104
|
+
break
|
|
105
|
+
data += chunk
|
|
106
|
+
if not data:
|
|
107
|
+
return []
|
|
108
|
+
keys, rest = decode(data)
|
|
109
|
+
if rest and self._ready(ESCAPE_TIMEOUT):
|
|
110
|
+
more, rest = decode(rest + self._read())
|
|
111
|
+
keys += more
|
|
112
|
+
if rest == "\x1b": # a lone Esc that nothing followed
|
|
113
|
+
keys.append("escape")
|
|
114
|
+
# Anything else left over is a sequence that never completed; dropping
|
|
115
|
+
# it is better than letting it swallow the next key press.
|
|
116
|
+
return keys
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Frame -> ANSI escape sequences.
|
|
2
|
+
|
|
3
|
+
Each frame is encoded row by row and only rows that changed since the last
|
|
4
|
+
frame are written. Busy effects change nearly every row, so the saving there
|
|
5
|
+
is small (under 10% across the built-ins), but paused effects, slow ones and
|
|
6
|
+
still parts of the screen cost almost nothing to redraw.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from staticfx.core.color import RGB
|
|
12
|
+
from staticfx.core.frame import Cell, Frame
|
|
13
|
+
|
|
14
|
+
ESC = "\x1b"
|
|
15
|
+
RESET = f"{ESC}[0m"
|
|
16
|
+
CLEAR_EOL = f"{ESC}[K"
|
|
17
|
+
|
|
18
|
+
_CUBE = (0, 95, 135, 175, 215, 255)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cube_index(value: int) -> int:
|
|
22
|
+
return min(range(6), key=lambda i: abs(_CUBE[i] - value))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def xterm256(color: RGB) -> int:
|
|
26
|
+
"""Nearest xterm-256 palette index (6x6x6 cube or the grey ramp)."""
|
|
27
|
+
r, g, b = color
|
|
28
|
+
if r == g == b:
|
|
29
|
+
if r < 8:
|
|
30
|
+
return 16
|
|
31
|
+
if r > 248:
|
|
32
|
+
return 231
|
|
33
|
+
return 232 + (r - 8) * 24 // 247
|
|
34
|
+
return 16 + 36 * _cube_index(r) + 6 * _cube_index(g) + _cube_index(b)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Renderer:
|
|
38
|
+
def __init__(self, truecolor: bool = True) -> None:
|
|
39
|
+
self.truecolor = truecolor
|
|
40
|
+
self._sgr: dict[RGB, str] = {}
|
|
41
|
+
self._previous: list[str] = []
|
|
42
|
+
|
|
43
|
+
def invalidate(self) -> None:
|
|
44
|
+
"""Forget what is on screen; the next frame is written in full."""
|
|
45
|
+
self._previous = []
|
|
46
|
+
|
|
47
|
+
def _color(self, color: RGB) -> str:
|
|
48
|
+
sequence = self._sgr.get(color)
|
|
49
|
+
if sequence is None:
|
|
50
|
+
if len(self._sgr) > 20_000: # animated palettes produce endless new colours
|
|
51
|
+
self._sgr.clear()
|
|
52
|
+
if self.truecolor:
|
|
53
|
+
sequence = f"{ESC}[38;2;{color.r};{color.g};{color.b}m"
|
|
54
|
+
else:
|
|
55
|
+
sequence = f"{ESC}[38;5;{xterm256(color)}m"
|
|
56
|
+
self._sgr[color] = sequence
|
|
57
|
+
return sequence
|
|
58
|
+
|
|
59
|
+
def encode_row(self, row: list[Cell]) -> str:
|
|
60
|
+
parts: list[str] = []
|
|
61
|
+
current: RGB | None = None
|
|
62
|
+
for cell in row:
|
|
63
|
+
char = cell.char
|
|
64
|
+
if char != " " and cell.color != current:
|
|
65
|
+
current = cell.color
|
|
66
|
+
parts.append(self._color(current))
|
|
67
|
+
parts.append(char)
|
|
68
|
+
parts.append(RESET)
|
|
69
|
+
parts.append(CLEAR_EOL)
|
|
70
|
+
return "".join(parts)
|
|
71
|
+
|
|
72
|
+
def full(self, frame: Frame) -> str:
|
|
73
|
+
"""The whole frame as one string, with no cursor positioning."""
|
|
74
|
+
return "\n".join(self.encode_row(row) for row in frame.cells)
|
|
75
|
+
|
|
76
|
+
def diff(self, frame: Frame) -> str:
|
|
77
|
+
"""Only the rows that changed since the previous call, with cursor moves."""
|
|
78
|
+
rows = [self.encode_row(row) for row in frame.cells]
|
|
79
|
+
previous = self._previous
|
|
80
|
+
if len(previous) != len(rows):
|
|
81
|
+
previous = [""] * len(rows)
|
|
82
|
+
out = [f"{ESC}[{y + 1};1H{line}" for y, line in enumerate(rows) if line != previous[y]]
|
|
83
|
+
self._previous = rows
|
|
84
|
+
return "".join(out)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Owning the terminal while the app runs.
|
|
2
|
+
|
|
3
|
+
:class:`Terminal` switches to the alternate screen, hides the cursor and puts
|
|
4
|
+
the tty in a raw-ish mode, and guarantees all of that is undone however the
|
|
5
|
+
program exits - normal quit, exception, ``SIGTERM`` - and around a Ctrl-Z
|
|
6
|
+
suspend.
|
|
7
|
+
|
|
8
|
+
Signal generation (ISIG) is turned off, so Ctrl-C and Ctrl-Z arrive as
|
|
9
|
+
ordinary keys. That lets each screen decide what they mean: Ctrl-C in the
|
|
10
|
+
Studio asks before discarding work instead of killing the process.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import signal
|
|
18
|
+
import sys
|
|
19
|
+
from types import FrameType
|
|
20
|
+
from typing import Any, TextIO
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import termios
|
|
24
|
+
except ImportError: # Windows
|
|
25
|
+
termios = None # type: ignore[assignment]
|
|
26
|
+
|
|
27
|
+
ESC = "\x1b"
|
|
28
|
+
ALT_SCREEN_ON = f"{ESC}[?1049h"
|
|
29
|
+
ALT_SCREEN_OFF = f"{ESC}[?1049l"
|
|
30
|
+
CURSOR_HIDE = f"{ESC}[?25l"
|
|
31
|
+
CURSOR_SHOW = f"{ESC}[?25h"
|
|
32
|
+
WRAP_OFF = f"{ESC}[?7l"
|
|
33
|
+
WRAP_ON = f"{ESC}[?7h"
|
|
34
|
+
CLEAR = f"{ESC}[2J{ESC}[H"
|
|
35
|
+
RESET = f"{ESC}[0m"
|
|
36
|
+
|
|
37
|
+
# Indexes into the list returned by termios.tcgetattr().
|
|
38
|
+
_IFLAG, _LFLAG, _CC = 0, 3, 6
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def terminal_size() -> tuple[int, int]:
|
|
42
|
+
"""Current ``(columns, lines)``.
|
|
43
|
+
|
|
44
|
+
Asks the tty directly: ``shutil.get_terminal_size`` prefers the
|
|
45
|
+
``COLUMNS``/``LINES`` environment variables, which go stale on resize.
|
|
46
|
+
"""
|
|
47
|
+
for stream in (sys.stdout, sys.stderr, sys.stdin):
|
|
48
|
+
try:
|
|
49
|
+
size = os.get_terminal_size(stream.fileno())
|
|
50
|
+
except (OSError, ValueError, AttributeError):
|
|
51
|
+
continue
|
|
52
|
+
if size.columns > 0 and size.lines > 0:
|
|
53
|
+
return size.columns, size.lines
|
|
54
|
+
size = shutil.get_terminal_size((80, 24))
|
|
55
|
+
return max(1, size.columns), max(1, size.lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_interactive() -> bool:
|
|
59
|
+
try:
|
|
60
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
61
|
+
except (AttributeError, ValueError):
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def supported() -> bool:
|
|
66
|
+
"""Interactive mode needs POSIX terminal control (Linux, macOS, WSL)."""
|
|
67
|
+
return termios is not None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Terminal:
|
|
71
|
+
def __init__(self, out: TextIO | None = None, fd: int | None = None) -> None:
|
|
72
|
+
self.out = out or sys.stdout
|
|
73
|
+
self.fd = sys.stdin.fileno() if fd is None else fd
|
|
74
|
+
self.resized = True # the screen needs a full redraw (set on start and after fg)
|
|
75
|
+
self._saved_mode: list | None = None
|
|
76
|
+
self._saved_handlers: dict[int, Any] = {}
|
|
77
|
+
self._active = False
|
|
78
|
+
|
|
79
|
+
def __enter__(self) -> Terminal:
|
|
80
|
+
self._saved_mode = termios.tcgetattr(self.fd)
|
|
81
|
+
self._install_handlers()
|
|
82
|
+
self._activate()
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
86
|
+
self._deactivate()
|
|
87
|
+
for signum, handler in self._saved_handlers.items():
|
|
88
|
+
signal.signal(signum, handler)
|
|
89
|
+
self._saved_handlers.clear()
|
|
90
|
+
|
|
91
|
+
def _activate(self) -> None:
|
|
92
|
+
mode = termios.tcgetattr(self.fd)
|
|
93
|
+
mode[_IFLAG] &= ~(termios.IXON | termios.ICRNL)
|
|
94
|
+
mode[_LFLAG] &= ~(termios.ECHO | termios.ICANON | termios.ISIG | termios.IEXTEN)
|
|
95
|
+
mode[_CC][termios.VMIN] = 1
|
|
96
|
+
mode[_CC][termios.VTIME] = 0
|
|
97
|
+
termios.tcsetattr(self.fd, termios.TCSAFLUSH, mode)
|
|
98
|
+
self.write(ALT_SCREEN_ON + CURSOR_HIDE + WRAP_OFF + CLEAR)
|
|
99
|
+
self._active = True
|
|
100
|
+
self.resized = True
|
|
101
|
+
|
|
102
|
+
def _deactivate(self) -> None:
|
|
103
|
+
if not self._active:
|
|
104
|
+
return
|
|
105
|
+
self._active = False
|
|
106
|
+
self.write(RESET + WRAP_ON + CURSOR_SHOW + ALT_SCREEN_OFF)
|
|
107
|
+
if self._saved_mode is not None:
|
|
108
|
+
termios.tcsetattr(self.fd, termios.TCSADRAIN, self._saved_mode)
|
|
109
|
+
|
|
110
|
+
def _install_handlers(self) -> None:
|
|
111
|
+
wanted = {
|
|
112
|
+
signal.SIGTERM: self._on_terminate,
|
|
113
|
+
signal.SIGHUP: self._on_terminate,
|
|
114
|
+
signal.SIGTSTP: self._on_stop,
|
|
115
|
+
signal.SIGCONT: self._on_continue,
|
|
116
|
+
}
|
|
117
|
+
for signum, handler in wanted.items():
|
|
118
|
+
self._saved_handlers[signum] = signal.signal(signum, handler)
|
|
119
|
+
|
|
120
|
+
def _on_terminate(self, signum: int, _frame: FrameType | None) -> None:
|
|
121
|
+
raise SystemExit(128 + signum)
|
|
122
|
+
|
|
123
|
+
def _on_stop(self, _signum: int, _frame: FrameType | None) -> None:
|
|
124
|
+
self.suspend()
|
|
125
|
+
|
|
126
|
+
def _on_continue(self, _signum: int, _frame: FrameType | None) -> None:
|
|
127
|
+
if not self._active:
|
|
128
|
+
self._activate()
|
|
129
|
+
|
|
130
|
+
def suspend(self) -> None:
|
|
131
|
+
"""Hand the terminal back to the shell and stop, like Ctrl-Z in any TUI.
|
|
132
|
+
|
|
133
|
+
Execution continues here after ``fg``; the SIGCONT handler has
|
|
134
|
+
restored the screen by then.
|
|
135
|
+
"""
|
|
136
|
+
self._deactivate()
|
|
137
|
+
signal.signal(signal.SIGTSTP, signal.SIG_DFL)
|
|
138
|
+
os.kill(os.getpid(), signal.SIGTSTP)
|
|
139
|
+
signal.signal(signal.SIGTSTP, self._on_stop)
|
|
140
|
+
if not self._active: # SIGCONT can arrive before the handler is reinstalled
|
|
141
|
+
self._activate()
|
|
142
|
+
|
|
143
|
+
def write(self, data: str) -> None:
|
|
144
|
+
self.out.write(data)
|
|
145
|
+
self.out.flush()
|
|
146
|
+
|
|
147
|
+
def size(self) -> tuple[int, int]:
|
|
148
|
+
return terminal_size()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Effects: the base class, the hand-written built-ins, the procedural engine and the registry."""
|
staticfx/effects/base.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The interface every effect implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
|
|
7
|
+
from staticfx.core.color import RGB
|
|
8
|
+
from staticfx.core.frame import Frame
|
|
9
|
+
from staticfx.themes import Theme
|
|
10
|
+
|
|
11
|
+
PALETTE_STEPS = 48
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Effect:
|
|
15
|
+
"""Base class for all effects.
|
|
16
|
+
|
|
17
|
+
Subclasses override any of:
|
|
18
|
+
|
|
19
|
+
* :meth:`on_resize` - rebuild size-dependent state (``width``/``height``
|
|
20
|
+
are already set). Also called by the default :meth:`on_reset`.
|
|
21
|
+
* :meth:`on_reset` - restart the animation from scratch.
|
|
22
|
+
* :meth:`advance` - move the simulation forward ``dt`` seconds.
|
|
23
|
+
* :meth:`render` - draw into the frame. Spaces are transparent, so draw
|
|
24
|
+
only what is visible.
|
|
25
|
+
|
|
26
|
+
The app calls the public :meth:`resize`, :meth:`reset`, :meth:`update`
|
|
27
|
+
and :meth:`draw`, which guarantee ``advance`` and ``render`` are never
|
|
28
|
+
called before the first resize or with a non-positive ``dt``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
#: Restrict output to 7-bit ASCII. Set by the registry after construction.
|
|
32
|
+
ascii_only = False
|
|
33
|
+
#: May trade resolution for frame rate. Set by the interactive app; effects
|
|
34
|
+
#: that have nothing to trade ignore it.
|
|
35
|
+
adaptive = False
|
|
36
|
+
|
|
37
|
+
def __init__(self, rng: random.Random, unicode: bool = False) -> None:
|
|
38
|
+
self.rng = rng
|
|
39
|
+
self.unicode = unicode # allow katakana and other non-ASCII glyphs
|
|
40
|
+
self.width = 0
|
|
41
|
+
self.height = 0
|
|
42
|
+
self._palette_theme: Theme | None = None
|
|
43
|
+
self._palette: list[RGB] = []
|
|
44
|
+
|
|
45
|
+
def resize(self, width: int, height: int) -> None:
|
|
46
|
+
self.width, self.height = max(1, int(width)), max(1, int(height))
|
|
47
|
+
self.on_resize()
|
|
48
|
+
|
|
49
|
+
def reset(self) -> None:
|
|
50
|
+
self.on_reset()
|
|
51
|
+
|
|
52
|
+
def update(self, dt: float) -> None:
|
|
53
|
+
if dt > 0.0 and self.width:
|
|
54
|
+
self.advance(dt)
|
|
55
|
+
|
|
56
|
+
def draw(self, frame: Frame, theme: Theme) -> None:
|
|
57
|
+
if self.width:
|
|
58
|
+
self.render(frame, theme)
|
|
59
|
+
|
|
60
|
+
def on_resize(self) -> None:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
def on_reset(self) -> None:
|
|
64
|
+
if self.width:
|
|
65
|
+
self.on_resize()
|
|
66
|
+
|
|
67
|
+
def advance(self, dt: float) -> None:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
def palette(self, theme: Theme) -> list[RGB]:
|
|
74
|
+
"""``PALETTE_STEPS`` colours sampled along the theme ramp (cached)."""
|
|
75
|
+
if theme is not self._palette_theme:
|
|
76
|
+
last = PALETTE_STEPS - 1
|
|
77
|
+
self._palette = [theme.ramp(i / last) for i in range(PALETTE_STEPS)]
|
|
78
|
+
self._palette_theme = theme
|
|
79
|
+
return self._palette
|
|
80
|
+
|
|
81
|
+
def color_at(self, theme: Theme, value: float) -> RGB:
|
|
82
|
+
"""Theme colour for a brightness in 0..1."""
|
|
83
|
+
palette = self.palette(theme)
|
|
84
|
+
if value <= 0.0:
|
|
85
|
+
return palette[0]
|
|
86
|
+
if value >= 1.0:
|
|
87
|
+
return palette[-1]
|
|
88
|
+
return palette[int(value * (PALETTE_STEPS - 1) + 0.5)]
|