release: Hermit 1.0.0 — home layout, icon font, install
ci / tcc-check (push) Failing after 40s
ci / linux (aarch64, 0) (push) Skipped
ci / linux (x86, 1) (push) Skipped
ci / linux (x86_64, 1) (push) Skipped
ci / windows (push) Skipped

Compact the home parliament chip, animate crab states like the HTML kit,
ship hermit-icons.ttf (sbix) + frames, and install the font from make/
bootstrap/package paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WHC
2026-10-29 21:57:46 +08:00
co-authored by Cursor
parent 8fcbd886c3
commit 41f4cf85ee
109 changed files with 472 additions and 218 deletions
+39 -3
View File
@@ -49,6 +49,35 @@ install_bin() {
ln -sf hermit "$prefix/bin/cst-agent"
}
install_font() {
local prefix="$1" dir="$2"
local ttf=""
if [[ -f "$dir/res/hermit-icons.ttf" ]]; then
ttf="$dir/res/hermit-icons.ttf"
elif [[ -f "$dir/share/hermit/hermit-icons.ttf" ]]; then
ttf="$dir/share/hermit/hermit-icons.ttf"
elif [[ -f "$dir/hermit-icons.ttf" ]]; then
ttf="$dir/hermit-icons.ttf"
fi
[[ -n "$ttf" ]] || {
echo "warning: hermit-icons.ttf not found; run 'make hermit-font' in the tree" >&2
return 0
}
mkdir -p "$prefix/share/hermit" "$prefix/share/fonts/hermit" \
"${HOME:-/tmp}/.local/share/fonts"
install -m 0644 "$ttf" "$prefix/share/hermit/hermit-icons.ttf"
install -m 0644 "$ttf" "$prefix/share/fonts/hermit/hermit-icons.ttf"
install -m 0644 "$ttf" "${HOME}/.local/share/fonts/hermit-icons.ttf"
if [[ -d "$dir/res/hermit-frames" ]]; then
mkdir -p "$prefix/share/hermit/frames"
cp -a "$dir/res/hermit-frames/." "$prefix/share/hermit/frames/"
fi
if command -v fc-cache >/dev/null 2>&1; then
fc-cache -f "${HOME}/.local/share/fonts" >/dev/null 2>&1 || true
fi
echo "==> installed Hermit Icons font"
}
install_helpers() {
local prefix="$1" dir="$2"
mkdir -p "$prefix/bin"
@@ -123,8 +152,12 @@ install_from_release() {
bin="$(find "$tmp" -type f \( -name hermit -o -name cst-agent \) | head -1)"
[[ -x "$bin" ]] || { echo "tarball missing hermit/cst-agent" >&2; rm -rf "$tmp"; return 1; }
install_bin "$prefix" "$bin"
# Font may live next to the binary inside the tarball.
local rel_dir
rel_dir="$(dirname "$bin")"
install_font "$prefix" "$rel_dir"
rm -rf "$tmp"
echo "==> installed release $ver ($arch) -> $prefix/bin/cst-agent"
echo "==> installed release $ver ($arch) -> $prefix/bin/hermit"
}
main() {
@@ -211,8 +244,11 @@ main() {
run_make -j"$jobs" check >/dev/null 2>&1 || echo "warning: some tests failed (run 'make check')"
fi
echo "==> install to $prefix/bin"
install_bin "$prefix" "$dir/cst-agent"
echo "==> bake Hermit Icons font (if tooling present)"
run_make hermit-font >/dev/null 2>&1 || true
echo "==> install to $prefix/bin (+ font)"
install_bin "$prefix" "$dir/hermit"
install_font "$prefix" "$dir"
install_helpers "$prefix" "$dir"
echo
+167 -137
View File
@@ -1,38 +1,121 @@
#!/usr/bin/env python3
"""Batch-convert icons/agent-motion SVG states into a color emoji-style font.
"""Bake Hermit animation frames (HTML-kit states) into PNG + sbix color TTF.
Geometry/colors follow icons/agent-motion. Animation mirrors the HTML kit:
idle breath/blink, ask tilt+?, thinking look+claw, coding >_, success smile,
error mark — as 8 discrete frames per state × bare/shell.
Outputs:
res/hermit-icons.ttf (CBDT/CBLC bitmap glyphs when fonttools works)
.gen/hermit_font_map.inc (PUA codepoint table for the TUI)
res/hermit-frames/{bare,shell}/{state}/fNN.png
res/hermit-icons.ttf
.gen/hermit_font_map.inc
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.
nix-shell -p python3Packages.pillow python3Packages.fonttools --run \\
'python3 scripts/build_hermit_font.py'
"""
from __future__ import annotations
import struct
import subprocess
import math
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ICONS = ROOT / "icons" / "agent-motion" / "icons"
OUT_FRAMES = ROOT / "res" / "hermit-frames"
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
SIZE = 64
SHELL = (0x27, 0x7F, 0x94, 255)
PROMPT = (0xA9, 0xD9, 0xD7, 255)
BODY = (0xF6, 0x80, 0x42, 255)
EYE = (0x99, 0x57, 0xAD, 255)
LEG = (0xA4, 0x57, 0x4F, 255)
MARK = (0xE9, 0xBA, 0x47, 255)
TRANS = (0, 0, 0, 0)
def codepoint(shell: int, state: int, frame: int) -> int:
return PUA_BASE + (shell * len(STATES) + state) * N_FRAME + frame
def draw_icon(state: str, frame: int, with_shell: bool):
from PIL import Image, ImageDraw
im = Image.new("RGBA", (SIZE, SIZE), TRANS)
d = ImageDraw.Draw(im)
phase = frame * (math.pi * 2 / N_FRAME)
t = frame / max(N_FRAME - 1, 1)
cx, cy = (40, 38) if with_shell else (32, 30)
breath = math.sin(phase) * 1.2
rot = 0.0
if state == "question":
rot = 6 * math.sin(phase * 0.5)
elif state == "coding":
rot = 1.4 * math.sin(phase * 2)
elif state == "error":
rot = 3 * math.sin(phase * 3) if 0.7 < t < 0.95 else 0
elif state == "success":
breath -= abs(math.sin(phase)) * 2.0
elif state == "thinking":
rot = -2
if with_shell:
d.rounded_rectangle([4, 8, 52, 44], radius=3, fill=SHELL)
d.line([(12, 18), (16, 22), (12, 26)], fill=PROMPT, width=2)
d.line([(22, 27), (30, 27)], fill=PROMPT, width=2)
ly = cy + 14 + breath * 0.3
d.line([(cx - 9, ly), (cx - 9, ly + 6)], fill=LEG, width=3)
d.line([(cx + 9, ly), (cx + 9, ly + 6)], fill=LEG, width=3)
bw, bh = 22, 15
bx0 = cx - bw // 2
by0 = int(cy - bh // 2 + breath)
shift = int(rot * 0.3)
d.rounded_rectangle(
[bx0 + shift, by0, bx0 + bw + shift, by0 + bh], radius=3, fill=BODY
)
blink = state in ("idle", "question") and 3 <= frame <= 4
ey = by0 + (7 if not blink else 9)
eh = 1 if blink else 4
bx0s = bx0 + shift
if state == "coding":
d.line([(bx0s + 4, ey), (bx0s + 7, ey + 2), (bx0s + 4, ey + 4)], fill=EYE, width=2)
d.line([(bx0s + 12, ey + 3), (bx0s + 17, ey + 3)], fill=EYE, width=2)
elif state == "success":
d.arc([bx0s + 4, ey, bx0s + 9, ey + 5], 200, 340, fill=EYE, width=2)
d.arc([bx0s + 12, ey, bx0s + 17, ey + 5], 200, 340, fill=EYE, width=2)
elif state == "error":
d.line([(bx0s + 4, ey), (bx0s + 8, ey + 2)], fill=EYE, width=2)
d.line([(bx0s + 14, ey + 2), (bx0s + 18, ey)], fill=EYE, width=2)
elif state == "thinking":
look = 1 if frame % 4 >= 2 else 0
d.line([(bx0s + 6 + look, ey), (bx0s + 6 + look, ey + eh)], fill=EYE, width=2)
d.line([(bx0s + 14 + look, ey), (bx0s + 14 + look, ey + eh)], fill=EYE, width=2)
else:
d.line([(bx0s + 6, ey), (bx0s + 6, ey + eh)], fill=EYE, width=2)
d.line([(bx0s + 14, ey), (bx0s + 14, ey + eh)], fill=EYE, width=2)
if state == "question":
d.arc([bx0s + bw - 2, by0 - 10, bx0s + bw + 8, by0], 200, 40, fill=EYE, width=2)
d.point((bx0s + bw + 3, by0 + 2), fill=EYE)
elif state == "thinking":
d.line([(bx0s + bw - 2, by0 + bh - 2), (bx0s + bw - 2, by0 + bh + 5)], fill=MARK, width=3)
d.line([(bx0s + bw - 2, by0 + bh + 5), (bx0s + bw - 8, by0 + bh + 5)], fill=MARK, width=3)
elif state == "error":
d.line([(bx0s + bw + 2, by0 - 6), (bx0s + bw + 2, by0)], fill=EYE, width=2)
d.point((bx0s + bw + 2, by0 + 4), fill=EYE)
return im
def write_map_inc() -> None:
OUT_INC.parent.mkdir(parents=True, exist_ok=True)
lines = [
@@ -40,162 +123,109 @@ def write_map_inc() -> None:
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) + " */",
"",
"static const char *const HERMIT_FONT_STATE_NAMES[] = {",
]
for s in STATES:
lines.append(f' "{s}",')
lines.append("};")
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)
def build_sbix_font(images: dict[int, object]) -> bool:
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
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.ttLib import newTable
from fontTools.ttLib.tables.sbixGlyph import Glyph as SbixGlyph
from fontTools.ttLib.tables.sbixStrike import Strike
except Exception as e:
print(f"fonttools CBDT path unavailable: {e}", file=sys.stderr)
print(f"fontTools missing: {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
cps = sorted(images)
glyph_order = [".notdef"] + [f"u{cp:04X}" for cp in cps]
cmap = {cp: f"u{cp:04X}" for cp in cps}
# 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 = FontBuilder(SIZE, 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.setupGlyf({n: empty for n in glyph_order})
fb.setupHorizontalMetrics({n: (SIZE, 0) for n in glyph_order})
fb.setupHorizontalHeader(ascent=SIZE, descent=0)
fb.setupNameTable(
{
"familyName": "Hermit Icons",
"styleName": "Regular",
"uniqueFontIdentifier": "Hermit Icons",
"uniqueFontIdentifier": "Hermit Icons 1.0",
"fullName": "Hermit Icons",
"psName": "HermitIcons",
"version": "0.10",
"version": "Version 1.000",
}
)
fb.setupOS2()
fb.setupOS2(sTypoAscender=SIZE, sTypoDescender=0, usWinAscent=SIZE, usWinDescent=0)
fb.setupPost()
font = fb.font
import io
strike = Strike(ppem=SIZE, resolution=72)
strike.glyphs = {}
for name in glyph_order:
if name == ".notdef":
continue
cp = int(name[1:], 16)
im = images[cp]
bio = io.BytesIO()
im.save(bio, format="PNG")
g = SbixGlyph()
g.glyphName = name
g.originOffsetX = 0
g.originOffsetY = 0
g.graphicType = "png "
g.imageData = bio.getvalue()
strike.glyphs[name] = g
sbix = newTable("sbix")
sbix.version = 1
sbix.flags = 1
sbix.strikes = {SIZE: strike}
font["sbix"] = sbix
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}")
font.save(str(OUT_TTF))
print(f"wrote {OUT_TTF} (sbix, {len(images)} glyphs)")
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
try:
from PIL import Image # noqa: F401
except ImportError:
print(
"font binary not built (install rsvg-convert/cairosvg + pillow + fonttools "
"or nanoemoji). Map .inc is ready for PUA emission.",
"need Pillow: nix-shell -p python3Packages.pillow python3Packages.fonttools",
file=sys.stderr,
)
return 0 # map is enough for compile
return 1
write_map_inc()
images: dict[int, object] = {}
for shell in (0, 1):
for si, st in enumerate(STATES):
d = OUT_FRAMES / ("shell" if shell else "bare") / st
d.mkdir(parents=True, exist_ok=True)
for fr in range(N_FRAME):
im = draw_icon(st, fr, with_shell=bool(shell))
im.save(d / f"f{fr:02d}.png")
images[codepoint(shell, si, fr)] = im
print(f"wrote {len(images)} PNGs under {OUT_FRAMES}")
if not build_sbix_font(images):
print("TTF not written — PNGs ready; install fonttools for the font", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
+14
View File
@@ -59,6 +59,20 @@ ln -sfr "$rel/hermit" "$rel/cst-agent" 2>/dev/null || ln -sf hermit "$rel/cst-ag
"${T}strip" "$rel/hermit"
cp -f README.md "$rel/README.md"
echo "$ver" >"$rel/VERSION"
# Ship icon font + animation frames with the release tarball.
if [[ ! -f res/hermit-icons.ttf ]]; then
python3 scripts/build_hermit_font.py >/dev/null 2>&1 || true
fi
if [[ -f res/hermit-icons.ttf ]]; then
mkdir -p "$rel/share/hermit" "$rel/share/fonts/hermit"
cp -f res/hermit-icons.ttf "$rel/hermit-icons.ttf"
cp -f res/hermit-icons.ttf "$rel/share/hermit/hermit-icons.ttf"
cp -f res/hermit-icons.ttf "$rel/share/fonts/hermit/hermit-icons.ttf"
fi
if [[ -d res/hermit-frames ]]; then
mkdir -p "$rel/share/hermit/frames"
cp -a res/hermit-frames/. "$rel/share/hermit/frames/"
fi
tgz="$(cst_pack_tgz dist "$name")"
# Compat alias package name for one release cycle
cp -f "$tgz" "dist/cst-agent-$ver-linux-$ARCH.tar.gz"