diff --git a/assets/icon-128.png b/assets/icon-128.png index e171b60..eb426e2 100644 Binary files a/assets/icon-128.png and b/assets/icon-128.png differ diff --git a/assets/icon-16.png b/assets/icon-16.png index 71e5f20..2eb7fb9 100644 Binary files a/assets/icon-16.png and b/assets/icon-16.png differ diff --git a/assets/icon-256.png b/assets/icon-256.png index ea96102..46fa794 100644 Binary files a/assets/icon-256.png and b/assets/icon-256.png differ diff --git a/assets/icon-32.png b/assets/icon-32.png index 11bbb4d..789083d 100644 Binary files a/assets/icon-32.png and b/assets/icon-32.png differ diff --git a/assets/icon-48.png b/assets/icon-48.png index 0a85539..6b8f666 100644 Binary files a/assets/icon-48.png and b/assets/icon-48.png differ diff --git a/assets/icon-512.png b/assets/icon-512.png index 7b869a9..d381ef0 100644 Binary files a/assets/icon-512.png and b/assets/icon-512.png differ diff --git a/assets/icon-64.png b/assets/icon-64.png index 047325b..0dc5f8f 100644 Binary files a/assets/icon-64.png and b/assets/icon-64.png differ diff --git a/assets/icon-preview.png b/assets/icon-preview.png index d2a30e6..79a091f 100644 Binary files a/assets/icon-preview.png and b/assets/icon-preview.png differ diff --git a/assets/syncwarden.ico b/assets/syncwarden.ico index 9112e0c..92dd3b1 100644 Binary files a/assets/syncwarden.ico and b/assets/syncwarden.ico differ diff --git a/cmd/icongen/main.go b/cmd/icongen/main.go index 0817c93..906281b 100644 --- a/cmd/icongen/main.go +++ b/cmd/icongen/main.go @@ -1,165 +1,447 @@ -// Generates static icon assets for the SyncWarden project. -// Run: go run ./cmd/icongen/ +// Icon generator for SyncWarden. +// 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 import ( "bytes" "encoding/binary" + "flag" "fmt" + "image" + "image/color" + "image/draw" "image/png" + "math" "os" "path/filepath" "github.com/fogleman/gg" - - "git.davoryn.de/calic/syncwarden/internal/icons" + xdraw "golang.org/x/image/draw" ) +// 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() { - dir := filepath.Join("assets") - os.MkdirAll(dir, 0755) + woodPath := flag.String("wood", "", "path to wood texture PNG (stammtisch.png)") + flag.Parse() - // 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 + if *woodPath == "" { + for _, p := range []string{ + "/tmp/stammtisch/stammtisch.png", + filepath.Join(os.TempDir(), "stammtisch", "stammtisch.png"), + } { + if _, err := os.Stat(p); err == nil { + *woodPath = p + break + } } - 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 + if *woodPath == "" { + fmt.Fprintln(os.Stderr, "wood texture not found; provide -wood flag") + os.Exit(1) } - 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)) + woodFull, err := loadPNG(*woodPath) + if err != nil { + fmt.Fprintf(os.Stderr, "load wood: %v\n", err) + os.Exit(1) + } + fmt.Printf("Wood texture: %dx%d\n", woodFull.Bounds().Dx(), woodFull.Bounds().Dy()) + + // Crop to just the flat plank surface of the tabletop. + // The wooden planks span roughly x=180..840, y=100..370 in the 1024×1024 image. + // Crop that rectangle, then scale to a square so wood fills the entire shield face. + woodRect := cropRect(woodFull, 200, 130, 820, 380) + woodImg := scaleImage(woodRect, 880, 880) + + // --- 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 a large composited preview showing all states - previewPath := filepath.Join(dir, "icon-preview.png") - generatePreview(previewPath) + // --- Static assets (idle/green, multiple sizes) → assets/ --- + assetsDir := "assets" + os.MkdirAll(assetsDir, 0755) + idle := stateColors[0].color + + icoEntries := map[int][]byte{} + for _, sz := range []int{16, 32, 48, 64, 128, 256, 512} { + 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)) + } + + // Multi-size .ico + icoPath := filepath.Join(assetsDir, "syncwarden.ico") + 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.") } -func buildMultiICO(sizes []int, pngs map[int][]byte) []byte { - count := 0 - for _, sz := range sizes { - if _, ok := pngs[sz]; ok { - count++ +// ─── Shield Rendering ───────────────────────────────────────────────────────── + +func renderShield(wood image.Image, motifColor color.RGBA, size int) *image.RGBA { + s := float64(size) + 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 - const entrySize = 16 - dataOffset := headerSize + entrySize*count + return face +} - buf := new(bytes.Buffer) +// ─── Syncthing Motif (Correct Asymmetric Geometry from Official SVG) ────────── - // 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 +// Geometry extracted from the official Syncthing SVG (viewBox 117.3×117.3): +// +// Ring center: (58.7, 58.5) ≈ shield center +// 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 - offset := dataOffset +func drawSyncthingMotif(dc *gg.Context, cx, cy, faceR float64) { + 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 { w, h int data []byte } - entries := make([]entry, 0, count) + var entries []entry for _, sz := range sizes { - data, ok := pngs[sz] - if !ok { - continue + if d, ok := pngs[sz]; ok { + entries = append(entries, entry{sz, sz, d}) } - 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 { - w := byte(e.w) + w, h := byte(e.w), byte(e.h) 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 + buf.WriteByte(0) + buf.WriteByte(0) + binary.Write(buf, binary.LittleEndian, uint16(1)) + binary.Write(buf, binary.LittleEndian, uint16(32)) + binary.Write(buf, binary.LittleEndian, uint32(len(e.data))) + binary.Write(buf, binary.LittleEndian, uint32(offset)) offset += len(e.data) } - - // Write image data for _, e := range entries { buf.Write(e.data) } - return buf.Bytes() + os.WriteFile(path, buf.Bytes(), 0644) } -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"}, - } +// ─── Preview Sheet ──────────────────────────────────────────────────────────── +func generatePreview(wood image.Image, path string) { sz := 128 pad := 20 - w := len(states)*(sz+pad) + pad - h := sz + pad*2 + 20 + w := len(stateColors)*(sz+pad) + pad + h := sz + pad*2 + 24 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 - } + for i, st := range stateColors { + img := renderShield(wood, st.color, sz) 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.DrawStringAnchored(st.name, float64(x)+float64(sz)/2, float64(pad+sz+14), 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 62d3aea..6f1eec9 100644 --- a/internal/icons/render.go +++ b/internal/icons/render.go @@ -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. diff --git a/internal/icons/states.go b/internal/icons/states.go index 65cb841..2a86e00 100644 --- a/internal/icons/states.go +++ b/internal/icons/states.go @@ -1,7 +1,5 @@ package icons -import "image/color" - // State represents the current sync state for icon rendering. type State int @@ -12,19 +10,3 @@ const ( StateError // Red — folder error 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] -} diff --git a/internal/icons/tray_disconnected.png b/internal/icons/tray_disconnected.png new file mode 100644 index 0000000..2a2f9a1 Binary files /dev/null and b/internal/icons/tray_disconnected.png differ diff --git a/internal/icons/tray_error.png b/internal/icons/tray_error.png new file mode 100644 index 0000000..ca3375b Binary files /dev/null and b/internal/icons/tray_error.png differ diff --git a/internal/icons/tray_idle.png b/internal/icons/tray_idle.png new file mode 100644 index 0000000..0dc5f8f Binary files /dev/null and b/internal/icons/tray_idle.png differ diff --git a/internal/icons/tray_paused.png b/internal/icons/tray_paused.png new file mode 100644 index 0000000..2a2e645 Binary files /dev/null and b/internal/icons/tray_paused.png differ diff --git a/internal/icons/tray_syncing.png b/internal/icons/tray_syncing.png new file mode 100644 index 0000000..85e61b1 Binary files /dev/null and b/internal/icons/tray_syncing.png differ