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/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
|
+
)
|