Files
theta-agent/cmd/icon-gen/main.go
T
wmantly 12d55a4454 feat(icons): clean tray badge set + Start menu/installer icon
New cmd/icon-gen renders the four state badges (Red/Yellow/Green/Blue) as a
rounded-square badge with a subtle vertical gradient and a crisp white theta,
256px with 4x4 supersampling. The tray embeds these (icons.go, generated) and
builds a proper multi-size Windows ICO (16..256) via exact box filtering --
replacing the old flat 48px circle and nearest-neighbour scaling.

The installer now bundles theta-agent.ico (multi-size, Blue badge) and uses it
for the Start menu 'Theta Agent Tray' shortcut, the setup.exe icon
(SetupIconFile), and the uninstaller display icon. Dead duplicate icon arrays
in the root package (tray_icons.go) removed.
2026-08-10 18:49:30 -07:00

328 lines
8.6 KiB
Go

// Command icon-gen renders the theta-agent tray icon set (Red/Yellow/Green/
// Blue state badges) and writes:
//
// - <tray-dir>/icons.go — the Go source with the embedded 256x256 PNG byte
// arrays the tray binary compiles in (gofmt-formatted).
// - <tray-dir>/icon-*.png — a preview of each color, for eyeballing.
// - <ico-path> — the multi-size Windows .ico (16..256) of the Blue badge,
// used as the installer's own icon, the Start menu shortcut icon, and the
// uninstaller display icon.
//
// Design: a rounded-square badge in the state color with a subtle vertical
// gradient, and a white theta (ring + horizontal bar) glyph knocked out of it.
// Rendered at 256px with 4x4 supersampling so the edges are crisp at every
// downscaled size. Pure stdlib; run with:
//
// go run ./cmd/icon-gen cmd/theta-agent-tray installer/windows/theta-agent.ico
//
// The generated icons.go and theta-agent.ico must not be edited by hand.
package main
import (
"bytes"
"encoding/binary"
"fmt"
"go/format"
"image"
"image/color"
"image/png"
"math"
"os"
"path/filepath"
"strings"
)
const size = 256
type badgeColor struct {
name string
base color.RGBA
}
var palette = []badgeColor{
{"Red", color.RGBA{0xEF, 0x44, 0x44, 0xFF}},
{"Yellow", color.RGBA{0xEA, 0xB3, 0x08, 0xFF}},
{"Green", color.RGBA{0x22, 0xC5, 0x5E, 0xFF}},
{"Blue", color.RGBA{0x3B, 0x82, 0xF6, 0xFF}},
}
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: go run ./cmd/icon-gen <tray-package-dir> <ico-output-path>")
os.Exit(2)
}
dir := os.Args[1]
icoPath := os.Args[2]
// The Blue badge doubles as the product/app icon (Start menu shortcut,
// installer, uninstaller).
var blue *image.RGBA
var src strings.Builder
src.WriteString("// Code generated by cmd/icon-gen; DO NOT EDIT.\n")
src.WriteString("\npackage main\n\n")
src.WriteString("// Theta Agent tray state badges, 256x256 PNG.\n")
src.WriteString("var (\n")
for _, c := range palette {
img := renderBadge(c.base)
preview := filepath.Join(dir, "icon-"+strings.ToLower(c.name)+".png")
writePNG(img, preview)
if c.name == "Blue" {
blue = img
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
fmt.Fprintln(os.Stderr, "encode:", err)
os.Exit(1)
}
src.WriteString(fmt.Sprintf("\ticon%s = %s\n", c.name, goBytes(buf.Bytes())))
}
src.WriteString(")\n")
out := filepath.Join(dir, "icons.go")
formatted, err := format.Source([]byte(src.String()))
if err != nil {
fmt.Fprintln(os.Stderr, "format:", err)
os.Exit(1)
}
if err := os.WriteFile(out, formatted, 0644); err != nil {
fmt.Fprintln(os.Stderr, "write:", err)
os.Exit(1)
}
fmt.Println("wrote", out)
if err := writeICO(blue, icoPath); err != nil {
fmt.Fprintln(os.Stderr, "ico:", err)
os.Exit(1)
}
fmt.Println("wrote", icoPath)
}
// renderBadge draws the state badge at `size` with 4x4 supersampling.
func renderBadge(base color.RGBA) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, size, size))
white := color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}
top := mix(base, white, 0.16)
bottom := mix(base, color.RGBA{0, 0, 0, 0xFF}, 0.20)
const margin = 8.0
const corner = 52.0
cx, cy := size/2.0, size/2.0
half := (float64(size) - 2*margin) / 2
// Theta glyph geometry (ring + horizontal bar, bar extends past the ring).
const ringOuter = 80.0
const ringInner = 56.0
const barHalf = 12.0
const barLen = 86.0
const ss = 4 // supersample factor
for py := 0; py < size; py++ {
for px := 0; px < size; px++ {
var accR, accG, accB, accA float64
for sy := 0; sy < ss; sy++ {
for sx := 0; sx < ss; sx++ {
x := float64(px) + (float64(sx)+0.5)/ss
y := float64(py) + (float64(sy)+0.5)/ss
if !inRoundedRect(x, y, cx, cy, half, corner) {
continue // transparent outside the badge
}
t := clamp01((y - (cy - half)) / (2 * half))
col := lerp(top, bottom, t)
if inTheta(x, y, cx, cy, ringOuter, ringInner, barHalf, barLen) {
col = white
}
accR += float64(col.R)
accG += float64(col.G)
accB += float64(col.B)
accA += 255
}
}
n := float64(ss * ss)
img.SetRGBA(px, py, color.RGBA{
R: uint8(accR / n),
G: uint8(accG / n),
B: uint8(accB / n),
A: uint8(accA / n),
})
}
}
return img
}
func inRoundedRect(x, y, cx, cy, half, r float64) bool {
dx := math.Abs(x-cx) - (half - r)
dy := math.Abs(y-cy) - (half - r)
if dx <= 0 && dy <= 0 {
return true
}
return dx*dx+dy*dy <= r*r
}
func inTheta(x, y, cx, cy, ringOuter, ringInner, barHalf, barLen float64) bool {
d := math.Hypot(x-cx, y-cy)
inRing := d >= ringInner && d <= ringOuter
inBar := math.Abs(y-cy) <= barHalf && math.Abs(x-cx) <= barLen
return inRing || inBar
}
func mix(a, b color.RGBA, t float64) color.RGBA {
return color.RGBA{
R: uint8(float64(a.R) + (float64(b.R)-float64(a.R))*t),
G: uint8(float64(a.G) + (float64(b.G)-float64(a.G))*t),
B: uint8(float64(a.B) + (float64(b.B)-float64(a.B))*t),
A: 0xFF,
}
}
func lerp(a, b color.RGBA, t float64) color.RGBA {
return mix(a, b, t)
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func writePNG(img *image.RGBA, path string) {
f, err := os.Create(path)
if err != nil {
fmt.Fprintln(os.Stderr, "create:", err)
return
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
fmt.Fprintln(os.Stderr, "encode:", err)
}
}
// icoSizes are the entries embedded in the .ico. The 256px source is an
// integer multiple of each, so every entry is an exact box-filter downscale.
var icoSizes = []int{16, 24, 32, 48, 64, 128, 256}
// writeICO writes a multi-size Windows .ico (classic BMP XOR+AND entries, the
// format LoadImage has always supported) from the 256px source image.
func writeICO(src *image.RGBA, path string) error {
sizes := icoSizes
var dir bytes.Buffer
var payload bytes.Buffer
offset := 6 + len(sizes)*16
dir.Write([]byte{0, 0, 1, 0, byte(len(sizes)), 0}) // ICONDIR
for _, s := range sizes {
bmp := toDIB(scaleBox(src, s))
w, h := byte(s), byte(s)
if s >= 256 {
w, h = 0, 0
}
dir.Write([]byte{w, h, 0, 0, 1, 0, 32, 0})
dir.Write(u32le(len(bmp)))
dir.Write(u32le(offset + payload.Len()))
payload.Write(bmp)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
if _, err := f.Write(dir.Bytes()); err != nil {
return err
}
_, err = f.Write(payload.Bytes())
return err
}
// scaleBox resizes src to w x w with an exact box (area-average) filter. Only
// correct when src's dimensions are an integer multiple of w — 256 is for
// every icoSizes entry — so no interpolation blur is introduced.
func scaleBox(src *image.RGBA, w int) *image.RGBA {
b := src.Bounds()
sw, sh := b.Dx(), b.Dy()
fx := sw / w
fy := sh / w
dst := image.NewRGBA(image.Rect(0, 0, w, w))
for y := 0; y < w; y++ {
for x := 0; x < w; x++ {
var r, g, bl, a int64
for sy := 0; sy < fy; sy++ {
yy := b.Min.Y + y*fy + sy
for sx := 0; sx < fx; sx++ {
xx := b.Min.X + x*fx + sx
cr, cg, cb, ca := src.At(xx, yy).RGBA()
r += int64(cr >> 8)
g += int64(cg >> 8)
bl += int64(cb >> 8)
a += int64(ca >> 8)
}
}
n := int64(fx * fy)
dst.SetRGBA(x, y, color.RGBA{
R: uint8(r / n),
G: uint8(g / n),
B: uint8(bl / n),
A: uint8(a / n),
})
}
}
return dst
}
// toDIB encodes an image as a 32-bit bottom-up DIB with an all-transparent
// AND mask — the classic icon bitmap entry.
func toDIB(img *image.RGBA) []byte {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
andRow := ((w + 31) / 32) * 4
dib := make([]byte, 0, 40+w*h*4+andRow*h)
dib = append(dib, u32le(40)...) // biSize
dib = append(dib, u32le(w)...) // biWidth
dib = append(dib, u32le(h*2)...) // biHeight (XOR + AND)
dib = append(dib, u16le(1)...) // biPlanes
dib = append(dib, u16le(32)...) // biBitCount
dib = append(dib, 0, 0, 0, 0) // biCompression = 0 (BI_RGB)
dib = append(dib, u32le(w*h*4+andRow*h)...)
dib = append(dib, make([]byte, 16)...) // remaining header fields
for y := 0; y < h; y++ {
srcY := b.Min.Y + (h - 1 - y) // DIB rows are bottom-up
for x := 0; x < w; x++ {
r, g, bl, a := img.At(b.Min.X+x, srcY).RGBA()
dib = append(dib, byte(bl>>8), byte(g>>8), byte(r>>8), byte(a>>8))
}
}
dib = append(dib, make([]byte, andRow*h)...) // AND mask: all zero
return dib
}
func u16le(v int) []byte {
return []byte{byte(v), byte(v >> 8)}
}
func u32le(v int) []byte {
var b [4]byte
binary.LittleEndian.PutUint32(b[:], uint32(v))
return b[:]
}
func goBytes(b []byte) string {
var sb strings.Builder
sb.WriteString("[]byte{")
for i, x := range b {
if i%12 == 0 {
sb.WriteString("\n\t\t")
}
sb.WriteString(fmt.Sprintf("0x%02x, ", x))
}
sb.WriteString("\n\t}")
return sb.String()
}