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/ui/browser.py ADDED
@@ -0,0 +1,269 @@
1
+ """The effect browser: groups on the left, effects on the right, live preview behind."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from staticfx.effects.builtin import CATEGORIES
10
+ from staticfx.effects.registry import GENERATED_CATEGORY, USER_CATEGORY
11
+ from staticfx.effects.spec import USER, EffectSpec
12
+ from staticfx.ui.dialogs import Confirm
13
+ from staticfx.ui.screen import Screen
14
+ from staticfx.ui.studio import open_studio
15
+ from staticfx.ui.widgets import Canvas, Cursor, fit
16
+
17
+ if TYPE_CHECKING:
18
+ from staticfx.app.app import App
19
+
20
+ GROUP_WIDTH = 19
21
+
22
+
23
+ @dataclass
24
+ class Group:
25
+ name: str
26
+ effects: Callable[[], list[EffectSpec]]
27
+ empty: str = "Nothing here yet"
28
+
29
+
30
+ def rank(spec: EffectSpec, query: str) -> int:
31
+ """Lower is better: exact name, name prefix, name contains, anything else."""
32
+ label = spec.label.lower()
33
+ if query in (label, spec.key):
34
+ return 0
35
+ if label.startswith(query):
36
+ return 1
37
+ return 2 if query in label else 3
38
+
39
+
40
+ class Browser(Screen):
41
+ help = (
42
+ ("↑ ↓ PgUp PgDn", "move"),
43
+ ("← →", "switch between groups and effects"),
44
+ ("Tab", "next group"),
45
+ ("Enter", "play the highlighted effect"),
46
+ ("/", "search the selected group (Enter plays, Esc clears)"),
47
+ ("f", "add to / remove from favourites"),
48
+ ("e", "open in the Studio (generated and your own effects)"),
49
+ ("n", "new effect"),
50
+ ("d Del", "delete one of your effects"),
51
+ ("Esc q", "close"),
52
+ )
53
+
54
+ def __init__(self, app: App) -> None:
55
+ super().__init__(app)
56
+ registry = app.registry
57
+ specs = registry.specs
58
+
59
+ def by_keys(keys: list[str] | tuple[str, ...]) -> list[EffectSpec]:
60
+ return [registry[k] for k in keys if k in registry]
61
+
62
+ def by_category(name: str) -> Callable[[], list[EffectSpec]]:
63
+ return lambda: [s for s in specs() if s.category == name]
64
+
65
+ new_key = app.keymap.label("new_effect", app.ascii_only)
66
+ fav_key = app.keymap.label("favorite", app.ascii_only)
67
+ self.groups = [
68
+ Group("All", specs),
69
+ Group(
70
+ "Favourites",
71
+ lambda: by_keys(app.settings.favorites),
72
+ f"None yet: press {fav_key} on an effect here or while it plays",
73
+ ),
74
+ Group("Recent", lambda: by_keys(app.settings.recent), "Nothing played yet"),
75
+ Group(
76
+ USER_CATEGORY, by_category(USER_CATEGORY), f"None yet: press {new_key} to make one"
77
+ ),
78
+ Group("Gallery", lambda: by_keys(registry.gallery)),
79
+ *(Group(name, by_category(name)) for name in CATEGORIES),
80
+ Group(GENERATED_CATEGORY, by_category(GENERATED_CATEGORY)),
81
+ ]
82
+ self.search_text = {s.key: s.search_text() for s in specs()}
83
+ self.group = Cursor()
84
+ self.cursor = Cursor()
85
+ self.focus = "list" # "list", "groups" or "search"
86
+ self.query = ""
87
+ self.items: list[EffectSpec] = []
88
+ self.counts: list[int] = []
89
+ self.rows = 10
90
+ self.refresh(keep=app.player.key)
91
+
92
+ @property
93
+ def current(self) -> EffectSpec | None:
94
+ return self.items[self.cursor.index] if self.items else None
95
+
96
+ def refresh(self, keep: str | None = None) -> None:
97
+ """Recompute the visible list, keeping the cursor on ``keep`` if it's still there."""
98
+ if keep is None and self.current is not None:
99
+ keep = self.current.key
100
+ items = self.groups[self.group.index].effects()
101
+ tokens = self.query.lower().split()
102
+ if tokens:
103
+ items = [s for s in items if all(t in self.search_text.get(s.key, "") for t in tokens)]
104
+ phrase = " ".join(tokens)
105
+ items.sort(key=lambda s: rank(s, phrase))
106
+ self.items = items
107
+ self.counts = [len(g.effects()) for g in self.groups]
108
+ keys = [s.key for s in items]
109
+ self.cursor.index = keys.index(keep) if keep in keys else 0
110
+ self.preview()
111
+
112
+ def preview(self) -> None:
113
+ self.app.player.preview(self.current.key if self.current else None)
114
+
115
+ def on_close(self) -> None:
116
+ self.app.player.preview(None)
117
+
118
+ def handle(self, key: str) -> None:
119
+ if self.focus == "search":
120
+ self.handle_search(key)
121
+ return
122
+ app, current = self.app, self.current
123
+ if key in ("escape", "ctrl-c", "q"):
124
+ self.close()
125
+ elif key == "/":
126
+ self.focus = "search"
127
+ elif key == "left":
128
+ self.focus = "groups"
129
+ elif key == "right":
130
+ self.focus = "list"
131
+ elif key in ("tab", "shift-tab"):
132
+ self.select_group(self.group.index + (1 if key == "tab" else -1))
133
+ elif self.focus == "groups" and self.group.handle(key, len(self.groups), 5):
134
+ self.select_group(self.group.index)
135
+ elif self.cursor.handle(key, len(self.items), self.rows):
136
+ self.preview()
137
+ elif key == "enter":
138
+ if self.focus == "groups":
139
+ self.focus = "list"
140
+ else:
141
+ self.play()
142
+ elif key == "n":
143
+ self.close()
144
+ open_studio(app, None)
145
+ elif current is None:
146
+ return
147
+ elif key == "f":
148
+ added = app.toggle_favorite(current.key)
149
+ app.toast(f"{'Added to' if added else 'Removed from'} favourites: {current.label}")
150
+ self.refresh()
151
+ elif key == "e":
152
+ self.edit(current)
153
+ elif key in ("d", "delete"):
154
+ self.delete(current)
155
+
156
+ def handle_search(self, key: str) -> None:
157
+ """Typing goes into the search; arrows still move through the results."""
158
+ if key in ("escape", "ctrl-c"):
159
+ self.query = ""
160
+ self.focus = "list"
161
+ self.refresh()
162
+ elif key == "enter":
163
+ self.play()
164
+ elif key in ("tab", "shift-tab"):
165
+ self.focus = "list"
166
+ elif self.cursor.handle(key, len(self.items), self.rows):
167
+ self.preview()
168
+ elif key == "backspace":
169
+ if not self.query:
170
+ self.focus = "list"
171
+ self.query = self.query[:-1]
172
+ self.refresh()
173
+ elif key == "space" or (len(key) == 1 and key.isprintable()):
174
+ self.query += " " if key == "space" else key
175
+ self.refresh()
176
+
177
+ def play(self) -> None:
178
+ if self.current is not None:
179
+ self.close()
180
+ self.app.play(self.current.key)
181
+
182
+ def select_group(self, index: int) -> None:
183
+ self.group.index = index % len(self.groups)
184
+ self.cursor.first = 0
185
+ self.refresh()
186
+
187
+ def edit(self, spec: EffectSpec) -> None:
188
+ if not spec.editable:
189
+ self.app.toast("Hand-written built-ins can't be edited; try a Generated effect")
190
+ return
191
+ self.close()
192
+ open_studio(self.app, spec.key)
193
+
194
+ def delete(self, spec: EffectSpec) -> None:
195
+ if spec.kind != USER:
196
+ self.app.toast("Only your own effects can be deleted")
197
+ return
198
+
199
+ def confirmed() -> None:
200
+ self.app.delete_effect(spec.key)
201
+ self.app.toast(f"Deleted {spec.label}")
202
+ self.refresh()
203
+
204
+ lines = [f"Delete '{spec.label}'?", "Its .sfx file will be removed."]
205
+ self.app.push(Confirm(self.app, "Delete effect", lines, confirmed))
206
+
207
+ def draw(self, canvas: Canvas) -> None:
208
+ app, c, s = self.app, canvas.chrome, canvas.style
209
+ w = canvas.width - 2 if canvas.width < 90 else min(84, max(64, canvas.width * 3 // 5))
210
+ h = canvas.height - 2
211
+ x, y = 1, 1
212
+ group = self.groups[self.group.index]
213
+ canvas.box(
214
+ x, y, w, h, "Effects", f"{len(self.items):,} of {self.counts[self.group.index]:,}"
215
+ )
216
+
217
+ if self.focus == "search":
218
+ canvas.text(x + 2, y + 1, f"Search: {self.query}_", s.selected, w - 4)
219
+ elif self.query:
220
+ canvas.text(x + 2, y + 1, f"Search: {self.query} (/ to change)", s.text, w - 4)
221
+ else:
222
+ canvas.text(x + 2, y + 1, "Press / to search", s.dim, w - 4)
223
+ split = GROUP_WIDTH + 1
224
+ canvas.rule(x, y + 2, w, split, c.top_tee)
225
+ body_top, body_bottom = y + 3, y + h - 4
226
+ self.rows = max(1, body_bottom - body_top)
227
+ canvas.column(x + split, body_top, body_bottom)
228
+ canvas.rule(x, body_bottom, w, split, c.bottom_tee)
229
+
230
+ # groups
231
+ for row, i in enumerate(self.group.window(self.rows, len(self.groups)), body_top):
232
+ g = self.groups[i]
233
+ marker = c.cursor if i == self.group.index else " "
234
+ focused = i == self.group.index and self.focus == "groups"
235
+ colour = s.selected if focused else s.text if i == self.group.index else s.dim
236
+ label = f"{marker} {fit(g.name, GROUP_WIDTH - 7, c.ellipsis)}"
237
+ canvas.text(x + 1, row, label, colour, GROUP_WIDTH - 4)
238
+ canvas.text(x + split - 5, row, f"{self.counts[i]:>4}", s.dim)
239
+
240
+ # effects
241
+ lx, lw = x + split + 2, w - split - 4
242
+ if not self.items:
243
+ canvas.text(lx, body_top, "No matches" if self.query else group.empty, s.dim, lw)
244
+ for row, index in enumerate(self.cursor.window(self.rows, len(self.items))):
245
+ spec = self.items[index]
246
+ selected = index == self.cursor.index
247
+ star = c.star if app.is_favorite(spec.key) else " "
248
+ marker = c.cursor if selected else " "
249
+ tag = "" if group.name == spec.category else spec.category
250
+ name = fit(spec.label, lw - len(tag) - 5, c.ellipsis)
251
+ colour = s.selected if selected and self.focus == "list" else s.text
252
+ canvas.text(lx, body_top + row, f"{marker}{star} {name}", colour, lw - len(tag))
253
+ canvas.text(lx + lw - len(tag), body_top + row, tag, s.dim)
254
+ canvas.scrollbar(x + w - 2, body_top, self.rows, self.cursor.first, len(self.items))
255
+
256
+ # description and keys
257
+ current = self.current
258
+ if current is not None:
259
+ canvas.text(x + 2, body_bottom + 1, current.description, s.text, w - 4)
260
+ if self.focus == "search":
261
+ keys = ["type to search", "Enter play", "↑↓ move", "Esc clear"]
262
+ else:
263
+ keys = ["Enter play", "/ search", "f favourite"]
264
+ if current is not None and current.editable:
265
+ keys.append("e edit")
266
+ if current is not None and current.kind == USER:
267
+ keys.append("d delete")
268
+ keys += ["n new", "Esc close"]
269
+ canvas.text(x + 2, body_bottom + 2, f" {c.dot} ".join(keys), s.dim, w - 4)
staticfx/ui/dialogs.py ADDED
@@ -0,0 +1,95 @@
1
+ """Small modal dialogs: a text prompt and a yes/no confirmation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import TYPE_CHECKING
7
+
8
+ from staticfx.library.files import NAME_LIMIT, clean_name
9
+ from staticfx.ui.screen import Screen
10
+ from staticfx.ui.widgets import Canvas
11
+
12
+ if TYPE_CHECKING:
13
+ from staticfx.app.app import App
14
+
15
+
16
+ class Prompt(Screen):
17
+ """Asks for one line of text. ``on_submit`` runs only if the user presses Enter."""
18
+
19
+ help = (("Enter", "confirm"), ("Esc", "cancel"))
20
+
21
+ def __init__(
22
+ self, app: App, title: str, on_submit: Callable[[str], None], text: str = ""
23
+ ) -> None:
24
+ super().__init__(app)
25
+ self.title = title
26
+ self.text = text[:NAME_LIMIT]
27
+ self.on_submit = on_submit
28
+ self.error = ""
29
+
30
+ def handle(self, key: str) -> None:
31
+ if key in ("escape", "ctrl-c"):
32
+ self.close()
33
+ elif key == "enter":
34
+ value = clean_name(self.text)
35
+ if not value:
36
+ self.error = "Type a name first"
37
+ return
38
+ self.close()
39
+ self.on_submit(value)
40
+ elif key == "backspace":
41
+ self.text = self.text[:-1]
42
+ elif len(self.text) < NAME_LIMIT and (
43
+ key == "space" or (len(key) == 1 and key.isprintable())
44
+ ):
45
+ self.text += " " if key == "space" else key
46
+ self.error = ""
47
+
48
+ def draw(self, canvas: Canvas) -> None:
49
+ s = canvas.style
50
+ width = min(canvas.width - 4, NAME_LIMIT + 8)
51
+ x, y = (canvas.width - width) // 2, canvas.height // 2 - 3
52
+ canvas.box(x, y, width, 6, self.title)
53
+ canvas.text(x + 2, y + 2, self.text[-(width - 6) :] + "_", s.selected, width - 4)
54
+ canvas.text(x + 2, y + 3, self.error, s.accent, width - 4)
55
+ canvas.text(x + 2, y + 4, "Enter save Esc cancel", s.dim, width - 4)
56
+
57
+
58
+ class Confirm(Screen):
59
+ """A yes/no question. ``on_yes`` runs only if the user presses y."""
60
+
61
+ help = (("y", "yes"), ("n Esc", "no"))
62
+
63
+ def __init__(self, app: App, title: str, lines: list[str], on_yes: Callable[[], None]) -> None:
64
+ super().__init__(app)
65
+ self.title = title
66
+ self.lines = lines
67
+ self.on_yes = on_yes
68
+
69
+ def handle(self, key: str) -> None:
70
+ if key in ("y", "Y"):
71
+ self.close()
72
+ self.on_yes()
73
+ elif key in ("n", "N", "escape", "ctrl-c", "enter"):
74
+ self.close()
75
+
76
+ def draw(self, canvas: Canvas) -> None:
77
+ s = canvas.style
78
+ width = min(canvas.width - 4, max(36, *(len(line) + 6 for line in self.lines)))
79
+ height = len(self.lines) + 5
80
+ x, y = (canvas.width - width) // 2, (canvas.height - height) // 2
81
+ canvas.box(x, y, width, height, self.title)
82
+ for i, line in enumerate(self.lines):
83
+ canvas.text(x + 3, y + 2 + i, line, s.text, width - 6)
84
+ canvas.text(x + 3, y + height - 2, "y yes n no", s.accent, width - 6)
85
+
86
+
87
+ def ask_look_name(app: App) -> None:
88
+ """Prompt for a name and save the current effect, theme and filters as a look."""
89
+
90
+ def save(name: str) -> None:
91
+ replaced = app.looks.put(app.current_look(name))
92
+ looks = app.keymap.label("looks", app.ascii_only)
93
+ app.toast(f"Look {'updated' if replaced else 'saved'}: {name} ({looks} to recall it)")
94
+
95
+ app.push(Prompt(app, "Save this look as", save))
staticfx/ui/help.py ADDED
@@ -0,0 +1,99 @@
1
+ """Every key, generated from the live key map and each screen's own list.
2
+
3
+ Because the main-view section is built from the current bindings, the help
4
+ screen is always right, including after the user rebinds keys.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from staticfx.app.keymap import ACTIONS, key_label
12
+ from staticfx.ui.browser import Browser
13
+ from staticfx.ui.pickers import LookPicker
14
+ from staticfx.ui.screen import Screen
15
+ from staticfx.ui.settings import SettingsScreen
16
+ from staticfx.ui.studio import Studio
17
+ from staticfx.ui.widgets import Canvas
18
+
19
+ if TYPE_CHECKING:
20
+ from staticfx.app.app import App
21
+
22
+ WIDTH = 66
23
+ ALWAYS = (
24
+ ("Esc", "close the current menu (twice in the main view: quit)"),
25
+ ("Ctrl-C", "close the current menu, or quit"),
26
+ ("Ctrl-Z", "suspend to the shell (fg comes back)"),
27
+ )
28
+
29
+
30
+ def help_lines(app: App) -> list[tuple[str, str, str]]:
31
+ """``(kind, keys, text)`` rows; kind is "group", "row" or "note"."""
32
+ lines: list[tuple[str, str, str]] = []
33
+ group = ""
34
+ for action, (action_group, description) in ACTIONS.items():
35
+ if action_group != group:
36
+ group = action_group
37
+ lines.append(("group", group, ""))
38
+ keys = app.keymap.keys_for(action)
39
+ shown = " ".join(key_label(k, app.ascii_only) for k in keys) or "(unbound)"
40
+ lines.append(("row", shown, description))
41
+ lines.append(("group", "Anywhere", ""))
42
+ lines += [("row", keys, text) for keys, text in ALWAYS]
43
+ for title, screen in (
44
+ ("Effect browser", Browser),
45
+ ("Effect Studio", Studio),
46
+ ("Looks", LookPicker),
47
+ ("Settings", SettingsScreen),
48
+ ):
49
+ lines.append(("group", title, ""))
50
+ lines += [("row", keys, text) for keys, text in screen.help]
51
+ settings = app.keymap.label("settings", app.ascii_only)
52
+ lines.append(
53
+ ("note", "", f"Rebind keys in Settings ({settings}). Strobe is unlocked there too.")
54
+ )
55
+ return lines
56
+
57
+
58
+ class HelpScreen(Screen):
59
+ def __init__(self, app: App) -> None:
60
+ super().__init__(app)
61
+ self.lines = help_lines(app)
62
+ self.scroll = 0
63
+ self.rows = 10
64
+
65
+ def handle(self, key: str) -> None:
66
+ moves = {"up": -1, "down": 1, "pageup": -self.rows, "pagedown": self.rows}
67
+ if key in moves:
68
+ self.scroll += moves[key]
69
+ elif key == "home":
70
+ self.scroll = 0
71
+ elif key == "end":
72
+ self.scroll = len(self.lines)
73
+ else:
74
+ self.close()
75
+ self.scroll = max(0, min(self.scroll, len(self.lines) - self.rows))
76
+
77
+ def draw(self, canvas: Canvas) -> None:
78
+ c, s = canvas.chrome, canvas.style
79
+ w = min(canvas.width - 2, WIDTH)
80
+ h = min(canvas.height - 2, len(self.lines) + 4)
81
+ x, y = (canvas.width - w) // 2, (canvas.height - h) // 2
82
+ canvas.box(x, y, w, h, "Keys")
83
+ self.rows = h - 4
84
+ self.scroll = max(0, min(self.scroll, len(self.lines) - self.rows))
85
+ key_width = 16
86
+ for line, (kind, keys, text) in enumerate(
87
+ self.lines[self.scroll : self.scroll + self.rows], y + 1
88
+ ):
89
+ if kind == "group":
90
+ canvas.text(x + 2, line, f"{c.h * 2} {keys} ", s.dim, w - 4)
91
+ elif kind == "note":
92
+ canvas.text(x + 2, line, text, s.accent, w - 4)
93
+ else:
94
+ canvas.text(x + 3, line, keys, s.selected, key_width)
95
+ canvas.text(x + 3 + key_width, line, text, s.text, w - 6 - key_width)
96
+ canvas.scrollbar(x + w - 2, y + 1, self.rows, self.scroll, len(self.lines))
97
+ canvas.rule(x, y + h - 3, w)
98
+ more = " (more below)" if self.scroll + self.rows < len(self.lines) else ""
99
+ canvas.text(x + 2, y + h - 2, f"↑ ↓ scroll any other key closes{more}", s.dim, w - 4)
staticfx/ui/pickers.py ADDED
@@ -0,0 +1,221 @@
1
+ """Short lists with live preview: themes and looks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from staticfx import themes
9
+ from staticfx.core.color import RGB, hsv
10
+ from staticfx.library.looks import BUILTIN_LOOKS, Look
11
+ from staticfx.ui.dialogs import Confirm, ask_look_name
12
+ from staticfx.ui.screen import Screen
13
+ from staticfx.ui.widgets import Canvas, Cursor
14
+
15
+ if TYPE_CHECKING:
16
+ from staticfx.app.app import App
17
+
18
+
19
+ @dataclass
20
+ class Entry:
21
+ key: str
22
+ label: str
23
+ note: str = ""
24
+ heading: bool = False # a section title, skipped by the cursor
25
+ swatch: tuple[RGB, ...] = () # colours shown as a strip on the right
26
+
27
+
28
+ class Picker(Screen):
29
+ """A left-docked list; the highlighted entry is previewed as you move."""
30
+
31
+ title = ""
32
+ footer = "Enter choose Esc close"
33
+ width = 46
34
+
35
+ def __init__(self, app: App) -> None:
36
+ super().__init__(app)
37
+ self.cursor = Cursor() # over selectable entries
38
+ self.view = Cursor() # over every row, for scrolling
39
+ self.entries: list[Entry] = []
40
+ self.rows = 10
41
+
42
+ def selectable(self) -> list[int]:
43
+ return [i for i, e in enumerate(self.entries) if not e.heading]
44
+
45
+ @property
46
+ def current(self) -> Entry | None:
47
+ choices = self.selectable()
48
+ return self.entries[choices[self.cursor.index]] if choices else None
49
+
50
+ def select(self, key: str) -> None:
51
+ for n, i in enumerate(self.selectable()):
52
+ if self.entries[i].key == key:
53
+ self.cursor.index = n
54
+ self.hover()
55
+
56
+ def hover(self) -> None:
57
+ """Preview the current entry."""
58
+
59
+ def choose(self, entry: Entry) -> None:
60
+ """Apply the entry the user picked."""
61
+
62
+ def extra_key(self, key: str) -> None:
63
+ """Keys beyond navigation, Enter and Esc."""
64
+
65
+ def handle(self, key: str) -> None:
66
+ if key in ("escape", "ctrl-c", "q"):
67
+ self.close()
68
+ elif self.cursor.handle(key, len(self.selectable()), self.rows):
69
+ self.hover()
70
+ elif key == "enter" and self.current is not None:
71
+ entry = self.current
72
+ self.close()
73
+ self.choose(entry)
74
+ else:
75
+ self.extra_key(key)
76
+
77
+ def draw(self, canvas: Canvas) -> None:
78
+ c, s = canvas.chrome, canvas.style
79
+ w = min(canvas.width - 2, self.width)
80
+ h = min(canvas.height - 2, len(self.entries) + 5)
81
+ x, y = 1, 1
82
+ canvas.box(x, y, w, h, self.title)
83
+ self.rows = h - 5
84
+ choices = self.selectable()
85
+ chosen = choices[self.cursor.index] if choices else -1
86
+ self.view.index = max(0, chosen) # scroll over all rows, headings included
87
+ for row, i in enumerate(self.view.window(self.rows, len(self.entries)), y + 1):
88
+ entry = self.entries[i]
89
+ if entry.heading:
90
+ canvas.text(x + 2, row, entry.label, s.dim, w - 4)
91
+ continue
92
+ selected = i == chosen
93
+ marker = c.cursor if selected else " "
94
+ right = x + w - 3 # one column in from the scrollbar
95
+ if entry.swatch:
96
+ right -= len(entry.swatch)
97
+ block = "#" if self.app.ascii_only else "█"
98
+ for offset, colour in enumerate(entry.swatch):
99
+ canvas.frame.put(right + offset, row, block, colour)
100
+ right -= 1
101
+ note_width = min(len(entry.note), (w - 8) // 2)
102
+ label_colour = s.selected if selected else s.text
103
+ canvas.text(
104
+ x + 2, row, f"{marker} {entry.label}", label_colour, right - x - 3 - note_width
105
+ )
106
+ canvas.text(right - note_width, row, entry.note, s.dim, note_width)
107
+ canvas.scrollbar(x + w - 2, y + 1, self.rows, self.view.first, len(self.entries))
108
+ canvas.rule(x, y + h - 3, w)
109
+ canvas.text(x + 2, y + h - 2, self.footer, s.dim, w - 4)
110
+
111
+
112
+ SWATCH_WIDTH = 12
113
+
114
+
115
+ def swatch(theme_name: str) -> tuple[RGB, ...]:
116
+ """A strip of colours along the theme's ramp (or around the hue wheel, for animated ones)."""
117
+ if theme_name in themes.STATIC_THEMES:
118
+ theme = themes.STATIC_THEMES[theme_name]
119
+ return tuple(theme.ramp(0.15 + 0.85 * i / (SWATCH_WIDTH - 1)) for i in range(SWATCH_WIDTH))
120
+ return tuple(hsv(i / SWATCH_WIDTH) for i in range(SWATCH_WIDTH))
121
+
122
+
123
+ class ThemePicker(Picker):
124
+ title = "Themes"
125
+ help = (("↑ ↓", "preview a theme"), ("Enter", "keep it"), ("Esc", "go back to the old one"))
126
+
127
+ def __init__(self, app: App) -> None:
128
+ super().__init__(app)
129
+ notes = {"rainbow": "animated", "strobe": "flashing"}
130
+ self.entries = [
131
+ Entry(name, themes.LABELS[name], notes.get(name, ""), swatch=swatch(name))
132
+ for name in app.theme_names()
133
+ ]
134
+ self.select(app.player.theme_name)
135
+
136
+ def hover(self) -> None:
137
+ if self.current is not None:
138
+ self.app.player.preview_theme = self.current.key
139
+
140
+ def on_close(self) -> None:
141
+ self.app.player.preview_theme = None
142
+
143
+ def choose(self, entry: Entry) -> None:
144
+ self.app.set_theme(entry.key)
145
+ self.app.toast(f"Theme: {entry.label}")
146
+
147
+
148
+ class LookPicker(Picker):
149
+ title = "Looks"
150
+ footer = "Enter apply s save current d delete Esc close"
151
+ width = 60
152
+ help = (
153
+ ("↑ ↓", "preview a look"),
154
+ ("Enter", "apply it"),
155
+ ("s", "save what's playing now as a look"),
156
+ ("d Del", "delete one of your looks"),
157
+ )
158
+
159
+ def __init__(self, app: App) -> None:
160
+ super().__init__(app)
161
+ self.looks: dict[str, Look] = {}
162
+ self.rebuild()
163
+
164
+ def rebuild(self) -> None:
165
+ mine = self.app.looks.looks
166
+ entries = [Entry("", "Your looks", heading=True)]
167
+ if not mine:
168
+ entries.append(Entry("", " none yet: press s to save one", heading=True))
169
+ for look in mine:
170
+ entries.append(
171
+ Entry(
172
+ f"mine:{look.name}",
173
+ look.name,
174
+ self.app.registry[look.effect].label
175
+ if look.effect in self.app.registry
176
+ else "missing effect",
177
+ )
178
+ )
179
+ entries.append(Entry("", "Built in", heading=True))
180
+ entries += [Entry(f"builtin:{look.name}", look.name, look.note) for look in BUILTIN_LOOKS]
181
+ self.entries = entries
182
+ self.looks = {f"mine:{look.name}": look for look in mine}
183
+ self.looks.update({f"builtin:{look.name}": look for look in BUILTIN_LOOKS})
184
+ self.cursor.clamp(len(self.selectable()))
185
+ self.hover()
186
+
187
+ def hover(self) -> None:
188
+ player = self.app.player
189
+ look = self.looks.get(self.current.key) if self.current else None
190
+ if look is None or (look.theme == "strobe" and not self.app.settings.allow_flashing):
191
+ player.preview(None)
192
+ player.preview_theme = None
193
+ return
194
+ player.preview(look.effect)
195
+ player.preview_theme = look.theme
196
+
197
+ def on_close(self) -> None:
198
+ self.app.player.preview(None)
199
+ self.app.player.preview_theme = None
200
+
201
+ def choose(self, entry: Entry) -> None:
202
+ self.app.apply_look(self.looks[entry.key])
203
+
204
+ def extra_key(self, key: str) -> None:
205
+ entry = self.current
206
+ if key.lower() == "s":
207
+ self.close()
208
+ ask_look_name(self.app)
209
+ elif key in ("d", "D", "delete") and entry is not None:
210
+ if not entry.key.startswith("mine:"):
211
+ self.app.toast("Built-in looks can't be deleted")
212
+ return
213
+ name = self.looks[entry.key].name
214
+
215
+ def confirmed() -> None:
216
+ self.app.looks.remove(name)
217
+ self.rebuild()
218
+
219
+ self.app.push(
220
+ Confirm(self.app, "Delete look", [f"Delete the look '{name}'?"], confirmed)
221
+ )