Add SD theme system
Adds installable SD-card themes with manifest downloads, theme registry parsing, themed home/chrome/settings/file browser support, FreeInk layout integration, theme documentation, and layout tests.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export compiled 1-bit UI icon headers as BMP assets for SD themes."""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ICON_HEADERS = [
|
||||
"book.h",
|
||||
"book24.h",
|
||||
"bookmark.h",
|
||||
"cover.h",
|
||||
"file24.h",
|
||||
"folder.h",
|
||||
"folder24.h",
|
||||
"hotspot.h",
|
||||
"image24.h",
|
||||
"library.h",
|
||||
"recent.h",
|
||||
"settings2.h",
|
||||
"text24.h",
|
||||
"transfer.h",
|
||||
"wifi.h",
|
||||
]
|
||||
|
||||
|
||||
def parse_icon_header(path: Path):
|
||||
text = path.read_text()
|
||||
size_match = re.search(r"//\s*size:\s*(\d+)x(\d+)", text)
|
||||
if not size_match:
|
||||
raise ValueError(f"missing size comment in {path}")
|
||||
width = int(size_match.group(1))
|
||||
height = int(size_match.group(2))
|
||||
|
||||
bitmap_match = re.search(r"static\s+const\s+uint8_t\s+\w+\s*\[\]\s*=\s*\{(?P<body>.*?)\};", text, re.DOTALL)
|
||||
if not bitmap_match:
|
||||
raise ValueError(f"missing bitmap data in {path}")
|
||||
bitmap_body = bitmap_match.group("body")
|
||||
|
||||
values = [int(m.group(1), 16) for m in re.finditer(r"0x([0-9A-Fa-f]{2})", bitmap_body)]
|
||||
expected = ((width + 7) // 8) * height
|
||||
if len(values) != expected:
|
||||
raise ValueError(f"{path}: expected {expected} bytes, found {len(values)}")
|
||||
return width, height, bytes(values)
|
||||
|
||||
|
||||
def get_bit(bitmap: bytes, width: int, x: int, y: int) -> int:
|
||||
stride = (width + 7) // 8
|
||||
return (bitmap[y * stride + x // 8] >> (7 - (x % 8))) & 1
|
||||
|
||||
|
||||
def set_bit(buf: bytearray, width: int, x: int, y: int, value: int):
|
||||
stride = (width + 7) // 8
|
||||
if value:
|
||||
buf[y * stride + x // 8] |= 1 << (7 - (x % 8))
|
||||
|
||||
|
||||
def rotate_1bit_cw(width: int, height: int, bitmap: bytes):
|
||||
rotated_width = height
|
||||
rotated_height = width
|
||||
rotated = bytearray(((rotated_width + 7) // 8) * rotated_height)
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
set_bit(rotated, rotated_width, height - 1 - y, x, get_bit(bitmap, width, x, y))
|
||||
return rotated_width, rotated_height, bytes(rotated)
|
||||
|
||||
|
||||
def write_1bit_bmp(path: Path, width: int, height: int, bitmap: bytes):
|
||||
src_stride = (width + 7) // 8
|
||||
dst_stride = ((width + 31) // 32) * 4
|
||||
pixel_bytes = dst_stride * height
|
||||
pixel_offset = 14 + 40 + 8
|
||||
file_size = pixel_offset + pixel_bytes
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("wb") as out:
|
||||
# BITMAPFILEHEADER
|
||||
out.write(b"BM")
|
||||
out.write(struct.pack("<IHHI", file_size, 0, 0, pixel_offset))
|
||||
# BITMAPINFOHEADER. Negative height stores rows top-down.
|
||||
out.write(struct.pack("<IiiHHIIiiII", 40, width, -height, 1, 1, 0, pixel_bytes, 0, 0, 2, 0))
|
||||
# Palette index 0 = black, index 1 = white. Existing icon arrays use 1s
|
||||
# for white/transparent background and 0s for ink.
|
||||
out.write(bytes([0, 0, 0, 0, 255, 255, 255, 0]))
|
||||
for y in range(height):
|
||||
row = bitmap[y * src_stride : (y + 1) * src_stride]
|
||||
out.write(row)
|
||||
out.write(b"\x00" * (dst_stride - src_stride))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--icons", default="src/components/icons")
|
||||
parser.add_argument("--themes", default="../crosspoint-tools/public/themes")
|
||||
args = parser.parse_args()
|
||||
|
||||
icon_root = Path(args.icons)
|
||||
theme_root = Path(args.themes)
|
||||
|
||||
parsed = []
|
||||
for header in ICON_HEADERS:
|
||||
icon_path = icon_root / header
|
||||
width, height, data = parse_icon_header(icon_path)
|
||||
width, height, data = rotate_1bit_cw(width, height, data)
|
||||
parsed.append((icon_path.stem, width, height, data))
|
||||
|
||||
for theme_dir in sorted(theme_root.iterdir()):
|
||||
if not theme_dir.is_dir() or not (theme_dir / "theme.json").exists():
|
||||
continue
|
||||
for name, width, height, data in parsed:
|
||||
write_1bit_bmp(theme_dir / "icons" / f"{name}.bmp", width, height, data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a themes.json manifest from SD theme package folders."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def safe_theme_dirs(root: Path):
|
||||
for child in sorted(root.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith(".") or child.name.startswith("_"):
|
||||
continue
|
||||
theme_json = child / "theme.json"
|
||||
if theme_json.exists():
|
||||
yield child
|
||||
|
||||
|
||||
def build_manifest(root: Path, base_url: str):
|
||||
themes = []
|
||||
for theme_dir in safe_theme_dirs(root):
|
||||
theme_doc = json.loads((theme_dir / "theme.json").read_text(encoding="utf-8"))
|
||||
files = []
|
||||
total = 0
|
||||
for file_path in sorted(p for p in theme_dir.rglob("*") if p.is_file()):
|
||||
rel = file_path.relative_to(theme_dir).as_posix()
|
||||
data = file_path.read_bytes()
|
||||
total += len(data)
|
||||
files.append(
|
||||
{
|
||||
"path": rel,
|
||||
"url": f"{theme_dir.name}/{rel}",
|
||||
"size": len(data),
|
||||
"crc32": zlib.crc32(data) & 0xFFFFFFFF,
|
||||
}
|
||||
)
|
||||
|
||||
themes.append(
|
||||
{
|
||||
"id": theme_doc["id"],
|
||||
"name": theme_doc.get("name", theme_doc["id"]),
|
||||
"version": theme_doc.get("version", 1),
|
||||
"description": theme_doc.get("description", ""),
|
||||
"files": files,
|
||||
"totalSize": total,
|
||||
}
|
||||
)
|
||||
|
||||
return {"version": 1, "baseUrl": base_url, "themes": themes}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", default="../crosspoint-tools/public/themes")
|
||||
parser.add_argument("--base-url", required=True)
|
||||
parser.add_argument("--output", default="../crosspoint-tools/public/themes/themes.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
manifest = build_manifest(Path(args.root), args.base_url)
|
||||
output = Path(args.output)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user