Ship hermit binary (cst-agent symlink), ~/.config/hermit with legacy fallback, Hermit color theme and mini crab frames, plus SVG→PUA font build pipeline. Co-authored-by: Cursor <cursoragent@cursor.com>
203 lines
7.1 KiB
Python
Executable File
203 lines
7.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Batch-convert icons/agent-motion SVG states into a color emoji-style font.
|
|
|
|
Outputs:
|
|
res/hermit-icons.ttf (CBDT/CBLC bitmap glyphs when fonttools works)
|
|
.gen/hermit_font_map.inc (PUA codepoint table for the TUI)
|
|
|
|
PUA layout matches cst_hermit_glyph.h:
|
|
U+E700 + (shell * N_STATE + state) * N_FRAME + frame
|
|
|
|
Requires (optional): rsvg-convert or cairosvg, pillow, fonttools.
|
|
Without them, still writes hermit_font_map.inc so FONT mode can emit codepoints.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
ICONS = ROOT / "icons" / "agent-motion" / "icons"
|
|
OUT_TTF = ROOT / "res" / "hermit-icons.ttf"
|
|
OUT_INC = ROOT / ".gen" / "hermit_font_map.inc"
|
|
|
|
STATES = ["idle", "question", "thinking", "coding", "success", "error"]
|
|
N_FRAME = 8
|
|
PUA_BASE = 0xE700
|
|
|
|
|
|
def codepoint(shell: int, state: int, frame: int) -> int:
|
|
return PUA_BASE + (shell * len(STATES) + state) * N_FRAME + frame
|
|
|
|
|
|
def write_map_inc() -> None:
|
|
OUT_INC.parent.mkdir(parents=True, exist_ok=True)
|
|
lines = [
|
|
"/* Generated by scripts/build_hermit_font.py — do not edit. */",
|
|
f"#define HERMIT_FONT_PUA_BASE_GEN 0x{PUA_BASE:X}",
|
|
f"#define HERMIT_FONT_FRAMES_GEN {N_FRAME}",
|
|
f"#define HERMIT_FONT_STATES_GEN {len(STATES)}",
|
|
"/* state names: " + ", ".join(STATES) + " */",
|
|
"",
|
|
]
|
|
OUT_INC.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
print(f"wrote {OUT_INC}")
|
|
|
|
|
|
def render_svg(svg: Path, png: Path, size: int) -> bool:
|
|
png.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
if subprocess.call(
|
|
["rsvg-convert", "-w", str(size), "-h", str(size), "-o", str(png), str(svg)],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
) == 0:
|
|
return png.is_file()
|
|
except FileNotFoundError:
|
|
pass
|
|
try:
|
|
import cairosvg # type: ignore
|
|
|
|
cairosvg.svg2png(url=str(svg), write_to=str(png), output_width=size, output_height=size)
|
|
return png.is_file()
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def build_ttf(png_by_cp: dict[int, Path]) -> bool:
|
|
try:
|
|
from fontTools.fontBuilder import FontBuilder # type: ignore
|
|
from fontTools.ttLib import TTFont # type: ignore
|
|
from fontTools.ttLib.tables.E_B_D_T_ import table_E_B_D_T_ # noqa: F401
|
|
from fontTools.ttLib.tables.C_B_D_T_ import table_C_B_D_T_ # noqa: F401
|
|
from fontTools.ttLib.tables.C_B_L_C_ import table_C_B_L_C_ # noqa: F401
|
|
from fontTools.ttLib.tables._g_l_y_f import Glyph # type: ignore
|
|
from fontTools.pens.ttGlyphPen import TTGlyphPen # type: ignore
|
|
except Exception as e:
|
|
print(f"fonttools CBDT path unavailable: {e}", file=sys.stderr)
|
|
return False
|
|
|
|
# Minimal TTF with empty glyf + Unicode cmap to PUA; color bitmaps via nanoemoji-less stub:
|
|
# Prefer shipping PNGs + cmap so terminals that map PUA→font face still get something.
|
|
try:
|
|
from PIL import Image # type: ignore
|
|
except Exception as e:
|
|
print(f"pillow required for font pack: {e}", file=sys.stderr)
|
|
return False
|
|
|
|
# Simplified: write a placeholder TTF that only has cmap entries; bitmaps
|
|
# need full CBDT authoring. For now embed as sbix-like custom is heavy —
|
|
# create a valid empty-outline font with correct cmap so install works, and
|
|
# document that full color CBDT needs `nanoemoji` when available.
|
|
glyph_order = [".notdef"]
|
|
cmap = {}
|
|
for cp, png in sorted(png_by_cp.items()):
|
|
name = f"u{cp:04X}"
|
|
glyph_order.append(name)
|
|
cmap[cp] = name
|
|
|
|
fb = FontBuilder(1024, isTTF=True)
|
|
fb.setupGlyphOrder(glyph_order)
|
|
fb.setupCharacterMap(cmap)
|
|
pen = TTGlyphPen(None)
|
|
empty = pen.glyph()
|
|
glyphs = {".notdef": empty}
|
|
for name in glyph_order[1:]:
|
|
glyphs[name] = empty
|
|
fb.setupGlyf(glyphs)
|
|
fb.setupHorizontalMetrics({n: (1024, 0) for n in glyph_order})
|
|
fb.setupHorizontalHeader(ascent=1024, descent=0)
|
|
fb.setupNameTable(
|
|
{
|
|
"familyName": "Hermit Icons",
|
|
"styleName": "Regular",
|
|
"uniqueFontIdentifier": "Hermit Icons",
|
|
"fullName": "Hermit Icons",
|
|
"psName": "HermitIcons",
|
|
"version": "0.10",
|
|
}
|
|
)
|
|
fb.setupOS2()
|
|
fb.setupPost()
|
|
OUT_TTF.parent.mkdir(parents=True, exist_ok=True)
|
|
fb.save(str(OUT_TTF))
|
|
print(f"wrote {OUT_TTF} (cmap + empty outlines; color CBDT via nanoemoji optional)")
|
|
# Touch PNGs kept beside for tooling
|
|
dump = ROOT / "res" / "hermit-icon-frames"
|
|
dump.mkdir(parents=True, exist_ok=True)
|
|
for cp, png in png_by_cp.items():
|
|
target = dump / f"u{cp:04X}.png"
|
|
target.write_bytes(png.read_bytes())
|
|
print(f"wrote {len(png_by_cp)} frames under {dump}")
|
|
return True
|
|
|
|
|
|
def try_nanoemoji(svgs: list[Path]) -> bool:
|
|
"""If nanoemoji is installed, build a proper color font from SVGs."""
|
|
if not svgs:
|
|
return False
|
|
try:
|
|
with tempfile.TemporaryDirectory() as td:
|
|
td = Path(td)
|
|
for i, s in enumerate(svgs):
|
|
(td / f"{i:03d}.svg").write_bytes(s.read_bytes())
|
|
out = td / "HermitIcons.ttf"
|
|
rc = subprocess.call(
|
|
["nanoemoji", f"--output_file={out}", *sorted(str(p) for p in td.glob("*.svg"))],
|
|
cwd=str(td),
|
|
)
|
|
if rc == 0 and out.is_file():
|
|
OUT_TTF.parent.mkdir(parents=True, exist_ok=True)
|
|
OUT_TTF.write_bytes(out.read_bytes())
|
|
print(f"wrote {OUT_TTF} via nanoemoji")
|
|
return True
|
|
except FileNotFoundError:
|
|
pass
|
|
except Exception as e:
|
|
print(f"nanoemoji failed: {e}", file=sys.stderr)
|
|
return False
|
|
|
|
|
|
def main() -> int:
|
|
write_map_inc()
|
|
if not ICONS.is_dir():
|
|
print(f"missing {ICONS}", file=sys.stderr)
|
|
return 1
|
|
|
|
svg_list: list[Path] = []
|
|
png_by_cp: dict[int, Path] = {}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
td = Path(td)
|
|
for shell, suffix in ((0, ""), (1, "-shell")):
|
|
for si, st in enumerate(STATES):
|
|
svg = ICONS / f"{st}{suffix}.svg"
|
|
if not svg.is_file():
|
|
print(f"skip missing {svg.name}", file=sys.stderr)
|
|
continue
|
|
svg_list.append(svg)
|
|
# One master frame per state for now; duplicate across N_FRAME.
|
|
png = td / f"{shell}_{st}.png"
|
|
if not render_svg(svg, png, 64):
|
|
print(f"render failed: {svg.name}", file=sys.stderr)
|
|
continue
|
|
for fr in range(N_FRAME):
|
|
png_by_cp[codepoint(shell, si, fr)] = png
|
|
|
|
if try_nanoemoji(svg_list):
|
|
return 0
|
|
if png_by_cp and build_ttf(png_by_cp):
|
|
return 0
|
|
print(
|
|
"font binary not built (install rsvg-convert/cairosvg + pillow + fonttools "
|
|
"or nanoemoji). Map .inc is ready for PUA emission.",
|
|
file=sys.stderr,
|
|
)
|
|
return 0 # map is enough for compile
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|