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/widgets.py
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
"""Everything on screen. Each widget paints its own lines so colour can follow
|
|
2
|
+
the sky or the song from one frame to the next."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from rich.cells import cell_len, set_cell_size
|
|
9
|
+
from rich.segment import Segment
|
|
10
|
+
from textual import events
|
|
11
|
+
from textual.geometry import Region
|
|
12
|
+
from textual.strip import Strip
|
|
13
|
+
from textual.widget import Widget
|
|
14
|
+
|
|
15
|
+
from .canvas import Canvas, RowText, lay_text, text_style
|
|
16
|
+
from .color import RGB, lerp, ramp
|
|
17
|
+
from .player import Deck
|
|
18
|
+
from .sky import SkyScene
|
|
19
|
+
from .spectrum import Spectrum
|
|
20
|
+
from .themes import Palette
|
|
21
|
+
from .vinyl import Vinyl
|
|
22
|
+
from .weather import Report, compass, icon_for
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .app import CloudspinApp
|
|
26
|
+
|
|
27
|
+
INK: RGB = (245, 247, 251) # text that sits on the sky
|
|
28
|
+
INK_SOFT: RGB = (208, 216, 230)
|
|
29
|
+
INK_RAIN: RGB = (150, 198, 255)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def seg(text: str, fg: RGB | None, bg: RGB | None, bold: bool = False, italic: bool = False) -> Segment:
|
|
33
|
+
return Segment(text, text_style(fg, bg, bold, italic))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fit(text: str, width: int) -> str:
|
|
37
|
+
"""Crop text to a cell width, ending with an ellipsis when it is cut."""
|
|
38
|
+
if width <= 0:
|
|
39
|
+
return ""
|
|
40
|
+
if cell_len(text) <= width:
|
|
41
|
+
return text
|
|
42
|
+
return set_cell_size(text, max(0, width - 1)).rstrip() + "…"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def clock(seconds: float) -> str:
|
|
46
|
+
seconds = max(0, int(seconds))
|
|
47
|
+
if seconds >= 3600:
|
|
48
|
+
return f"{seconds // 3600}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}"
|
|
49
|
+
return f"{seconds // 60}:{seconds % 60:02d}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Painted(Widget):
|
|
53
|
+
"""A widget that redraws its lines only when the state behind them changes."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, **kwargs) -> None:
|
|
56
|
+
super().__init__(**kwargs)
|
|
57
|
+
self._rows: list[Strip] = []
|
|
58
|
+
self._seen: object = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def cloud(self) -> CloudspinApp:
|
|
62
|
+
return self.app # type: ignore[return-value]
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def pal(self) -> Palette:
|
|
66
|
+
return self.cloud.palette
|
|
67
|
+
|
|
68
|
+
def state(self) -> object:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def paint(self, w: int, h: int) -> list[list[Segment]]:
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
def sync(self, force: bool = False) -> None:
|
|
75
|
+
w, h = self.size.width, self.size.height
|
|
76
|
+
if w <= 0 or h <= 0:
|
|
77
|
+
return
|
|
78
|
+
key = (w, h, self.pal.key(), self.state())
|
|
79
|
+
if force or key != self._seen:
|
|
80
|
+
self._seen = key
|
|
81
|
+
fill = text_style(None, self.pal.bg)
|
|
82
|
+
lines = self.paint(w, h)
|
|
83
|
+
self._rows = [Strip(line).adjust_cell_length(w, fill) for line in lines[:h]]
|
|
84
|
+
self.refresh()
|
|
85
|
+
|
|
86
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
87
|
+
self.sync(force=True)
|
|
88
|
+
|
|
89
|
+
def render_line(self, y: int) -> Strip:
|
|
90
|
+
if y < len(self._rows):
|
|
91
|
+
return self._rows[y]
|
|
92
|
+
return Strip.blank(self.size.width, text_style(None, self.pal.bg))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Pixels(Widget):
|
|
96
|
+
"""A widget backed by a half-block pixel canvas, repainting only changed rows."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, **kwargs) -> None:
|
|
99
|
+
super().__init__(**kwargs)
|
|
100
|
+
self.cv = Canvas()
|
|
101
|
+
self._rows: list[Strip] = []
|
|
102
|
+
self._keys: list[object] = []
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def cloud(self) -> CloudspinApp:
|
|
106
|
+
return self.app # type: ignore[return-value]
|
|
107
|
+
|
|
108
|
+
def fit_canvas(self) -> bool:
|
|
109
|
+
"""Match the canvas to the widget. True when there is something to draw on."""
|
|
110
|
+
w, h = self.size.width, self.size.height
|
|
111
|
+
if w <= 0 or h <= 0:
|
|
112
|
+
return False
|
|
113
|
+
if self.cv.w != w or self.cv.h != h * 2:
|
|
114
|
+
self.cv.resize(w, h * 2)
|
|
115
|
+
self._rows = [Strip.blank(w)] * h
|
|
116
|
+
self._keys = [None] * h
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
def present(self, texts: dict[int, RowText] | None = None) -> None:
|
|
120
|
+
cv = self.cv
|
|
121
|
+
w = cv.w
|
|
122
|
+
rows = cv.h // 2
|
|
123
|
+
first = last = -1
|
|
124
|
+
for y in range(rows):
|
|
125
|
+
text = texts.get(y) if texts else None
|
|
126
|
+
key: object = cv.row_key(y)
|
|
127
|
+
if text:
|
|
128
|
+
key = (key, tuple(sorted(text.items())))
|
|
129
|
+
if key != self._keys[y]:
|
|
130
|
+
self._keys[y] = key
|
|
131
|
+
self._rows[y] = Strip(cv.segments(y, text), w)
|
|
132
|
+
if first < 0:
|
|
133
|
+
first = y
|
|
134
|
+
last = y
|
|
135
|
+
if first >= 0:
|
|
136
|
+
self.refresh(Region(0, first, w, last - first + 1))
|
|
137
|
+
|
|
138
|
+
def render_line(self, y: int) -> Strip:
|
|
139
|
+
if y < len(self._rows):
|
|
140
|
+
return self._rows[y]
|
|
141
|
+
return Strip.blank(self.size.width)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# -- the sky -----------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
_DIGITS = {
|
|
147
|
+
"0": "111101101101111",
|
|
148
|
+
"1": "010110010010111",
|
|
149
|
+
"2": "111001111100111",
|
|
150
|
+
"3": "111001111001111",
|
|
151
|
+
"4": "101101111001001",
|
|
152
|
+
"5": "111100111001111",
|
|
153
|
+
"6": "111100111101111",
|
|
154
|
+
"7": "111001001001001",
|
|
155
|
+
"8": "111101111101111",
|
|
156
|
+
"9": "111101111001111",
|
|
157
|
+
"-": "000000111000000",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
_ICON_TONES: dict[str, RGB] = {
|
|
161
|
+
"Y": (255, 214, 96),
|
|
162
|
+
"O": (255, 170, 72),
|
|
163
|
+
"W": (240, 244, 250),
|
|
164
|
+
"G": (168, 178, 196),
|
|
165
|
+
"D": (118, 126, 146),
|
|
166
|
+
"B": (120, 180, 255),
|
|
167
|
+
"S": (232, 240, 255),
|
|
168
|
+
"L": (255, 232, 120),
|
|
169
|
+
"F": (186, 194, 208),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_ICONS: dict[str, tuple[str, ...]] = {
|
|
173
|
+
"clear": ("O......O", "..YYYY..", ".YYYYYY.", ".YYYYYY.", "..YYYY..", "O......O"),
|
|
174
|
+
"partly": ("....YYY.", "..WWYYYY", ".WWWWYY.", "WWWWWWW.", "GGGGGGG.", "........"),
|
|
175
|
+
"cloud": ("........", "..WWW...", ".WWWWWW.", "WWWWWWWW", "GGGGGGGG", "........"),
|
|
176
|
+
"rain": ("..WWW...", ".WWWWWW.", "GGGGGGGG", "........", ".B..B.B.", "B..B.B.."),
|
|
177
|
+
"snow": ("..WWW...", ".WWWWWW.", "GGGGGGGG", "........", ".S..S..S", "..S..S.."),
|
|
178
|
+
"storm": ("..GGG...", ".GGGGGG.", "DDDDDDDD", "...LL...", "..LL....", "...L...."),
|
|
179
|
+
"fog": ("........", "FFFFFF..", "........", "..FFFFFF", "........", "FFFFF..."),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class SkyView(Pixels):
|
|
184
|
+
"""The sky, with the weather written on it."""
|
|
185
|
+
|
|
186
|
+
def __init__(self, **kwargs) -> None:
|
|
187
|
+
super().__init__(**kwargs)
|
|
188
|
+
self.scene = SkyScene()
|
|
189
|
+
self.report: Report | None = None
|
|
190
|
+
self.note = "" # shown in place of the weather when there is none
|
|
191
|
+
self.hint = ""
|
|
192
|
+
self.caption = "" # what the sky is doing and why, top right
|
|
193
|
+
self._tier = ""
|
|
194
|
+
|
|
195
|
+
def set_report(self, report: Report | None) -> None:
|
|
196
|
+
self.report = report
|
|
197
|
+
self._arrange()
|
|
198
|
+
|
|
199
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
200
|
+
self._arrange()
|
|
201
|
+
self.step(0.0)
|
|
202
|
+
|
|
203
|
+
def _arrange(self) -> None:
|
|
204
|
+
if not self.fit_canvas():
|
|
205
|
+
return
|
|
206
|
+
w, rows = self.size.width, self.size.height
|
|
207
|
+
has_days = self.report is not None and len(self.report.days) > 1
|
|
208
|
+
if rows >= 17 and w >= 60 and has_days:
|
|
209
|
+
self._tier = "full"
|
|
210
|
+
elif rows >= 11:
|
|
211
|
+
self._tier = "big"
|
|
212
|
+
else:
|
|
213
|
+
self._tier = "small"
|
|
214
|
+
self.scene.set_foreground(8 if self._tier == "full" else 0)
|
|
215
|
+
self.scene.scrim = min(w, 68) if (self.report is not None or self.note) else 0
|
|
216
|
+
self.scene.resize(w, rows * 2)
|
|
217
|
+
|
|
218
|
+
def step(self, dt: float) -> None:
|
|
219
|
+
if not self.fit_canvas():
|
|
220
|
+
return
|
|
221
|
+
if self.scene.w != self.cv.w or self.scene.h != self.cv.h:
|
|
222
|
+
self._arrange()
|
|
223
|
+
self.scene.step(dt)
|
|
224
|
+
self.scene.render(self.cv)
|
|
225
|
+
self.present(self._write())
|
|
226
|
+
|
|
227
|
+
# -- the words -----------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def _write(self) -> dict[int, RowText]:
|
|
230
|
+
texts: dict[int, RowText] = {}
|
|
231
|
+
w, rows = self.size.width, self.size.height
|
|
232
|
+
x0 = 3 if w >= 50 else 1
|
|
233
|
+
room = w - x0 - 1
|
|
234
|
+
report = self.report
|
|
235
|
+
used = 0
|
|
236
|
+
if report is None:
|
|
237
|
+
if self.note and rows >= 2:
|
|
238
|
+
used = lay_text(texts, x0, 1, fit(self.note, room), INK, True)
|
|
239
|
+
if self.hint and rows >= 4:
|
|
240
|
+
lay_text(texts, x0, 2, fit(self.hint, room), INK_SOFT)
|
|
241
|
+
elif self._tier == "small":
|
|
242
|
+
used = self._write_small(texts, report, x0, room, rows)
|
|
243
|
+
else:
|
|
244
|
+
used = self._write_big(texts, report, x0, room)
|
|
245
|
+
if self._tier == "full":
|
|
246
|
+
self._write_days(texts, report, x0, w, rows)
|
|
247
|
+
if self.caption and rows >= 2:
|
|
248
|
+
text = fit(self.caption, w - x0 - used - 6)
|
|
249
|
+
if cell_len(text) >= 8:
|
|
250
|
+
lay_text(texts, w - cell_len(text) - 2, 1, text, INK_SOFT)
|
|
251
|
+
return texts
|
|
252
|
+
|
|
253
|
+
def _stats(self, report: Report, room: int) -> str:
|
|
254
|
+
parts = [
|
|
255
|
+
f"Wind {round(report.wind_speed)} {report.wind_unit} {compass(report.wind_direction)}",
|
|
256
|
+
f"Humidity {report.humidity}%",
|
|
257
|
+
f"Sunset {report.sunset:%H:%M}" if report.sunrise <= report.local_now() < report.sunset
|
|
258
|
+
else f"Sunrise {report.next_sunrise:%H:%M}",
|
|
259
|
+
] # fmt: skip
|
|
260
|
+
while parts and cell_len(" ".join(parts)) > room:
|
|
261
|
+
parts.pop()
|
|
262
|
+
return " ".join(parts)
|
|
263
|
+
|
|
264
|
+
def _write_small(self, texts: dict[int, RowText], report: Report, x0: int, room: int, rows: int) -> int:
|
|
265
|
+
name = fit(report.place.label, room)
|
|
266
|
+
used = lay_text(texts, x0, 1 if rows >= 3 else 0, name, INK, True)
|
|
267
|
+
if rows >= 3:
|
|
268
|
+
line = f"{round(report.temperature)}° {report.description}"
|
|
269
|
+
lay_text(texts, x0, 2, fit(line, room), INK)
|
|
270
|
+
if rows >= 5:
|
|
271
|
+
lay_text(texts, x0, 3, self._stats(report, room), INK_SOFT)
|
|
272
|
+
return used
|
|
273
|
+
|
|
274
|
+
def _write_big(self, texts: dict[int, RowText], report: Report, x0: int, room: int) -> int:
|
|
275
|
+
name = fit(report.place.label, min(room, 30))
|
|
276
|
+
used = lay_text(texts, x0, 1, name, INK, True)
|
|
277
|
+
when = f"{report.local_now():%a %-d %b %H:%M}"
|
|
278
|
+
if used + 3 + len(when) <= min(room, 52):
|
|
279
|
+
used += 3 + lay_text(texts, x0 + used + 3, 1, when, INK_SOFT)
|
|
280
|
+
number = str(round(report.temperature))
|
|
281
|
+
wide = self._digits(number, x0, 6)
|
|
282
|
+
tx = x0 + wide + 3
|
|
283
|
+
text_room = room - wide - 3
|
|
284
|
+
lay_text(texts, tx, 4, fit(report.description, text_room), INK, True)
|
|
285
|
+
lay_text(texts, tx, 5, fit(f"Feels like {round(report.feels_like)}°", text_room), INK_SOFT)
|
|
286
|
+
lay_text(texts, tx, 6, fit(f"High {round(report.high)}° Low {round(report.low)}°", text_room), INK_SOFT)
|
|
287
|
+
lay_text(texts, x0, 9, self._stats(report, room), INK_SOFT)
|
|
288
|
+
return used
|
|
289
|
+
|
|
290
|
+
def _digits(self, number: str, x: int, y: int) -> int:
|
|
291
|
+
"""Draw big block numerals onto the sky; returns the width used."""
|
|
292
|
+
cv = self.cv
|
|
293
|
+
cursor = x
|
|
294
|
+
for ch in number:
|
|
295
|
+
bits = _DIGITS.get(ch)
|
|
296
|
+
if bits is None:
|
|
297
|
+
continue
|
|
298
|
+
for row in range(5):
|
|
299
|
+
for col in range(3):
|
|
300
|
+
if bits[row * 3 + col] == "1":
|
|
301
|
+
for oy in range(2):
|
|
302
|
+
for ox in range(2):
|
|
303
|
+
cv.blend(cursor + col * 2 + ox + 1, y + row * 2 + oy + 1, (0, 0, 0), 96)
|
|
304
|
+
for row in range(5):
|
|
305
|
+
tone = lerp((255, 255, 255), (222, 230, 244), row / 4.0)
|
|
306
|
+
for col in range(3):
|
|
307
|
+
if bits[row * 3 + col] == "1":
|
|
308
|
+
for oy in range(2):
|
|
309
|
+
for ox in range(2):
|
|
310
|
+
cv.put(cursor + col * 2 + ox, y + row * 2 + oy, tone)
|
|
311
|
+
cursor += 8
|
|
312
|
+
for dx, dy in ((0, 0), (1, 0), (2, 0), (0, 1), (2, 1), (0, 2), (1, 2), (2, 2)):
|
|
313
|
+
cv.put(cursor + dx, y + dy, (255, 255, 255))
|
|
314
|
+
return cursor + 3 - x
|
|
315
|
+
|
|
316
|
+
def _write_days(self, texts: dict[int, RowText], report: Report, x0: int, w: int, rows: int) -> None:
|
|
317
|
+
days = report.days[1:]
|
|
318
|
+
pitch = 19
|
|
319
|
+
count = min(len(days), 6, max(1, (w - x0 * 2 + 5) // pitch))
|
|
320
|
+
top = rows - 4
|
|
321
|
+
cv = self.cv
|
|
322
|
+
for n, day in enumerate(days[:count]):
|
|
323
|
+
x = x0 + n * pitch
|
|
324
|
+
art = _ICONS.get(icon_for(day.code), _ICONS["cloud"])
|
|
325
|
+
for iy, line in enumerate(art):
|
|
326
|
+
for ix, mark in enumerate(line):
|
|
327
|
+
tone = _ICON_TONES.get(mark)
|
|
328
|
+
if tone is not None:
|
|
329
|
+
cv.put(x + ix, top * 2 + iy, tone)
|
|
330
|
+
tx = x + 10
|
|
331
|
+
lay_text(texts, tx, top, f"{day.day:%a}", INK, True)
|
|
332
|
+
high = f"{round(day.high)}°"
|
|
333
|
+
lay_text(texts, tx, top + 1, high, INK)
|
|
334
|
+
lay_text(texts, tx + len(high) + 1, top + 1, f"{round(day.low)}°", INK_SOFT)
|
|
335
|
+
if day.rain_chance is not None and day.rain_chance >= 15:
|
|
336
|
+
lay_text(texts, tx, top + 2, f"{day.rain_chance}%", INK_RAIN)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# -- the record --------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class DiscView(Pixels):
|
|
343
|
+
def __init__(self, **kwargs) -> None:
|
|
344
|
+
super().__init__(**kwargs)
|
|
345
|
+
self.vinyl = Vinyl()
|
|
346
|
+
self._last: object = None
|
|
347
|
+
|
|
348
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
349
|
+
self._last = None
|
|
350
|
+
self.step(0.0)
|
|
351
|
+
|
|
352
|
+
def step(self, dt: float) -> None:
|
|
353
|
+
if not self.fit_canvas():
|
|
354
|
+
return
|
|
355
|
+
app = self.cloud
|
|
356
|
+
deck = app.deck
|
|
357
|
+
vinyl = self.vinyl
|
|
358
|
+
vinyl.resize(self.cv.w, self.cv.h)
|
|
359
|
+
vinyl.step(dt, deck.playing, deck.current is not None, deck.progress)
|
|
360
|
+
pal = app.palette
|
|
361
|
+
art = app.look.art if app.look is not None else None
|
|
362
|
+
pulse = app.spectrum.energy
|
|
363
|
+
key = (round(vinyl.angle, 3), round(vinyl.reach, 3), pal.key(), int(pulse * 48), id(art))
|
|
364
|
+
if key == self._last:
|
|
365
|
+
return
|
|
366
|
+
self._last = key
|
|
367
|
+
vinyl.render(self.cv, pal.bg, pal.disc, pal.disc2, pal.accent, pulse, art)
|
|
368
|
+
self.present()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# -- now playing -------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class NowPlaying(Painted):
|
|
375
|
+
def state(self) -> object:
|
|
376
|
+
deck = self.cloud.deck
|
|
377
|
+
track = deck.current
|
|
378
|
+
if track is None:
|
|
379
|
+
return (None, len(deck.tracks), self.cloud.silent_reason)
|
|
380
|
+
return (track.path, track.title, track.artist, track.album, self.cloud.silent_reason)
|
|
381
|
+
|
|
382
|
+
def paint(self, w: int, h: int) -> list[list[Segment]]:
|
|
383
|
+
pal = self.pal
|
|
384
|
+
deck = self.cloud.deck
|
|
385
|
+
track = deck.current
|
|
386
|
+
badge = self.cloud.silent_reason
|
|
387
|
+
lines: list[list[Segment]] = []
|
|
388
|
+
if track is None:
|
|
389
|
+
title = "Nothing on the platter"
|
|
390
|
+
second = "Press space to start playing" if deck.tracks else "Start Cloudspin with a music folder to fill the queue"
|
|
391
|
+
third = ""
|
|
392
|
+
else:
|
|
393
|
+
title = track.title
|
|
394
|
+
second = track.artist or ("Internet stream" if track.is_stream else "Unknown artist")
|
|
395
|
+
third = track.album
|
|
396
|
+
room = w - (cell_len(badge) + 3 if badge and w > 50 else 0)
|
|
397
|
+
first = [seg(fit(title, room), pal.text, pal.bg, bold=True)]
|
|
398
|
+
if badge and w > 50:
|
|
399
|
+
gap = w - cell_len(fit(title, room)) - cell_len(badge)
|
|
400
|
+
first += [seg(" " * max(1, gap), None, pal.bg), seg(badge, pal.faint, pal.bg)]
|
|
401
|
+
lines.append(first)
|
|
402
|
+
if h == 2 and third and track is not None and track.artist:
|
|
403
|
+
# Two rows only: the album shares the artist's line.
|
|
404
|
+
name = fit(second, w)
|
|
405
|
+
rest = w - cell_len(name) - 3
|
|
406
|
+
lines.append([seg(name, pal.soft, pal.bg)] + ([seg(" " + fit(third, rest), pal.faint, pal.bg)] if rest > 6 else []))
|
|
407
|
+
return lines
|
|
408
|
+
lines.append([seg(fit(second, w), pal.soft, pal.bg)])
|
|
409
|
+
lines.append([seg(fit(third, w), pal.faint, pal.bg)])
|
|
410
|
+
return lines
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class SpectrumView(Widget):
|
|
414
|
+
"""Bars that move with the music."""
|
|
415
|
+
|
|
416
|
+
_BLOCKS = " ▁▂▃▄▅▆▇█"
|
|
417
|
+
|
|
418
|
+
def __init__(self, **kwargs) -> None:
|
|
419
|
+
super().__init__(**kwargs)
|
|
420
|
+
self._rows: list[Strip] = []
|
|
421
|
+
self._seen: object = None
|
|
422
|
+
|
|
423
|
+
@property
|
|
424
|
+
def cloud(self) -> CloudspinApp:
|
|
425
|
+
return self.app # type: ignore[return-value]
|
|
426
|
+
|
|
427
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
428
|
+
self._seen = None
|
|
429
|
+
self.sync()
|
|
430
|
+
|
|
431
|
+
def sync(self) -> None:
|
|
432
|
+
w, h = self.size.width, self.size.height
|
|
433
|
+
if w <= 0 or h <= 0:
|
|
434
|
+
return
|
|
435
|
+
app = self.cloud
|
|
436
|
+
spectrum = app.spectrum
|
|
437
|
+
bars = min(64, (w + 1) // 3)
|
|
438
|
+
spectrum.resize(bars)
|
|
439
|
+
pal = app.palette
|
|
440
|
+
eighths = h * 8
|
|
441
|
+
key = (w, h, pal.key(), tuple(int(v * eighths) for v in spectrum.levels), tuple(int(v * eighths) for v in spectrum.peaks))
|
|
442
|
+
if key == self._seen:
|
|
443
|
+
return
|
|
444
|
+
self._seen = key
|
|
445
|
+
bg = pal.bg
|
|
446
|
+
pad = (w - (bars * 3 - 1)) // 2
|
|
447
|
+
blocks = self._BLOCKS
|
|
448
|
+
rows: list[Strip] = []
|
|
449
|
+
heights = [v * eighths for v in spectrum.levels]
|
|
450
|
+
caps = [int(v * eighths) // 8 for v in spectrum.peaks]
|
|
451
|
+
for r in range(h):
|
|
452
|
+
b = h - 1 - r
|
|
453
|
+
tone = ramp(((0.0, pal.accent), (1.0, pal.accent2)), (b + 0.5) / h)
|
|
454
|
+
bar_style = text_style(tone, bg)
|
|
455
|
+
cap_style = text_style(pal.soft if b else pal.faint, bg)
|
|
456
|
+
fill = text_style(None, bg)
|
|
457
|
+
line: list[Segment] = [Segment(" " * pad, fill)] if pad > 0 else []
|
|
458
|
+
for i in range(bars):
|
|
459
|
+
level = int(heights[i]) - b * 8
|
|
460
|
+
if level >= 8:
|
|
461
|
+
line.append(Segment("██", bar_style))
|
|
462
|
+
elif level > 0:
|
|
463
|
+
line.append(Segment(blocks[level] * 2, bar_style))
|
|
464
|
+
elif caps[i] == b:
|
|
465
|
+
line.append(Segment("▁▁", cap_style))
|
|
466
|
+
else:
|
|
467
|
+
line.append(Segment(" ", fill))
|
|
468
|
+
if i < bars - 1:
|
|
469
|
+
line.append(Segment(" ", fill))
|
|
470
|
+
rows.append(Strip(line).adjust_cell_length(w, fill))
|
|
471
|
+
self._rows = rows
|
|
472
|
+
self.refresh()
|
|
473
|
+
|
|
474
|
+
def render_line(self, y: int) -> Strip:
|
|
475
|
+
if y < len(self._rows):
|
|
476
|
+
return self._rows[y]
|
|
477
|
+
return Strip.blank(self.size.width, text_style(None, self.cloud.palette.bg))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class Seekbar(Painted):
|
|
481
|
+
"""Where the song is. Click or drag to move through it."""
|
|
482
|
+
|
|
483
|
+
def __init__(self, **kwargs) -> None:
|
|
484
|
+
super().__init__(**kwargs)
|
|
485
|
+
self._drag = False
|
|
486
|
+
self._bar = (0, 1)
|
|
487
|
+
|
|
488
|
+
def state(self) -> object:
|
|
489
|
+
deck = self.cloud.deck
|
|
490
|
+
if deck.current is None:
|
|
491
|
+
return None
|
|
492
|
+
width = max(1, self._bar[1])
|
|
493
|
+
return (int(deck.position), int(deck.duration), int(deck.progress * width * 2))
|
|
494
|
+
|
|
495
|
+
def paint(self, w: int, h: int) -> list[list[Segment]]:
|
|
496
|
+
pal = self.pal
|
|
497
|
+
deck = self.cloud.deck
|
|
498
|
+
loaded = deck.current is not None
|
|
499
|
+
left = clock(deck.position) if loaded else "0:00"
|
|
500
|
+
right = clock(deck.duration) if loaded and deck.duration > 0 else "0:00" if not loaded else "live"
|
|
501
|
+
start = len(left) + 2
|
|
502
|
+
width = max(1, w - start - len(right) - 2)
|
|
503
|
+
self._bar = (start, width)
|
|
504
|
+
line = [seg(left + " ", pal.soft, pal.bg)]
|
|
505
|
+
spot = deck.progress * width if loaded and deck.duration > 0 else 0.0
|
|
506
|
+
filled = min(width - 1, int(spot))
|
|
507
|
+
for i in range(width):
|
|
508
|
+
if i < filled:
|
|
509
|
+
tone = ramp(((0.0, pal.accent), (1.0, pal.accent2)), i / max(1, width - 1))
|
|
510
|
+
line.append(seg("━", tone, pal.bg))
|
|
511
|
+
elif i == filled and loaded:
|
|
512
|
+
line.append(seg("●", pal.text, pal.bg))
|
|
513
|
+
else:
|
|
514
|
+
line.append(seg("━", pal.lifted, pal.bg))
|
|
515
|
+
line.append(seg(" " + right, pal.soft, pal.bg))
|
|
516
|
+
return [line]
|
|
517
|
+
|
|
518
|
+
def _seek(self, x: int) -> None:
|
|
519
|
+
start, width = self._bar
|
|
520
|
+
self.cloud.deck.seek_to((x - start) / max(1, width - 1))
|
|
521
|
+
self.sync(force=True)
|
|
522
|
+
|
|
523
|
+
def on_mouse_down(self, event: events.MouseDown) -> None:
|
|
524
|
+
self._drag = True
|
|
525
|
+
self.capture_mouse()
|
|
526
|
+
self._seek(event.x)
|
|
527
|
+
|
|
528
|
+
def on_mouse_move(self, event: events.MouseMove) -> None:
|
|
529
|
+
if self._drag:
|
|
530
|
+
self._seek(event.x)
|
|
531
|
+
|
|
532
|
+
def on_mouse_up(self, event: events.MouseUp) -> None:
|
|
533
|
+
if self._drag:
|
|
534
|
+
self._drag = False
|
|
535
|
+
self.release_mouse()
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
class Controls(Painted):
|
|
539
|
+
"""Shuffle, previous, play, next, repeat, and the volume."""
|
|
540
|
+
|
|
541
|
+
def __init__(self, **kwargs) -> None:
|
|
542
|
+
super().__init__(**kwargs)
|
|
543
|
+
self._hover = ""
|
|
544
|
+
self._zones: list[tuple[int, int, str]] = []
|
|
545
|
+
self._volume = (0, 1)
|
|
546
|
+
|
|
547
|
+
def state(self) -> object:
|
|
548
|
+
deck = self.cloud.deck
|
|
549
|
+
return (deck.playing, deck.shuffle, deck.repeat, deck.volume, self._hover)
|
|
550
|
+
|
|
551
|
+
def paint(self, w: int, h: int) -> list[list[Segment]]:
|
|
552
|
+
pal = self.pal
|
|
553
|
+
deck = self.cloud.deck
|
|
554
|
+
bg = pal.bg
|
|
555
|
+
pills = [
|
|
556
|
+
("shuffle", " ⇄ ", deck.shuffle, False),
|
|
557
|
+
("previous", " ◀◀ ", False, False),
|
|
558
|
+
("toggle", " ▐ ▌ " if deck.playing else " ▶ ", False, True),
|
|
559
|
+
("next", " ▶▶ ", False, False),
|
|
560
|
+
("repeat", " ↻1 " if deck.repeat == "one" else " ↻ ", deck.repeat != "off", False),
|
|
561
|
+
]
|
|
562
|
+
pills_width = sum(len(text) + 2 for _, text, _, _ in pills) + len(pills) - 1
|
|
563
|
+
# The volume line takes whatever room the buttons leave, and steps aside when there is none.
|
|
564
|
+
volume_cells = max(0, min(10, w - pills_width - 12))
|
|
565
|
+
if volume_cells < 4:
|
|
566
|
+
volume_cells = 0 # no room for the line: just the number
|
|
567
|
+
show_volume = w >= pills_width + 10
|
|
568
|
+
volume_width = volume_cells + 8 if show_volume else 0
|
|
569
|
+
x = max(0, (w - pills_width) // 2)
|
|
570
|
+
if show_volume and x + pills_width > w - volume_width - 2:
|
|
571
|
+
x = max(0, w - volume_width - 2 - pills_width)
|
|
572
|
+
line: list[Segment] = [seg(" " * x, None, bg)]
|
|
573
|
+
zones = []
|
|
574
|
+
for name, text, lit, primary in pills:
|
|
575
|
+
hovered = self._hover == name
|
|
576
|
+
if primary:
|
|
577
|
+
face = lerp(pal.accent, (255, 255, 255), 0.22 if hovered else 0.0)
|
|
578
|
+
ink = lerp(pal.bg, (0, 0, 0), 0.35)
|
|
579
|
+
else:
|
|
580
|
+
face = pal.lifted if hovered else pal.raised
|
|
581
|
+
ink = pal.accent if lit else pal.text if hovered else pal.soft
|
|
582
|
+
line.append(seg("▐", face, bg))
|
|
583
|
+
line.append(seg(text, ink, face, bold=primary or lit))
|
|
584
|
+
line.append(seg("▌", face, bg))
|
|
585
|
+
zones.append((x, x + len(text) + 2, name))
|
|
586
|
+
x += len(text) + 2
|
|
587
|
+
line.append(seg(" ", None, bg))
|
|
588
|
+
x += 1
|
|
589
|
+
self._zones = zones
|
|
590
|
+
if show_volume:
|
|
591
|
+
gap = w - volume_width - x
|
|
592
|
+
line.append(seg(" " * max(0, gap), None, bg))
|
|
593
|
+
filled = round(deck.volume / 100 * volume_cells)
|
|
594
|
+
start = w - volume_width
|
|
595
|
+
line.append(seg("Vol ", pal.faint, bg))
|
|
596
|
+
line.append(seg("━" * filled, pal.accent if self._hover != "volume" else pal.text, bg))
|
|
597
|
+
line.append(seg("━" * (volume_cells - filled), pal.lifted, bg))
|
|
598
|
+
line.append(seg(f"{deck.volume:>4}", pal.soft, bg))
|
|
599
|
+
if volume_cells:
|
|
600
|
+
self._volume = (start + 4, volume_cells)
|
|
601
|
+
self._zones.append((start + 4, start + 4 + volume_cells, "volume"))
|
|
602
|
+
return [line]
|
|
603
|
+
|
|
604
|
+
def _zone(self, x: int) -> str:
|
|
605
|
+
for start, end, name in self._zones:
|
|
606
|
+
if start <= x < end:
|
|
607
|
+
return name
|
|
608
|
+
return ""
|
|
609
|
+
|
|
610
|
+
def on_mouse_move(self, event: events.MouseMove) -> None:
|
|
611
|
+
zone = self._zone(event.x)
|
|
612
|
+
if zone != self._hover:
|
|
613
|
+
self._hover = zone
|
|
614
|
+
self.sync()
|
|
615
|
+
|
|
616
|
+
def on_leave(self, event: events.Leave) -> None:
|
|
617
|
+
if self._hover:
|
|
618
|
+
self._hover = ""
|
|
619
|
+
self.sync()
|
|
620
|
+
|
|
621
|
+
def on_click(self, event: events.Click) -> None:
|
|
622
|
+
zone = self._zone(event.x)
|
|
623
|
+
app = self.cloud
|
|
624
|
+
if zone == "volume":
|
|
625
|
+
start, cells = self._volume
|
|
626
|
+
app.set_volume(round((event.x - start + 1) / cells * 100))
|
|
627
|
+
elif zone == "toggle":
|
|
628
|
+
app.action_toggle_play()
|
|
629
|
+
elif zone == "next":
|
|
630
|
+
app.action_next_track()
|
|
631
|
+
elif zone == "previous":
|
|
632
|
+
app.action_previous_track()
|
|
633
|
+
elif zone == "shuffle":
|
|
634
|
+
app.action_shuffle()
|
|
635
|
+
elif zone == "repeat":
|
|
636
|
+
app.action_repeat()
|
|
637
|
+
self.sync()
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
class QueueView(Painted):
|
|
641
|
+
"""What plays after this."""
|
|
642
|
+
|
|
643
|
+
def __init__(self, **kwargs) -> None:
|
|
644
|
+
super().__init__(**kwargs)
|
|
645
|
+
self._hover = -1
|
|
646
|
+
self._shown: list[int] = []
|
|
647
|
+
|
|
648
|
+
def state(self) -> object:
|
|
649
|
+
deck = self.cloud.deck
|
|
650
|
+
rows = max(0, self.size.height - 2)
|
|
651
|
+
coming = deck.upcoming(rows)
|
|
652
|
+
return (
|
|
653
|
+
deck.index,
|
|
654
|
+
deck.shuffle,
|
|
655
|
+
deck.repeat,
|
|
656
|
+
len(deck.tracks),
|
|
657
|
+
self._hover,
|
|
658
|
+
tuple((i, deck.tracks[i].title, deck.tracks[i].artist, int(deck.tracks[i].duration)) for i in coming),
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
def paint(self, w: int, h: int) -> list[list[Segment]]:
|
|
662
|
+
pal = self.pal
|
|
663
|
+
deck = self.cloud.deck
|
|
664
|
+
bg = pal.bg
|
|
665
|
+
total = len(deck.tracks)
|
|
666
|
+
count = f"{total} song{'' if total == 1 else 's'}" if total else ""
|
|
667
|
+
head = [seg("Up next", pal.text, bg, bold=True)]
|
|
668
|
+
if count and w > 22:
|
|
669
|
+
head += [seg(" " * (w - 7 - len(count)), None, bg), seg(count, pal.faint, bg)]
|
|
670
|
+
lines = [head, [seg("", None, bg)]]
|
|
671
|
+
coming = deck.upcoming(max(0, h - 2))
|
|
672
|
+
self._shown = coming
|
|
673
|
+
if not coming:
|
|
674
|
+
message = "The queue ends here" if total else "No music loaded"
|
|
675
|
+
lines.append([seg(fit(message, w), pal.faint, bg)])
|
|
676
|
+
return lines
|
|
677
|
+
for row, index in enumerate(coming):
|
|
678
|
+
track = deck.tracks[index]
|
|
679
|
+
hovered = row == self._hover
|
|
680
|
+
face = pal.raised if hovered else bg
|
|
681
|
+
time = clock(track.duration) if track.duration else ""
|
|
682
|
+
room = w - (len(time) + 2 if time else 0)
|
|
683
|
+
title = fit(track.title, room)
|
|
684
|
+
line = [seg(title, pal.text if hovered else pal.soft, face)]
|
|
685
|
+
left = room - cell_len(title)
|
|
686
|
+
if track.artist and left > 8:
|
|
687
|
+
artist = fit(track.artist, left - 2)
|
|
688
|
+
line.append(seg(" " + artist, pal.faint, face))
|
|
689
|
+
left -= cell_len(artist) + 2
|
|
690
|
+
line.append(seg(" " * max(0, left), None, face))
|
|
691
|
+
if time:
|
|
692
|
+
line.append(seg(" " + time, pal.faint, face))
|
|
693
|
+
lines.append(line)
|
|
694
|
+
return lines
|
|
695
|
+
|
|
696
|
+
def on_mouse_move(self, event: events.MouseMove) -> None:
|
|
697
|
+
row = event.y - 2
|
|
698
|
+
row = row if 0 <= row < len(self._shown) else -1
|
|
699
|
+
if row != self._hover:
|
|
700
|
+
self._hover = row
|
|
701
|
+
self.sync()
|
|
702
|
+
|
|
703
|
+
def on_leave(self, event: events.Leave) -> None:
|
|
704
|
+
if self._hover != -1:
|
|
705
|
+
self._hover = -1
|
|
706
|
+
self.sync()
|
|
707
|
+
|
|
708
|
+
def on_click(self, event: events.Click) -> None:
|
|
709
|
+
row = event.y - 2
|
|
710
|
+
if 0 <= row < len(self._shown):
|
|
711
|
+
self.cloud.deck.play(self._shown[row])
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
class KeyHints(Painted):
|
|
715
|
+
"""The keys worth knowing, or a short message when something just happened."""
|
|
716
|
+
|
|
717
|
+
HINTS = (
|
|
718
|
+
# Most useful first: narrow terminals only have room for the start of this list.
|
|
719
|
+
("space", "play"),
|
|
720
|
+
("n p", "skip"),
|
|
721
|
+
("o", "link"),
|
|
722
|
+
("?", "help"),
|
|
723
|
+
("q", "quit"),
|
|
724
|
+
("w", "sky"),
|
|
725
|
+
("t", "colours"),
|
|
726
|
+
("c", "city"),
|
|
727
|
+
("← →", "seek"),
|
|
728
|
+
("+ −", "volume"),
|
|
729
|
+
("s", "shuffle"),
|
|
730
|
+
("r", "repeat"),
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
def state(self) -> object:
|
|
734
|
+
return self.cloud.message
|
|
735
|
+
|
|
736
|
+
def paint(self, w: int, h: int) -> list[list[Segment]]:
|
|
737
|
+
pal = self.pal
|
|
738
|
+
bg = pal.bg
|
|
739
|
+
message = self.cloud.message
|
|
740
|
+
if message:
|
|
741
|
+
return [[seg(" " + fit(message, w - 4), pal.text, bg)]]
|
|
742
|
+
line: list[Segment] = [seg(" ", None, bg)]
|
|
743
|
+
used = 2
|
|
744
|
+
for keys, what in self.HINTS:
|
|
745
|
+
need = len(keys) + 1 + len(what) + 3
|
|
746
|
+
if used + need > w:
|
|
747
|
+
break
|
|
748
|
+
line += [seg(keys, pal.soft, bg, bold=True), seg(" " + what + " ", pal.faint, bg)]
|
|
749
|
+
used += need
|
|
750
|
+
return [line]
|