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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """StaticFX: animated ASCII effects for your terminal."""
2
+
3
+ __version__ = "2.0.0"
staticfx/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """``python -m staticfx``."""
2
+
3
+ from staticfx.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """The interactive application: state, key map, player and run loop."""
staticfx/app/app.py ADDED
@@ -0,0 +1,335 @@
1
+ """The application: settings, the user's library, the player and the screen stack.
2
+
3
+ Nothing here touches the terminal (see ``session.py``), so the whole app can
4
+ be driven key by key in tests.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import random
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from staticfx import themes
15
+ from staticfx.app.keymap import Keymap
16
+ from staticfx.app.player import Player
17
+ from staticfx.core.frame import Frame
18
+ from staticfx.effects.engine import describe
19
+ from staticfx.effects.registry import BUILTIN, USER, USER_CATEGORY, Registry
20
+ from staticfx.effects.spec import EffectSpec, recipe_spec
21
+ from staticfx.library import config
22
+ from staticfx.library.config import Settings
23
+ from staticfx.library.effects import EffectLibrary, UserEffect
24
+ from staticfx.library.looks import Look, LookStore
25
+ from staticfx.ui.play import PlayScreen
26
+ from staticfx.ui.screen import Screen
27
+ from staticfx.ui.widgets import ASCII, UNICODE, Canvas, Style, fit
28
+
29
+ DEFAULT_EFFECT = "matrix"
30
+ RECENT_LIMIT = 30
31
+ MIN_WIDTH, MIN_HEIGHT = 40, 12
32
+
33
+
34
+ class App:
35
+ def __init__(
36
+ self,
37
+ settings: Settings | None = None,
38
+ data_dir: Path | None = None,
39
+ *,
40
+ utf8: bool = True,
41
+ persist_settings: bool = True,
42
+ registry: Registry | None = None,
43
+ rng: random.Random | None = None,
44
+ ) -> None:
45
+ self.settings = settings or Settings()
46
+ self.data_dir = data_dir
47
+ # False for one-off commands and --no-save: nothing writes config.json or
48
+ # runs one-time migrations, so those only happen in a real session.
49
+ self.persist_settings = persist_settings and data_dir is not None
50
+ self.utf8 = utf8 # can the terminal show non-ASCII characters at all?
51
+ self.ascii_only, unicode = self._characters()
52
+ self.chrome = ASCII if self.ascii_only else UNICODE
53
+ self.rng = rng or random.Random()
54
+ self.registry = registry or Registry()
55
+ self.library = EffectLibrary(data_dir / "effects") if data_dir else None
56
+ self.looks = LookStore(data_dir / "looks.json" if data_dir else None)
57
+ self.keymap = Keymap()
58
+ self.notes: list[str] = [] # problems found while loading, shown once at startup
59
+ self._session_only: dict[str, Any] = {} # setting -> value to save instead
60
+
61
+ self.player = Player(self.registry, self.rng, unicode=unicode, ascii_only=self.ascii_only)
62
+ self.player.speed = self.settings.speed
63
+ self.player.post.crt = self.settings.crt
64
+ self.player.post.glitch = self.settings.glitch
65
+ self.player.post.recolour = self.settings.recolour
66
+
67
+ self.screens: list[Screen] = []
68
+ self.running = True
69
+ self.suspend_requested = False
70
+ self.dirty = False # settings changed since the last save
71
+ self.toast_text = ""
72
+ self.toast_time = 0.0
73
+ self.auto = False
74
+ self.auto_timer = 0.0
75
+ self.show_status = False
76
+ self.width, self.height = 80, 24
77
+
78
+ def start(self) -> None:
79
+ """Load the user's things and pick the first effect. Call once, before running."""
80
+ self.notes += self.keymap.apply_changes(self.settings.keybinds)
81
+ self.notes += self.looks.load()
82
+ if self.library is not None and self.data_dir is not None:
83
+ if self.persist_settings and not self.settings.imported_plugins:
84
+ self._import_plugins(self.library, self.data_dir / "plugins")
85
+ effects, problems = self.library.load()
86
+ self.notes += [f"skipped {problem}" for problem in problems]
87
+ for effect in effects:
88
+ self.registry.put_user(self.user_spec(effect))
89
+
90
+ self.settings.favorites = [k for k in self.settings.favorites if k in self.registry]
91
+ self.settings.recent = [k for k in self.settings.recent if k in self.registry]
92
+ if self.settings.theme == "strobe" and not self.settings.allow_flashing:
93
+ self.settings.theme = themes.DEFAULT
94
+ self.player.set_theme(self.settings.theme)
95
+
96
+ try:
97
+ self.play(self.registry.resolve(self.settings.effect), announce=False)
98
+ except KeyError:
99
+ self.notes.append(f"'{self.settings.effect}' no longer exists; playing Matrix Rain")
100
+ self.play(DEFAULT_EFFECT, announce=False)
101
+ self.screens = [PlayScreen(self)]
102
+
103
+ def override(self, name: str, value: Any) -> None:
104
+ """Change a setting for this session only, as command-line options do."""
105
+ self._session_only.setdefault(name, getattr(self.settings, name))
106
+ setattr(self.settings, name, value)
107
+
108
+ def keep(self, name: str) -> None:
109
+ """Make a session-only setting permanent (the user changed it in Settings)."""
110
+ self._session_only.pop(name, None)
111
+
112
+ def _import_plugins(self, library: EffectLibrary, folder: Path) -> None:
113
+ """One-time copy of 1.x plugin files into My Effects."""
114
+ self.settings.imported_plugins = True
115
+ self.dirty = True
116
+ if not folder.is_dir():
117
+ return
118
+ count, problems = library.import_legacy_plugins(folder)
119
+ self.notes += problems
120
+ if count:
121
+ self.notes.append(f"Copied {count} effect(s) from the old plugins folder to My Effects")
122
+ self.save_settings() # record it now, so a crash can't make it copy them twice
123
+
124
+ def _characters(self) -> tuple[bool, bool]:
125
+ """(ascii_only, katakana) from the setting and what the terminal supports."""
126
+ ascii_only = self.settings.characters == "ascii" or not self.utf8
127
+ return ascii_only, self.settings.characters == "katakana" and not ascii_only
128
+
129
+ def apply_characters(self) -> None:
130
+ """Rebuild what's on screen after the Characters setting changed."""
131
+ self.ascii_only, unicode = self._characters()
132
+ self.chrome = ASCII if self.ascii_only else UNICODE
133
+ self.player.ascii_only, self.player.unicode = self.ascii_only, unicode
134
+ self.player.reload()
135
+
136
+ def user_spec(self, effect: UserEffect) -> EffectSpec:
137
+ description = effect.description or describe(effect.recipe)
138
+ return recipe_spec(effect.key, effect.name, description, USER_CATEGORY, USER, effect.recipe)
139
+
140
+ @property
141
+ def top(self) -> Screen:
142
+ return self.screens[-1]
143
+
144
+ def push(self, screen: Screen) -> None:
145
+ self.screens.append(screen)
146
+
147
+ def pop(self, screen: Screen | None = None) -> None:
148
+ """Remove ``screen`` (default: the top one). The play screen can't be removed."""
149
+ screen = screen or self.top
150
+ if screen in self.screens[1:]:
151
+ self.screens.remove(screen)
152
+ screen.on_close()
153
+
154
+ def handle_key(self, key: str) -> None:
155
+ if key == "ctrl-z":
156
+ self.suspend_requested = True
157
+ elif self.too_small and key in ("q", "ctrl-c"):
158
+ self.running = False
159
+ else:
160
+ self.top.handle(key)
161
+
162
+ @property
163
+ def too_small(self) -> bool:
164
+ return self.width < MIN_WIDTH or self.height < MIN_HEIGHT
165
+
166
+ def play(self, key: str, announce: bool = True) -> None:
167
+ self.player.play(key)
168
+ self.settings.effect = key
169
+ recent = self.settings.recent
170
+ if key in recent:
171
+ recent.remove(key)
172
+ recent.insert(0, key)
173
+ del recent[RECENT_LIMIT:]
174
+ self.dirty = True
175
+ self.auto_timer = self.settings.auto_seconds
176
+ if announce:
177
+ self.toast(self.registry[key].label)
178
+
179
+ def step(self, delta: int) -> None:
180
+ keys = self.registry.keys()
181
+ index = keys.index(self.player.key) if self.player.key in keys else 0
182
+ self.play(keys[(index + delta) % len(keys)])
183
+
184
+ def play_random(self) -> None:
185
+ choices = [k for k in self.auto_pool() if k != self.player.key and k in self.registry]
186
+ if choices:
187
+ self.play(self.rng.choice(choices))
188
+ else:
189
+ self.step(1)
190
+
191
+ def play_previous(self) -> None:
192
+ if len(self.settings.recent) < 2:
193
+ self.toast("Nothing to go back to yet")
194
+ else:
195
+ self.play(self.settings.recent[1])
196
+
197
+ def auto_pool(self) -> list[str]:
198
+ """What auto-cycle and random pick from: favourites if you have a few,
199
+ otherwise the built-ins plus the Gallery of the best generated effects."""
200
+ if len(self.settings.favorites) >= 2:
201
+ return list(self.settings.favorites)
202
+ builtins = [s.key for s in self.registry.specs() if s.kind == BUILTIN]
203
+ return builtins + list(self.registry.gallery)
204
+
205
+ def toggle_auto(self) -> None:
206
+ self.auto = not self.auto
207
+ self.auto_timer = self.settings.auto_seconds
208
+ source = "favourites" if len(self.settings.favorites) >= 2 else "built-ins and Gallery"
209
+ state = f"every {self.settings.auto_seconds:g}s from {source}" if self.auto else "off"
210
+ self.toast(f"Auto-cycle {state}")
211
+
212
+ def is_favorite(self, key: str) -> bool:
213
+ return key in self.settings.favorites
214
+
215
+ def toggle_favorite(self, key: str) -> bool:
216
+ """Returns True if ``key`` is now a favourite."""
217
+ favorites = self.settings.favorites
218
+ added = key not in favorites
219
+ if added:
220
+ favorites.append(key)
221
+ else:
222
+ favorites.remove(key)
223
+ self.dirty = True
224
+ return added
225
+
226
+ def set_theme(self, name: str) -> None:
227
+ self.player.set_theme(name)
228
+ self.settings.theme = self.player.theme_name
229
+ self.dirty = True
230
+
231
+ def theme_names(self) -> list[str]:
232
+ return themes.names(self.settings.allow_flashing)
233
+
234
+ def current_look(self, name: str) -> Look:
235
+ post = self.player.post
236
+ return Look(
237
+ name=name,
238
+ effect=self.player.key,
239
+ theme=self.player.theme_name,
240
+ speed=round(self.player.speed, 2),
241
+ crt=post.crt,
242
+ glitch=post.glitch,
243
+ recolour=post.recolour,
244
+ )
245
+
246
+ def apply_look(self, look: Look) -> None:
247
+ if look.effect not in self.registry:
248
+ self.toast(f"'{look.name}' uses an effect that no longer exists")
249
+ return
250
+ if look.theme == "strobe" and not self.settings.allow_flashing:
251
+ settings = self.keymap.label("settings", self.ascii_only)
252
+ self.toast(f"That look uses Strobe; allow flashing in Settings ({settings}) first")
253
+ return
254
+ self.set_theme(look.theme)
255
+ self.player.speed = look.speed
256
+ post = self.player.post
257
+ post.crt, post.glitch, post.recolour = look.crt, look.glitch, look.recolour
258
+ self.play(look.effect, announce=False)
259
+ self.toast(f"Look: {look.name}")
260
+
261
+ def save_effect(self, effect: UserEffect) -> bool:
262
+ if self.library is None:
263
+ self.toast("Can't save: StaticFX is running without a data folder")
264
+ return False
265
+ try:
266
+ self.library.save(effect)
267
+ except OSError as exc:
268
+ self.toast(f"Couldn't save: {exc.strerror or exc}")
269
+ return False
270
+ self.registry.put_user(self.user_spec(effect))
271
+ return True
272
+
273
+ def delete_effect(self, key: str) -> None:
274
+ if self.library is not None:
275
+ self.library.delete(key.removeprefix("my_"))
276
+ self.registry.remove_user(key)
277
+ for keys in (self.settings.favorites, self.settings.recent):
278
+ if key in keys:
279
+ keys.remove(key)
280
+ self.dirty = True
281
+ if self.player.key == key:
282
+ self.play(DEFAULT_EFFECT, announce=False)
283
+
284
+ def toast(self, text: str, seconds: float = 2.0) -> None:
285
+ self.toast_text, self.toast_time = text, seconds
286
+
287
+ def resize(self, width: int, height: int) -> None:
288
+ self.width, self.height = width, height
289
+ self.player.resize(width, height)
290
+
291
+ def update(self, dt: float) -> None:
292
+ self.player.update(dt)
293
+ for screen in self.screens:
294
+ screen.update(dt)
295
+ self.toast_time = max(0.0, self.toast_time - dt)
296
+ if self.auto and len(self.screens) == 1:
297
+ self.auto_timer -= dt
298
+ if self.auto_timer <= 0:
299
+ self.play_random()
300
+
301
+ def draw(self, frame: Frame) -> None:
302
+ theme = self.player.draw(frame)
303
+ canvas = Canvas(frame, self.chrome, Style.from_theme(theme))
304
+ if self.too_small:
305
+ self._draw_too_small(canvas)
306
+ return
307
+ for screen in self.screens:
308
+ screen.draw(canvas)
309
+ if self.toast_time > 0 and self.toast_text:
310
+ text = f" {fit(self.toast_text, frame.width - 4, self.chrome.ellipsis)} "
311
+ canvas.text(frame.width - len(text) - 1, frame.height - 1, text, canvas.style.accent)
312
+
313
+ def _draw_too_small(self, canvas: Canvas) -> None:
314
+ lines = ["Terminal too small", f"need {MIN_WIDTH}x{MIN_HEIGHT}", "(q quits)"]
315
+ top = max(0, canvas.height // 2 - 1)
316
+ for i, line in enumerate(lines):
317
+ line = fit(line, canvas.width, "")
318
+ canvas.text(max(0, (canvas.width - len(line)) // 2), top + i, line, canvas.style.title)
319
+
320
+ def save_settings(self) -> None:
321
+ """Write ``config.json`` if anything changed. Failures are reported, not raised."""
322
+ if not self.dirty:
323
+ return
324
+ self.dirty = False
325
+ settings = self.settings
326
+ post = self.player.post
327
+ settings.speed = round(self.player.speed, 2)
328
+ settings.crt, settings.glitch, settings.recolour = post.crt, post.glitch, post.recolour
329
+ settings.keybinds = self.keymap.changes()
330
+ if self.persist_settings and self.data_dir is not None:
331
+ try:
332
+ saved = dataclasses.replace(settings, **self._session_only)
333
+ config.save(saved, self.data_dir / "config.json")
334
+ except OSError as exc:
335
+ self.toast(f"Couldn't save settings: {exc.strerror or exc}")
staticfx/app/keymap.py ADDED
@@ -0,0 +1,163 @@
1
+ """Key bindings for the main view.
2
+
3
+ There is one table of actions (shown in this order in the help screen) and
4
+ one table of default keys. Only the user's changes are saved, so a future
5
+ release can change a default without old config files pinning the old one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ # action -> (group, description)
13
+ ACTIONS: dict[str, tuple[str, str]] = {
14
+ "next": ("Browse", "Next effect"),
15
+ "previous": ("Browse", "Previous effect"),
16
+ "random": ("Browse", "Random effect"),
17
+ "back": ("Browse", "Back to the last effect"),
18
+ "auto": ("Browse", "Auto-cycle through effects"),
19
+ "browse": ("Menus", "Browse and search effects"),
20
+ "themes": ("Menus", "Choose a theme"),
21
+ "looks": ("Menus", "Saved looks"),
22
+ "settings": ("Menus", "Settings and key bindings"),
23
+ "help": ("Menus", "All keys"),
24
+ "new_effect": ("Create", "New effect in the Studio"),
25
+ "edit_effect": ("Create", "Edit this effect in the Studio"),
26
+ "favorite": ("Create", "Add to / remove from favourites"),
27
+ "save_look": ("Create", "Save effect + theme + filters as a look"),
28
+ "next_theme": ("Look", "Next theme"),
29
+ "crt": ("Look", "CRT scanlines filter"),
30
+ "glitch": ("Look", "Glitch filter"),
31
+ "recolour": ("Look", "Rainbow recolour filter"),
32
+ "pause": ("Playback", "Pause"),
33
+ "restart": ("Playback", "Restart the effect"),
34
+ "faster": ("Playback", "Faster"),
35
+ "slower": ("Playback", "Slower"),
36
+ "status": ("Playback", "Show or hide the status bar"),
37
+ "quit": ("Playback", "Quit"),
38
+ }
39
+
40
+ DEFAULT_KEYS: dict[str, str] = {
41
+ "right": "next",
42
+ "space": "next",
43
+ "left": "previous",
44
+ "x": "random",
45
+ "b": "back",
46
+ "a": "auto",
47
+ "m": "browse",
48
+ "enter": "browse",
49
+ "t": "themes",
50
+ "l": "looks",
51
+ "o": "settings",
52
+ "?": "help",
53
+ "h": "help",
54
+ "n": "new_effect",
55
+ "e": "edit_effect",
56
+ "f": "favorite",
57
+ "s": "save_look",
58
+ "c": "next_theme",
59
+ "v": "crt",
60
+ "g": "glitch",
61
+ "w": "recolour",
62
+ "p": "pause",
63
+ "z": "restart",
64
+ "up": "faster",
65
+ "+": "faster",
66
+ "=": "faster",
67
+ "down": "slower",
68
+ "-": "slower",
69
+ "i": "status",
70
+ "q": "quit",
71
+ }
72
+
73
+ # Keys that always mean the same thing and can't be rebound: Esc and Ctrl-C
74
+ # back out of anything, Ctrl-Z suspends.
75
+ RESERVED = frozenset({"escape", "ctrl-c", "ctrl-z"})
76
+ UNBOUND = "none"
77
+
78
+ KEY_LABELS = {
79
+ "left": "←",
80
+ "right": "→",
81
+ "up": "↑",
82
+ "down": "↓",
83
+ "space": "Space",
84
+ "enter": "Enter",
85
+ "tab": "Tab",
86
+ "escape": "Esc",
87
+ "backspace": "Backspace",
88
+ "delete": "Del",
89
+ "pageup": "PgUp",
90
+ "pagedown": "PgDn",
91
+ "home": "Home",
92
+ "end": "End",
93
+ }
94
+ ASCII_ARROWS = {"left": "Left", "right": "Right", "up": "Up", "down": "Down"}
95
+
96
+
97
+ def key_label(key: str, ascii_only: bool = False) -> str:
98
+ """How a key is written for people: ``→``, ``Ctrl-E``, ``f``.
99
+
100
+ Letters are shown in lower case so nobody reaches for Shift.
101
+ """
102
+ if ascii_only and key in ASCII_ARROWS:
103
+ return ASCII_ARROWS[key]
104
+ if key in KEY_LABELS:
105
+ return KEY_LABELS[key]
106
+ if key.startswith("ctrl-"):
107
+ return "Ctrl-" + key[5:].upper()
108
+ return key
109
+
110
+
111
+ @dataclass
112
+ class Keymap:
113
+ bindings: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_KEYS))
114
+
115
+ def action(self, key: str) -> str | None:
116
+ """The action for ``key``. Letters work with or without Shift (or Caps Lock)
117
+ unless the upper-case letter has a binding of its own."""
118
+ action = self.bindings.get(key)
119
+ if action is None and len(key) == 1 and key.isupper():
120
+ action = self.bindings.get(key.lower())
121
+ return None if action in (None, UNBOUND) else action
122
+
123
+ def keys_for(self, action: str) -> list[str]:
124
+ """Keys bound to ``action``, defaults first so labels stay familiar."""
125
+ order = list(DEFAULT_KEYS)
126
+ keys = [k for k, a in self.bindings.items() if a == action]
127
+ return sorted(keys, key=lambda k: order.index(k) if k in order else len(order))
128
+
129
+ def label(self, action: str, ascii_only: bool = False) -> str:
130
+ """The first key for ``action``, for hints like "press m to browse"."""
131
+ keys = self.keys_for(action)
132
+ return key_label(keys[0], ascii_only) if keys else "(unbound)"
133
+
134
+ def bind(self, key: str, action: str) -> str | None:
135
+ """Bind ``key`` to ``action``. Returns the action that lost the key, if any."""
136
+ if key in RESERVED:
137
+ raise ValueError(f"{key_label(key)} is reserved")
138
+ if action != UNBOUND and action not in ACTIONS:
139
+ raise ValueError(f"unknown action '{action}'")
140
+ previous = self.action(key)
141
+ self.bindings[key] = action
142
+ return previous if previous not in (None, action) else None
143
+
144
+ def unbind(self, action: str) -> None:
145
+ for key in self.keys_for(action):
146
+ self.bindings[key] = UNBOUND
147
+
148
+ def reset(self) -> None:
149
+ self.bindings = dict(DEFAULT_KEYS)
150
+
151
+ def changes(self) -> dict[str, str]:
152
+ """What differs from the defaults: all that gets saved."""
153
+ return {k: a for k, a in self.bindings.items() if DEFAULT_KEYS.get(k, UNBOUND) != a}
154
+
155
+ def apply_changes(self, changes: dict[str, str]) -> list[str]:
156
+ """Apply saved changes; returns a warning for each one that was invalid."""
157
+ warnings = []
158
+ for key, action in changes.items():
159
+ try:
160
+ self.bind(key, action)
161
+ except ValueError as exc:
162
+ warnings.append(f"key binding '{key}': {exc}")
163
+ return warnings
staticfx/app/player.py ADDED
@@ -0,0 +1,143 @@
1
+ """What is on screen: the current effect, theme, speed and filters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from collections import OrderedDict
7
+
8
+ from staticfx import themes
9
+ from staticfx.core.frame import Frame
10
+ from staticfx.effects.base import Effect
11
+ from staticfx.effects.registry import Registry
12
+ from staticfx.postfx import PostFX
13
+ from staticfx.themes import Strobe, Theme
14
+
15
+ SPEED_MIN, SPEED_MAX = 0.1, 5.0
16
+ SPEED_STEP = 1.2
17
+ RAINBOW_SPEED = 0.18 # hue turns per second
18
+ PREVIEW_CACHE = 6
19
+
20
+
21
+ class Player:
22
+ """Owns the playing effect and anything that changes how it looks.
23
+
24
+ Menus can *preview* an effect or theme without committing to it: the
25
+ preview is drawn instead of the real choice until it is cleared.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ registry: Registry,
31
+ rng: random.Random,
32
+ *,
33
+ unicode: bool = False,
34
+ ascii_only: bool = False,
35
+ ) -> None:
36
+ self.registry = registry
37
+ self.rng = rng
38
+ self.unicode = unicode
39
+ self.ascii_only = ascii_only
40
+ self.width, self.height = 80, 24
41
+ self.speed = 1.0
42
+ self.paused = False
43
+ self.post = PostFX(rng=rng)
44
+ self.adaptive = False # trade resolution for frame rate (interactive only)
45
+
46
+ self.theme_name = themes.DEFAULT
47
+ self.preview_theme: str | None = None
48
+ self._strobe = Strobe(rng)
49
+ self._clock = 0.0
50
+
51
+ self.key = ""
52
+ self.effect: Effect | None = None
53
+ self.preview_key: str | None = None
54
+ self._previews: OrderedDict[str, Effect] = OrderedDict()
55
+ self.override: Effect | None = None # drawn instead of anything else (the Studio)
56
+
57
+ def _build(self, key: str) -> Effect:
58
+ effect = self.registry.make(key, self.rng, unicode=self.unicode, ascii_only=self.ascii_only)
59
+ effect.adaptive = self.adaptive
60
+ effect.resize(self.width, self.height)
61
+ return effect
62
+
63
+ def play(self, key: str) -> None:
64
+ """Switch to effect ``key`` (raises ``KeyError`` if there's no such effect)."""
65
+ self.effect = self._build(key)
66
+ self.key = key
67
+ self.preview_key = None
68
+
69
+ def reload(self) -> None:
70
+ """Rebuild the current effect, e.g. after its recipe was edited and saved."""
71
+ self._previews.clear()
72
+ if self.key in self.registry:
73
+ self.play(self.key)
74
+
75
+ def restart(self) -> None:
76
+ if self.effect is not None:
77
+ self.effect.reset()
78
+
79
+ def preview(self, key: str | None) -> None:
80
+ """Show ``key`` instead of the current effect (``None`` stops previewing).
81
+
82
+ Recently previewed effects are kept alive, so scrolling back and forth
83
+ in a menu doesn't restart them every time.
84
+ """
85
+ if key is None or key == self.key or key not in self.registry:
86
+ self.preview_key = None
87
+ return
88
+ if key not in self._previews:
89
+ self._previews[key] = self._build(key)
90
+ while len(self._previews) > PREVIEW_CACHE:
91
+ self._previews.popitem(last=False)
92
+ self._previews.move_to_end(key)
93
+ self.preview_key = key
94
+
95
+ @property
96
+ def showing(self) -> Effect | None:
97
+ if self.override is not None:
98
+ return self.override
99
+ if self.preview_key is not None:
100
+ return self._previews[self.preview_key]
101
+ return self.effect
102
+
103
+ def set_theme(self, name: str) -> None:
104
+ self.theme_name = themes.normalize(name)
105
+
106
+ def theme(self) -> Theme:
107
+ name = self.preview_theme or self.theme_name
108
+ if name == "rainbow":
109
+ return themes.rainbow(self._clock * RAINBOW_SPEED)
110
+ if name == "strobe":
111
+ return self._strobe.theme()
112
+ return themes.STATIC_THEMES[name]
113
+
114
+ def faster(self) -> None:
115
+ self.speed = min(SPEED_MAX, self.speed * SPEED_STEP)
116
+
117
+ def slower(self) -> None:
118
+ self.speed = max(SPEED_MIN, self.speed / SPEED_STEP)
119
+
120
+ def resize(self, width: int, height: int) -> None:
121
+ self.width, self.height = width, height
122
+ for effect in (self.effect, self.override, *self._previews.values()):
123
+ if effect is not None:
124
+ effect.resize(width, height)
125
+
126
+ def update(self, dt: float) -> None:
127
+ self._clock += dt
128
+ if (self.preview_theme or self.theme_name) == "strobe":
129
+ self._strobe.update(dt)
130
+ self.post.update(dt)
131
+ effect = self.showing
132
+ if effect is not None and not self.paused:
133
+ effect.update(dt * self.speed)
134
+
135
+ def draw(self, frame: Frame) -> Theme:
136
+ """Draw the effect and filters into ``frame``; returns the theme used."""
137
+ theme = self.theme()
138
+ frame.clear()
139
+ effect = self.showing
140
+ if effect is not None:
141
+ effect.draw(frame, theme)
142
+ self.post.apply(frame)
143
+ return theme