diff --git a/assets/icon-128.png b/assets/icon-128.png new file mode 100644 index 0000000..e171b60 Binary files /dev/null and b/assets/icon-128.png differ diff --git a/assets/icon-16.png b/assets/icon-16.png new file mode 100644 index 0000000..71e5f20 Binary files /dev/null and b/assets/icon-16.png differ diff --git a/assets/icon-256.png b/assets/icon-256.png new file mode 100644 index 0000000..ea96102 Binary files /dev/null and b/assets/icon-256.png differ diff --git a/assets/icon-32.png b/assets/icon-32.png new file mode 100644 index 0000000..11bbb4d Binary files /dev/null and b/assets/icon-32.png differ diff --git a/assets/icon-48.png b/assets/icon-48.png new file mode 100644 index 0000000..0a85539 Binary files /dev/null and b/assets/icon-48.png differ diff --git a/assets/icon-512.png b/assets/icon-512.png new file mode 100644 index 0000000..7b869a9 Binary files /dev/null and b/assets/icon-512.png differ diff --git a/assets/icon-64.png b/assets/icon-64.png new file mode 100644 index 0000000..047325b Binary files /dev/null and b/assets/icon-64.png differ diff --git a/assets/icon-preview.png b/assets/icon-preview.png new file mode 100644 index 0000000..d2a30e6 Binary files /dev/null and b/assets/icon-preview.png differ diff --git a/assets/syncwarden.ico b/assets/syncwarden.ico new file mode 100644 index 0000000..9112e0c Binary files /dev/null and b/assets/syncwarden.ico differ diff --git a/cmd/icongen/main.go b/cmd/icongen/main.go new file mode 100644 index 0000000..0817c93 --- /dev/null +++ b/cmd/icongen/main.go @@ -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) +} diff --git a/internal/icons/render.go b/internal/icons/render.go index 45abdf1..62d3aea 100644 --- a/internal/icons/render.go +++ b/internal/icons/render.go @@ -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 {