Add shield icon: medieval round shield with Syncthing motif warpaint
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>
This commit is contained in:
Axel Meyer
2026-03-03 22:35:40 +01:00
parent 28d9ff6f54
commit cdeae01398
11 changed files with 369 additions and 39 deletions

View File

@@ -3,6 +3,7 @@ package icons
import (
"bytes"
"encoding/binary"
"image/color"
"image/png"
"math"
"runtime"
@@ -15,8 +16,13 @@ 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) {
dc := gg.NewContext(iconSize, iconSize)
drawSyncIcon(dc, state)
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 {
@@ -25,53 +31,212 @@ func Render(state State) ([]byte, error) {
pngData := buf.Bytes()
if runtime.GOOS == "windows" {
return wrapPNGInICO(pngData, iconSize, iconSize), nil
return wrapPNGInICO(pngData, size, size), nil
}
return pngData, nil
}
// drawSyncIcon draws a circular sync arrows icon in the state's color.
func drawSyncIcon(dc *gg.Context, state State) {
c := colorForState(state)
cx := float64(iconSize) / 2
cy := float64(iconSize) / 2
radius := float64(iconSize)*0.35
arrowWidth := 5.0
// 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))
dc.SetColor(c)
dc.SetLineWidth(arrowWidth)
dc.SetLineCap(gg.LineCapRound)
// Draw two circular arcs (sync arrows)
// Top arc: from 220° to 320°
drawArcWithArrow(dc, cx, cy, radius, degToRad(220), degToRad(320), arrowWidth)
// Bottom arc: from 40° to 140°
drawArcWithArrow(dc, cx, cy, radius, degToRad(40), degToRad(140), arrowWidth)
var buf bytes.Buffer
if err := png.Encode(&buf, dc.Image()); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func drawArcWithArrow(dc *gg.Context, cx, cy, radius, startAngle, endAngle, lineWidth float64) {
// Draw the arc
dc.DrawArc(cx, cy, radius, startAngle, endAngle)
// 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()
// Draw arrowhead at the end of the arc
endX := cx + radius*math.Cos(endAngle)
endY := cy + radius*math.Sin(endAngle)
// Arrow direction is tangent to the circle at the endpoint
tangentAngle := endAngle + math.Pi/2
arrowLen := lineWidth * 2.5
// Two points of the arrowhead
a1x := endX + arrowLen*math.Cos(tangentAngle+0.5)
a1y := endY + arrowLen*math.Sin(tangentAngle+0.5)
a2x := endX + arrowLen*math.Cos(tangentAngle-0.5)
a2y := endY + arrowLen*math.Sin(tangentAngle-0.5)
dc.MoveTo(a1x, a1y)
dc.LineTo(endX, endY)
dc.LineTo(a2x, a2y)
// 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 {