// 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) }