feat: add tray layout controls

This commit is contained in:
Steve Cliff
2026-08-20 09:31:15 +01:00
parent 45ee82692d
commit cb00340c55
10 changed files with 523 additions and 31 deletions
+33 -6
View File
@@ -18,6 +18,8 @@ The project uses pure Go and sets `CGO_ENABLED=0`. No C compiler, Windows SDK,
```text ```text
cmd/fancywin/ CLI entry point 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/config/ YAML model, loading, and validation
internal/layout/ Platform-neutral zone geometry internal/layout/ Platform-neutral zone geometry
internal/platform/platform_windows.go Win32 backend internal/platform/platform_windows.go Win32 backend
@@ -56,8 +58,10 @@ The script:
5. Writes `dist\fancywin.exe` and copies the example to 5. Writes `dist\fancywin.exe` and copies the example to
`dist\fancywin.yaml`. `dist\fancywin.yaml`.
The generated executable is a console subsystem application so users can see The generated executable remains a console-subsystem application so diagnostics
configuration and Win32 errors directly. 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 ## Cross-compiling
@@ -138,6 +142,13 @@ Test both x64 and ARM64 where hardware is available:
same-integrity elevated run can do so. same-integrity elevated run can do so.
11. Disconnect and reconnect a monitor and confirm current work-area information 11. Disconnect and reconnect a monitor and confirm current work-area information
is used for subsequent moves. 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 ## Architecture
@@ -153,13 +164,29 @@ At startup the backend:
1. Requests per-monitor-v2 DPI awareness. 1. Requests per-monitor-v2 DPI awareness.
2. Creates a named mutex for single-instance enforcement. 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 `EVENT_SYSTEM_MOVESIZEEND` with `WINEVENT_OUTOFCONTEXT` and
`WINEVENT_SKIPOWNPROCESS`. `WINEVENT_SKIPOWNPROCESS`.
4. Registers configured system-wide hotkeys against the current thread. 5. Registers configured system-wide hotkeys against the current thread.
5. Creates a two-second timer for configuration reload checks and a 25 ms timer 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. 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 ### Mouse snapping
+39 -23
View File
@@ -34,9 +34,14 @@ You need these two files in the same directory:
The supplied [example configuration](fancywin.example.yaml) can be copied or The supplied [example configuration](fancywin.example.yaml) can be copied or
renamed to `fancywin.yaml`. renamed to `fancywin.yaml`.
Double-click `fancywin.exe` to start it. A console window remains open while Double-click `fancywin.exe` to start it. FancyWin runs quietly in the Windows
FancyWin is running and shows its status, detected monitors, configuration notification area without leaving a console or taskbar window open. Hover over
reloads, and any errors. Closing that console stops FancyWin. 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. 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 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 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 invalid, the previous valid configuration stays in use. Restart with `--debug`
use. to investigate configuration errors.
### General settings ### General settings
@@ -218,8 +223,10 @@ layouts:
- { name: right, x: 66.667, y: 0, width: 33.333, height: 100 } - { name: right, x: 66.667, y: 0, width: 33.333, height: 100 }
``` ```
Changing `active_layout` to `equal-thirds` is applied automatically within Select a name from the tray menu to switch immediately. FancyWin also updates
about two seconds; FancyWin does not need to be restarted. `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 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 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 # Print the FancyWin version
.\fancywin.exe -version .\fancywin.exe -version
# Print detailed window-move and snapping diagnostics # Keep a console open and print detailed diagnostics
.\fancywin.exe -debug .\fancywin.exe -debug
``` ```
@@ -322,25 +329,17 @@ necessary.
## Troubleshooting ## Troubleshooting
### FancyWin closes immediately ### FancyWin does not appear to start
Open PowerShell in the FancyWin directory and run: Open PowerShell in the FancyWin directory and run:
```powershell ```powershell
.\fancywin.exe .\fancywin.exe --debug
``` ```
The console will remain visible so you can read the error. Common causes are a The console remains attached so you can read errors. Common causes are a missing
missing `fancywin.yaml`, invalid YAML, a shortcut conflict, or another running `fancywin.yaml`, invalid YAML, a shortcut conflict, or another running copy of
copy of FancyWin. FancyWin. Debug output also reports whether Windows emitted a move event, whether the
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
window passed filtering, whether Shift was active during the drag, which monitor window passed filtering, whether Shift was active during the drag, which monitor
and zone were selected, and whether Windows accepted the final move request. 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 ### Configuration changes are not applied
Wait at least two seconds and check the console. FancyWin keeps using the last Wait at least two seconds. FancyWin keeps using the last valid configuration
valid configuration when a new edit cannot be parsed or validated. 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 ### The zone overlay does not appear
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+14 -2
View File
@@ -11,7 +11,9 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
const version = "0.3.0" const version = "0.5.1"
var backgroundMode bool
func main() { func main() {
exe, err := os.Executable() exe, err := os.Executable()
@@ -37,6 +39,10 @@ func main() {
_, _ = os.Stdout.Write(b) _, _ = os.Stdout.Write(b)
return return
} }
backgroundMode = !*check && !*debug
if backgroundMode {
platform.PrepareBackground()
}
cfg, err := config.Load(*configPath) cfg, err := config.Load(*configPath)
if err != nil { if err != nil {
fatal(fmt.Errorf("%s: %w", filepath.Clean(*configPath), err)) 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)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+57
View File
@@ -200,6 +200,63 @@ func (c Config) LayoutFor(device string) (Layout, bool) {
return Layout{}, false 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 { func (c Config) IsExcluded(exe string) bool {
// Windows paths may be validated on another OS during cross-compilation. // Windows paths may be validated on another OS during cross-compilation.
normalized := strings.ReplaceAll(exe, `\`, "/") normalized := strings.ReplaceAll(exe, `\`, "/")
+32
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "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) { func TestExcluded(t *testing.T) {
c := Default() c := Default()
c.Excluded = []string{"notepad", "exact.exe"} c.Excluded = []string{"notepad", "exact.exe"}
+4
View File
@@ -4,9 +4,13 @@ package platform
import ( import (
"errors" "errors"
"github.com/stevec/fancywin/internal/config" "github.com/stevec/fancywin/internal/config"
) )
func PrepareBackground() {}
func ShowError(_, _ string) {}
func Run(_ string, _ config.Config, _ bool) error { func Run(_ string, _ config.Config, _ bool) error {
return errors.New("the window manager can only run on Windows") return errors.New("the window manager can only run on Windows")
} }
+49
View File
@@ -76,7 +76,9 @@ var (
procSetProcessDPIAwareV2 = user32.NewProc("SetProcessDpiAwarenessContext") procSetProcessDPIAwareV2 = user32.NewProc("SetProcessDpiAwarenessContext")
procEnumDisplayMonitors = user32.NewProc("EnumDisplayMonitors") procEnumDisplayMonitors = user32.NewProc("EnumDisplayMonitors")
procCreateMutex = kernel32.NewProc("CreateMutexW") procCreateMutex = kernel32.NewProc("CreateMutexW")
procFreeConsole = kernel32.NewProc("FreeConsole")
procDwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute") procDwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute")
procMessageBox = user32.NewProc("MessageBoxW")
) )
type point struct{ X, Y int32 } type point struct{ X, Y int32 }
@@ -121,10 +123,20 @@ type engine struct {
debug bool debug bool
overlay *overlayManager overlay *overlayManager
overlayFailed bool overlayFailed bool
tray *trayIcon
} }
var activeEngine *engine 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 { func Run(configPath string, cfg config.Config, debug bool) error {
runtime.LockOSThread() runtime.LockOSThread()
defer runtime.UnlockOSThread() 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{}} e := &engine{configPath: configPath, cfg: cfg, hotkeys: make(map[int]hotkeyBinding), debug: debug, overlay: &overlayManager{}}
defer e.overlay.hide() 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() e.rememberConfigTime()
activeEngine = e activeEngine = e
defer func() { activeEngine = nil }() defer func() { activeEngine = nil }()
@@ -690,5 +708,36 @@ func (e *engine) reloadIfChanged() {
_ = e.registerHotkeys() _ = e.registerHotkeys()
return return
} }
e.tray.setLayouts(cfg.ActiveLayout, cfg.LayoutNames())
log.Printf("configuration reloaded: active layout=%q", cfg.ActiveLayout) 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)
}
+295
View File
@@ -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
}