From e7194171f3db691e4aa99cefa602a689018b5e26 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 29 Sep 2026 10:15:05 +0200 Subject: build: assemble dist/udt with fonts and wallpaper Co-Authored-By: Claude Opus 5.5 --- build | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/build b/build index 9097679..4a6c1d0 100755 --- a/build +++ b/build @@ -11,9 +11,13 @@ current wallpaper next to theme.txt. Everything is staged in a temporary directory and swapped in at the end, so a failed run leaves the previous dist/udt/ untouched. Never touches /boot: root copies dist/udt/ there. """ +import os import re +import shutil import struct +import subprocess import sys +import tempfile import zlib from pathlib import Path @@ -28,6 +32,13 @@ FONTS = { "inconsolata-18.pf2": ("Inconsolata Nerd Font Mono", 18, "Inconsolata Nerd Font Mono Regular 18"), } +RENDERED = REPO / "theme.txt" +BUILD = REPO / "dist" +OUT = BUILD / "udt" +FONT_CACHE = BUILD / "fonts" # fonts do not follow the scheme, so built once +WALLPAPER = Path.home() / ".cache/udt/wpaper" +PANEL_ALPHA = 0xd9 # ~85%, enough to read text over any wallpaper +MAX_SIDE = 2560 # widest mode in use; GRUB crops, a bigger image only costs /boot def die(msg): @@ -83,6 +94,75 @@ def unpng(data): return w, h, px +def run(*cmd): + p = subprocess.run(cmd, capture_output=True, text=True) + if p.returncode: + die(f"{cmd[0]} failed:\n{p.stderr.strip()}") + return p.stdout + + +def pf2_name(path): + """The NAME section of a .pf2, which is what theme.txt refers to.""" + data = path.read_bytes() + pos = 0 + while pos < len(data): + tag, n = struct.unpack(">4sI", data[pos:pos + 8]) + if tag == b"NAME": + return data[pos + 8:pos + 8 + n].rstrip(b"\0").decode() + pos += 8 + n + die(f"{path.name} has no NAME section") + + +def font(fname, pattern, size, name): + """A cached .pf2, built from the TTF fontconfig resolves the pattern to.""" + cached = FONT_CACHE / fname + if cached.exists(): + return cached + family = pattern.split(":")[0] + # fc-match never fails: a missing family comes back as a fallback font. + if family not in run("fc-match", "-f", "%{family}", pattern).split(","): + die(f"font {family!r} is not installed") + ttf = run("fc-match", "-f", "%{file}", pattern) + FONT_CACHE.mkdir(parents=True, exist_ok=True) + tmp = cached.with_suffix(".tmp") + run("grub-mkfont", "-s", str(size), "-o", str(tmp), ttf) + if pf2_name(tmp) != name: + die(f"{fname} is named {pf2_name(tmp)!r}, theme.txt expects {name!r}") + tmp.rename(cached) + return cached + + +def os_name(): + for line in Path("/etc/os-release").read_text().splitlines(): + if line.startswith("NAME="): + return line[5:].strip('"') + return "GNU/Linux" + + +def assemble(stage, text, c, wallpaper, fonts): + (stage / "theme.txt").write_text(text.replace("%TITLE%", os_name())) + slices = { + "panel.png": (8, 8, (*c["bg_alt"], PANEL_ALPHA)), + # The selected item: a 4px accent bar on the left, surface behind. + "select_w.png": (4, 1, (*c["accent"], 255)), + "select_c.png": (1, 1, (*c["surface"], 255)), + "select_e.png": (8, 1, (*c["surface"], 255)), + } + for s in ("nw", "n", "ne", "w", "c", "e", "sw", "s", "se"): + slices[f"terminal_box_{s}.png"] = (1, 1, (*c["bg_deep"], 255)) + for fname, (w, h, rgba) in slices.items(): + (stage / fname).write_bytes(png(w, h, rgba)) + for f in fonts: + shutil.copy(f, stage / f.name) + # [0]: first frame only. Baseline JPEG, alpha flattened: GRUB reads + # neither progressive JPEG nor webp, and has no use for transparency here. + bg = "#%02x%02x%02x" % c["bg_deep"] + run("magick", f"{wallpaper}[0]", "-auto-orient", + "-background", bg, "-alpha", "remove", + "-resize", f"{MAX_SIDE}x{MAX_SIDE}>", "-strip", + "-interlace", "none", "-quality", "90", str(stage / "background.jpg")) + + def selftest(): w, h, px = unpng(png(3, 2, (0x12, 0x34, 0x56, 0xd9))) assert (w, h, px) == (3, 2, {(0x12, 0x34, 0x56, 0xd9)}), (w, h, px) @@ -105,6 +185,28 @@ def selftest(): def main(): if sys.argv[1:] == ["--selftest"]: return selftest() + for tool in ("magick", "grub-mkfont", "fc-match"): + if not shutil.which(tool): + die(f"{tool} not found") + if not RENDERED.exists(): + die("theme.txt missing; run udt-palette first") + text = RENDERED.read_text() + c = read_colours(text) + try: + wallpaper = WALLPAPER.resolve(strict=True) + except FileNotFoundError: + die(f"no wallpaper at {WALLPAPER}") + fonts = [font(f, *spec) for f, spec in FONTS.items()] + BUILD.mkdir(exist_ok=True) + stage = Path(tempfile.mkdtemp(dir=BUILD, prefix=".udt-")) + try: + assemble(stage, text, c, wallpaper, fonts) + os.chmod(stage, 0o755) # mkdtemp makes it 0700; root copies it as is + shutil.rmtree(OUT, ignore_errors=True) + stage.rename(OUT) + finally: + shutil.rmtree(stage, ignore_errors=True) + print(f"build: {OUT}") if __name__ == "__main__": -- cgit v1.2.3