Match macOS Dock behavior using the same idle/connected glyph assets. Co-authored-by: Cursor <cursoragent@cursor.com>
296 lines
6.3 KiB
Go
296 lines
6.3 KiB
Go
//go:build windows
|
|
|
|
package dockicon
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"image"
|
|
"image/png"
|
|
"sync"
|
|
"unsafe"
|
|
|
|
"golang.org/x/image/draw"
|
|
"golang.org/x/sys/windows"
|
|
)
|
|
|
|
//go:embed icon_idle.png
|
|
var iconIdlePNG []byte
|
|
|
|
//go:embed icon_connected.png
|
|
var iconConnectedPNG []byte
|
|
|
|
var (
|
|
user32 = windows.NewLazySystemDLL("user32.dll")
|
|
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
|
|
|
|
procSendMessage = user32.NewProc("SendMessageW")
|
|
procCreateIconFromResource = user32.NewProc("CreateIconFromResourceEx")
|
|
procCreateIconIndirect = user32.NewProc("CreateIconIndirect")
|
|
procDestroyIcon = user32.NewProc("DestroyIcon")
|
|
procCreateDIBSection = gdi32.NewProc("CreateDIBSection")
|
|
procCreateBitmap = gdi32.NewProc("CreateBitmap")
|
|
procDeleteObject = gdi32.NewProc("DeleteObject")
|
|
|
|
mu sync.Mutex
|
|
|
|
hwnd uintptr
|
|
last *bool
|
|
|
|
idleSmall windows.Handle
|
|
idleBig windows.Handle
|
|
connSmall windows.Handle
|
|
connBig windows.Handle
|
|
iconsReady bool
|
|
)
|
|
|
|
const (
|
|
wmSetIcon = 0x0080
|
|
iconSmall = 0
|
|
iconBig = 1
|
|
biRGB = 0
|
|
dibRGBColors = 0
|
|
)
|
|
|
|
type iconInfo struct {
|
|
FIcon int32
|
|
XHotspot uint32
|
|
YHotspot uint32
|
|
HbmMask windows.Handle
|
|
HbmColor windows.Handle
|
|
}
|
|
|
|
type bitmapInfoHeader struct {
|
|
BiSize uint32
|
|
BiWidth int32
|
|
BiHeight int32
|
|
BiPlanes uint16
|
|
BiBitCount uint16
|
|
BiCompression uint32
|
|
BiSizeImage uint32
|
|
BiXPelsPerMeter int32
|
|
BiYPelsPerMeter int32
|
|
BiClrUsed uint32
|
|
BiClrImportant uint32
|
|
}
|
|
|
|
type bitmapInfo struct {
|
|
Header bitmapInfoHeader
|
|
Colors [1]uint32
|
|
}
|
|
|
|
// BindWindow attaches the main HWND so SetConnected can update the taskbar icon.
|
|
func BindWindow(h unsafe.Pointer) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
hwnd = uintptr(h)
|
|
if err := ensureIconsLocked(); err != nil {
|
|
return
|
|
}
|
|
connected := last != nil && *last
|
|
applyLocked(connected)
|
|
}
|
|
|
|
// SetConnected swaps the taskbar/window icon: sea-wave N when connected, original when idle.
|
|
func SetConnected(connected bool) {
|
|
mu.Lock()
|
|
if last != nil && *last == connected {
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
v := connected
|
|
last = &v
|
|
if hwnd == 0 {
|
|
mu.Unlock()
|
|
if OnIconChange != nil {
|
|
OnIconChange(connected)
|
|
}
|
|
return
|
|
}
|
|
if err := ensureIconsLocked(); err != nil {
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
applyLocked(connected)
|
|
mu.Unlock()
|
|
if OnIconChange != nil {
|
|
OnIconChange(connected)
|
|
}
|
|
}
|
|
|
|
// StatusIcon returns a shared HICON for the current connection state (do not DestroyIcon).
|
|
func StatusIcon(connected bool) windows.Handle {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if err := ensureIconsLocked(); err != nil {
|
|
return 0
|
|
}
|
|
if connected {
|
|
return connBig
|
|
}
|
|
return idleBig
|
|
}
|
|
|
|
func applyLocked(connected bool) {
|
|
if hwnd == 0 {
|
|
return
|
|
}
|
|
small, big := idleSmall, idleBig
|
|
if connected {
|
|
small, big = connSmall, connBig
|
|
}
|
|
if small != 0 {
|
|
procSendMessage.Call(hwnd, wmSetIcon, iconSmall, uintptr(small))
|
|
}
|
|
if big != 0 {
|
|
procSendMessage.Call(hwnd, wmSetIcon, iconBig, uintptr(big))
|
|
}
|
|
}
|
|
|
|
func ensureIconsLocked() error {
|
|
if iconsReady {
|
|
return nil
|
|
}
|
|
var err error
|
|
idleSmall, err = hiconFromPNG(iconIdlePNG, 16)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
idleBig, err = hiconFromPNG(iconIdlePNG, 32)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
connSmall, err = hiconFromPNG(iconConnectedPNG, 16)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
connBig, err = hiconFromPNG(iconConnectedPNG, 32)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
iconsReady = true
|
|
return nil
|
|
}
|
|
|
|
func hiconFromPNG(src []byte, size int) (windows.Handle, error) {
|
|
resized, err := resizePNG(src, size)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if h := createIconFromPNGResource(resized, size); h != 0 {
|
|
return h, nil
|
|
}
|
|
return createIconFromImage(resized, size)
|
|
}
|
|
|
|
func resizePNG(src []byte, size int) ([]byte, error) {
|
|
img, err := png.Decode(bytes.NewReader(src))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dst := image.NewRGBA(image.Rect(0, 0, size, size))
|
|
draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, dst); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func createIconFromPNGResource(pngBytes []byte, size int) windows.Handle {
|
|
if len(pngBytes) == 0 {
|
|
return 0
|
|
}
|
|
h, _, _ := procCreateIconFromResource.Call(
|
|
uintptr(unsafe.Pointer(&pngBytes[0])),
|
|
uintptr(len(pngBytes)),
|
|
1,
|
|
0x00030000,
|
|
uintptr(size),
|
|
uintptr(size),
|
|
0,
|
|
)
|
|
return windows.Handle(h)
|
|
}
|
|
|
|
func createIconFromImage(pngBytes []byte, size int) (windows.Handle, error) {
|
|
img, err := png.Decode(bytes.NewReader(pngBytes))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
rgba, ok := img.(*image.RGBA)
|
|
if !ok {
|
|
tmp := image.NewRGBA(img.Bounds())
|
|
draw.Draw(tmp, tmp.Bounds(), img, img.Bounds().Min, draw.Src)
|
|
rgba = tmp
|
|
}
|
|
|
|
hbmColor, err := dibFromRGBA(rgba)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// Monochrome mask: all zeros => fully opaque where color has alpha handled by 32-bit DIB.
|
|
hbmMask, _, err2 := procCreateBitmap.Call(uintptr(size), uintptr(size), 1, 1, 0)
|
|
if hbmMask == 0 {
|
|
procDeleteObject.Call(uintptr(hbmColor))
|
|
if err2 != nil {
|
|
return 0, err2
|
|
}
|
|
return 0, windows.ERROR_INVALID_HANDLE
|
|
}
|
|
|
|
ii := iconInfo{
|
|
FIcon: 1,
|
|
XHotspot: 0,
|
|
YHotspot: 0,
|
|
HbmMask: windows.Handle(hbmMask),
|
|
HbmColor: hbmColor,
|
|
}
|
|
h, _, err3 := procCreateIconIndirect.Call(uintptr(unsafe.Pointer(&ii)))
|
|
procDeleteObject.Call(uintptr(hbmMask))
|
|
procDeleteObject.Call(uintptr(hbmColor))
|
|
if h == 0 {
|
|
if err3 != nil {
|
|
return 0, err3
|
|
}
|
|
return 0, windows.ERROR_INVALID_HANDLE
|
|
}
|
|
return windows.Handle(h), nil
|
|
}
|
|
|
|
func dibFromRGBA(src *image.RGBA) (windows.Handle, error) {
|
|
w := src.Bounds().Dx()
|
|
h := src.Bounds().Dy()
|
|
bi := bitmapInfo{
|
|
Header: bitmapInfoHeader{
|
|
BiSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
|
|
BiWidth: int32(w),
|
|
BiHeight: -int32(h), // top-down
|
|
BiPlanes: 1,
|
|
BiBitCount: 32,
|
|
BiCompression: biRGB,
|
|
},
|
|
}
|
|
var bits unsafe.Pointer
|
|
hbm, _, err := procCreateDIBSection.Call(0, uintptr(unsafe.Pointer(&bi)), dibRGBColors, uintptr(unsafe.Pointer(&bits)), 0, 0)
|
|
if hbm == 0 || bits == nil {
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return 0, windows.ERROR_INVALID_HANDLE
|
|
}
|
|
// Windows wants BGRA.
|
|
dst := unsafe.Slice((*byte)(bits), w*h*4)
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
i := src.PixOffset(x, y)
|
|
o := (y*w + x) * 4
|
|
dst[o+0] = src.Pix[i+2] // B
|
|
dst[o+1] = src.Pix[i+1] // G
|
|
dst[o+2] = src.Pix[i+0] // R
|
|
dst[o+3] = src.Pix[i+3] // A
|
|
}
|
|
}
|
|
return windows.Handle(hbm), nil
|
|
}
|