diff --git a/BUILD.md b/BUILD.md index e8cf851..4092101 100644 --- a/BUILD.md +++ b/BUILD.md @@ -18,6 +18,8 @@ The project uses pure Go and sets `CGO_ENABLED=0`. No C compiler, Windows SDK, ```text cmd/fancywin/ CLI entry point +assets/fancywin.png Full-resolution generated icon source +internal/assets/ Embedded runtime image assets internal/config/ YAML model, loading, and validation internal/layout/ Platform-neutral zone geometry internal/platform/platform_windows.go Win32 backend @@ -56,8 +58,10 @@ The script: 5. Writes `dist\fancywin.exe` and copies the example to `dist\fancywin.yaml`. -The generated executable is a console subsystem application so users can see -configuration and Win32 errors directly. +The generated executable remains a console-subsystem application so diagnostics +work without a second binary. Normal execution calls `FreeConsole` and operates +through the notification-area icon. `--debug` retains the console, while +startup failures in detached mode are shown with `MessageBoxW`. ## Cross-compiling @@ -138,6 +142,13 @@ Test both x64 and ARM64 where hardware is available: same-integrity elevated run can do so. 11. Disconnect and reconnect a monitor and confirm current work-area information is used for subsequent moves. +12. Confirm the custom icon is crisp in the normal and overflow notification + areas at 100%, 150%, and 200% scaling. +13. Select each named layout from the tray, confirm the check mark and overlay + change immediately, and verify `active_layout` changes without disturbing + YAML comments or layout formatting. +14. Right-click the tray icon, select `Exit`, and confirm the process and icon + both disappear cleanly. ## Architecture @@ -153,13 +164,29 @@ At startup the backend: 1. Requests per-monitor-v2 DPI awareness. 2. Creates a named mutex for single-instance enforcement. -3. Installs a `SetWinEventHook` covering `EVENT_SYSTEM_MOVESIZESTART` through +3. Creates a hidden window and registers a notification-area icon with + `Shell_NotifyIconW`. +4. Installs a `SetWinEventHook` covering `EVENT_SYSTEM_MOVESIZESTART` through `EVENT_SYSTEM_MOVESIZEEND` with `WINEVENT_OUTOFCONTEXT` and `WINEVENT_SKIPOWNPROCESS`. -4. Registers configured system-wide hotkeys against the current thread. -5. Creates a two-second timer for configuration reload checks and a 25 ms timer +5. Registers configured system-wide hotkeys against the current thread. +6. Creates a two-second timer for configuration reload checks and a 25 ms timer for sampling the mouse button, Shift, and active drag state. -6. Enters a standard Win32 `GetMessage` loop. +7. Enters a standard Win32 `GetMessage` loop. + +The tray icon is a generated FancyWin mark embedded as a 64×64 RGBA PNG. At +startup the standard-library PNG decoder supplies premultiplied BGRA pixels to a +32-bit GDI DIB section, which `CreateIconIndirect` converts to an alpha-aware +native `HICON`; it never needs to be extracted to disk. A stock Windows icon is +used only if conversion unexpectedly fails. + +The tooltip contains the active layout. The context menu deduplicates configured +layouts by case-insensitive name, checks the active one, and provides `Exit`. +Selecting a layout updates runtime state immediately and rewrites only the +top-level `active_layout` YAML line so the choice persists. `Exit` posts +`WM_QUIT` to the existing message loop for orderly hook, hotkey, overlay, icon, +and tray cleanup. A registered `TaskbarCreated` message restores the icon after +Windows Explorer restarts. ### Mouse snapping diff --git a/README.md b/README.md index 05c17bf..21f6171 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,14 @@ You need these two files in the same directory: The supplied [example configuration](fancywin.example.yaml) can be copied or renamed to `fancywin.yaml`. -Double-click `fancywin.exe` to start it. A console window remains open while -FancyWin is running and shows its status, detected monitors, configuration -reloads, and any errors. Closing that console stops FancyWin. +Double-click `fancywin.exe` to start it. FancyWin runs quietly in the Windows +notification area without leaving a console or taskbar window open. Hover over +its tray icon to see the active layout. Right-click the icon to select any +configured layout or choose `Exit` to stop FancyWin. The active layout is marked +with a check. + +Run `fancywin.exe --debug` from PowerShell when you want a console showing +status, detected monitors, drag diagnostics, configuration reloads, and errors. Only one copy of FancyWin can run at a time. @@ -127,8 +132,8 @@ hotkeys: FancyWin checks the file for unknown fields and invalid values. While it is running, a valid edit takes effect within about two seconds. If an edit is -invalid, the error is printed and the previous valid configuration stays in -use. +invalid, the previous valid configuration stays in use. Restart with `--debug` +to investigate configuration errors. ### General settings @@ -218,8 +223,10 @@ layouts: - { name: right, x: 66.667, y: 0, width: 33.333, height: 100 } ``` -Changing `active_layout` to `equal-thirds` is applied automatically within -about two seconds; FancyWin does not need to be restarted. +Select a name from the tray menu to switch immediately. FancyWin also updates +`active_layout` in the YAML so the choice survives a restart. Alternatively, +changing `active_layout` by hand is applied automatically within about two +seconds; FancyWin does not need to be restarted. Use `monitor: "*"` as the fallback layout for every display that does not have a specific entry. At startup, FancyWin prints detected device names such as @@ -303,7 +310,7 @@ Run these commands from PowerShell or Command Prompt in the FancyWin directory: # Print the FancyWin version .\fancywin.exe -version -# Print detailed window-move and snapping diagnostics +# Keep a console open and print detailed diagnostics .\fancywin.exe -debug ``` @@ -322,25 +329,17 @@ necessary. ## Troubleshooting -### FancyWin closes immediately +### FancyWin does not appear to start Open PowerShell in the FancyWin directory and run: ```powershell -.\fancywin.exe +.\fancywin.exe --debug ``` -The console will remain visible so you can read the error. Common causes are a -missing `fancywin.yaml`, invalid YAML, a shortcut conflict, or another running -copy of FancyWin. - -For detailed diagnostics, stop FancyWin and restart it with: - -```powershell -.\fancywin.exe -debug -``` - -The debug output reports whether Windows emitted a move event, whether the +The console remains attached so you can read errors. Common causes are a missing +`fancywin.yaml`, invalid YAML, a shortcut conflict, or another running copy of +FancyWin. Debug output also reports whether Windows emitted a move event, whether the window passed filtering, whether Shift was active during the drag, which monitor and zone were selected, and whether Windows accepted the final move request. @@ -370,8 +369,25 @@ borders, but it cannot override size rules imposed by the application itself. ### Configuration changes are not applied -Wait at least two seconds and check the console. FancyWin keeps using the last -valid configuration when a new edit cannot be parsed or validated. +Wait at least two seconds. FancyWin keeps using the last valid configuration +when a new edit cannot be parsed or validated. Restart with `--debug` and repeat +the edit to see the exact error. + +### The tray icon is missing + +- Check the notification-area overflow menu behind the upward arrow; Windows + may place new icons there automatically. +- Start FancyWin with `--debug` and check for a notification-area error. +- Only one FancyWin instance may run at a time. +- If Windows Explorer restarts, FancyWin automatically adds its icon again. + +### A layout is missing from the tray menu + +- Confirm every layout entry has a non-empty `name`. +- Monitor-specific variants sharing the same name intentionally appear as one + menu choice. +- Check the YAML with `fancywin.exe -check`. +- Layouts added while FancyWin is running appear after the configuration reload. ### The zone overlay does not appear diff --git a/assets/fancywin.png b/assets/fancywin.png new file mode 100644 index 0000000..045ad85 Binary files /dev/null and b/assets/fancywin.png differ diff --git a/cmd/fancywin/main.go b/cmd/fancywin/main.go index b150cfc..46f0c2c 100644 --- a/cmd/fancywin/main.go +++ b/cmd/fancywin/main.go @@ -11,7 +11,9 @@ import ( "gopkg.in/yaml.v3" ) -const version = "0.3.0" +const version = "0.5.1" + +var backgroundMode bool func main() { exe, err := os.Executable() @@ -37,6 +39,10 @@ func main() { _, _ = os.Stdout.Write(b) return } + backgroundMode = !*check && !*debug + if backgroundMode { + platform.PrepareBackground() + } cfg, err := config.Load(*configPath) if err != nil { fatal(fmt.Errorf("%s: %w", filepath.Clean(*configPath), err)) @@ -51,4 +57,10 @@ func main() { } } -func fatal(err error) { fmt.Fprintln(os.Stderr, "fancywin:", err); os.Exit(1) } +func fatal(err error) { + fmt.Fprintln(os.Stderr, "fancywin:", err) + if backgroundMode { + platform.ShowError("FancyWin", err.Error()) + } + os.Exit(1) +} diff --git a/internal/assets/fancywin-tray.png b/internal/assets/fancywin-tray.png new file mode 100644 index 0000000..c1548f0 Binary files /dev/null and b/internal/assets/fancywin-tray.png differ diff --git a/internal/config/config.go b/internal/config/config.go index 186ce61..b12b2e0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -200,6 +200,63 @@ func (c Config) LayoutFor(device string) (Layout, bool) { return Layout{}, false } +func (c Config) LayoutNames() []string { + seen := make(map[string]bool) + var names []string + for _, l := range c.Layouts { + name := strings.TrimSpace(l.Name) + key := strings.ToLower(name) + if name != "" && !seen[key] { + seen[key] = true + names = append(names, name) + } + } + return names +} + +// SetActiveLayout updates only the top-level active_layout line, preserving the +// user's comments, layout formatting, and ordering. +func SetActiveLayout(path, name string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + encoded, err := yaml.Marshal(strings.TrimSpace(name)) + if err != nil { + return err + } + value := strings.TrimSpace(string(encoded)) + newline := "\n" + if strings.Contains(string(data), "\r\n") { + newline = "\r\n" + } + lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + replaced := false + for i, line := range lines { + if strings.HasPrefix(line, "active_layout:") { + lines[i] = "active_layout: " + value + replaced = true + break + } + } + if !replaced { + insertAt := 0 + for i, line := range lines { + if strings.HasPrefix(line, "version:") || strings.HasPrefix(line, "gap:") || strings.HasPrefix(line, "shift_drag:") { + insertAt = i + 1 + } + } + lines = append(lines, "") + copy(lines[insertAt+1:], lines[insertAt:]) + lines[insertAt] = "active_layout: " + value + } + info, err := os.Stat(path) + if err != nil { + return err + } + return os.WriteFile(path, []byte(strings.Join(lines, newline)), info.Mode().Perm()) +} + func (c Config) IsExcluded(exe string) bool { // Windows paths may be validated on another OS during cross-compilation. normalized := strings.ReplaceAll(exe, `\`, "/") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e547606..633d293 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -64,6 +65,37 @@ func TestMissingActiveLayoutFails(t *testing.T) { } } +func TestLayoutNamesAreUniqueAndOrdered(t *testing.T) { + c := Default() + c.Layouts = append(c.Layouts, + Layout{Name: "Columns", Monitor: `\\.\DISPLAY2`}, + Layout{Name: "Rows", Monitor: "*"}, + ) + got := c.LayoutNames() + if len(got) != 2 || got[0] != "columns" || got[1] != "Rows" { + t.Fatalf("unexpected names: %#v", got) + } +} + +func TestSetActiveLayoutPreservesConfiguration(t *testing.T) { + path := filepath.Join(t.TempDir(), "fancywin.yaml") + original := "# keep this comment\r\nversion: 1\r\nactive_layout: old # old value\r\nlayouts: []\r\n" + if err := os.WriteFile(path, []byte(original), 0o640); err != nil { + t.Fatal(err) + } + if err := SetActiveLayout(path, "new layout"); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "# keep this comment\r\n") || !strings.Contains(text, "active_layout: new layout\r\n") || !strings.Contains(text, "layouts: []\r\n") { + t.Fatalf("unexpected rewritten file: %q", text) + } +} + func TestExcluded(t *testing.T) { c := Default() c.Excluded = []string{"notepad", "exact.exe"} diff --git a/internal/platform/platform_other.go b/internal/platform/platform_other.go index 4facc46..15d5101 100644 --- a/internal/platform/platform_other.go +++ b/internal/platform/platform_other.go @@ -4,9 +4,13 @@ package platform import ( "errors" + "github.com/stevec/fancywin/internal/config" ) +func PrepareBackground() {} +func ShowError(_, _ string) {} + func Run(_ string, _ config.Config, _ bool) error { return errors.New("the window manager can only run on Windows") } diff --git a/internal/platform/platform_windows.go b/internal/platform/platform_windows.go index 007dd1d..454ae05 100644 --- a/internal/platform/platform_windows.go +++ b/internal/platform/platform_windows.go @@ -76,7 +76,9 @@ var ( procSetProcessDPIAwareV2 = user32.NewProc("SetProcessDpiAwarenessContext") procEnumDisplayMonitors = user32.NewProc("EnumDisplayMonitors") procCreateMutex = kernel32.NewProc("CreateMutexW") + procFreeConsole = kernel32.NewProc("FreeConsole") procDwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute") + procMessageBox = user32.NewProc("MessageBoxW") ) type point struct{ X, Y int32 } @@ -121,10 +123,20 @@ type engine struct { debug bool overlay *overlayManager overlayFailed bool + tray *trayIcon } var activeEngine *engine +func PrepareBackground() { procFreeConsole.Call() } + +func ShowError(title, message string) { + t, _ := windows.UTF16PtrFromString(title) + m, _ := windows.UTF16PtrFromString(message) + const mbOKIconErrorSetForeground = 0x00000000 | 0x00000010 | 0x00010000 + procMessageBox.Call(0, uintptr(unsafe.Pointer(m)), uintptr(unsafe.Pointer(t)), mbOKIconErrorSetForeground) +} + func Run(configPath string, cfg config.Config, debug bool) error { runtime.LockOSThread() defer runtime.UnlockOSThread() @@ -142,6 +154,12 @@ func Run(configPath string, cfg config.Config, debug bool) error { e := &engine{configPath: configPath, cfg: cfg, hotkeys: make(map[int]hotkeyBinding), debug: debug, overlay: &overlayManager{}} defer e.overlay.hide() + tray, err := newTrayIcon(cfg.ActiveLayout, cfg.LayoutNames()) + if err != nil { + return err + } + e.tray = tray + defer e.tray.close() e.rememberConfigTime() activeEngine = e defer func() { activeEngine = nil }() @@ -690,5 +708,36 @@ func (e *engine) reloadIfChanged() { _ = e.registerHotkeys() return } + e.tray.setLayouts(cfg.ActiveLayout, cfg.LayoutNames()) log.Printf("configuration reloaded: active layout=%q", cfg.ActiveLayout) } + +func (e *engine) selectLayout(name string) { + e.mu.RLock() + cfg := e.cfg + e.mu.RUnlock() + found := false + for _, candidate := range cfg.LayoutNames() { + if strings.EqualFold(candidate, name) { + name = candidate + found = true + break + } + } + if !found || strings.EqualFold(cfg.ActiveLayout, name) { + return + } + if err := config.SetActiveLayout(e.configPath, name); err != nil { + log.Printf("cannot select layout %q: %v", name, err) + ShowError("FancyWin", fmt.Sprintf("Could not select layout %q:\n\n%v", name, err)) + return + } + cfg.ActiveLayout = name + e.mu.Lock() + e.cfg = cfg + e.mu.Unlock() + e.rememberConfigTime() + e.overlay.hide() + e.tray.setLayouts(name, cfg.LayoutNames()) + log.Printf("active layout changed from tray: %q", name) +} diff --git a/internal/platform/tray_windows.go b/internal/platform/tray_windows.go new file mode 100644 index 0000000..82b502b --- /dev/null +++ b/internal/platform/tray_windows.go @@ -0,0 +1,295 @@ +//go:build windows + +package platform + +import ( + "bytes" + "fmt" + "image/png" + "strings" + "unsafe" + + "github.com/stevec/fancywin/internal/assets" + "golang.org/x/sys/windows" +) + +const ( + wmAppTray = 0x8001 + wmRButtonUp = 0x0205 + wmContextMenu = 0x007B + wmNull = 0x0000 + nimAdd = 0x00000000 + nimModify = 0x00000001 + nimDelete = 0x00000002 + nifMessage = 0x00000001 + nifIcon = 0x00000002 + nifTip = 0x00000004 + mfString = 0x00000000 + mfGray = 0x00000001 + mfChecked = 0x00000008 + mfSeparator = 0x00000800 + tpmRightButton = 0x00000002 + tpmReturnCmd = 0x00000100 + tpmNonotify = 0x00000080 + trayCommandExit = 1001 + trayLayoutBase = 2000 + idiApplication = 32512 + biRGB = 0 + dibRGBColors = 0 +) + +var ( + shell32 = windows.NewLazySystemDLL("shell32.dll") + procShellNotifyIcon = shell32.NewProc("Shell_NotifyIconW") + procLoadIcon = user32.NewProc("LoadIconW") + procCreatePopupMenu = user32.NewProc("CreatePopupMenu") + procAppendMenu = user32.NewProc("AppendMenuW") + procTrackPopupMenu = user32.NewProc("TrackPopupMenu") + procDestroyMenu = user32.NewProc("DestroyMenu") + procSetForegroundWindow = user32.NewProc("SetForegroundWindow") + procPostMessage = user32.NewProc("PostMessageW") + procPostQuitMessage = user32.NewProc("PostQuitMessage") + procRegisterWindowMessage = user32.NewProc("RegisterWindowMessageW") + procCreateIconIndirect = user32.NewProc("CreateIconIndirect") + procDestroyIcon = user32.NewProc("DestroyIcon") + procCreateDIBSection = gdi32.NewProc("CreateDIBSection") + procCreateBitmap = gdi32.NewProc("CreateBitmap") + trayClassName, _ = windows.UTF16PtrFromString("FancyWinTrayWindow") + trayWindowProc = windows.NewCallback(trayWndProc) + activeTray *trayIcon +) + +type notifyIconData struct { + Size uint32 + Window windows.Handle + ID uint32 + Flags uint32 + CallbackMessage uint32 + Icon windows.Handle + Tip [128]uint16 + State uint32 + StateMask uint32 + Info [256]uint16 + TimeoutOrVersion uint32 + InfoTitle [64]uint16 + InfoFlags uint32 + GUID [16]byte + BalloonIcon windows.Handle +} + +type trayIcon struct { + hwnd windows.Handle + icon windows.Handle + activeLayout string + layouts []string + taskbarMessage uint32 + ownsIcon bool +} + +type bitmapInfoHeader struct { + Size uint32 + Width int32 + Height int32 + Planes uint16 + BitCount uint16 + Compression uint32 + SizeImage uint32 + XPelsPerMeter int32 + YPelsPerMeter int32 + ClrUsed uint32 + ClrImportant uint32 +} + +type iconInfo struct { + Icon int32 + XHotspot uint32 + YHotspot uint32 + Mask windows.Handle + Color windows.Handle +} + +func newTrayIcon(activeLayout string, layouts []string) (*trayIcon, error) { + instance, _, instanceErr := procGetModuleHandle.Call(0) + if instance == 0 { + return nil, fmt.Errorf("tray GetModuleHandleW: %v", instanceErr) + } + wc := windowClassEx{ + Size: uint32(unsafe.Sizeof(windowClassEx{})), + WndProc: trayWindowProc, + Instance: windows.Handle(instance), + ClassName: trayClassName, + } + if atom, _, err := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&wc))); atom == 0 { + return nil, fmt.Errorf("register tray window class: %v", err) + } + + raw, _, createErr := procCreateWindowEx.Call( + 0, + uintptr(unsafe.Pointer(trayClassName)), + uintptr(unsafe.Pointer(trayClassName)), + 0, 0, 0, 0, 0, 0, 0, instance, 0, + ) + if raw == 0 { + return nil, fmt.Errorf("create tray message window: %v", createErr) + } + icon, iconErr := loadFancyIcon() + ownsIcon := icon != 0 + if icon == 0 { + fallback, _, fallbackErr := procLoadIcon.Call(0, idiApplication) + if fallback == 0 { + procDestroyWindow.Call(raw) + return nil, fmt.Errorf("load tray icon: generated icon: %v; fallback: %v", iconErr, fallbackErr) + } + icon = windows.Handle(fallback) + } + taskbarName, _ := windows.UTF16PtrFromString("TaskbarCreated") + taskbarMessage, _, _ := procRegisterWindowMessage.Call(uintptr(unsafe.Pointer(taskbarName))) + + t := &trayIcon{ + hwnd: windows.Handle(raw), icon: windows.Handle(icon), + activeLayout: activeLayout, layouts: append([]string(nil), layouts...), + taskbarMessage: uint32(taskbarMessage), ownsIcon: ownsIcon, + } + activeTray = t + if err := t.notify(nimAdd); err != nil { + activeTray = nil + procDestroyWindow.Call(raw) + return nil, err + } + return t, nil +} + +func (t *trayIcon) close() { + if t == nil || t.hwnd == 0 { + return + } + _ = t.notify(nimDelete) + procDestroyWindow.Call(uintptr(t.hwnd)) + if t.ownsIcon && t.icon != 0 { + procDestroyIcon.Call(uintptr(t.icon)) + } + if activeTray == t { + activeTray = nil + } + t.hwnd = 0 +} + +func (t *trayIcon) setLayouts(active string, layouts []string) { + if t == nil { + return + } + t.activeLayout = active + t.layouts = append(t.layouts[:0], layouts...) + _ = t.notify(nimModify) +} + +func (t *trayIcon) notify(operation uintptr) error { + nid := notifyIconData{ + Size: uint32(unsafe.Sizeof(notifyIconData{})), Window: t.hwnd, ID: 1, + Flags: nifMessage | nifIcon | nifTip, CallbackMessage: wmAppTray, Icon: t.icon, + } + tip, _ := windows.UTF16FromString("FancyWin — " + t.activeLayout) + copy(nid.Tip[:len(nid.Tip)-1], tip) + if ok, _, err := procShellNotifyIcon.Call(operation, uintptr(unsafe.Pointer(&nid))); ok == 0 { + return fmt.Errorf("update notification-area icon: %v", err) + } + return nil +} + +func trayWndProc(rawHWND, message, wParam, lParam uintptr) uintptr { + t := activeTray + if t != nil && t.taskbarMessage != 0 && uint32(message) == t.taskbarMessage { + _ = t.notify(nimAdd) // Explorer was restarted; restore the icon. + return 0 + } + if message == wmAppTray && (uint32(lParam) == wmRButtonUp || uint32(lParam) == wmContextMenu) { + if t != nil { + t.showMenu() + } + return 0 + } + r, _, _ := procDefWindowProc.Call(rawHWND, message, wParam, lParam) + return r +} + +func (t *trayIcon) showMenu() { + menu, _, _ := procCreatePopupMenu.Call() + if menu == 0 { + return + } + defer procDestroyMenu.Call(menu) + heading, _ := windows.UTF16PtrFromString("Layouts") + exit, _ := windows.UTF16PtrFromString("Exit") + procAppendMenu.Call(menu, mfString|mfGray, 0, uintptr(unsafe.Pointer(heading))) + for i, name := range t.layouts { + label, _ := windows.UTF16PtrFromString(name) + flags := uintptr(mfString) + if strings.EqualFold(name, t.activeLayout) { + flags |= mfChecked + } + procAppendMenu.Call(menu, flags, uintptr(trayLayoutBase+i), uintptr(unsafe.Pointer(label))) + } + procAppendMenu.Call(menu, mfSeparator, 0, 0) + procAppendMenu.Call(menu, mfString, trayCommandExit, uintptr(unsafe.Pointer(exit))) + + var p point + if ok, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&p))); ok == 0 { + return + } + procSetForegroundWindow.Call(uintptr(t.hwnd)) + command, _, _ := procTrackPopupMenu.Call(menu, tpmRightButton|tpmReturnCmd|tpmNonotify, uintptr(p.X), uintptr(p.Y), 0, uintptr(t.hwnd), 0) + procPostMessage.Call(uintptr(t.hwnd), wmNull, 0, 0) + if command == trayCommandExit { + procPostQuitMessage.Call(0) + } else if command >= trayLayoutBase && command < uintptr(trayLayoutBase+len(t.layouts)) { + if e := activeEngine; e != nil { + e.selectLayout(t.layouts[int(command)-trayLayoutBase]) + } + } +} + +func loadFancyIcon() (windows.Handle, error) { + img, err := png.Decode(bytes.NewReader(assets.TrayIconPNG)) + if err != nil { + return 0, err + } + bounds := img.Bounds() + w, h := bounds.Dx(), bounds.Dy() + if w < 1 || h < 1 { + return 0, fmt.Errorf("embedded icon is empty") + } + header := bitmapInfoHeader{ + Size: uint32(unsafe.Sizeof(bitmapInfoHeader{})), Width: int32(w), Height: -int32(h), + Planes: 1, BitCount: 32, Compression: biRGB, SizeImage: uint32(w * h * 4), + } + var bits unsafe.Pointer + color, _, dibErr := procCreateDIBSection.Call(0, uintptr(unsafe.Pointer(&header)), dibRGBColors, uintptr(unsafe.Pointer(&bits)), 0, 0) + if color == 0 || bits == nil { + return 0, fmt.Errorf("CreateDIBSection: %v", dibErr) + } + defer procDeleteObject.Call(color) + pixels := unsafe.Slice((*byte)(bits), w*h*4) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + r, g, b, a := img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA() + i := (y*w + x) * 4 + pixels[i+0] = byte(b >> 8) + pixels[i+1] = byte(g >> 8) + pixels[i+2] = byte(r >> 8) + pixels[i+3] = byte(a >> 8) + } + } + maskStride := ((w + 15) / 16) * 2 + maskBits := make([]byte, maskStride*h) + mask, _, maskErr := procCreateBitmap.Call(uintptr(w), uintptr(h), 1, 1, uintptr(unsafe.Pointer(&maskBits[0]))) + if mask == 0 { + return 0, fmt.Errorf("CreateBitmap icon mask: %v", maskErr) + } + defer procDeleteObject.Call(mask) + info := iconInfo{Icon: 1, Mask: windows.Handle(mask), Color: windows.Handle(color)} + icon, _, iconErr := procCreateIconIndirect.Call(uintptr(unsafe.Pointer(&info))) + if icon == 0 { + return 0, fmt.Errorf("CreateIconIndirect: %v", iconErr) + } + return windows.Handle(icon), nil +}