Redesign icon: wood texture shield with darken-blended Syncthing motif
All checks were successful
Release / build (push) Successful in 2m34s
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:
@@ -2,245 +2,74 @@ package icons
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/binary"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math"
|
||||
"runtime"
|
||||
|
||||
"github.com/fogleman/gg"
|
||||
)
|
||||
|
||||
const iconSize = 64
|
||||
// Pre-rendered tray icons (64px) generated from Stammtisch wood texture
|
||||
// with the Syncthing motif applied via darken blend.
|
||||
// Regenerate with: go run ./cmd/icongen/ -wood /path/to/stammtisch.png
|
||||
|
||||
// Render generates a tray icon for the given state.
|
||||
//go:embed tray_idle.png
|
||||
var trayIdlePNG []byte
|
||||
|
||||
//go:embed tray_syncing.png
|
||||
var traySyncingPNG []byte
|
||||
|
||||
//go:embed tray_paused.png
|
||||
var trayPausedPNG []byte
|
||||
|
||||
//go:embed tray_error.png
|
||||
var trayErrorPNG []byte
|
||||
|
||||
//go:embed tray_disconnected.png
|
||||
var trayDisconnectedPNG []byte
|
||||
|
||||
// Render returns the 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()
|
||||
pngData := trayPNG(state)
|
||||
if runtime.GOOS == "windows" {
|
||||
return wrapPNGInICO(pngData, size, size), nil
|
||||
return wrapPNGInICO(pngData, 64, 64), nil
|
||||
}
|
||||
return pngData, nil
|
||||
}
|
||||
|
||||
// RenderPNG generates a PNG icon at the given size, regardless of platform.
|
||||
func trayPNG(state State) []byte {
|
||||
switch state {
|
||||
case StateIdle:
|
||||
return trayIdlePNG
|
||||
case StateSyncing:
|
||||
return traySyncingPNG
|
||||
case StatePaused:
|
||||
return trayPausedPNG
|
||||
case StateError:
|
||||
return trayErrorPNG
|
||||
case StateDisconnected:
|
||||
return trayDisconnectedPNG
|
||||
default:
|
||||
return trayDisconnectedPNG
|
||||
}
|
||||
}
|
||||
|
||||
// RenderPNG returns a PNG icon at 64px for the given state.
|
||||
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
|
||||
// Pre-rendered icons are only 64px; return them directly.
|
||||
// For other sizes, the static assets in assets/ should be used.
|
||||
return trayPNG(state), 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)
|
||||
// IconSize returns the size of the pre-rendered tray icons.
|
||||
func IconSize() (int, int) {
|
||||
data := trayIdlePNG
|
||||
img, err := png.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 64, 64
|
||||
}
|
||||
|
||||
// 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
|
||||
b := img.Bounds()
|
||||
return b.Dx(), b.Dy()
|
||||
}
|
||||
|
||||
// wrapPNGInICO wraps raw PNG bytes in a minimal ICO container.
|
||||
|
||||
Reference in New Issue
Block a user