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/app/session.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Running the app in a real terminal, and rendering it without one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from staticfx.app.app import App
|
|
10
|
+
from staticfx.core.clock import FrameClock
|
|
11
|
+
from staticfx.core.frame import Frame
|
|
12
|
+
from staticfx.core.keys import KeyReader
|
|
13
|
+
from staticfx.core.renderer import Renderer
|
|
14
|
+
from staticfx.core.terminal import CLEAR, Terminal
|
|
15
|
+
|
|
16
|
+
SAVE_INTERVAL = 1.0 # seconds; settings change on every effect switch, so batch the writes
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def supports_truecolor() -> bool:
|
|
20
|
+
"""Best guess from the environment; ``staticfx`` can be told otherwise in Settings."""
|
|
21
|
+
if os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit"):
|
|
22
|
+
return True
|
|
23
|
+
term = os.environ.get("TERM", "")
|
|
24
|
+
return not ("256color" in term or term in ("linux", "screen", "xterm"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def supports_utf8() -> bool:
|
|
28
|
+
encoding = getattr(sys.stdout, "encoding", None) or ""
|
|
29
|
+
return encoding.lower().replace("-", "") == "utf8"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def wants_truecolor(app: App) -> bool:
|
|
33
|
+
colour = app.settings.colour
|
|
34
|
+
return supports_truecolor() if colour == "auto" else colour == "truecolor"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run(app: App, duration: float | None = None) -> None:
|
|
38
|
+
"""The interactive loop. Returns when the user quits or ``duration`` seconds pass."""
|
|
39
|
+
renderer = Renderer()
|
|
40
|
+
started = last_save = time.monotonic()
|
|
41
|
+
try:
|
|
42
|
+
with Terminal() as terminal:
|
|
43
|
+
reader = KeyReader(terminal.fd)
|
|
44
|
+
clock = FrameClock(app.settings.fps)
|
|
45
|
+
frame = Frame(*terminal.size())
|
|
46
|
+
app.resize(frame.width, frame.height)
|
|
47
|
+
if app.notes:
|
|
48
|
+
extra = len(app.notes) - 1
|
|
49
|
+
more = f" (+{extra} more, listed when you quit)" if extra else ""
|
|
50
|
+
app.toast(app.notes[0] + more, 6.0)
|
|
51
|
+
|
|
52
|
+
while app.running:
|
|
53
|
+
dt = clock.tick()
|
|
54
|
+
for key in reader.read():
|
|
55
|
+
app.handle_key(key)
|
|
56
|
+
if not app.running:
|
|
57
|
+
break
|
|
58
|
+
if app.suspend_requested:
|
|
59
|
+
app.suspend_requested = False
|
|
60
|
+
terminal.suspend()
|
|
61
|
+
|
|
62
|
+
size = terminal.size()
|
|
63
|
+
if terminal.resized or size != (frame.width, frame.height):
|
|
64
|
+
terminal.resized = False
|
|
65
|
+
frame.resize(*size)
|
|
66
|
+
app.resize(*size)
|
|
67
|
+
renderer.invalidate()
|
|
68
|
+
terminal.write(CLEAR)
|
|
69
|
+
|
|
70
|
+
clock.interval = 1.0 / app.settings.fps
|
|
71
|
+
renderer.truecolor = wants_truecolor(app)
|
|
72
|
+
app.update(dt)
|
|
73
|
+
app.draw(frame)
|
|
74
|
+
terminal.write(renderer.diff(frame))
|
|
75
|
+
|
|
76
|
+
now = time.monotonic()
|
|
77
|
+
if app.dirty and now - last_save >= SAVE_INTERVAL:
|
|
78
|
+
app.save_settings()
|
|
79
|
+
last_save = now
|
|
80
|
+
if duration is not None and now - started >= duration:
|
|
81
|
+
break
|
|
82
|
+
clock.wait()
|
|
83
|
+
finally:
|
|
84
|
+
app.save_settings()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_frames(app: App, frames: int, fps: float = 30.0) -> Frame:
|
|
88
|
+
"""Simulate ``frames`` frames headlessly and return the last one.
|
|
89
|
+
|
|
90
|
+
Uses a fixed timestep and no adaptive resolution, so the same seed always
|
|
91
|
+
gives the same picture.
|
|
92
|
+
"""
|
|
93
|
+
frame = Frame(app.width, app.height)
|
|
94
|
+
for _ in range(max(1, frames)):
|
|
95
|
+
app.player.update(1.0 / fps)
|
|
96
|
+
app.player.draw(frame)
|
|
97
|
+
return frame
|
staticfx/cli/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The ``staticfx`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from staticfx.cli.commands import HANDLERS
|
|
8
|
+
from staticfx.cli.parser import COMMANDS, build, translate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> int:
|
|
12
|
+
args = translate(list(sys.argv[1:] if argv is None else argv))
|
|
13
|
+
if not args or (args[0] not in COMMANDS and args[0] not in ("-h", "--help", "--version")):
|
|
14
|
+
args = ["play", *args] # plain `staticfx [options]` plays
|
|
15
|
+
options = build().parse_args(args)
|
|
16
|
+
# Output may be piped or the locale may be C; a glyph must never crash a listing.
|
|
17
|
+
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
|
18
|
+
if reconfigure is not None:
|
|
19
|
+
reconfigure(errors="replace")
|
|
20
|
+
return HANDLERS[options.command](options)
|
staticfx/cli/commands.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""One function per subcommand. Each returns the process exit code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import platform
|
|
8
|
+
import random
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from staticfx import __version__, themes
|
|
14
|
+
from staticfx.app import session
|
|
15
|
+
from staticfx.app.app import App
|
|
16
|
+
from staticfx.core.frame import Frame
|
|
17
|
+
from staticfx.core.renderer import Renderer
|
|
18
|
+
from staticfx.core.terminal import is_interactive, supported, terminal_size
|
|
19
|
+
from staticfx.effects.engine import generate
|
|
20
|
+
from staticfx.effects.spec import BUILTIN, GENERATED, USER
|
|
21
|
+
from staticfx.integrations import overlay, system_info
|
|
22
|
+
from staticfx.library import config
|
|
23
|
+
from staticfx.library.effects import EffectLibrary, UserEffect, export
|
|
24
|
+
from staticfx.library.files import clean_name, default_data_dir, slugify
|
|
25
|
+
from staticfx.library.looks import BUILTIN_LOOKS, find_look
|
|
26
|
+
|
|
27
|
+
EXIT_OK, EXIT_ERROR, EXIT_USAGE = 0, 1, 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fail(message: str, code: int = EXIT_USAGE) -> int:
|
|
31
|
+
print(f"staticfx: {message}", file=sys.stderr)
|
|
32
|
+
return code
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def data_dir(args: argparse.Namespace) -> Path:
|
|
36
|
+
return Path(args.data_dir).expanduser() if args.data_dir else default_data_dir()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_app(args: argparse.Namespace, persist: bool = False) -> App:
|
|
40
|
+
"""The app with the user's settings and library loaded, and the command-line
|
|
41
|
+
options from the "what to show" group applied on top."""
|
|
42
|
+
folder = data_dir(args)
|
|
43
|
+
settings, warnings = config.load(folder / "config.json")
|
|
44
|
+
seed = getattr(args, "seed", None)
|
|
45
|
+
app = App(
|
|
46
|
+
settings,
|
|
47
|
+
folder,
|
|
48
|
+
utf8=session.supports_utf8(),
|
|
49
|
+
persist_settings=persist,
|
|
50
|
+
rng=random.Random(seed),
|
|
51
|
+
)
|
|
52
|
+
app.notes += warnings
|
|
53
|
+
if getattr(args, "ascii", False):
|
|
54
|
+
app.override("characters", "ascii")
|
|
55
|
+
elif getattr(args, "katakana", False):
|
|
56
|
+
app.override("characters", "katakana")
|
|
57
|
+
app.apply_characters()
|
|
58
|
+
app.start()
|
|
59
|
+
return app
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def apply_look_options(app: App, args: argparse.Namespace) -> str | None:
|
|
63
|
+
"""Apply --look, --effect, --theme, --speed and the filters. Returns an error, if any."""
|
|
64
|
+
if args.look:
|
|
65
|
+
look = find_look(args.look, app.looks)
|
|
66
|
+
if look is None:
|
|
67
|
+
names = ", ".join(look.name for look in (*app.looks.looks, *BUILTIN_LOOKS))
|
|
68
|
+
return f"no look called '{args.look}' (try one of: {names})"
|
|
69
|
+
app.settings.allow_flashing |= look.theme == "strobe" and args.theme == "strobe"
|
|
70
|
+
app.apply_look(look)
|
|
71
|
+
effect = getattr(args, "effect_name", None) or args.effect
|
|
72
|
+
if effect:
|
|
73
|
+
if effect.lower() == "random":
|
|
74
|
+
app.play_random()
|
|
75
|
+
else:
|
|
76
|
+
try:
|
|
77
|
+
app.play(app.registry.resolve(effect), announce=False)
|
|
78
|
+
except KeyError:
|
|
79
|
+
return f"unknown effect '{effect}' (search with: staticfx list {effect})"
|
|
80
|
+
if args.theme:
|
|
81
|
+
app.set_theme(args.theme)
|
|
82
|
+
if args.speed:
|
|
83
|
+
app.player.speed = min(5.0, max(0.1, args.speed))
|
|
84
|
+
post = app.player.post
|
|
85
|
+
for name in ("crt", "glitch", "recolour"):
|
|
86
|
+
value = getattr(args, name)
|
|
87
|
+
if value is not None:
|
|
88
|
+
setattr(post, name, value)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def play(args: argparse.Namespace) -> int:
|
|
93
|
+
if not supported():
|
|
94
|
+
return fail("the interactive app needs Linux, macOS or WSL (try `staticfx render`)")
|
|
95
|
+
if not is_interactive():
|
|
96
|
+
return fail("the interactive app needs a terminal; for piped output use `staticfx render`")
|
|
97
|
+
app = load_app(args, persist=not args.no_save)
|
|
98
|
+
app.player.adaptive = True
|
|
99
|
+
app.player.reload()
|
|
100
|
+
if args.fps:
|
|
101
|
+
app.override("fps", args.fps)
|
|
102
|
+
if args.color256:
|
|
103
|
+
app.override("colour", "256")
|
|
104
|
+
error = apply_look_options(app, args)
|
|
105
|
+
if error:
|
|
106
|
+
return fail(error)
|
|
107
|
+
if args.auto:
|
|
108
|
+
low, high = config.AUTO_RANGE
|
|
109
|
+
app.override("auto_seconds", min(high, max(low, args.auto)))
|
|
110
|
+
app.auto = True
|
|
111
|
+
app.auto_timer = app.settings.auto_seconds
|
|
112
|
+
with contextlib.suppress(KeyboardInterrupt): # SIGINT from outside; Ctrl-C is a key
|
|
113
|
+
session.run(app, args.duration)
|
|
114
|
+
for note in app.notes:
|
|
115
|
+
print(f"staticfx: {note}", file=sys.stderr)
|
|
116
|
+
return EXIT_OK
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def render(args: argparse.Namespace) -> int:
|
|
120
|
+
app = load_app(args)
|
|
121
|
+
error = apply_look_options(app, args)
|
|
122
|
+
if error:
|
|
123
|
+
return fail(error)
|
|
124
|
+
app.resize(*args.size)
|
|
125
|
+
frame = session.render_frames(app, args.frames)
|
|
126
|
+
text = (
|
|
127
|
+
Renderer(truecolor=not args.color256).full(frame) if args.ansi else "\n".join(frame.lines())
|
|
128
|
+
)
|
|
129
|
+
if args.out:
|
|
130
|
+
try:
|
|
131
|
+
Path(args.out).write_text(text + "\n", encoding="utf-8")
|
|
132
|
+
except OSError as exc:
|
|
133
|
+
return fail(f"can't write {args.out}: {exc.strerror}", EXIT_ERROR)
|
|
134
|
+
else:
|
|
135
|
+
print(text)
|
|
136
|
+
return EXIT_OK
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def fetch(args: argparse.Namespace) -> int:
|
|
140
|
+
try:
|
|
141
|
+
info = system_info(args.tool)
|
|
142
|
+
except RuntimeError as exc:
|
|
143
|
+
return fail(str(exc), EXIT_ERROR)
|
|
144
|
+
app = load_app(args)
|
|
145
|
+
error = apply_look_options(app, args)
|
|
146
|
+
if error:
|
|
147
|
+
return fail(error)
|
|
148
|
+
width, height = terminal_size() if is_interactive() else (80, 24)
|
|
149
|
+
app.resize(width, height - 1)
|
|
150
|
+
frame = session.render_frames(app, 90)
|
|
151
|
+
overlay(frame, app.player.theme(), info)
|
|
152
|
+
print(Renderer(truecolor=session.wants_truecolor(app)).full(frame))
|
|
153
|
+
return EXIT_OK
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def list_effects(args: argparse.Namespace) -> int:
|
|
157
|
+
app = load_app(args)
|
|
158
|
+
specs = app.registry.specs()
|
|
159
|
+
if args.kind == "gallery":
|
|
160
|
+
specs = [app.registry[k] for k in app.registry.gallery]
|
|
161
|
+
elif args.kind:
|
|
162
|
+
kind = {"mine": USER, "generated": GENERATED}.get(args.kind, args.kind)
|
|
163
|
+
specs = [s for s in specs if s.kind == kind]
|
|
164
|
+
if args.favorites:
|
|
165
|
+
specs = [s for s in specs if app.is_favorite(s.key)]
|
|
166
|
+
words = [w.lower() for w in args.query]
|
|
167
|
+
specs = [s for s in specs if all(w in s.search_text() for w in words)]
|
|
168
|
+
for spec in specs:
|
|
169
|
+
star = "*" if app.is_favorite(spec.key) else " "
|
|
170
|
+
print(f"{star} {spec.key:<20} {spec.label:<30} {spec.category}")
|
|
171
|
+
if not specs:
|
|
172
|
+
print("No effects match.", file=sys.stderr)
|
|
173
|
+
return EXIT_OK
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def list_themes(args: argparse.Namespace) -> int:
|
|
177
|
+
for key, label in themes.LABELS.items():
|
|
178
|
+
note = " (flashing: allow it in Settings first)" if key == "strobe" else ""
|
|
179
|
+
print(f"{key:<10} {label}{note}")
|
|
180
|
+
return EXIT_OK
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def list_looks(args: argparse.Namespace) -> int:
|
|
184
|
+
app = load_app(args)
|
|
185
|
+
for heading, looks in (("Yours", app.looks.looks), ("Built in", BUILTIN_LOOKS)):
|
|
186
|
+
print(f"{heading}:")
|
|
187
|
+
if not looks:
|
|
188
|
+
save = app.keymap.label("save_look", ascii_only=True)
|
|
189
|
+
print(f" (none yet: press {save} while an effect plays to save one)")
|
|
190
|
+
for look in looks:
|
|
191
|
+
print(f" {look.name:<20} {look.effect:<18} {look.theme:<10} {look.note}")
|
|
192
|
+
return EXIT_OK
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def new_effect(args: argparse.Namespace) -> int:
|
|
196
|
+
name = clean_name(args.name)
|
|
197
|
+
if not name:
|
|
198
|
+
return fail("the effect needs a name")
|
|
199
|
+
library = EffectLibrary(data_dir(args) / "effects")
|
|
200
|
+
seed = args.seed if args.seed is not None else random.randrange(10**6, 10**7)
|
|
201
|
+
effect = UserEffect(library.unique_id(name), name, generate(seed)[0])
|
|
202
|
+
try:
|
|
203
|
+
path = library.save(effect)
|
|
204
|
+
except OSError as exc:
|
|
205
|
+
return fail(f"can't save: {exc.strerror}", EXIT_ERROR)
|
|
206
|
+
print(f"Created '{name}' in {path}\nPlay it with: staticfx -e {effect.key}")
|
|
207
|
+
return EXIT_OK
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def import_effect(args: argparse.Namespace) -> int:
|
|
211
|
+
library = EffectLibrary(data_dir(args) / "effects")
|
|
212
|
+
try:
|
|
213
|
+
effect = library.import_file(Path(args.file))
|
|
214
|
+
except (ValueError, OSError) as exc:
|
|
215
|
+
return fail(f"import failed: {exc}", EXIT_ERROR)
|
|
216
|
+
print(f"Imported '{effect.name}'. Play it with: staticfx -e {effect.key}")
|
|
217
|
+
return EXIT_OK
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def export_effect(args: argparse.Namespace) -> int:
|
|
221
|
+
app = load_app(args)
|
|
222
|
+
try:
|
|
223
|
+
spec = app.registry[app.registry.resolve(args.effect)]
|
|
224
|
+
except KeyError:
|
|
225
|
+
return fail(f"unknown effect '{args.effect}'")
|
|
226
|
+
if spec.recipe is None:
|
|
227
|
+
return fail(f"'{spec.label}' is hand-written; only Studio and generated effects export")
|
|
228
|
+
effect = UserEffect(slugify(spec.label), spec.label, spec.recipe, spec.description)
|
|
229
|
+
try:
|
|
230
|
+
path = export(effect, Path(args.file))
|
|
231
|
+
except OSError as exc:
|
|
232
|
+
return fail(f"can't write {args.file}: {exc.strerror}", EXIT_ERROR)
|
|
233
|
+
print(f"Exported '{spec.label}' to {path}")
|
|
234
|
+
return EXIT_OK
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def doctor(args: argparse.Namespace) -> int:
|
|
238
|
+
"""Report on the environment and saved files, then render every effect once."""
|
|
239
|
+
folder = data_dir(args)
|
|
240
|
+
print(f"StaticFX {__version__} on Python {platform.python_version()} ({platform.system()})")
|
|
241
|
+
width, height = terminal_size()
|
|
242
|
+
traits = [
|
|
243
|
+
"interactive" if is_interactive() else "not a terminal",
|
|
244
|
+
f"{width}x{height}",
|
|
245
|
+
"UTF-8" if session.supports_utf8() else "ASCII only",
|
|
246
|
+
"truecolor" if session.supports_truecolor() else "256 colours",
|
|
247
|
+
]
|
|
248
|
+
print(f"terminal {', '.join(traits)} (colour support is a best guess)")
|
|
249
|
+
if not supported():
|
|
250
|
+
print(" interactive mode is not supported here (needs Linux, macOS or WSL)")
|
|
251
|
+
print(f"data folder {folder}")
|
|
252
|
+
|
|
253
|
+
app = load_app(args)
|
|
254
|
+
registry = app.registry
|
|
255
|
+
counts = f"{registry.count(BUILTIN)} built in, {registry.count(GENERATED)} generated"
|
|
256
|
+
print(f"effects {len(registry)}: {counts}, {registry.count(USER)} yours")
|
|
257
|
+
print(f"looks {len(app.looks.looks)} saved, {len(BUILTIN_LOOKS)} built in")
|
|
258
|
+
for note in app.notes:
|
|
259
|
+
print(f" warning {note}")
|
|
260
|
+
|
|
261
|
+
keys = [s.key for s in registry.specs() if s.kind != GENERATED]
|
|
262
|
+
keys += [s.key for s in registry.specs() if s.kind == GENERATED][::20]
|
|
263
|
+
print(f"self-test rendering {len(keys)} effects...", end="", flush=True)
|
|
264
|
+
failures, started = [], time.perf_counter()
|
|
265
|
+
theme = themes.STATIC_THEMES[themes.DEFAULT]
|
|
266
|
+
for key in keys:
|
|
267
|
+
try:
|
|
268
|
+
effect = registry.make(key, random.Random(1))
|
|
269
|
+
effect.resize(80, 24)
|
|
270
|
+
frame = Frame(80, 24)
|
|
271
|
+
for _ in range(5):
|
|
272
|
+
effect.update(1 / 30)
|
|
273
|
+
effect.draw(frame, theme)
|
|
274
|
+
except Exception as exc: # report every broken effect rather than stopping at one
|
|
275
|
+
failures.append(f"{key}: {exc.__class__.__name__}: {exc}")
|
|
276
|
+
print(f" {len(keys) - len(failures)} ok in {time.perf_counter() - started:.1f}s")
|
|
277
|
+
for failure in failures:
|
|
278
|
+
print(f" FAILED {failure}")
|
|
279
|
+
return EXIT_ERROR if failures else EXIT_OK
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
HANDLERS = {
|
|
283
|
+
"play": play,
|
|
284
|
+
"render": render,
|
|
285
|
+
"fetch": fetch,
|
|
286
|
+
"list": list_effects,
|
|
287
|
+
"themes": list_themes,
|
|
288
|
+
"looks": list_looks,
|
|
289
|
+
"new": new_effect,
|
|
290
|
+
"import": import_effect,
|
|
291
|
+
"export": export_effect,
|
|
292
|
+
"doctor": doctor,
|
|
293
|
+
}
|
staticfx/cli/parser.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Command-line arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from staticfx import __version__, themes
|
|
8
|
+
|
|
9
|
+
COMMANDS = (
|
|
10
|
+
"play",
|
|
11
|
+
"list",
|
|
12
|
+
"themes",
|
|
13
|
+
"looks",
|
|
14
|
+
"new",
|
|
15
|
+
"import",
|
|
16
|
+
"export",
|
|
17
|
+
"render",
|
|
18
|
+
"fetch",
|
|
19
|
+
"doctor",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
EPILOG = """\
|
|
23
|
+
In the app: arrows browse, m opens the effect browser, t themes, l looks,
|
|
24
|
+
n the Studio, o settings, ? every key.
|
|
25
|
+
|
|
26
|
+
examples:
|
|
27
|
+
staticfx play (picks up where you left off)
|
|
28
|
+
staticfx -e plasma -t ocean start on an effect and theme
|
|
29
|
+
staticfx --auto 20 screensaver: a new effect every 20 seconds
|
|
30
|
+
staticfx list fire search effects
|
|
31
|
+
staticfx render galaxy --size 80x24 --ansi > galaxy.txt
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def size(text: str) -> tuple[int, int]:
|
|
36
|
+
try:
|
|
37
|
+
width, height = (int(part) for part in text.lower().split("x"))
|
|
38
|
+
except ValueError:
|
|
39
|
+
raise argparse.ArgumentTypeError("size must look like 80x24") from None
|
|
40
|
+
if not (1 <= width <= 1000 and 1 <= height <= 500):
|
|
41
|
+
raise argparse.ArgumentTypeError("size must be between 1x1 and 1000x500")
|
|
42
|
+
return width, height
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def positive(text: str) -> float:
|
|
46
|
+
try:
|
|
47
|
+
value = float(text)
|
|
48
|
+
except ValueError:
|
|
49
|
+
raise argparse.ArgumentTypeError(f"not a number: {text}") from None
|
|
50
|
+
if value <= 0:
|
|
51
|
+
raise argparse.ArgumentTypeError("must be greater than zero")
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def theme(text: str) -> str:
|
|
56
|
+
try:
|
|
57
|
+
return themes.normalize(text)
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise argparse.ArgumentTypeError(str(exc)) from None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build() -> argparse.ArgumentParser:
|
|
63
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
64
|
+
common.add_argument("--data-dir", metavar="DIR", help="where effects, looks and settings live")
|
|
65
|
+
|
|
66
|
+
look = argparse.ArgumentParser(add_help=False)
|
|
67
|
+
group = look.add_argument_group("what to show")
|
|
68
|
+
group.add_argument("-e", "--effect", help="effect key, name, proc42-style number, or 'random'")
|
|
69
|
+
group.add_argument("-t", "--theme", type=theme, help=f"one of: {', '.join(themes.LABELS)}")
|
|
70
|
+
group.add_argument("--look", help="start from a saved or built-in look")
|
|
71
|
+
group.add_argument("--speed", type=positive, help="animation speed (1.0 is normal)")
|
|
72
|
+
group.add_argument("--crt", action=argparse.BooleanOptionalAction, help="CRT scanlines")
|
|
73
|
+
group.add_argument("--glitch", action=argparse.BooleanOptionalAction, help="glitch filter")
|
|
74
|
+
group.add_argument("--recolour", action=argparse.BooleanOptionalAction, help="rainbow recolour")
|
|
75
|
+
group.add_argument("--seed", type=int, help="random seed, for reproducible output")
|
|
76
|
+
group.add_argument("--ascii", action="store_true", help="plain ASCII only")
|
|
77
|
+
group.add_argument("--katakana", action="store_true", help="katakana in the rain effects")
|
|
78
|
+
|
|
79
|
+
parser = argparse.ArgumentParser(
|
|
80
|
+
prog="staticfx",
|
|
81
|
+
description="Animated ASCII effects for your terminal.",
|
|
82
|
+
epilog=EPILOG,
|
|
83
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument("--version", action="version", version=f"StaticFX {__version__}")
|
|
86
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
87
|
+
|
|
88
|
+
play = sub.add_parser("play", parents=[common, look], help="run the app (the default)")
|
|
89
|
+
play.add_argument(
|
|
90
|
+
"--auto",
|
|
91
|
+
type=positive,
|
|
92
|
+
nargs="?",
|
|
93
|
+
const=12.0,
|
|
94
|
+
metavar="SECS",
|
|
95
|
+
help="change effect every SECS seconds (default 12)",
|
|
96
|
+
)
|
|
97
|
+
play.add_argument("--duration", type=positive, metavar="SECS", help="quit after SECS seconds")
|
|
98
|
+
play.add_argument("--fps", type=int, choices=range(5, 61), metavar="5-60", help="frame rate")
|
|
99
|
+
play.add_argument("--256", dest="color256", action="store_true", help="force 256 colours")
|
|
100
|
+
play.add_argument("--no-save", action="store_true", help="don't save settings on exit")
|
|
101
|
+
|
|
102
|
+
lister = sub.add_parser("list", parents=[common], help="list or search effects")
|
|
103
|
+
lister.add_argument("query", nargs="*", help="words to search for")
|
|
104
|
+
lister.add_argument("--kind", choices=("builtin", "generated", "mine", "gallery"))
|
|
105
|
+
lister.add_argument("--favorites", action="store_true", help="only your favourites")
|
|
106
|
+
|
|
107
|
+
sub.add_parser("themes", parents=[common], help="list themes")
|
|
108
|
+
sub.add_parser("looks", parents=[common], help="list saved and built-in looks")
|
|
109
|
+
|
|
110
|
+
new = sub.add_parser("new", parents=[common], help="create a random effect in My Effects")
|
|
111
|
+
new.add_argument("name")
|
|
112
|
+
new.add_argument("--seed", type=int, help="pick the starting recipe")
|
|
113
|
+
|
|
114
|
+
importer = sub.add_parser("import", parents=[common], help="add a .sfx file to My Effects")
|
|
115
|
+
importer.add_argument("file")
|
|
116
|
+
|
|
117
|
+
exporter = sub.add_parser("export", parents=[common], help="save an effect as a .sfx file")
|
|
118
|
+
exporter.add_argument("effect", help="one of your effects, or a generated one (proc_NNN)")
|
|
119
|
+
exporter.add_argument("file")
|
|
120
|
+
|
|
121
|
+
render = sub.add_parser("render", parents=[common, look], help="print one frame and exit")
|
|
122
|
+
render.add_argument("effect_name", nargs="?", metavar="EFFECT")
|
|
123
|
+
render.add_argument("--size", type=size, default=(80, 24), metavar="WxH")
|
|
124
|
+
render.add_argument(
|
|
125
|
+
"--frames", type=int, default=60, metavar="N", help="frames to simulate first (default 60)"
|
|
126
|
+
)
|
|
127
|
+
render.add_argument("--ansi", action="store_true", help="keep colours (escape codes)")
|
|
128
|
+
render.add_argument("--256", dest="color256", action="store_true", help="256-colour codes")
|
|
129
|
+
render.add_argument("--out", metavar="FILE", help="write to FILE instead of the screen")
|
|
130
|
+
|
|
131
|
+
fetch = sub.add_parser("fetch", parents=[common, look], help="system info over an effect")
|
|
132
|
+
fetch.add_argument(
|
|
133
|
+
"--tool", choices=("fastfetch", "neofetch"), help="default: whichever is installed"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
sub.add_parser("doctor", parents=[common], help="check your setup and run a self-test")
|
|
137
|
+
return parser
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# StaticFX 1.x flags, mapped to the commands that replaced them.
|
|
141
|
+
LEGACY_COMMANDS = {
|
|
142
|
+
"--list": "list",
|
|
143
|
+
"--favorites": "list",
|
|
144
|
+
"--themes": "themes",
|
|
145
|
+
"--looks": "looks",
|
|
146
|
+
"--presets": "looks",
|
|
147
|
+
"--scenes": "looks",
|
|
148
|
+
"--new-effect": "new",
|
|
149
|
+
"--import": "import",
|
|
150
|
+
"--export": "export",
|
|
151
|
+
"--frames": "render",
|
|
152
|
+
"--fetch": "fetch",
|
|
153
|
+
"--smoke-test": "doctor",
|
|
154
|
+
"--info": "doctor",
|
|
155
|
+
}
|
|
156
|
+
LEGACY_RENAMES = {
|
|
157
|
+
"--mode": "--effect",
|
|
158
|
+
"--rainbow": "--recolour",
|
|
159
|
+
"--no-rainbow": "--no-recolour",
|
|
160
|
+
"--unicode": "--katakana",
|
|
161
|
+
"--color256": "--256",
|
|
162
|
+
"--preset": "--look",
|
|
163
|
+
"--scene": "--look",
|
|
164
|
+
"--no-config": "--no-save",
|
|
165
|
+
}
|
|
166
|
+
LEGACY_KEPT = {"--favorites": "--favorites", "--fetch": "--tool", "--frames": "--frames"}
|
|
167
|
+
LEGACY_DROPPED = {"--no-unicode", "--no-katakana", "--no-256", "--no-color256", "--plugin-dir"}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def translate(argv: list[str]) -> list[str]:
|
|
171
|
+
"""Rewrite a 1.x command line (``--list fire``) into the new form (``list fire``)."""
|
|
172
|
+
command = None
|
|
173
|
+
out: list[str] = []
|
|
174
|
+
args = iter(argv)
|
|
175
|
+
for arg in args:
|
|
176
|
+
if arg in LEGACY_DROPPED:
|
|
177
|
+
if arg == "--plugin-dir":
|
|
178
|
+
next(args, None)
|
|
179
|
+
elif arg in LEGACY_COMMANDS:
|
|
180
|
+
command = command or LEGACY_COMMANDS[arg]
|
|
181
|
+
if arg in LEGACY_KEPT: # these become the command *and* keep an option
|
|
182
|
+
out.append(LEGACY_KEPT[arg])
|
|
183
|
+
else:
|
|
184
|
+
out.append(LEGACY_RENAMES.get(arg, arg))
|
|
185
|
+
return [command, *out] if command else out
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Terminal plumbing with no knowledge of effects: colour, frames, rendering, input."""
|
staticfx/core/clock.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Frame pacing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
# A frame that took longer than this (a suspend, a debugger pause, a slow
|
|
8
|
+
# terminal) is treated as if it took this long, so simulations never take
|
|
9
|
+
# one enormous step.
|
|
10
|
+
MAX_STEP = 0.25
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FrameClock:
|
|
14
|
+
def __init__(self, fps: float) -> None:
|
|
15
|
+
self.interval = 1.0 / max(1.0, fps)
|
|
16
|
+
self._last = time.perf_counter()
|
|
17
|
+
self._frame_start = self._last
|
|
18
|
+
|
|
19
|
+
def tick(self) -> float:
|
|
20
|
+
"""Start a frame; returns the seconds since the previous one."""
|
|
21
|
+
now = time.perf_counter()
|
|
22
|
+
dt = min(MAX_STEP, max(0.0, now - self._last))
|
|
23
|
+
self._last = self._frame_start = now
|
|
24
|
+
return dt
|
|
25
|
+
|
|
26
|
+
def wait(self) -> None:
|
|
27
|
+
"""Sleep off whatever is left of this frame's time budget."""
|
|
28
|
+
remaining = self.interval - (time.perf_counter() - self._frame_start)
|
|
29
|
+
if remaining > 0:
|
|
30
|
+
time.sleep(remaining)
|