package icons import ( "bytes" "encoding/binary" "image/color" "image/png" "math" "runtime" "github.com/fogleman/gg" ) const iconSize = 64 // Render generates a tray icon for the given state. // Returns ICO bytes on Windows, PNG bytes on other platforms. func Render(state State) ([]byte, error) { return RenderSize(state, iconSize) } // RenderSize generates an icon at the given pixel size. func RenderSize(state State, size int) ([]byte, error) { dc := gg.NewContext(size, size) drawShieldIcon(dc, state, float64(size)) var buf bytes.Buffer if err := png.Encode(&buf, dc.Image()); err != nil { return nil, err } pngData := buf.Bytes() if runtime.GOOS == "windows" { return wrapPNGInICO(pngData, size, size), nil } return pngData, nil } // RenderPNG generates a PNG icon at the given size, regardless of platform. func RenderPNG(state State, size int) ([]byte, error) { dc := gg.NewContext(size, size) drawShieldIcon(dc, state, float64(size)) var buf bytes.Buffer if err := png.Encode(&buf, dc.Image()); err != nil { return nil, err } return buf.Bytes(), nil } // Shield color palette — warm wood tones inspired by Stammtisch var ( shieldRim = color.RGBA{55, 42, 30, 255} // dark iron rim shieldOuter = color.RGBA{130, 82, 38, 255} // darker wood ring shieldMid = color.RGBA{175, 118, 58, 255} // warm wood body shieldInner = color.RGBA{195, 140, 72, 255} // lighter wood center shieldLight = color.RGBA{210, 162, 95, 255} // highlight zone grainDark = color.RGBA{145, 90, 40, 200} // wood grain shadow rivetBody = color.RGBA{105, 95, 80, 255} // iron rivet rivetShine = color.RGBA{160, 150, 130, 255} // rivet highlight bossEdge = color.RGBA{80, 65, 45, 255} // boss rim bossBody = color.RGBA{145, 130, 105, 255} // boss metal bossShine = color.RGBA{190, 180, 160, 255} // boss highlight motifShadow = color.RGBA{40, 30, 20, 100} // dark outline behind motif ) func drawShieldIcon(dc *gg.Context, state State, s float64) { cx := s / 2 cy := s / 2 sc := s / 64 // scale factor relative to 64px base // 1. Shield rim (dark outer ring) rimR := s * 0.47 dc.DrawCircle(cx, cy, rimR) dc.SetColor(shieldRim) dc.Fill() // 2. Shield face — layered wood rings faceR := s * 0.42 drawWoodFace(dc, cx, cy, faceR) // 3. Wood grain arcs (skip at very small sizes) if s >= 48 { drawWoodGrain(dc, cx, cy, faceR, sc) } // 4. Rivets around rim (skip below 32px) if s >= 32 { rivetCount := 12 if s < 48 { rivetCount = 8 } drawRivets(dc, cx, cy, rimR-2*sc, sc, rivetCount) } // 5. Syncthing motif — "warpaint" in state color drawSyncthingMotif(dc, cx, cy, faceR, state, sc) // 6. Center boss drawBoss(dc, cx, cy, sc) } func drawWoodFace(dc *gg.Context, cx, cy, r float64) { layers := []struct { frac float64 c color.RGBA }{ {1.00, shieldOuter}, {0.90, shieldMid}, {0.72, shieldInner}, {0.48, shieldLight}, {0.30, shieldInner}, } for _, l := range layers { dc.DrawCircle(cx, cy, r*l.frac) dc.SetColor(l.c) dc.Fill() } } func drawWoodGrain(dc *gg.Context, cx, cy, r, sc float64) { dc.SetColor(grainDark) dc.SetLineWidth(math.Max(0.8, 0.7*sc)) dc.SetLineCap(gg.LineCapRound) arcs := []struct{ radius, startDeg, sweepDeg float64 }{ {0.83, 25, 55}, {0.83, 195, 50}, {0.62, 95, 65}, {0.62, 275, 50}, {0.42, 5, 75}, {0.42, 165, 60}, } for _, a := range arcs { dc.DrawArc(cx, cy, r*a.radius, degToRad(a.startDeg), degToRad(a.startDeg+a.sweepDeg)) dc.Stroke() } } func drawRivets(dc *gg.Context, cx, cy, r, sc float64, count int) { rr := math.Max(1.2, 1.8*sc) // rivet radius for i := 0; i < count; i++ { a := float64(i) * 2 * math.Pi / float64(count) rx := cx + r*math.Cos(a) ry := cy + r*math.Sin(a) dc.DrawCircle(rx, ry, rr) dc.SetColor(rivetBody) dc.Fill() // Highlight if sc >= 1.0 { dc.DrawCircle(rx-rr*0.2, ry-rr*0.3, rr*0.45) dc.SetColor(rivetShine) dc.Fill() } } } func drawSyncthingMotif(dc *gg.Context, cx, cy, faceR float64, state State, sc float64) { stateCol := colorForState(state) ringR := faceR * 0.58 lineW := math.Max(1.8, 2.8*sc) nodeR := math.Max(2.2, 3.2*sc) // Node positions: top, bottom-left, bottom-right nodeAngles := []float64{-90, 150, 30} nodes := make([][2]float64, 3) for i, deg := range nodeAngles { rad := degToRad(deg) nodes[i] = [2]float64{ cx + ringR*math.Cos(rad), cy + ringR*math.Sin(rad), } } // --- Shadow layer (dark outline behind everything for contrast) --- shadowOff := math.Max(0.5, 0.8*sc) dc.SetColor(motifShadow) dc.SetLineWidth(lineW + 2*shadowOff) dc.SetLineCap(gg.LineCapRound) // Shadow ring dc.DrawCircle(cx, cy, ringR) dc.Stroke() // Shadow spokes for _, n := range nodes { dc.DrawLine(cx, cy, n[0], n[1]) } dc.Stroke() // Shadow nodes for _, n := range nodes { dc.DrawCircle(n[0], n[1], nodeR+shadowOff) dc.Fill() } // --- Motif layer (state-colored) --- dc.SetColor(stateCol) dc.SetLineWidth(lineW) dc.SetLineCap(gg.LineCapRound) // Ring dc.DrawCircle(cx, cy, ringR) dc.Stroke() // Spokes from center to nodes for _, n := range nodes { dc.DrawLine(cx, cy, n[0], n[1]) } dc.Stroke() // Node circles for _, n := range nodes { dc.DrawCircle(n[0], n[1], nodeR) dc.SetColor(stateCol) dc.Fill() } } func drawBoss(dc *gg.Context, cx, cy, sc float64) { br := math.Max(2.5, 4.5*sc) // Edge ring dc.DrawCircle(cx, cy, br+0.8*sc) dc.SetColor(bossEdge) dc.Fill() // Body dc.DrawCircle(cx, cy, br) dc.SetColor(bossBody) dc.Fill() // Highlight (upper-left) dc.DrawCircle(cx-br*0.22, cy-br*0.28, br*0.5) dc.SetColor(bossShine) dc.Fill() } func degToRad(deg float64) float64 { return deg * math.Pi / 180 } // wrapPNGInICO wraps raw PNG bytes in a minimal ICO container. func wrapPNGInICO(pngData []byte, width, height int) []byte { const headerSize = 6 const entrySize = 16 imageOffset := headerSize + entrySize buf := new(bytes.Buffer) // ICONDIR header binary.Write(buf, binary.LittleEndian, uint16(0)) // Reserved binary.Write(buf, binary.LittleEndian, uint16(1)) // Type: 1 = icon binary.Write(buf, binary.LittleEndian, uint16(1)) // Count: 1 image // ICONDIRENTRY w := byte(width) if width >= 256 { w = 0 } h := byte(height) if height >= 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(pngData))) // BytesInRes binary.Write(buf, binary.LittleEndian, uint32(imageOffset)) // ImageOffset buf.Write(pngData) return buf.Bytes() }