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.

Files changed (65) hide show
  1. staticfx/__init__.py +3 -0
  2. staticfx/__main__.py +5 -0
  3. staticfx/app/__init__.py +1 -0
  4. staticfx/app/app.py +335 -0
  5. staticfx/app/keymap.py +163 -0
  6. staticfx/app/player.py +143 -0
  7. staticfx/app/session.py +97 -0
  8. staticfx/cli/__init__.py +20 -0
  9. staticfx/cli/commands.py +293 -0
  10. staticfx/cli/parser.py +185 -0
  11. staticfx/core/__init__.py +1 -0
  12. staticfx/core/clock.py +30 -0
  13. staticfx/core/color.py +89 -0
  14. staticfx/core/frame.py +85 -0
  15. staticfx/core/glyphs.py +15 -0
  16. staticfx/core/keys.py +116 -0
  17. staticfx/core/renderer.py +84 -0
  18. staticfx/core/terminal.py +148 -0
  19. staticfx/effects/__init__.py +1 -0
  20. staticfx/effects/base.py +88 -0
  21. staticfx/effects/builtin/__init__.py +203 -0
  22. staticfx/effects/builtin/fields.py +404 -0
  23. staticfx/effects/builtin/geometry.py +77 -0
  24. staticfx/effects/builtin/particles.py +623 -0
  25. staticfx/effects/builtin/shapes.py +255 -0
  26. staticfx/effects/builtin/signals.py +298 -0
  27. staticfx/effects/builtin/simulations.py +439 -0
  28. staticfx/effects/builtin/terminal.py +362 -0
  29. staticfx/effects/curated.py +16 -0
  30. staticfx/effects/engine/__init__.py +11 -0
  31. staticfx/effects/engine/effect.py +267 -0
  32. staticfx/effects/engine/filters.py +115 -0
  33. staticfx/effects/engine/generate.py +203 -0
  34. staticfx/effects/engine/layers.py +396 -0
  35. staticfx/effects/engine/recipe.py +215 -0
  36. staticfx/effects/engine/schema.py +87 -0
  37. staticfx/effects/engine/warps.py +203 -0
  38. staticfx/effects/registry.py +103 -0
  39. staticfx/effects/spec.py +51 -0
  40. staticfx/integrations.py +48 -0
  41. staticfx/library/__init__.py +1 -0
  42. staticfx/library/config.py +122 -0
  43. staticfx/library/effects.py +159 -0
  44. staticfx/library/files.py +44 -0
  45. staticfx/library/legacy.py +180 -0
  46. staticfx/library/looks.py +134 -0
  47. staticfx/postfx.py +78 -0
  48. staticfx/py.typed +0 -0
  49. staticfx/themes.py +162 -0
  50. staticfx/ui/__init__.py +1 -0
  51. staticfx/ui/browser.py +269 -0
  52. staticfx/ui/dialogs.py +95 -0
  53. staticfx/ui/help.py +99 -0
  54. staticfx/ui/pickers.py +221 -0
  55. staticfx/ui/play.py +213 -0
  56. staticfx/ui/screen.py +39 -0
  57. staticfx/ui/settings.py +262 -0
  58. staticfx/ui/studio.py +433 -0
  59. staticfx/ui/widgets.py +196 -0
  60. staticfx-2.0.0.dist-info/METADATA +164 -0
  61. staticfx-2.0.0.dist-info/RECORD +65 -0
  62. staticfx-2.0.0.dist-info/WHEEL +5 -0
  63. staticfx-2.0.0.dist-info/entry_points.txt +2 -0
  64. staticfx-2.0.0.dist-info/licenses/LICENSE +21 -0
  65. 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]
@@ -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."""
@@ -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)]