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/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]