Files
roro9stack/scripts/make_icons.py
T
twislaandClaude Opus 5.5 e2326e113b Launcher: a grid of icons, with badges; the list stays as a Setting
The Launcher shows the Apps as a 4 x 3 grid of 32 x 32 icons, the
selected one's name under it (issue #9). The icons are the website's
own, doubled, and two drawn the same way for SSH and the Shell; they
are PNGs in assets/icons, turned into arrays by scripts/make_icons.py,
which CI checks. The selected tile is the website's: dark blue on cyan,
with notched corners.

- GridModel (lib/ui, host-tested) holds the selection and the scrolling.
- App::badge(): a dot on the tile for unread IRC messages, a Track
  recording, a Capture running, an SSH session open.
- Settings > Launcher: Grid or List.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 23:14:37 +02:00

116 lines
5.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Turns the Launcher's icons (assets/icons/<App id>.png) into C arrays: src/ui/icons.h and icons.cpp.
An icon is 32 x 32 and one colour: a pixel is drawn where the picture is light and opaque, and left
alone where it is dark or transparent. Any image editor's PNG will do (grey, RGB, palette, with or
without alpha, 1 or 8 bits a channel, not interlaced). Run this after changing a picture:
python3 scripts/make_icons.py
CI runs it with --check, which fails when the arrays are not what the pictures give.
"""
import os
import struct
import sys
import zlib
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
SOURCE = os.path.join(ROOT, "assets", "icons")
SIZE = 32
def read_png(path):
"""The picture as rows of booleans: True where a pixel is drawn."""
data = open(path, "rb").read()
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError("not a PNG")
at, idat, palette, alpha = 8, b"", None, None
while at < len(data):
(length,), kind = struct.unpack(">I", data[at:at + 4]), data[at + 4:at + 8]
body = data[at + 8:at + 8 + length]
at += 12 + length
if kind == b"IHDR":
width, height, depth, colour, _, _, interlace = struct.unpack(">IIBBBBB", body)
elif kind == b"PLTE":
palette = [body[i:i + 3] for i in range(0, len(body), 3)]
elif kind == b"tRNS":
alpha = body
elif kind == b"IDAT":
idat += body
if interlace or depth not in (1, 8) or (depth == 1 and colour not in (0, 3)):
raise ValueError("save it as a plain 8-bit or 1-bit PNG, not interlaced")
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[colour]
step = channels if depth == 8 else 1 # bytes a pixel, for the filters
stride = (width * channels * depth + 7) // 8
raw, rows, before = zlib.decompress(idat), [], bytes(stride)
for y in range(height):
kind, line = raw[y * (stride + 1)], bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)])
for i in range(stride):
a = line[i - step] if i >= step else 0
b, c = before[i], before[i - step] if i >= step else 0
if kind == 1:
line[i] = (line[i] + a) & 255
elif kind == 2:
line[i] = (line[i] + b) & 255
elif kind == 3:
line[i] = (line[i] + (a + b) // 2) & 255
elif kind == 4:
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255
before = bytes(line)
row = []
for x in range(width):
if depth == 1:
v = (line[x // 8] >> (7 - x % 8)) & 1
px = [v * 255] if colour == 0 else [v]
else:
px = line[x * channels:(x + 1) * channels]
if colour == 3:
opaque = alpha is None or px[0] >= len(alpha) or alpha[px[0]] > 127
light = sum(palette[px[0]]) > 381
elif colour in (0, 4):
opaque, light = colour == 0 or px[1] > 127, px[0] > 127
else:
opaque, light = colour == 2 or px[3] > 127, sum(px[:3]) > 381
row.append(opaque and light)
rows.append(row)
return rows
def main():
names = sorted(f[:-4] for f in os.listdir(SOURCE) if f.endswith(".png"))
header = ["// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file.", "#pragma once", "",
"#include <cstdint>", "", "namespace roro::icons {", "",
"// One bit a pixel, the leftmost first, a row after another: what Canvas::drawBitmap takes.",
f"constexpr int kSize = {SIZE};", ""]
body = ["// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file.", '#include "icons.h"', "",
"namespace roro::icons {", ""]
for name in names:
rows = read_png(os.path.join(SOURCE, name + ".png"))
if len(rows) != SIZE or len(rows[0]) != SIZE:
sys.exit(f"{name}.png is {len(rows[0])} x {len(rows)}: an icon is {SIZE} x {SIZE}")
ident = name.replace("-", "_")
header.append(f"extern const uint8_t {ident}[{SIZE * SIZE // 8}]; // {name}.png")
body.append(f"const uint8_t {ident}[{SIZE * SIZE // 8}] = {{")
for row in rows:
packed = [sum(0x80 >> i for i in range(8) if row[b * 8 + i]) for b in range(SIZE // 8)]
body.append(" " + ", ".join(f"0x{v:02x}" for v in packed) + ", // " + "".join("#" if v else "." for v in row))
body += ["};", ""]
header += ["", "} // namespace roro::icons", ""]
body += ["} // namespace roro::icons", ""]
out = {os.path.join(ROOT, "src", "ui", "icons.h"): "\n".join(header), os.path.join(ROOT, "src", "ui", "icons.cpp"): "\n".join(body)}
if "--check" in sys.argv:
stale = [os.path.relpath(p, ROOT) for p, text in out.items() if not os.path.exists(p) or open(p).read() != text]
if stale:
sys.exit("out of date: " + ", ".join(stale) + " (run scripts/make_icons.py)")
print(f"{len(names)} icons, up to date")
return
for path, text in out.items():
open(path, "w").write(text)
print(f"{len(names)} icons written")
if __name__ == "__main__":
main()