cloudspin 0.1.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.
- cloudspin/__init__.py +3 -0
- cloudspin/__main__.py +4 -0
- cloudspin/app.py +497 -0
- cloudspin/canvas.py +321 -0
- cloudspin/cli.py +98 -0
- cloudspin/color.py +78 -0
- cloudspin/config.py +105 -0
- cloudspin/library.py +140 -0
- cloudspin/mood.py +245 -0
- cloudspin/player.py +447 -0
- cloudspin/screens.py +130 -0
- cloudspin/sky.py +829 -0
- cloudspin/songlook.py +116 -0
- cloudspin/spectrum.py +83 -0
- cloudspin/themes.py +100 -0
- cloudspin/vinyl.py +182 -0
- cloudspin/weather.py +310 -0
- cloudspin/widgets.py +750 -0
- cloudspin-0.1.0.dist-info/METADATA +206 -0
- cloudspin-0.1.0.dist-info/RECORD +23 -0
- cloudspin-0.1.0.dist-info/WHEEL +4 -0
- cloudspin-0.1.0.dist-info/entry_points.txt +2 -0
- cloudspin-0.1.0.dist-info/licenses/LICENSE +21 -0
cloudspin/canvas.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""A truecolor pixel canvas drawn with half-block characters.
|
|
2
|
+
|
|
3
|
+
Every terminal cell holds two stacked pixels: the upper one is the foreground
|
|
4
|
+
of a ``▀`` and the lower one is the background. Pixels live in three flat
|
|
5
|
+
bytearrays so whole rows and columns can be copied or recoloured in C.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
from rich.cells import cell_len
|
|
13
|
+
from rich.color import Color
|
|
14
|
+
from rich.segment import Segment
|
|
15
|
+
from rich.style import Style
|
|
16
|
+
|
|
17
|
+
from .color import RGB
|
|
18
|
+
|
|
19
|
+
_PIXEL_STYLES: dict[int, Style] = {}
|
|
20
|
+
_TEXT_STYLES: dict[tuple, Style] = {}
|
|
21
|
+
_CACHE_LIMIT = 60_000
|
|
22
|
+
|
|
23
|
+
#: Per-row text overlay: cell x -> (character, packed fg, bold) or None for
|
|
24
|
+
#: the trailing half of a double-width character.
|
|
25
|
+
RowText = dict[int, tuple[str, int, bool] | None]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pack(c: RGB) -> int:
|
|
29
|
+
return (c[0] << 16) | (c[1] << 8) | c[2]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def text_style(
|
|
33
|
+
fg: RGB | None,
|
|
34
|
+
bg: RGB | None,
|
|
35
|
+
bold: bool = False,
|
|
36
|
+
italic: bool = False,
|
|
37
|
+
underline: bool = False,
|
|
38
|
+
) -> Style:
|
|
39
|
+
key = (fg, bg, bold, italic, underline)
|
|
40
|
+
style = _TEXT_STYLES.get(key)
|
|
41
|
+
if style is None:
|
|
42
|
+
if len(_TEXT_STYLES) > _CACHE_LIMIT:
|
|
43
|
+
_TEXT_STYLES.clear()
|
|
44
|
+
style = Style(
|
|
45
|
+
color=Color.from_rgb(*fg) if fg is not None else None,
|
|
46
|
+
bgcolor=Color.from_rgb(*bg) if bg is not None else None,
|
|
47
|
+
bold=bold or None,
|
|
48
|
+
italic=italic or None,
|
|
49
|
+
underline=underline or None,
|
|
50
|
+
)
|
|
51
|
+
_TEXT_STYLES[key] = style
|
|
52
|
+
return style
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _pixel_style(fg: int, bg: int) -> Style:
|
|
56
|
+
key = (fg << 24) | bg
|
|
57
|
+
style = _PIXEL_STYLES.get(key)
|
|
58
|
+
if style is None:
|
|
59
|
+
if len(_PIXEL_STYLES) > _CACHE_LIMIT:
|
|
60
|
+
_PIXEL_STYLES.clear()
|
|
61
|
+
style = Style(
|
|
62
|
+
color=Color.from_rgb(fg >> 16, (fg >> 8) & 255, fg & 255),
|
|
63
|
+
bgcolor=Color.from_rgb(bg >> 16, (bg >> 8) & 255, bg & 255),
|
|
64
|
+
)
|
|
65
|
+
_PIXEL_STYLES[key] = style
|
|
66
|
+
return style
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _fill_style(bg: int) -> Style:
|
|
70
|
+
key = -1 - bg
|
|
71
|
+
style = _PIXEL_STYLES.get(key)
|
|
72
|
+
if style is None:
|
|
73
|
+
if len(_PIXEL_STYLES) > _CACHE_LIMIT:
|
|
74
|
+
_PIXEL_STYLES.clear()
|
|
75
|
+
style = Style(bgcolor=Color.from_rgb(bg >> 16, (bg >> 8) & 255, bg & 255))
|
|
76
|
+
_PIXEL_STYLES[key] = style
|
|
77
|
+
return style
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def lay_text(rows: dict[int, RowText], x: int, row: int, text: str, fg: RGB, bold: bool = False) -> int:
|
|
81
|
+
"""Place text into an overlay map; returns the cell width that was used."""
|
|
82
|
+
target = rows.setdefault(row, {})
|
|
83
|
+
packed = pack(fg)
|
|
84
|
+
start = x
|
|
85
|
+
for ch in text:
|
|
86
|
+
width = cell_len(ch)
|
|
87
|
+
if width <= 0:
|
|
88
|
+
continue
|
|
89
|
+
target[x] = (ch, packed, bold)
|
|
90
|
+
if width == 2:
|
|
91
|
+
target[x + 1] = None
|
|
92
|
+
x += width
|
|
93
|
+
return x - start
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Canvas:
|
|
97
|
+
__slots__ = ("w", "h", "r", "g", "b")
|
|
98
|
+
|
|
99
|
+
def __init__(self, w: int = 0, h: int = 0) -> None:
|
|
100
|
+
self.w = 0
|
|
101
|
+
self.h = 0
|
|
102
|
+
self.r = bytearray()
|
|
103
|
+
self.g = bytearray()
|
|
104
|
+
self.b = bytearray()
|
|
105
|
+
self.resize(w, h)
|
|
106
|
+
|
|
107
|
+
def resize(self, w: int, h: int) -> None:
|
|
108
|
+
self.w = max(0, w)
|
|
109
|
+
self.h = max(0, h)
|
|
110
|
+
n = self.w * self.h
|
|
111
|
+
self.r = bytearray(n)
|
|
112
|
+
self.g = bytearray(n)
|
|
113
|
+
self.b = bytearray(n)
|
|
114
|
+
|
|
115
|
+
# -- whole-buffer operations -------------------------------------------
|
|
116
|
+
|
|
117
|
+
def fill(self, c: RGB) -> None:
|
|
118
|
+
n = self.w * self.h
|
|
119
|
+
self.r[:] = bytes((c[0],)) * n
|
|
120
|
+
self.g[:] = bytes((c[1],)) * n
|
|
121
|
+
self.b[:] = bytes((c[2],)) * n
|
|
122
|
+
|
|
123
|
+
def blit(self, other: Canvas) -> None:
|
|
124
|
+
self.r[:] = other.r
|
|
125
|
+
self.g[:] = other.g
|
|
126
|
+
self.b[:] = other.b
|
|
127
|
+
|
|
128
|
+
def fill_rows(self, colors: list[RGB]) -> None:
|
|
129
|
+
"""Paint one colour per pixel row, top to bottom."""
|
|
130
|
+
w = self.w
|
|
131
|
+
for y, c in enumerate(colors[: self.h]):
|
|
132
|
+
i = y * w
|
|
133
|
+
self.r[i : i + w] = bytes((c[0],)) * w
|
|
134
|
+
self.g[i : i + w] = bytes((c[1],)) * w
|
|
135
|
+
self.b[i : i + w] = bytes((c[2],)) * w
|
|
136
|
+
|
|
137
|
+
def lighten(self, amount: int) -> None:
|
|
138
|
+
"""Add a flat amount of white to every pixel (lightning)."""
|
|
139
|
+
if amount <= 0:
|
|
140
|
+
return
|
|
141
|
+
table = bytes(min(255, v + amount) for v in range(256))
|
|
142
|
+
self.r[:] = self.r.translate(table)
|
|
143
|
+
self.g[:] = self.g.translate(table)
|
|
144
|
+
self.b[:] = self.b.translate(table)
|
|
145
|
+
|
|
146
|
+
def remap_column(self, x: int, y_end: int, table: bytes) -> None:
|
|
147
|
+
"""Run the pixels of column x, rows [0, y_end), through a lookup table."""
|
|
148
|
+
if not (0 <= x < self.w) or y_end <= 0:
|
|
149
|
+
return
|
|
150
|
+
end = min(y_end, self.h) * self.w
|
|
151
|
+
w = self.w
|
|
152
|
+
self.r[x:end:w] = self.r[x:end:w].translate(table)
|
|
153
|
+
self.g[x:end:w] = self.g[x:end:w].translate(table)
|
|
154
|
+
self.b[x:end:w] = self.b[x:end:w].translate(table)
|
|
155
|
+
|
|
156
|
+
def fill_column(self, x: int, y_start: int, c: RGB) -> None:
|
|
157
|
+
"""Fill column x from row y_start down to the bottom edge."""
|
|
158
|
+
if not (0 <= x < self.w):
|
|
159
|
+
return
|
|
160
|
+
y_start = max(0, y_start)
|
|
161
|
+
n = self.h - y_start
|
|
162
|
+
if n <= 0:
|
|
163
|
+
return
|
|
164
|
+
start = y_start * self.w + x
|
|
165
|
+
w = self.w
|
|
166
|
+
self.r[start::w] = bytes((c[0],)) * n
|
|
167
|
+
self.g[start::w] = bytes((c[1],)) * n
|
|
168
|
+
self.b[start::w] = bytes((c[2],)) * n
|
|
169
|
+
|
|
170
|
+
# -- single pixels -----------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def put(self, x: int, y: int, c: RGB) -> None:
|
|
173
|
+
if 0 <= x < self.w and 0 <= y < self.h:
|
|
174
|
+
i = y * self.w + x
|
|
175
|
+
self.r[i] = c[0]
|
|
176
|
+
self.g[i] = c[1]
|
|
177
|
+
self.b[i] = c[2]
|
|
178
|
+
|
|
179
|
+
def blend(self, x: int, y: int, c: RGB, a: int) -> None:
|
|
180
|
+
"""Alpha-blend a colour onto a pixel; ``a`` runs 0..256."""
|
|
181
|
+
if a > 0 and 0 <= x < self.w and 0 <= y < self.h:
|
|
182
|
+
i = y * self.w + x
|
|
183
|
+
v = self.r[i]
|
|
184
|
+
self.r[i] = v + ((c[0] - v) * a >> 8)
|
|
185
|
+
v = self.g[i]
|
|
186
|
+
self.g[i] = v + ((c[1] - v) * a >> 8)
|
|
187
|
+
v = self.b[i]
|
|
188
|
+
self.b[i] = v + ((c[2] - v) * a >> 8)
|
|
189
|
+
|
|
190
|
+
def add(self, x: int, y: int, c: RGB, a: int) -> None:
|
|
191
|
+
"""Additively blend light onto a pixel; ``a`` runs 0..256."""
|
|
192
|
+
if a > 0 and 0 <= x < self.w and 0 <= y < self.h:
|
|
193
|
+
i = y * self.w + x
|
|
194
|
+
v = self.r[i] + (c[0] * a >> 8)
|
|
195
|
+
self.r[i] = 255 if v > 255 else v
|
|
196
|
+
v = self.g[i] + (c[1] * a >> 8)
|
|
197
|
+
self.g[i] = 255 if v > 255 else v
|
|
198
|
+
v = self.b[i] + (c[2] * a >> 8)
|
|
199
|
+
self.b[i] = 255 if v > 255 else v
|
|
200
|
+
|
|
201
|
+
# -- shapes ------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def disc(self, cx: float, cy: float, radius: float, c: RGB, a: int = 256) -> None:
|
|
204
|
+
"""Anti-aliased filled circle."""
|
|
205
|
+
y0 = max(0, int(cy - radius - 1))
|
|
206
|
+
y1 = min(self.h - 1, int(cy + radius + 2))
|
|
207
|
+
x0 = max(0, int(cx - radius - 1))
|
|
208
|
+
x1 = min(self.w - 1, int(cx + radius + 2))
|
|
209
|
+
for y in range(y0, y1 + 1):
|
|
210
|
+
dy = y - cy
|
|
211
|
+
for x in range(x0, x1 + 1):
|
|
212
|
+
cover = radius + 0.5 - math.hypot(x - cx, dy)
|
|
213
|
+
if cover > 0.0:
|
|
214
|
+
self.blend(x, y, c, a if cover >= 1.0 else int(a * cover))
|
|
215
|
+
|
|
216
|
+
def glow(self, cx: float, cy: float, radius: float, c: RGB, strength: float) -> None:
|
|
217
|
+
"""Additive radial glow with a quadratic falloff."""
|
|
218
|
+
if radius <= 0 or strength <= 0:
|
|
219
|
+
return
|
|
220
|
+
y0 = max(0, int(cy - radius))
|
|
221
|
+
y1 = min(self.h - 1, int(cy + radius + 1))
|
|
222
|
+
x0 = max(0, int(cx - radius))
|
|
223
|
+
x1 = min(self.w - 1, int(cx + radius + 1))
|
|
224
|
+
inv = 1.0 / radius
|
|
225
|
+
peak = strength * 256.0
|
|
226
|
+
for y in range(y0, y1 + 1):
|
|
227
|
+
dy = y - cy
|
|
228
|
+
for x in range(x0, x1 + 1):
|
|
229
|
+
f = 1.0 - math.hypot(x - cx, dy) * inv
|
|
230
|
+
if f > 0.0:
|
|
231
|
+
self.add(x, y, c, int(peak * f * f))
|
|
232
|
+
|
|
233
|
+
def line(
|
|
234
|
+
self, x0: float, y0: float, x1: float, y1: float, c: RGB, a: int = 256, width: float = 1.0
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Anti-aliased line with round caps."""
|
|
237
|
+
half = width / 2.0
|
|
238
|
+
pad = half + 1.0
|
|
239
|
+
bx0 = max(0, int(min(x0, x1) - pad))
|
|
240
|
+
bx1 = min(self.w - 1, int(max(x0, x1) + pad + 1))
|
|
241
|
+
by0 = max(0, int(min(y0, y1) - pad))
|
|
242
|
+
by1 = min(self.h - 1, int(max(y0, y1) + pad + 1))
|
|
243
|
+
dx = x1 - x0
|
|
244
|
+
dy = y1 - y0
|
|
245
|
+
length_sq = dx * dx + dy * dy
|
|
246
|
+
for y in range(by0, by1 + 1):
|
|
247
|
+
for x in range(bx0, bx1 + 1):
|
|
248
|
+
if length_sq > 0.0:
|
|
249
|
+
t = ((x - x0) * dx + (y - y0) * dy) / length_sq
|
|
250
|
+
t = 0.0 if t < 0.0 else 1.0 if t > 1.0 else t
|
|
251
|
+
else:
|
|
252
|
+
t = 0.0
|
|
253
|
+
cover = half + 0.5 - math.hypot(x - (x0 + dx * t), y - (y0 + dy * t))
|
|
254
|
+
if cover > 0.0:
|
|
255
|
+
self.blend(x, y, c, a if cover >= 1.0 else int(a * cover))
|
|
256
|
+
|
|
257
|
+
# -- output ------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def row_key(self, row: int) -> bytes:
|
|
260
|
+
"""The raw pixels behind one text row, for change detection."""
|
|
261
|
+
i = row * 2 * self.w
|
|
262
|
+
j = i + 2 * self.w
|
|
263
|
+
return bytes(self.r[i:j]) + bytes(self.g[i:j]) + bytes(self.b[i:j])
|
|
264
|
+
|
|
265
|
+
def segments(self, row: int, text: RowText | None = None) -> list[Segment]:
|
|
266
|
+
"""Render one text row (two pixel rows) as Rich segments."""
|
|
267
|
+
w = self.w
|
|
268
|
+
top = row * 2 * w
|
|
269
|
+
bottom = top + w
|
|
270
|
+
r, g, b = self.r, self.g, self.b
|
|
271
|
+
has_bottom = bottom + w <= len(r)
|
|
272
|
+
out: list[Segment] = []
|
|
273
|
+
run_key = None
|
|
274
|
+
run_len = 0
|
|
275
|
+
run_style: Style | None = None
|
|
276
|
+
run_char = " "
|
|
277
|
+
for x in range(w):
|
|
278
|
+
i = top + x
|
|
279
|
+
fg = (r[i] << 16) | (g[i] << 8) | b[i]
|
|
280
|
+
if has_bottom:
|
|
281
|
+
j = bottom + x
|
|
282
|
+
bg = (r[j] << 16) | (g[j] << 8) | b[j]
|
|
283
|
+
else:
|
|
284
|
+
bg = fg
|
|
285
|
+
if text is not None and x in text:
|
|
286
|
+
if run_len:
|
|
287
|
+
out.append(Segment(run_char * run_len, run_style))
|
|
288
|
+
run_len = 0
|
|
289
|
+
run_key = None
|
|
290
|
+
cell = text[x]
|
|
291
|
+
if cell is not None:
|
|
292
|
+
ch, tfg, bold = cell
|
|
293
|
+
ur = ((fg >> 16) + (bg >> 16)) >> 1
|
|
294
|
+
ug = (((fg >> 8) & 255) + ((bg >> 8) & 255)) >> 1
|
|
295
|
+
ub = ((fg & 255) + (bg & 255)) >> 1
|
|
296
|
+
light = (ur * 3 + ug * 6 + ub) // 10
|
|
297
|
+
if light > 150:
|
|
298
|
+
# Words stay readable when a bright cloud drifts behind them.
|
|
299
|
+
ur, ug, ub = ur * 150 // light, ug * 150 // light, ub * 150 // light
|
|
300
|
+
under = (ur, ug, ub)
|
|
301
|
+
out.append(
|
|
302
|
+
Segment(ch, text_style((tfg >> 16, (tfg >> 8) & 255, tfg & 255), under, bold))
|
|
303
|
+
)
|
|
304
|
+
continue
|
|
305
|
+
key = (fg << 24) | bg
|
|
306
|
+
if key == run_key:
|
|
307
|
+
run_len += 1
|
|
308
|
+
continue
|
|
309
|
+
if run_len:
|
|
310
|
+
out.append(Segment(run_char * run_len, run_style))
|
|
311
|
+
run_key = key
|
|
312
|
+
run_len = 1
|
|
313
|
+
if fg == bg:
|
|
314
|
+
run_char = " "
|
|
315
|
+
run_style = _fill_style(bg)
|
|
316
|
+
else:
|
|
317
|
+
run_char = "▀"
|
|
318
|
+
run_style = _pixel_style(fg, bg)
|
|
319
|
+
if run_len:
|
|
320
|
+
out.append(Segment(run_char * run_len, run_style))
|
|
321
|
+
return out
|
cloudspin/cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="cloudspin",
|
|
15
|
+
description="A terminal music player under a living sky.",
|
|
16
|
+
epilog="Settings live in ~/.config/cloudspin/config.toml. Press ? inside the app for the keys.",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument("music", nargs="*", help="files, folders or stream URLs to play (default: your music folder)")
|
|
19
|
+
parser.add_argument("--city", help="city for the weather, for example --city \"New Delhi\"")
|
|
20
|
+
parser.add_argument("--sky", metavar="MODE", help="song, weather, or a scene: storm, rain, evening, night, day, dawn, snow, fog, overcast")
|
|
21
|
+
parser.add_argument("--colors", metavar="THEME", help="sky, cover, amber, orchid, lagoon, moss, rose, mono, or one of your own")
|
|
22
|
+
parser.add_argument("--units", choices=("metric", "imperial"), help="°C and km/h, or °F and mph")
|
|
23
|
+
parser.add_argument("--fps", type=int, metavar="N", help="animation frames per second (4-60, default 24)")
|
|
24
|
+
parser.add_argument("--demo", action="store_true", help="run a silent demo with made-up songs")
|
|
25
|
+
parser.add_argument("--version", action="version", version=f"cloudspin {__version__}")
|
|
26
|
+
return parser
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: list[str] | None = None) -> int:
|
|
30
|
+
args = build_parser().parse_args(argv)
|
|
31
|
+
|
|
32
|
+
# Imported late so --help and --version stay instant.
|
|
33
|
+
from . import library, mood, weather
|
|
34
|
+
from .app import CloudspinApp
|
|
35
|
+
from .config import Config
|
|
36
|
+
from .player import DEMO_TRACKS, BackendError, Deck, DemoBackend, MpvBackend
|
|
37
|
+
from .themes import load_themes
|
|
38
|
+
|
|
39
|
+
config = Config.load()
|
|
40
|
+
if args.units:
|
|
41
|
+
config.units = args.units
|
|
42
|
+
if args.fps:
|
|
43
|
+
config.fps = max(4, min(60, args.fps))
|
|
44
|
+
if args.sky:
|
|
45
|
+
if args.sky not in ("song", "weather") and args.sky not in mood.SCENES:
|
|
46
|
+
print(f"cloudspin: unknown sky “{args.sky}”. Try: song, weather, {', '.join(mood.SCENES)}", file=sys.stderr)
|
|
47
|
+
return 2
|
|
48
|
+
config.sky = args.sky
|
|
49
|
+
if args.colors:
|
|
50
|
+
themes = load_themes(config.themes)
|
|
51
|
+
if args.colors not in themes:
|
|
52
|
+
print(f"cloudspin: unknown theme “{args.colors}”. Try: {', '.join(themes)}", file=sys.stderr)
|
|
53
|
+
return 2
|
|
54
|
+
config.colors = args.colors
|
|
55
|
+
if args.city:
|
|
56
|
+
try:
|
|
57
|
+
place = weather.geocode(args.city)
|
|
58
|
+
except weather.WeatherError as error:
|
|
59
|
+
print(f"cloudspin: {error}", file=sys.stderr)
|
|
60
|
+
return 1
|
|
61
|
+
config.city, config.country = place.name, place.country
|
|
62
|
+
config.latitude, config.longitude = place.latitude, place.longitude
|
|
63
|
+
config.save()
|
|
64
|
+
|
|
65
|
+
notice = ""
|
|
66
|
+
silent_reason = ""
|
|
67
|
+
if args.demo:
|
|
68
|
+
tracks = [type(t)(**vars(t)) for t in DEMO_TRACKS]
|
|
69
|
+
backend = DemoBackend()
|
|
70
|
+
silent_reason = "Silent demo"
|
|
71
|
+
else:
|
|
72
|
+
sources = args.music or [config.music]
|
|
73
|
+
tracks = library.scan(sources)
|
|
74
|
+
if not tracks:
|
|
75
|
+
where = ", ".join(str(Path(s).expanduser()) if "://" not in s else s for s in sources)
|
|
76
|
+
tracks = [type(t)(**vars(t)) for t in DEMO_TRACKS]
|
|
77
|
+
backend = DemoBackend()
|
|
78
|
+
silent_reason = "Silent demo"
|
|
79
|
+
notice = f"No music found in {where}. Showing a silent demo; start with: cloudspin <folder>"
|
|
80
|
+
else:
|
|
81
|
+
try:
|
|
82
|
+
backend = MpvBackend(volume=config.volume)
|
|
83
|
+
except BackendError as error:
|
|
84
|
+
backend = DemoBackend()
|
|
85
|
+
silent_reason = "No sound"
|
|
86
|
+
notice = str(error)
|
|
87
|
+
|
|
88
|
+
deck = Deck(backend, tracks, volume=config.volume)
|
|
89
|
+
app = CloudspinApp(config, deck, silent_reason=silent_reason, notice=notice)
|
|
90
|
+
try:
|
|
91
|
+
app.run()
|
|
92
|
+
finally:
|
|
93
|
+
deck.close()
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
raise SystemExit(main())
|
cloudspin/color.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Small RGB helpers. Colours are plain ``(r, g, b)`` tuples of 0-255 ints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
RGB = tuple[int, int, int]
|
|
8
|
+
|
|
9
|
+
WHITE: RGB = (255, 255, 255)
|
|
10
|
+
BLACK: RGB = (0, 0, 0)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse(value: str) -> RGB:
|
|
14
|
+
"""Parse ``#rgb`` or ``#rrggbb`` into an RGB tuple."""
|
|
15
|
+
text = value.strip().lstrip("#")
|
|
16
|
+
if len(text) == 3:
|
|
17
|
+
text = "".join(ch * 2 for ch in text)
|
|
18
|
+
if len(text) != 6:
|
|
19
|
+
raise ValueError(f"not a hex colour: {value!r}")
|
|
20
|
+
try:
|
|
21
|
+
return (int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16))
|
|
22
|
+
except ValueError:
|
|
23
|
+
raise ValueError(f"not a hex colour: {value!r}") from None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def to_hex(c: RGB) -> str:
|
|
27
|
+
return f"#{c[0]:02x}{c[1]:02x}{c[2]:02x}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def clamp(v: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
|
31
|
+
return lo if v < lo else hi if v > hi else v
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def lerp(a: RGB, b: RGB, t: float) -> RGB:
|
|
35
|
+
t = 0.0 if t < 0.0 else 1.0 if t > 1.0 else t
|
|
36
|
+
return (
|
|
37
|
+
int(a[0] + (b[0] - a[0]) * t + 0.5),
|
|
38
|
+
int(a[1] + (b[1] - a[1]) * t + 0.5),
|
|
39
|
+
int(a[2] + (b[2] - a[2]) * t + 0.5),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def scale(c: RGB, k: float) -> RGB:
|
|
44
|
+
return (
|
|
45
|
+
min(255, max(0, int(c[0] * k + 0.5))),
|
|
46
|
+
min(255, max(0, int(c[1] * k + 0.5))),
|
|
47
|
+
min(255, max(0, int(c[2] * k + 0.5))),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ramp(stops: Sequence[tuple[float, RGB]], t: float) -> RGB:
|
|
52
|
+
"""Piecewise-linear gradient through ``(position, colour)`` stops."""
|
|
53
|
+
if t <= stops[0][0]:
|
|
54
|
+
return stops[0][1]
|
|
55
|
+
for (p0, c0), (p1, c1) in zip(stops, stops[1:]):
|
|
56
|
+
if t <= p1:
|
|
57
|
+
return lerp(c0, c1, (t - p0) / (p1 - p0) if p1 > p0 else 0.0)
|
|
58
|
+
return stops[-1][1]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def smoothstep(lo: float, hi: float, v: float) -> float:
|
|
62
|
+
if hi == lo:
|
|
63
|
+
return 0.0 if v < lo else 1.0
|
|
64
|
+
t = clamp((v - lo) / (hi - lo))
|
|
65
|
+
return t * t * (3.0 - 2.0 * t)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def approach(current: RGB, target: RGB, k: float) -> RGB:
|
|
69
|
+
"""Move a colour toward a target; snaps once it is a rounding step away."""
|
|
70
|
+
if current == target:
|
|
71
|
+
return current
|
|
72
|
+
out = []
|
|
73
|
+
for a, b in zip(current, target):
|
|
74
|
+
step = (b - a) * k
|
|
75
|
+
if abs(step) < 1.0:
|
|
76
|
+
step = 1.0 if b > a else -1.0 if b < a else 0.0
|
|
77
|
+
out.append(int(a + step))
|
|
78
|
+
return (out[0], out[1], out[2])
|
cloudspin/config.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Settings in ``~/.config/cloudspin/config.toml``.
|
|
2
|
+
|
|
3
|
+
The file is yours to edit. Cloudspin only rewrites the handful of values it
|
|
4
|
+
changes from inside the app (city, colours, sky, volume) and keeps your
|
|
5
|
+
``[themes]`` and ``[moods]`` tables as they are.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def config_path() -> Path:
|
|
17
|
+
root = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
18
|
+
return Path(root) / "cloudspin" / "config.toml"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cache_dir() -> Path:
|
|
22
|
+
root = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
23
|
+
return Path(root) / "cloudspin"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Config:
|
|
28
|
+
city: str = ""
|
|
29
|
+
latitude: float | None = None
|
|
30
|
+
longitude: float | None = None
|
|
31
|
+
country: str = ""
|
|
32
|
+
units: str = "metric" # or "imperial"
|
|
33
|
+
colors: str = "sky" # "sky", "cover", or a theme name
|
|
34
|
+
sky: str = "song" # "song", "weather", or a scene name
|
|
35
|
+
music: str = "~/Music"
|
|
36
|
+
volume: int = 70
|
|
37
|
+
fps: int = 24
|
|
38
|
+
themes: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
39
|
+
moods: dict[str, str] = field(default_factory=dict)
|
|
40
|
+
path: Path | None = None
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def load(cls, path: Path | None = None) -> Config:
|
|
44
|
+
path = path or config_path()
|
|
45
|
+
config = cls(path=path)
|
|
46
|
+
try:
|
|
47
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
48
|
+
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError):
|
|
49
|
+
return config
|
|
50
|
+
for key in ("city", "country", "units", "colors", "sky", "music"):
|
|
51
|
+
if isinstance(data.get(key), str):
|
|
52
|
+
setattr(config, key, data[key])
|
|
53
|
+
for key in ("latitude", "longitude"):
|
|
54
|
+
if isinstance(data.get(key), (int, float)) and not isinstance(data.get(key), bool):
|
|
55
|
+
setattr(config, key, float(data[key]))
|
|
56
|
+
for key, lo, hi in (("volume", 0, 100), ("fps", 4, 60)):
|
|
57
|
+
if isinstance(data.get(key), int) and not isinstance(data.get(key), bool):
|
|
58
|
+
setattr(config, key, max(lo, min(hi, data[key])))
|
|
59
|
+
if config.units not in ("metric", "imperial"):
|
|
60
|
+
config.units = "metric"
|
|
61
|
+
themes = data.get("themes")
|
|
62
|
+
if isinstance(themes, dict):
|
|
63
|
+
config.themes = {
|
|
64
|
+
str(name): {str(k): str(v) for k, v in table.items() if isinstance(v, str)}
|
|
65
|
+
for name, table in themes.items()
|
|
66
|
+
if isinstance(table, dict)
|
|
67
|
+
}
|
|
68
|
+
moods = data.get("moods")
|
|
69
|
+
if isinstance(moods, dict):
|
|
70
|
+
config.moods = {str(word): str(scene) for word, scene in moods.items() if isinstance(scene, str)}
|
|
71
|
+
return config
|
|
72
|
+
|
|
73
|
+
def save(self) -> None:
|
|
74
|
+
if self.path is None:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
def quote(text: str) -> str:
|
|
78
|
+
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
79
|
+
|
|
80
|
+
lines = [
|
|
81
|
+
"# Cloudspin settings. Edit freely; see the README for every option.",
|
|
82
|
+
f"city = {quote(self.city)}",
|
|
83
|
+
]
|
|
84
|
+
if self.latitude is not None and self.longitude is not None:
|
|
85
|
+
lines += [f"latitude = {self.latitude:.4f}", f"longitude = {self.longitude:.4f}"]
|
|
86
|
+
lines += [
|
|
87
|
+
f"country = {quote(self.country)}",
|
|
88
|
+
f"units = {quote(self.units)}",
|
|
89
|
+
f"colors = {quote(self.colors)}",
|
|
90
|
+
f"sky = {quote(self.sky)}",
|
|
91
|
+
f"music = {quote(self.music)}",
|
|
92
|
+
f"volume = {self.volume}",
|
|
93
|
+
f"fps = {self.fps}",
|
|
94
|
+
]
|
|
95
|
+
if self.moods:
|
|
96
|
+
lines += ["", "[moods]"]
|
|
97
|
+
lines += [f"{quote(word)} = {quote(scene)}" for word, scene in self.moods.items()]
|
|
98
|
+
for name, table in self.themes.items():
|
|
99
|
+
lines += ["", f"[themes.{quote(name)}]"]
|
|
100
|
+
lines += [f"{key} = {quote(value)}" for key, value in table.items()]
|
|
101
|
+
try:
|
|
102
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
self.path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
104
|
+
except OSError:
|
|
105
|
+
pass
|