Redesign icon: wood texture shield with darken-blended Syncthing motif
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>
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 788 B After Width: | Height: | Size: 852 B |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 108 KiB |
@@ -1,165 +1,447 @@
|
|||||||
// Generates static icon assets for the SyncWarden project.
|
// Icon generator for SyncWarden.
|
||||||
// Run: go run ./cmd/icongen/
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
"image/png"
|
"image/png"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/fogleman/gg"
|
"github.com/fogleman/gg"
|
||||||
|
xdraw "golang.org/x/image/draw"
|
||||||
"git.davoryn.de/calic/syncwarden/internal/icons"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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() {
|
func main() {
|
||||||
dir := filepath.Join("assets")
|
woodPath := flag.String("wood", "", "path to wood texture PNG (stammtisch.png)")
|
||||||
os.MkdirAll(dir, 0755)
|
flag.Parse()
|
||||||
|
|
||||||
// Generate PNGs at standard icon sizes (idle/green as canonical)
|
if *woodPath == "" {
|
||||||
sizes := []int{16, 32, 48, 64, 128, 256, 512}
|
for _, p := range []string{
|
||||||
pngEntries := make(map[int][]byte) // size → PNG data
|
"/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, sz := range sizes {
|
woodFull, err := loadPNG(*woodPath)
|
||||||
data, err := icons.RenderPNG(icons.StateIdle, sz)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error rendering %d: %v\n", sz, err)
|
fmt.Fprintf(os.Stderr, "load wood: %v\n", err)
|
||||||
continue
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
pngEntries[sz] = data
|
fmt.Printf("Wood texture: %dx%d\n", woodFull.Bounds().Dx(), woodFull.Bounds().Dy())
|
||||||
|
|
||||||
fname := fmt.Sprintf("icon-%d.png", sz)
|
// Crop to just the flat plank surface of the tabletop.
|
||||||
fpath := filepath.Join(dir, fname)
|
// The wooden planks span roughly x=180..840, y=100..370 in the 1024×1024 image.
|
||||||
if err := os.WriteFile(fpath, data, 0644); err != nil {
|
// Crop that rectangle, then scale to a square so wood fills the entire shield face.
|
||||||
fmt.Fprintf(os.Stderr, "error writing %s: %v\n", fpath, err)
|
woodRect := cropRect(woodFull, 200, 130, 820, 380)
|
||||||
continue
|
woodImg := scaleImage(woodRect, 880, 880)
|
||||||
}
|
|
||||||
fmt.Printf(" %s (%d bytes)\n", fpath, len(data))
|
// --- 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate multi-size .ico (16, 32, 48, 256)
|
// --- Static assets (idle/green, multiple sizes) → assets/ ---
|
||||||
icoSizes := []int{16, 32, 48, 256}
|
assetsDir := "assets"
|
||||||
icoData := buildMultiICO(icoSizes, pngEntries)
|
os.MkdirAll(assetsDir, 0755)
|
||||||
icoPath := filepath.Join(dir, "syncwarden.ico")
|
idle := stateColors[0].color
|
||||||
if err := os.WriteFile(icoPath, icoData, 0644); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error writing ico: %v\n", err)
|
icoEntries := map[int][]byte{}
|
||||||
} else {
|
for _, sz := range []int{16, 32, 48, 64, 128, 256, 512} {
|
||||||
fmt.Printf(" %s (%d bytes)\n", icoPath, len(icoData))
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a large composited preview showing all states
|
// Multi-size .ico
|
||||||
previewPath := filepath.Join(dir, "icon-preview.png")
|
icoPath := filepath.Join(assetsDir, "syncwarden.ico")
|
||||||
generatePreview(previewPath)
|
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.")
|
fmt.Println("Done.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildMultiICO(sizes []int, pngs map[int][]byte) []byte {
|
// ─── Shield Rendering ─────────────────────────────────────────────────────────
|
||||||
count := 0
|
|
||||||
for _, sz := range sizes {
|
func renderShield(wood image.Image, motifColor color.RGBA, size int) *image.RGBA {
|
||||||
if _, ok := pngs[sz]; ok {
|
s := float64(size)
|
||||||
count++
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerSize = 6
|
return face
|
||||||
const entrySize = 16
|
}
|
||||||
dataOffset := headerSize + entrySize*count
|
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
// ─── Syncthing Motif (Correct Asymmetric Geometry from Official SVG) ──────────
|
||||||
|
|
||||||
// ICONDIR header
|
// Geometry extracted from the official Syncthing SVG (viewBox 117.3×117.3):
|
||||||
binary.Write(buf, binary.LittleEndian, uint16(0)) // Reserved
|
//
|
||||||
binary.Write(buf, binary.LittleEndian, uint16(1)) // Type: icon
|
// Ring center: (58.7, 58.5) ≈ shield center
|
||||||
binary.Write(buf, binary.LittleEndian, uint16(count)) // Count
|
// 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.
|
||||||
|
|
||||||
// Calculate offsets
|
func drawSyncthingMotif(dc *gg.Context, cx, cy, faceR float64) {
|
||||||
offset := dataOffset
|
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 {
|
type entry struct {
|
||||||
w, h int
|
w, h int
|
||||||
data []byte
|
data []byte
|
||||||
}
|
}
|
||||||
entries := make([]entry, 0, count)
|
var entries []entry
|
||||||
for _, sz := range sizes {
|
for _, sz := range sizes {
|
||||||
data, ok := pngs[sz]
|
if d, ok := pngs[sz]; ok {
|
||||||
if !ok {
|
entries = append(entries, entry{sz, sz, d})
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
entries = append(entries, entry{sz, sz, data})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write ICONDIRENTRY records
|
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 {
|
for _, e := range entries {
|
||||||
w := byte(e.w)
|
w, h := byte(e.w), byte(e.h)
|
||||||
if e.w >= 256 {
|
if e.w >= 256 {
|
||||||
w = 0
|
w = 0
|
||||||
}
|
}
|
||||||
h := byte(e.h)
|
|
||||||
if e.h >= 256 {
|
if e.h >= 256 {
|
||||||
h = 0
|
h = 0
|
||||||
}
|
}
|
||||||
buf.WriteByte(w)
|
buf.WriteByte(w)
|
||||||
buf.WriteByte(h)
|
buf.WriteByte(h)
|
||||||
buf.WriteByte(0) // ColorCount
|
buf.WriteByte(0)
|
||||||
buf.WriteByte(0) // Reserved
|
buf.WriteByte(0)
|
||||||
binary.Write(buf, binary.LittleEndian, uint16(1)) // Planes
|
binary.Write(buf, binary.LittleEndian, uint16(1))
|
||||||
binary.Write(buf, binary.LittleEndian, uint16(32)) // BitCount
|
binary.Write(buf, binary.LittleEndian, uint16(32))
|
||||||
binary.Write(buf, binary.LittleEndian, uint32(len(e.data))) // BytesInRes
|
binary.Write(buf, binary.LittleEndian, uint32(len(e.data)))
|
||||||
binary.Write(buf, binary.LittleEndian, uint32(offset)) // ImageOffset
|
binary.Write(buf, binary.LittleEndian, uint32(offset))
|
||||||
offset += len(e.data)
|
offset += len(e.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write image data
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
buf.Write(e.data)
|
buf.Write(e.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buf.Bytes()
|
os.WriteFile(path, buf.Bytes(), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generatePreview(path string) {
|
// ─── Preview Sheet ────────────────────────────────────────────────────────────
|
||||||
states := []struct {
|
|
||||||
s icons.State
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{icons.StateIdle, "Idle"},
|
|
||||||
{icons.StateSyncing, "Syncing"},
|
|
||||||
{icons.StatePaused, "Paused"},
|
|
||||||
{icons.StateError, "Error"},
|
|
||||||
{icons.StateDisconnected, "Disconnected"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
func generatePreview(wood image.Image, path string) {
|
||||||
sz := 128
|
sz := 128
|
||||||
pad := 20
|
pad := 20
|
||||||
w := len(states)*(sz+pad) + pad
|
w := len(stateColors)*(sz+pad) + pad
|
||||||
h := sz + pad*2 + 20
|
h := sz + pad*2 + 24
|
||||||
|
|
||||||
dc := gg.NewContext(w, h)
|
dc := gg.NewContext(w, h)
|
||||||
dc.SetHexColor("#1a1a2e")
|
dc.SetHexColor("#1a1a2e")
|
||||||
dc.DrawRectangle(0, 0, float64(w), float64(h))
|
dc.DrawRectangle(0, 0, float64(w), float64(h))
|
||||||
dc.Fill()
|
dc.Fill()
|
||||||
|
|
||||||
for i, st := range states {
|
for i, st := range stateColors {
|
||||||
// Render at 128px
|
img := renderShield(wood, st.color, sz)
|
||||||
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)
|
x := pad + i*(sz+pad)
|
||||||
dc.DrawImage(img, x, pad)
|
dc.DrawImage(img, x, pad)
|
||||||
|
|
||||||
// Label
|
|
||||||
dc.SetHexColor("#cccccc")
|
dc.SetHexColor("#cccccc")
|
||||||
dc.DrawStringAnchored(st.name, float64(x)+float64(sz)/2, float64(pad+sz+12), 0.5, 0.5)
|
dc.DrawStringAnchored(st.name, float64(x)+float64(sz)/2, float64(pad+sz+14), 0.5, 0.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
dc.SavePNG(path)
|
dc.SavePNG(path)
|
||||||
fmt.Printf(" %s\n", path)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,245 +2,74 @@ package icons
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
_ "embed"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"image/color"
|
|
||||||
"image/png"
|
"image/png"
|
||||||
"math"
|
|
||||||
"runtime"
|
"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.
|
// Returns ICO bytes on Windows, PNG bytes on other platforms.
|
||||||
func Render(state State) ([]byte, error) {
|
func Render(state State) ([]byte, error) {
|
||||||
return RenderSize(state, iconSize)
|
pngData := trayPNG(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
|
|
||||||
if err := png.Encode(&buf, dc.Image()); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
pngData := buf.Bytes()
|
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return wrapPNGInICO(pngData, size, size), nil
|
return wrapPNGInICO(pngData, 64, 64), nil
|
||||||
}
|
}
|
||||||
return pngData, 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) {
|
func RenderPNG(state State, size int) ([]byte, error) {
|
||||||
dc := gg.NewContext(size, size)
|
// Pre-rendered icons are only 64px; return them directly.
|
||||||
drawShieldIcon(dc, state, float64(size))
|
// For other sizes, the static assets in assets/ should be used.
|
||||||
|
return trayPNG(state), nil
|
||||||
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
|
// IconSize returns the size of the pre-rendered tray icons.
|
||||||
var (
|
func IconSize() (int, int) {
|
||||||
shieldRim = color.RGBA{55, 42, 30, 255} // dark iron rim
|
data := trayIdlePNG
|
||||||
shieldOuter = color.RGBA{130, 82, 38, 255} // darker wood ring
|
img, err := png.Decode(bytes.NewReader(data))
|
||||||
shieldMid = color.RGBA{175, 118, 58, 255} // warm wood body
|
if err != nil {
|
||||||
shieldInner = color.RGBA{195, 140, 72, 255} // lighter wood center
|
return 64, 64
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
b := img.Bounds()
|
||||||
// 4. Rivets around rim (skip below 32px)
|
return b.Dx(), b.Dy()
|
||||||
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.
|
// wrapPNGInICO wraps raw PNG bytes in a minimal ICO container.
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package icons
|
package icons
|
||||||
|
|
||||||
import "image/color"
|
|
||||||
|
|
||||||
// State represents the current sync state for icon rendering.
|
// State represents the current sync state for icon rendering.
|
||||||
type State int
|
type State int
|
||||||
|
|
||||||
@@ -12,19 +10,3 @@ const (
|
|||||||
StateError // Red — folder error
|
StateError // Red — folder error
|
||||||
StateDisconnected // Dark gray — cannot reach Syncthing
|
StateDisconnected // Dark gray — cannot reach Syncthing
|
||||||
)
|
)
|
||||||
|
|
||||||
// colors maps each state to its icon color.
|
|
||||||
var colors = map[State]color.RGBA{
|
|
||||||
StateIdle: {76, 175, 80, 255}, // green
|
|
||||||
StateSyncing: {33, 150, 243, 255}, // blue
|
|
||||||
StatePaused: {158, 158, 158, 255}, // gray
|
|
||||||
StateError: {244, 67, 54, 255}, // red
|
|
||||||
StateDisconnected: {97, 97, 97, 255}, // dark gray
|
|
||||||
}
|
|
||||||
|
|
||||||
func colorForState(s State) color.RGBA {
|
|
||||||
if c, ok := colors[s]; ok {
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
return colors[StateDisconnected]
|
|
||||||
}
|
|
||||||
|
|||||||
BIN
internal/icons/tray_disconnected.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
internal/icons/tray_error.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
internal/icons/tray_idle.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
internal/icons/tray_paused.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
internal/icons/tray_syncing.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |