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>
BIN
assets/icon-128.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/icon-16.png
Normal file
|
After Width: | Height: | Size: 788 B |
BIN
assets/icon-256.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/icon-32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
assets/icon-48.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
assets/icon-512.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
assets/icon-64.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
assets/icon-preview.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/syncwarden.ico
Normal file
|
After Width: | Height: | Size: 40 KiB |
165
cmd/icongen/main.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// Generates static icon assets for the SyncWarden project.
|
||||||
|
// Run: go run ./cmd/icongen/
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/fogleman/gg"
|
||||||
|
|
||||||
|
"git.davoryn.de/calic/syncwarden/internal/icons"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dir := filepath.Join("assets")
|
||||||
|
os.MkdirAll(dir, 0755)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a large composited preview showing all states
|
||||||
|
previewPath := filepath.Join(dir, "icon-preview.png")
|
||||||
|
generatePreview(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++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerSize = 6
|
||||||
|
const entrySize = 16
|
||||||
|
dataOffset := headerSize + entrySize*count
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Calculate offsets
|
||||||
|
offset := dataOffset
|
||||||
|
type entry struct {
|
||||||
|
w, h int
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
entries := make([]entry, 0, count)
|
||||||
|
for _, sz := range sizes {
|
||||||
|
data, ok := pngs[sz]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, entry{sz, sz, data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write ICONDIRENTRY records
|
||||||
|
for _, e := range entries {
|
||||||
|
w := byte(e.w)
|
||||||
|
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
|
||||||
|
offset += len(e.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write image data
|
||||||
|
for _, e := range entries {
|
||||||
|
buf.Write(e.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
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"},
|
||||||
|
}
|
||||||
|
|
||||||
|
sz := 128
|
||||||
|
pad := 20
|
||||||
|
w := len(states)*(sz+pad) + pad
|
||||||
|
h := sz + pad*2 + 20
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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.SavePNG(path)
|
||||||
|
fmt.Printf(" %s\n", path)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package icons
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"image/color"
|
||||||
"image/png"
|
"image/png"
|
||||||
"math"
|
"math"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -15,8 +16,13 @@ const iconSize = 64
|
|||||||
// Render generates a tray icon for the given state.
|
// Render generates a tray icon for the given state.
|
||||||
// Returns ICO bytes on Windows, PNG bytes on other platforms.
|
// Returns ICO bytes on Windows, PNG bytes on other platforms.
|
||||||
func Render(state State) ([]byte, error) {
|
func Render(state State) ([]byte, error) {
|
||||||
dc := gg.NewContext(iconSize, iconSize)
|
return RenderSize(state, iconSize)
|
||||||
drawSyncIcon(dc, state)
|
}
|
||||||
|
|
||||||
|
// 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
|
var buf bytes.Buffer
|
||||||
if err := png.Encode(&buf, dc.Image()); err != nil {
|
if err := png.Encode(&buf, dc.Image()); err != nil {
|
||||||
@@ -25,53 +31,212 @@ func Render(state State) ([]byte, error) {
|
|||||||
|
|
||||||
pngData := buf.Bytes()
|
pngData := buf.Bytes()
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return wrapPNGInICO(pngData, iconSize, iconSize), nil
|
return wrapPNGInICO(pngData, size, size), nil
|
||||||
}
|
}
|
||||||
return pngData, nil
|
return pngData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// drawSyncIcon draws a circular sync arrows icon in the state's color.
|
// RenderPNG generates a PNG icon at the given size, regardless of platform.
|
||||||
func drawSyncIcon(dc *gg.Context, state State) {
|
func RenderPNG(state State, size int) ([]byte, error) {
|
||||||
c := colorForState(state)
|
dc := gg.NewContext(size, size)
|
||||||
cx := float64(iconSize) / 2
|
drawShieldIcon(dc, state, float64(size))
|
||||||
cy := float64(iconSize) / 2
|
|
||||||
radius := float64(iconSize)*0.35
|
|
||||||
arrowWidth := 5.0
|
|
||||||
|
|
||||||
dc.SetColor(c)
|
var buf bytes.Buffer
|
||||||
dc.SetLineWidth(arrowWidth)
|
if err := png.Encode(&buf, dc.Image()); err != nil {
|
||||||
dc.SetLineCap(gg.LineCapRound)
|
return nil, err
|
||||||
|
}
|
||||||
// Draw two circular arcs (sync arrows)
|
return buf.Bytes(), nil
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func drawArcWithArrow(dc *gg.Context, cx, cy, radius, startAngle, endAngle, lineWidth float64) {
|
// Shield color palette — warm wood tones inspired by Stammtisch
|
||||||
// Draw the arc
|
var (
|
||||||
dc.DrawArc(cx, cy, radius, startAngle, endAngle)
|
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()
|
dc.Stroke()
|
||||||
|
|
||||||
// Draw arrowhead at the end of the arc
|
// Shadow spokes
|
||||||
endX := cx + radius*math.Cos(endAngle)
|
for _, n := range nodes {
|
||||||
endY := cy + radius*math.Sin(endAngle)
|
dc.DrawLine(cx, cy, n[0], n[1])
|
||||||
|
}
|
||||||
// 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)
|
|
||||||
dc.Stroke()
|
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 {
|
func degToRad(deg float64) float64 {
|
||||||
|
|||||||