The whole appliance UI is now configuration for a desktop Debian already ships: a Plasma 6.3 session with a colour scheme, a look and feel package that builds the bar, window rules, and KDE's own Kiosk restrictions. Two things were measured rather than assumed, and both changed a decision. Plasma restarts a crashed panel three times a minute and then gives up, which is the failure a classroom has already had. Four lines of systemd drop-in make it unconditional; the panel then survived eight SIGKILLs a second apart, back each time in 2.4s. Plasma draws a minimised task and a background task identically, so the five states the room needs cannot all be shown. Rather than write a task manager, the classroom removes minimise: clicking a launcher always means show me this. Co-Authored-By: Claude Opus 5 <[email protected]>
181 lines
6.1 KiB
Python
Executable File
181 lines
6.1 KiB
Python
Executable File
#!/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)")
|