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/__init__.py
ADDED
staticfx/__main__.py
ADDED
staticfx/app/__init__.py
ADDED
|
@@ -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
|