Redesign icon: wood texture shield with darken-blended Syncthing motif
All checks were successful
Release / build (push) Successful in 2m34s

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>
This commit is contained in:
Axel Meyer
2026-03-03 23:01:51 +01:00
parent cdeae01398
commit 110bb715ff
17 changed files with 426 additions and 333 deletions

View File

@@ -1,165 +1,447 @@
// Generates static icon assets for the SyncWarden project.
// Run: go run ./cmd/icongen/
// 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"
"git.davoryn.de/calic/syncwarden/internal/icons"
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() {
dir := filepath.Join("assets")
os.MkdirAll(dir, 0755)
woodPath := flag.String("wood", "", "path to wood texture PNG (stammtisch.png)")
flag.Parse()
// 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
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
}
}
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
if *woodPath == "" {
fmt.Fprintln(os.Stderr, "wood texture not found; provide -wood flag")
os.Exit(1)
}
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))
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)
}
// Generate a large composited preview showing all states
previewPath := filepath.Join(dir, "icon-preview.png")
generatePreview(previewPath)
// --- 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.")
}
func buildMultiICO(sizes []int, pngs map[int][]byte) []byte {
count := 0
for _, sz := range sizes {
if _, ok := pngs[sz]; ok {
count++
// ─── 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)
}
}
const headerSize = 6
const entrySize = 16
dataOffset := headerSize + entrySize*count
return face
}
buf := new(bytes.Buffer)
// ─── Syncthing Motif (Correct Asymmetric Geometry from Official SVG) ──────────
// 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
// 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.
// Calculate offsets
offset := dataOffset
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
}
entries := make([]entry, 0, count)
var entries []entry
for _, sz := range sizes {
data, ok := pngs[sz]
if !ok {
continue
if d, ok := pngs[sz]; ok {
entries = append(entries, entry{sz, sz, d})
}
entries = append(entries, entry{sz, sz, data})
}
// Write ICONDIRENTRY records
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 := byte(e.w)
w, h := byte(e.w), byte(e.h)
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
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)
}
// Write image data
for _, e := range entries {
buf.Write(e.data)
}
return buf.Bytes()
os.WriteFile(path, buf.Bytes(), 0644)
}
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"},
}
// ─── Preview Sheet ────────────────────────────────────────────────────────────
func generatePreview(wood image.Image, path string) {
sz := 128
pad := 20
w := len(states)*(sz+pad) + pad
h := sz + pad*2 + 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 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
}
for i, st := range stateColors {
img := renderShield(wood, st.color, sz)
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.DrawStringAnchored(st.name, float64(x)+float64(sz)/2, float64(pad+sz+14), 0.5, 0.5)
}
dc.SavePNG(path)
fmt.Printf(" %s\n", path)
}