Public Access
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
116 lines
5.3 KiB
Python
Executable File
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()
|