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
@@ -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
@@ -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)
@@ -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)