aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-29 10:15:05 +0200
committerDanilo M. <danix@danix.xyz>2026-09-29 10:15:05 +0200
commite7194171f3db691e4aa99cefa602a689018b5e26 (patch)
tree01b508fea15db603b1218b21ddeeea1cec834c83
parent457bb2ca770e7fa94a9401e637bfeac6fc61c462 (diff)
downloadgrub-theme-udt-e7194171f3db691e4aa99cefa602a689018b5e26.tar.gz
grub-theme-udt-e7194171f3db691e4aa99cefa602a689018b5e26.zip
build: assemble dist/udt with fonts and wallpaper
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rwxr-xr-xbuild102
1 files changed, 102 insertions, 0 deletions
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__":