The Loyaly mark everywhere a person sees the product
Brand assets in brand/ (the 512px mark, sizes for each surface, a multi-size .ico). Windows executables carry it as a compiled-in resource (rsrc_windows_amd64.syso from go-winres) so Explorer, the taskbar and the installer show it; installer/build.ps1 therefore uses a plain go build rather than wails build, which would add a second copy and fail the link. The tray icon is the mark with a state dot over its corner - a plain coloured circle read as a generic status light among other icons - rendered from the embedded PNG at 32px so it survives 150% scaling. The desktop app's login, setup and sidebar marks, the head-office web app's mark and favicon, and the engine dashboard's favicon are the same file. Also found while packaging: no wheel so far shipped static/, so the engine's own dashboard at :8010 on a Windows source install would have failed with a missing file. package-data now includes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
@@ -2,28 +2,32 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// iconFor renders the tray icon at run time rather than embedding four PNGs.
|
||||
// iconFor renders the tray icon at run time: the Loyaly mark with a state
|
||||
// dot in the corner. Green, amber, red or grey is the only thing a taskbar
|
||||
// conveys at this size, and the mark is what makes it OURS among a row of
|
||||
// other icons - a plain coloured circle read as a generic status light.
|
||||
//
|
||||
// A 16x16 filled circle is all the taskbar shows at this size, and generating
|
||||
// it means the four states cannot drift apart visually or have one file go
|
||||
// missing from a build.
|
||||
// The mark is embedded once at 128px and scaled down here, so the four
|
||||
// states cannot drift apart and no file can go missing from a build.
|
||||
//
|
||||
// The encoding is per-platform and is NOT cosmetic. systray writes these bytes
|
||||
// to a temp file and, on Windows, hands the path to LoadImageW with
|
||||
// IMAGE_ICON|LR_LOADFROMFILE — which decodes .ico and nothing else. A PNG
|
||||
// IMAGE_ICON|LR_LOADFROMFILE - which decodes .ico and nothing else. A PNG
|
||||
// there returns 0, systray logs "unable to set icon", and the product ships
|
||||
// with no tray icon at all: the one control surface a shop manager has.
|
||||
func iconFor(state string) []byte {
|
||||
img := circle(colorFor(state))
|
||||
img := trayImage(colorFor(state))
|
||||
if runtime.GOOS == "windows" {
|
||||
return encodeICO(img)
|
||||
}
|
||||
@@ -46,30 +50,93 @@ func colorFor(state string) color.RGBA {
|
||||
}
|
||||
}
|
||||
|
||||
const iconSize = 16
|
||||
// 32px rather than 16: Windows shows 16 at 100% scaling and 24 at 150%, and
|
||||
// scaling a 32 down looks right at both, where a 16 scaled up looks like 2005.
|
||||
const iconSize = 32
|
||||
|
||||
func circle(c color.RGBA) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
const r = 6.5
|
||||
cx, cy := float64(iconSize)/2-0.5, float64(iconSize)/2-0.5
|
||||
//go:embed tray-logo.png
|
||||
var trayLogoPNG []byte
|
||||
|
||||
var (
|
||||
trayLogoOnce sync.Once
|
||||
trayLogo *image.RGBA
|
||||
)
|
||||
|
||||
// logo is the embedded mark, decoded once and box-filtered down to iconSize.
|
||||
// A box filter rather than nearest-neighbour: 128->32 is an exact 4x4 average
|
||||
// and nearest would drop three pixels in four, which shreds the thin outline.
|
||||
func logo() *image.RGBA {
|
||||
trayLogoOnce.Do(func() {
|
||||
src, err := png.Decode(bytes.NewReader(trayLogoPNG))
|
||||
if err != nil {
|
||||
trayLogo = image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
return
|
||||
}
|
||||
b := src.Bounds()
|
||||
f := b.Dx() / iconSize
|
||||
out := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
for y := 0; y < iconSize; y++ {
|
||||
for x := 0; x < iconSize; x++ {
|
||||
var r, g, bl, a uint64
|
||||
for dy := 0; dy < f; dy++ {
|
||||
for dx := 0; dx < f; dx++ {
|
||||
// Premultiplied so transparent pixels do not drag the
|
||||
// colour of the edge towards black.
|
||||
pr, pg, pb, pa := src.At(b.Min.X+x*f+dx, b.Min.Y+y*f+dy).RGBA()
|
||||
r += uint64(pr)
|
||||
g += uint64(pg)
|
||||
bl += uint64(pb)
|
||||
a += uint64(pa)
|
||||
}
|
||||
}
|
||||
n := uint64(f * f)
|
||||
out.SetRGBA(x, y, color.RGBA{
|
||||
R: uint8(r / n >> 8), G: uint8(g / n >> 8), B: uint8(bl / n >> 8), A: uint8(a / n >> 8),
|
||||
})
|
||||
}
|
||||
}
|
||||
trayLogo = out
|
||||
})
|
||||
return trayLogo
|
||||
}
|
||||
|
||||
// trayImage is the mark with a state dot over its bottom-right corner, ringed
|
||||
// so it reads against both the yellow of the mark and a dark taskbar.
|
||||
func trayImage(c color.RGBA) *image.RGBA {
|
||||
base := logo()
|
||||
img := image.NewRGBA(base.Bounds())
|
||||
copy(img.Pix, base.Pix)
|
||||
const r = 6.0
|
||||
cx, cy := float64(iconSize)-r-0.5, float64(iconSize)-r-0.5
|
||||
ring := color.RGBA{R: 0x11, G: 0x14, B: 0x18, A: 0xFF}
|
||||
for y := 0; y < iconSize; y++ {
|
||||
for x := 0; x < iconSize; x++ {
|
||||
dx, dy := float64(x)-cx, float64(y)-cy
|
||||
d := dx*dx + dy*dy
|
||||
switch {
|
||||
case d <= (r-1)*(r-1):
|
||||
case d <= (r-1.5)*(r-1.5):
|
||||
img.SetRGBA(x, y, c)
|
||||
case d <= r*r:
|
||||
img.SetRGBA(x, y, ring)
|
||||
case d <= (r+1)*(r+1):
|
||||
// One-pixel feathered edge; a hard-aliased circle looks broken
|
||||
// next to every other icon in the tray.
|
||||
a := uint8(float64(c.A) * (r*r - d) / (r*r - (r-1)*(r-1)))
|
||||
img.SetRGBA(x, y, color.RGBA{R: c.R, G: c.G, B: c.B, A: a})
|
||||
a := uint8(255 * ((r+1)*(r+1) - d) / ((r+1)*(r+1) - r*r))
|
||||
bg := img.RGBAAt(x, y)
|
||||
img.SetRGBA(x, y, blend(bg, ring, a))
|
||||
}
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func blend(under, over color.RGBA, a uint8) color.RGBA {
|
||||
fa := float64(a) / 255
|
||||
mix := func(u, o uint8) uint8 { return uint8(float64(u)*(1-fa) + float64(o)*fa) }
|
||||
ua := float64(under.A)/255*(1-fa) + fa
|
||||
return color.RGBA{R: mix(under.R, over.R), G: mix(under.G, over.G), B: mix(under.B, over.B), A: uint8(ua * 255)}
|
||||
}
|
||||
|
||||
// encodeICO writes a single-image .ico holding an uncompressed 32-bit DIB.
|
||||
//
|
||||
// Vista and later also accept a PNG stored inside the .ico container, which
|
||||
@@ -94,8 +161,8 @@ func encodeICO(img *image.RGBA) []byte {
|
||||
// ICONDIRENTRY. 256 is encoded as 0 in these byte fields; at 16px it is moot.
|
||||
b.WriteByte(byte(w))
|
||||
b.WriteByte(byte(h))
|
||||
b.WriteByte(0) // palette size: none
|
||||
b.WriteByte(0) // reserved
|
||||
b.WriteByte(0) // palette size: none
|
||||
b.WriteByte(0) // reserved
|
||||
binary.Write(&b, binary.LittleEndian, uint16(1)) // colour planes
|
||||
binary.Write(&b, binary.LittleEndian, uint16(32)) // bits per pixel
|
||||
binary.Write(&b, binary.LittleEndian, uint32(dib)) // bytes in resource
|
||||
|
||||
Reference in New Issue
Block a user