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