Files
syncwarden/cmd/icongen/main.go
Axel Meyer 110bb715ff
All checks were successful
Release / build (push) Successful in 2m34s
Redesign icon: wood texture shield with darken-blended Syncthing motif
Replace procedural icon renderer with pre-rendered textured icons:
- Use Stammtisch wood texture as shield face (crop tabletop planks)
- Apply Syncthing motif via darken blend ("warpaint on wood" effect)
- Fix Syncthing logo geometry: asymmetric node placement (76°/116°/168°
  gaps) with offset hub, matching the official SVG
- Metal rim with rivets and directional lighting
- Embed pre-rendered PNGs via go:embed (no runtime rendering)
- Icon generator in cmd/icongen/ takes wood texture as input

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:01:51 +01:00

448 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Icon generator for SyncWarden.
// Uses the Stammtisch wood texture as the shield face and applies
// the Syncthing network motif with a darken blend ("warpaint on wood").
//
// Usage: go run ./cmd/icongen/ -wood /path/to/stammtisch.png
package main
import (
"bytes"
"encoding/binary"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"math"
"os"
"path/filepath"
"github.com/fogleman/gg"
xdraw "golang.org/x/image/draw"
)
// State colors (must match internal/icons/states.go)
var stateColors = []struct {
name string
color color.RGBA
}{
{"idle", color.RGBA{76, 175, 80, 255}},
{"syncing", color.RGBA{33, 150, 243, 255}},
{"paused", color.RGBA{158, 158, 158, 255}},
{"error", color.RGBA{244, 67, 54, 255}},
{"disconnected", color.RGBA{97, 97, 97, 255}},
}
func main() {
woodPath := flag.String("wood", "", "path to wood texture PNG (stammtisch.png)")
flag.Parse()
if *woodPath == "" {
for _, p := range []string{
"/tmp/stammtisch/stammtisch.png",
filepath.Join(os.TempDir(), "stammtisch", "stammtisch.png"),
} {
if _, err := os.Stat(p); err == nil {
*woodPath = p
break
}
}
if *woodPath == "" {
fmt.Fprintln(os.Stderr, "wood texture not found; provide -wood flag")
os.Exit(1)
}
}
woodFull, err := loadPNG(*woodPath)
if err != nil {
fmt.Fprintf(os.Stderr, "load wood: %v\n", err)
os.Exit(1)
}
fmt.Printf("Wood texture: %dx%d\n", woodFull.Bounds().Dx(), woodFull.Bounds().Dy())
// Crop to just the flat plank surface of the tabletop.
// The wooden planks span roughly x=180..840, y=100..370 in the 1024×1024 image.
// Crop that rectangle, then scale to a square so wood fills the entire shield face.
woodRect := cropRect(woodFull, 200, 130, 820, 380)
woodImg := scaleImage(woodRect, 880, 880)
// --- Tray icons (64px, all 5 states) → internal/icons/ ---
trayDir := filepath.Join("internal", "icons")
for _, st := range stateColors {
img := renderShield(woodImg, st.color, 64)
path := filepath.Join(trayDir, "tray_"+st.name+".png")
savePNG(img, path)
fmt.Printf(" %s\n", path)
}
// --- Static assets (idle/green, multiple sizes) → assets/ ---
assetsDir := "assets"
os.MkdirAll(assetsDir, 0755)
idle := stateColors[0].color
icoEntries := map[int][]byte{}
for _, sz := range []int{16, 32, 48, 64, 128, 256, 512} {
img := renderShield(woodImg, idle, sz)
path := filepath.Join(assetsDir, fmt.Sprintf("icon-%d.png", sz))
data := savePNG(img, path)
icoEntries[sz] = data
fmt.Printf(" %s (%d bytes)\n", path, len(data))
}
// Multi-size .ico
icoPath := filepath.Join(assetsDir, "syncwarden.ico")
writeMultiICO(icoPath, icoEntries, []int{16, 32, 48, 256})
fmt.Printf(" %s\n", icoPath)
// Preview sheet (all states at 128px)
previewPath := filepath.Join(assetsDir, "icon-preview.png")
generatePreview(woodImg, previewPath)
fmt.Printf(" %s\n", previewPath)
fmt.Println("Done.")
}
// ─── Shield Rendering ─────────────────────────────────────────────────────────
func renderShield(wood image.Image, motifColor color.RGBA, size int) *image.RGBA {
s := float64(size)
cx, cy := s/2, s/2
shieldR := s * 0.47
rimW := math.Max(2.5, s*0.058)
faceR := shieldR - rimW
out := image.NewRGBA(image.Rect(0, 0, size, size))
// Layer 1: Metal rim
rimDc := gg.NewContext(size, size)
drawRim(rimDc, cx, cy, shieldR, rimW, s)
draw.Draw(out, out.Bounds(), rimDc.Image(), image.Point{}, draw.Over)
// Layer 2: Wood face (circular crop of texture) + darken motif blend
woodFace := renderWoodFaceWithMotif(wood, motifColor, size, cx, cy, faceR)
draw.Draw(out, out.Bounds(), woodFace, image.Point{}, draw.Over)
// Layer 3: Rivets on top
if size >= 32 {
rivetDc := gg.NewContext(size, size)
count := 16
if size < 64 {
count = 10
}
if size < 48 {
count = 8
}
drawRivets(rivetDc, cx, cy, shieldR-rimW*0.5, s, count)
draw.Draw(out, out.Bounds(), rivetDc.Image(), image.Point{}, draw.Over)
}
return out
}
func renderWoodFaceWithMotif(wood image.Image, motifColor color.RGBA, size int, cx, cy, faceR float64) *image.RGBA {
faceDiam := int(math.Ceil(faceR * 2))
woodScaled := scaleImage(wood, faceDiam, faceDiam)
// Create motif mask using gg (white shapes on transparent)
maskDc := gg.NewContext(size, size)
maskDc.SetColor(color.White)
drawSyncthingMotif(maskDc, cx, cy, faceR)
maskImg := maskDc.Image()
fOX := cx - faceR
fOY := cy - faceR
face := image.NewRGBA(image.Rect(0, 0, size, size))
for py := 0; py < size; py++ {
for px := 0; px < size; px++ {
dx := float64(px) - cx
dy := float64(py) - cy
dist := math.Sqrt(dx*dx + dy*dy)
if dist > faceR+0.5 {
continue
}
wx := int(float64(px) - fOX)
wy := int(float64(py) - fOY)
if wx < 0 || wx >= faceDiam || wy < 0 || wy >= faceDiam {
continue
}
r, g, b, _ := woodScaled.At(wx, wy).RGBA()
wc := color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), 255}
// Anti-alias circle edge
alpha := 1.0
if dist > faceR-0.5 {
alpha = math.Max(0, faceR+0.5-dist)
}
// Check motif mask → darken blend
_, _, _, ma := maskImg.At(px, py).RGBA()
maskA := float64(ma) / 65535.0
final := wc
if maskA > 0.01 {
darkened := darkenBlend(wc, motifColor)
final = lerpColor(wc, darkened, maskA)
}
final.A = uint8(alpha * 255)
face.SetRGBA(px, py, final)
}
}
return face
}
// ─── Syncthing Motif (Correct Asymmetric Geometry from Official SVG) ──────────
// Geometry extracted from the official Syncthing SVG (viewBox 117.3×117.3):
//
// Ring center: (58.7, 58.5) ≈ shield center
// Ring radius: 43.7
// Hub center: (67.5, 64.4) offset right+down from ring center
// Node 1: angle ≈ -26° (top-right)
// Node 2: angle ≈ 50° (bottom-right)
// Node 3: angle ≈ 166° (left)
// Stroke width: 6
//
// Gaps: 76°, 116°, 168° — distinctly asymmetric, gives the "spinning" feel.
func drawSyncthingMotif(dc *gg.Context, cx, cy, faceR float64) {
ringR := faceR * 0.62
hubX := cx + ringR*0.201
hubY := cy + ringR*0.135
strokeW := ringR * 0.14
nodeR := ringR * 0.125
hubR := ringR * 0.155
dc.SetLineWidth(strokeW)
dc.SetLineCap(gg.LineCapRound)
// Outer ring
dc.DrawCircle(cx, cy, ringR)
dc.Stroke()
// Three outer nodes + spokes to hub (asymmetric angles)
nodeAngles := [3]float64{-26.2, 50.2, 165.9}
for _, deg := range nodeAngles {
rad := deg * math.Pi / 180
nx := cx + ringR*math.Cos(rad)
ny := cy + ringR*math.Sin(rad)
// Spoke from hub to node
dc.DrawLine(hubX, hubY, nx, ny)
dc.Stroke()
// Node dot
dc.DrawCircle(nx, ny, nodeR)
dc.Fill()
}
// Hub dot (larger)
dc.DrawCircle(hubX, hubY, hubR)
dc.Fill()
}
// ─── Metal Rim ────────────────────────────────────────────────────────────────
func drawRim(dc *gg.Context, cx, cy, outerR, rimW, s float64) {
// Drop shadow
dc.DrawCircle(cx+s*0.005, cy+s*0.008, outerR+s*0.005)
dc.SetColor(color.RGBA{10, 8, 5, 120})
dc.Fill()
// Outer dark edge
dc.DrawCircle(cx, cy, outerR)
dc.SetColor(color.RGBA{38, 32, 25, 255})
dc.Fill()
// Rim body (iron)
dc.DrawCircle(cx, cy, outerR-outerR*0.02)
dc.SetColor(color.RGBA{72, 62, 50, 255})
dc.Fill()
// Upper-left highlight arc (light source)
dc.SetLineWidth(rimW * 0.35)
dc.SetLineCap(gg.LineCapRound)
dc.SetColor(color.RGBA{105, 95, 78, 220})
dc.DrawArc(cx, cy, outerR-rimW*0.5, degToRad(200), degToRad(320))
dc.Stroke()
// Subtle bottom shadow arc
dc.SetColor(color.RGBA{30, 25, 18, 150})
dc.SetLineWidth(rimW * 0.25)
dc.DrawArc(cx, cy, outerR-rimW*0.5, degToRad(30), degToRad(150))
dc.Stroke()
// Inner edge (dark groove where rim meets wood)
innerEdgeR := outerR - rimW
dc.SetLineWidth(math.Max(1, s*0.012))
dc.SetColor(color.RGBA{28, 22, 15, 255})
dc.DrawCircle(cx, cy, innerEdgeR)
dc.Stroke()
}
func drawRivets(dc *gg.Context, cx, cy, r, s float64, count int) {
rivetR := math.Max(1.0, 1.6*s/64)
for i := 0; i < count; i++ {
a := float64(i)*2*math.Pi/float64(count) + 0.15 // slight rotation offset
rx := cx + r*math.Cos(a)
ry := cy + r*math.Sin(a)
// Rivet shadow
dc.DrawCircle(rx+rivetR*0.15, ry+rivetR*0.2, rivetR)
dc.SetColor(color.RGBA{25, 20, 15, 180})
dc.Fill()
// Rivet body
dc.DrawCircle(rx, ry, rivetR)
dc.SetColor(color.RGBA{90, 80, 68, 255})
dc.Fill()
// Rivet highlight
if s >= 48 {
dc.DrawCircle(rx-rivetR*0.25, ry-rivetR*0.3, rivetR*0.4)
dc.SetColor(color.RGBA{145, 135, 115, 255})
dc.Fill()
}
}
}
// ─── Blend Helpers ────────────────────────────────────────────────────────────
func darkenBlend(wood, paint color.RGBA) color.RGBA {
return color.RGBA{
R: minU8(wood.R, paint.R),
G: minU8(wood.G, paint.G),
B: minU8(wood.B, paint.B),
A: wood.A,
}
}
func lerpColor(a, b color.RGBA, t float64) color.RGBA {
return color.RGBA{
R: uint8(float64(a.R)*(1-t) + float64(b.R)*t),
G: uint8(float64(a.G)*(1-t) + float64(b.G)*t),
B: uint8(float64(a.B)*(1-t) + float64(b.B)*t),
A: uint8(float64(a.A)*(1-t) + float64(b.A)*t),
}
}
func minU8(a, b uint8) uint8 {
if a < b {
return a
}
return b
}
func degToRad(d float64) float64 { return d * math.Pi / 180 }
// ─── Image Helpers ────────────────────────────────────────────────────────────
func cropRect(src image.Image, x1, y1, x2, y2 int) image.Image {
r := image.Rect(x1, y1, x2, y2).Intersect(src.Bounds())
dst := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
draw.Draw(dst, dst.Bounds(), src, r.Min, draw.Src)
return dst
}
func scaleImage(src image.Image, w, h int) image.Image {
dst := image.NewRGBA(image.Rect(0, 0, w, h))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Over, nil)
return dst
}
func loadPNG(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return png.Decode(f)
}
func savePNG(img image.Image, path string) []byte {
var buf bytes.Buffer
png.Encode(&buf, img)
os.WriteFile(path, buf.Bytes(), 0644)
return buf.Bytes()
}
// ─── Multi-size ICO ───────────────────────────────────────────────────────────
func writeMultiICO(path string, pngs map[int][]byte, sizes []int) {
type entry struct {
w, h int
data []byte
}
var entries []entry
for _, sz := range sizes {
if d, ok := pngs[sz]; ok {
entries = append(entries, entry{sz, sz, d})
}
}
const headerSize = 6
const dirEntrySize = 16
buf := new(bytes.Buffer)
binary.Write(buf, binary.LittleEndian, uint16(0))
binary.Write(buf, binary.LittleEndian, uint16(1))
binary.Write(buf, binary.LittleEndian, uint16(len(entries)))
offset := headerSize + dirEntrySize*len(entries)
for _, e := range entries {
w, h := byte(e.w), byte(e.h)
if e.w >= 256 {
w = 0
}
if e.h >= 256 {
h = 0
}
buf.WriteByte(w)
buf.WriteByte(h)
buf.WriteByte(0)
buf.WriteByte(0)
binary.Write(buf, binary.LittleEndian, uint16(1))
binary.Write(buf, binary.LittleEndian, uint16(32))
binary.Write(buf, binary.LittleEndian, uint32(len(e.data)))
binary.Write(buf, binary.LittleEndian, uint32(offset))
offset += len(e.data)
}
for _, e := range entries {
buf.Write(e.data)
}
os.WriteFile(path, buf.Bytes(), 0644)
}
// ─── Preview Sheet ────────────────────────────────────────────────────────────
func generatePreview(wood image.Image, path string) {
sz := 128
pad := 20
w := len(stateColors)*(sz+pad) + pad
h := sz + pad*2 + 24
dc := gg.NewContext(w, h)
dc.SetHexColor("#1a1a2e")
dc.DrawRectangle(0, 0, float64(w), float64(h))
dc.Fill()
for i, st := range stateColors {
img := renderShield(wood, st.color, sz)
x := pad + i*(sz+pad)
dc.DrawImage(img, x, pad)
dc.SetHexColor("#cccccc")
dc.DrawStringAnchored(st.name, float64(x)+float64(sz)/2, float64(pad+sz+14), 0.5, 0.5)
}
dc.SavePNG(path)
}