All checks were successful
Release / build (push) Successful in 2m47s
Redesign tray/app icon as a wooden round shield with iron rim, rivets, wood grain, and the Syncthing network motif (ring + 3 nodes + spokes) painted in the state color. Dark shadow outline for wood contrast. Inspired by Stammtisch hero icon style (warm, illustrated wood tones). - Dynamic tray icon changes color per state (green/blue/gray/red) - Static assets: PNG at 7 sizes + multi-size .ico + preview sheet - Icon generator utility (cmd/icongen) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
166 lines
3.9 KiB
Go
166 lines
3.9 KiB
Go
// Generates static icon assets for the SyncWarden project.
|
|
// Run: go run ./cmd/icongen/
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"image/png"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/fogleman/gg"
|
|
|
|
"git.davoryn.de/calic/syncwarden/internal/icons"
|
|
)
|
|
|
|
func main() {
|
|
dir := filepath.Join("assets")
|
|
os.MkdirAll(dir, 0755)
|
|
|
|
// Generate PNGs at standard icon sizes (idle/green as canonical)
|
|
sizes := []int{16, 32, 48, 64, 128, 256, 512}
|
|
pngEntries := make(map[int][]byte) // size → PNG data
|
|
|
|
for _, sz := range sizes {
|
|
data, err := icons.RenderPNG(icons.StateIdle, sz)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error rendering %d: %v\n", sz, err)
|
|
continue
|
|
}
|
|
pngEntries[sz] = data
|
|
|
|
fname := fmt.Sprintf("icon-%d.png", sz)
|
|
fpath := filepath.Join(dir, fname)
|
|
if err := os.WriteFile(fpath, data, 0644); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error writing %s: %v\n", fpath, err)
|
|
continue
|
|
}
|
|
fmt.Printf(" %s (%d bytes)\n", fpath, len(data))
|
|
}
|
|
|
|
// Generate multi-size .ico (16, 32, 48, 256)
|
|
icoSizes := []int{16, 32, 48, 256}
|
|
icoData := buildMultiICO(icoSizes, pngEntries)
|
|
icoPath := filepath.Join(dir, "syncwarden.ico")
|
|
if err := os.WriteFile(icoPath, icoData, 0644); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error writing ico: %v\n", err)
|
|
} else {
|
|
fmt.Printf(" %s (%d bytes)\n", icoPath, len(icoData))
|
|
}
|
|
|
|
// Generate a large composited preview showing all states
|
|
previewPath := filepath.Join(dir, "icon-preview.png")
|
|
generatePreview(previewPath)
|
|
|
|
fmt.Println("Done.")
|
|
}
|
|
|
|
func buildMultiICO(sizes []int, pngs map[int][]byte) []byte {
|
|
count := 0
|
|
for _, sz := range sizes {
|
|
if _, ok := pngs[sz]; ok {
|
|
count++
|
|
}
|
|
}
|
|
|
|
const headerSize = 6
|
|
const entrySize = 16
|
|
dataOffset := headerSize + entrySize*count
|
|
|
|
buf := new(bytes.Buffer)
|
|
|
|
// ICONDIR header
|
|
binary.Write(buf, binary.LittleEndian, uint16(0)) // Reserved
|
|
binary.Write(buf, binary.LittleEndian, uint16(1)) // Type: icon
|
|
binary.Write(buf, binary.LittleEndian, uint16(count)) // Count
|
|
|
|
// Calculate offsets
|
|
offset := dataOffset
|
|
type entry struct {
|
|
w, h int
|
|
data []byte
|
|
}
|
|
entries := make([]entry, 0, count)
|
|
for _, sz := range sizes {
|
|
data, ok := pngs[sz]
|
|
if !ok {
|
|
continue
|
|
}
|
|
entries = append(entries, entry{sz, sz, data})
|
|
}
|
|
|
|
// Write ICONDIRENTRY records
|
|
for _, e := range entries {
|
|
w := byte(e.w)
|
|
if e.w >= 256 {
|
|
w = 0
|
|
}
|
|
h := byte(e.h)
|
|
if e.h >= 256 {
|
|
h = 0
|
|
}
|
|
buf.WriteByte(w)
|
|
buf.WriteByte(h)
|
|
buf.WriteByte(0) // ColorCount
|
|
buf.WriteByte(0) // Reserved
|
|
binary.Write(buf, binary.LittleEndian, uint16(1)) // Planes
|
|
binary.Write(buf, binary.LittleEndian, uint16(32)) // BitCount
|
|
binary.Write(buf, binary.LittleEndian, uint32(len(e.data))) // BytesInRes
|
|
binary.Write(buf, binary.LittleEndian, uint32(offset)) // ImageOffset
|
|
offset += len(e.data)
|
|
}
|
|
|
|
// Write image data
|
|
for _, e := range entries {
|
|
buf.Write(e.data)
|
|
}
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func generatePreview(path string) {
|
|
states := []struct {
|
|
s icons.State
|
|
name string
|
|
}{
|
|
{icons.StateIdle, "Idle"},
|
|
{icons.StateSyncing, "Syncing"},
|
|
{icons.StatePaused, "Paused"},
|
|
{icons.StateError, "Error"},
|
|
{icons.StateDisconnected, "Disconnected"},
|
|
}
|
|
|
|
sz := 128
|
|
pad := 20
|
|
w := len(states)*(sz+pad) + pad
|
|
h := sz + pad*2 + 20
|
|
|
|
dc := gg.NewContext(w, h)
|
|
dc.SetHexColor("#1a1a2e")
|
|
dc.DrawRectangle(0, 0, float64(w), float64(h))
|
|
dc.Fill()
|
|
|
|
for i, st := range states {
|
|
// Render at 128px
|
|
data, err := icons.RenderPNG(st.s, sz)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
img, err := png.Decode(bytes.NewReader(data))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
x := pad + i*(sz+pad)
|
|
dc.DrawImage(img, x, pad)
|
|
|
|
// Label
|
|
dc.SetHexColor("#cccccc")
|
|
dc.DrawStringAnchored(st.name, float64(x)+float64(sz)/2, float64(pad+sz+12), 0.5, 0.5)
|
|
}
|
|
|
|
dc.SavePNG(path)
|
|
fmt.Printf(" %s\n", path)
|
|
}
|