#!/usr/bin/env python3 """Render the classroom's idle desktop background. This is a STAND-IN. The classroom's chosen wallpaper is an illustrated landscape that is not in this repository; when it arrives it replaces the output of this script at the same path and nothing else changes. It exists so the desktop can be judged in the right palette rather than against the bright confetti background it is replacing: sage, teal, blue and lavender, a river leading into the picture, and a large quiet area in the upper middle where nothing happens. No image library is installed on the development host, so the PNG is written by hand — zlib and struct are enough for a truecolour image. """ import math import pathlib import struct import zlib WIDTH = 1920 HEIGHT = 1080 OUT = pathlib.Path(__file__).resolve().parent.parent / "config" / "wallpapers" def mix(a, b, t): t = 0.0 if t < 0 else 1.0 if t > 1 else t return tuple(a[i] + (b[i] - a[i]) * t for i in range(3)) def ease(t): """Smoothstep. Bands in a gradient are the one thing that would read as a rendering fault rather than as a picture.""" t = 0.0 if t < 0 else 1.0 if t > 1 else t return t * t * (3 - 2 * t) # The palette, top of the sky downwards. SKY = [ (0.00, (34, 38, 58)), # deep lavender-slate (0.30, (58, 68, 96)), # lavender (0.55, (86, 108, 130)), # blue (0.78, (139, 165, 168)), # sage haze at the horizon (1.00, (178, 198, 192)), ] HORIZON = 0.56 # where the land starts, as a fraction of the height GLOW_X, GLOW_Y = 0.56, 0.545 GLOW = (232, 232, 214) RIDGES = [ # (base height, amplitude, wavelength, phase, colour, haze) (0.575, 0.028, 2.1, 0.4, (74, 96, 110), 0.55), (0.615, 0.040, 1.5, 2.2, (60, 84, 92), 0.34), (0.665, 0.052, 1.1, 4.1, (48, 72, 74), 0.18), ] RIVER_TOP = 0.585 RIVER_COLOUR = (96, 140, 146) RIVER_LIGHT = (186, 212, 202) BANK = (34, 52, 48) # the sage foreground the river runs between def sky_colour(y): t = y / HEIGHT for i in range(len(SKY) - 1): t0, c0 = SKY[i] t1, c1 = SKY[i + 1] if t <= t1 or i == len(SKY) - 2: return mix(c0, c1, ease((t - t0) / (t1 - t0))) return SKY[-1][1] def river_edges(y): """The river narrows towards the horizon and opens towards the viewer, which is what makes it lead into the picture rather than across it.""" t = (y / HEIGHT - RIVER_TOP) / (1 - RIVER_TOP) if t < 0: return None t = ease(t) centre = 0.56 - 0.14 * t * t half = 0.004 + 0.30 * t * t return (centre - half) * WIDTH, (centre + half) * WIDTH def render(): rows = [] for y in range(HEIGHT): base = sky_colour(y) row = bytearray() edges = river_edges(y) yn = y / HEIGHT # Ridge heights for this column are cheap to evaluate, so the land # is computed per pixel rather than filled as polygons. for x in range(WIDTH): xn = x / WIDTH r, g, b = base # A soft light low in the sky, off centre. It is the only # bright thing in the picture. dx = (xn - GLOW_X) * 1.7 dy = yn - GLOW_Y d = math.sqrt(dx * dx + dy * dy) glow = math.exp(-(d * d) / 0.055) * 0.55 if glow > 0.002: r, g, b = mix((r, g, b), GLOW, glow) land = None for height, amp, wavelength, phase, colour, haze in RIDGES: ridge = height - amp * ( 0.6 * math.sin(xn * math.pi * 2 * wavelength + phase) + 0.4 * math.sin(xn * math.pi * 2 * wavelength * 2.3 + phase * 1.7) ) if yn >= ridge: # Nearer ridges are darker and less hazy, which is the # whole of the depth in this picture. shade = mix(colour, (r, g, b), haze) depth = ease((yn - ridge) / 0.16) land = mix(shade, tuple(c * 0.82 for c in shade), depth) if land is not None: r, g, b = land if edges is not None: left, right = edges if left <= x <= right: width = max(right - left, 1.0) across = (x - left) / width # Light gathers down the middle of the water and the # banks stay dark, so the river reads as a surface. sheen = math.exp(-((across - 0.5) ** 2) / 0.06) ripple = 0.5 + 0.5 * math.sin( yn * 130 + math.sin(xn * 18) * 1.4 ) water = mix(RIVER_COLOUR, RIVER_LIGHT, 0.30 * sheen + 0.10 * ripple) water = mix(water, (r, g, b), max(0.0, 0.45 - 0.45 * ease((yn - RIVER_TOP) / 0.25))) edge = min(across, 1 - across) r, g, b = mix(BANK, water, ease(edge / 0.05)) elif yn > RIVER_TOP + 0.10: near = ease((yn - RIVER_TOP - 0.10) / 0.34) r, g, b = mix((r, g, b), BANK, near * 0.85) # The bottom of the screen carries the bar. Settling it down # keeps the panel legible without putting a band across the # picture. if yn > 0.78: r, g, b = mix((r, g, b), (16, 24, 28), ease((yn - 0.78) / 0.24) * 0.62) row += bytes((int(r + 0.5) & 255, int(g + 0.5) & 255, int(b + 0.5) & 255)) rows.append(bytes(row)) return rows def write_png(path, rows): raw = b"".join(b"\x00" + row for row in rows) def chunk(tag, data): payload = tag + data return ( struct.pack(">I", len(data)) + payload + struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF) ) png = b"\x89PNG\r\n\x1a\n" png += chunk(b"IHDR", struct.pack(">IIBBBBB", WIDTH, HEIGHT, 8, 2, 0, 0, 0)) png += chunk(b"IDAT", zlib.compress(raw, 9)) png += chunk(b"IEND", b"") path.write_bytes(png) if __name__ == "__main__": OUT.mkdir(parents=True, exist_ok=True) target = OUT / "classroom-calm.png" write_png(target, render()) print(f"wrote {target} ({target.stat().st_size} bytes)")