//go:build windows package platform import ( "fmt" "log" "os" "runtime" "strconv" "strings" "sync" "time" "unsafe" "github.com/stevec/fancywin/internal/config" "github.com/stevec/fancywin/internal/layout" "golang.org/x/sys/windows" ) const ( eventSystemMoveSizeStart = 0x000A eventSystemMoveSizeEnd = 0x000B wineventOutOfContext = 0x0000 wineventSkipOwnProcess = 0x0002 objectIDWindow = 0 wmHotkey = 0x0312 wmTimer = 0x0113 wmQuit = 0x0012 monitorDefaultToNearest = 2 monitorDefaultToNull = 0 modAlt = 0x0001 modControl = 0x0002 modShift = 0x0004 modWin = 0x0008 modNoRepeat = 0x4000 vkLButton = 0x01 vkShift = 0x10 gaRoot = 2 gwOwner = 4 wsChild = 0x40000000 swRestore = 9 swpNoZOrder = 0x0004 swpNoActivate = 0x0010 swpNoOwnerZOrder = 0x0200 ) var ( user32 = windows.NewLazySystemDLL("user32.dll") kernel32 = windows.NewLazySystemDLL("kernel32.dll") dwmapi = windows.NewLazySystemDLL("dwmapi.dll") procSetWinEventHook = user32.NewProc("SetWinEventHook") procUnhookWinEvent = user32.NewProc("UnhookWinEvent") procGetMessage = user32.NewProc("GetMessageW") procTranslateMessage = user32.NewProc("TranslateMessage") procDispatchMessage = user32.NewProc("DispatchMessageW") procRegisterHotKey = user32.NewProc("RegisterHotKey") procUnregisterHotKey = user32.NewProc("UnregisterHotKey") procGetForegroundWindow = user32.NewProc("GetForegroundWindow") procGetCursorPos = user32.NewProc("GetCursorPos") procWindowFromPoint = user32.NewProc("WindowFromPoint") procMonitorFromPoint = user32.NewProc("MonitorFromPoint") procMonitorFromWindow = user32.NewProc("MonitorFromWindow") procGetMonitorInfo = user32.NewProc("GetMonitorInfoW") procGetAsyncKeyState = user32.NewProc("GetAsyncKeyState") procSetWindowPos = user32.NewProc("SetWindowPos") procShowWindow = user32.NewProc("ShowWindow") procIsWindowVisible = user32.NewProc("IsWindowVisible") procGetAncestor = user32.NewProc("GetAncestor") procGetWindow = user32.NewProc("GetWindow") procGetWindowLongPtr = user32.NewProc("GetWindowLongPtrW") procGetWindowRect = user32.NewProc("GetWindowRect") procGetWindowThreadProcessID = user32.NewProc("GetWindowThreadProcessId") procSetTimer = user32.NewProc("SetTimer") procKillTimer = user32.NewProc("KillTimer") 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 } type rect struct{ Left, Top, Right, Bottom int32 } type message struct { HWnd windows.Handle Message uint32 _ uint32 WParam uintptr LParam uintptr Time uint32 Pt point Private uint32 } type monitorInfoEx struct { Size uint32 Monitor rect Work rect Flags uint32 Device [32]uint16 } type hotkeyBinding struct { id int action string keys string } type engine struct { mu sync.RWMutex configPath string cfg config.Config configTime time.Time dragging windows.Handle dragShift bool mouseDown bool mouseWindow windows.Handle mouseStart rect mouseMoved bool mouseShift bool fallbackHandled windows.Handle hook windows.Handle hotkeys map[int]hotkeyBinding 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() procSetProcessDPIAwareV2.Call(^uintptr(3)) // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (-4) name, _ := windows.UTF16PtrFromString("Local\\FancyWin-Portable-Window-Manager") mutex, _, createErr := procCreateMutex.Call(0, 0, uintptr(unsafe.Pointer(name))) if mutex == 0 { return fmt.Errorf("create single-instance mutex: %v", createErr) } defer windows.CloseHandle(windows.Handle(mutex)) if createErr == windows.ERROR_ALREADY_EXISTS { return fmt.Errorf("another instance is already running") } 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 }() callback := windows.NewCallback(winEventCallback) hook, _, callErr := procSetWinEventHook.Call(eventSystemMoveSizeStart, eventSystemMoveSizeEnd, 0, callback, 0, 0, wineventOutOfContext|wineventSkipOwnProcess) if hook == 0 { return fmt.Errorf("install window move hook: %v", callErr) } e.hook = windows.Handle(hook) defer procUnhookWinEvent.Call(hook) if err := e.registerHotkeys(); err != nil { return err } defer e.unregisterHotkeys() configTimer, _, configTimerErr := procSetTimer.Call(0, 1, 2000, 0) if configTimer == 0 { return fmt.Errorf("create configuration timer: %v", configTimerErr) } defer procKillTimer.Call(0, configTimer) shiftTimer, _, shiftTimerErr := procSetTimer.Call(0, 2, 25, 0) if shiftTimer == 0 { return fmt.Errorf("create drag activation timer: %v", shiftTimerErr) } defer procKillTimer.Call(0, shiftTimer) log.Printf("ready: layout=%q, Shift+drag snapping=%v, overlay=%v, %d hotkeys", cfg.ActiveLayout, cfg.ShiftDrag, cfg.Overlay.Enabled, len(e.hotkeys)) logMonitors() var msg message for { r, _, err := procGetMessage.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0) if int32(r) == -1 { return fmt.Errorf("message loop: %v", err) } if r == 0 || msg.Message == wmQuit { return nil } switch msg.Message { case wmHotkey: e.handleHotkey(int(msg.WParam)) case wmTimer: switch msg.WParam { case configTimer: e.reloadIfChanged() case shiftTimer: e.pollInput() } } procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg))) procDispatchMessage.Call(uintptr(unsafe.Pointer(&msg))) } } func winEventCallback(_, event, rawHWND, idObject, idChild, _, _ uintptr) uintptr { e := activeEngine hwnd := windows.Handle(rawHWND) if e == nil || hwnd == 0 || idObject != objectIDWindow || idChild != 0 { return 0 } switch event { case eventSystemMoveSizeStart: if e.canManage(hwnd) { e.fallbackHandled = 0 e.dragging = hwnd e.dragShift = keyDown(vkShift) e.debugf("move started: hwnd=0x%x app=%q shift=%v", uintptr(hwnd), executableForWindow(hwnd), e.dragShift) } else { e.debugf("move ignored: hwnd=0x%x app=%q is not a manageable top-level window", uintptr(hwnd), executableForWindow(hwnd)) } case eventSystemMoveSizeEnd: if e.dragging == hwnd { if e.fallbackHandled == hwnd { e.dragging = 0 e.dragShift = false e.fallbackHandled = 0 e.debugf("native move end arrived after mouse fallback: hwnd=0x%x", uintptr(hwnd)) break } activated := e.dragShift || keyDown(vkShift) e.dragging = 0 e.dragShift = false if e.mouseWindow == hwnd { e.mouseWindow = 0 // The native event path owns this drag. e.mouseMoved = false } e.mu.RLock() enabled := e.cfg.ShiftDrag e.mu.RUnlock() if enabled && activated { if ok, reason := e.snapAtCursor(hwnd); !ok { log.Printf("window was not snapped: %s", reason) } else { e.debugf("window snapped: hwnd=0x%x", uintptr(hwnd)) } } else { e.debugf("move ended without snapping: hwnd=0x%x shift_drag=%v shift=%v", uintptr(hwnd), enabled, activated) } } else { e.debugf("move end ignored: hwnd=0x%x did not match active drag 0x%x", uintptr(hwnd), uintptr(e.dragging)) } } return 0 } func (e *engine) pollInput() { if e.dragging != 0 { e.dragShift = keyDown(vkShift) } down := keyDown(vkLButton) switch { case down && !e.mouseDown: e.beginMouseTracking() case down && e.mouseDown: e.updateMouseTracking() case !down && e.mouseDown: e.endMouseTracking() } e.mouseDown = down e.updateOverlay() } func (e *engine) updateOverlay() { hide := func() { if e.overlay.hwnd != 0 { e.debugf("zone overlay hidden") } e.overlay.hide() } e.mu.RLock() cfg := e.cfg e.mu.RUnlock() activeDrag := e.mouseDown && (e.dragging != 0 || (e.mouseWindow != 0 && e.mouseMoved)) if !cfg.Overlay.Enabled || !activeDrag || !keyDown(vkShift) { hide() return } var p point if ok, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&p))); ok == 0 { hide() return } mon, _, _ := procMonitorFromPoint.Call(packPoint(p), monitorDefaultToNull) if mon == 0 { hide() return } mi, ok := monitorDetails(windows.Handle(mon)) if !ok { hide() return } selected, ok := cfg.LayoutFor(mi.device()) if !ok { hide() return } wasVisible := e.overlay.hwnd != 0 if err := e.overlay.show(windows.Handle(mon), toLayoutRect(mi.Work), selected.Zones, cfg.Gap, cfg.Overlay); err != nil { hide() if !e.overlayFailed { log.Printf("cannot show zone overlay: %v", err) e.overlayFailed = true } } else if !wasVisible { e.debugf("zone overlay shown: monitor=%s zones=%d", mi.device(), len(selected.Zones)) } } func (e *engine) beginMouseTracking() { var p point if ok, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&p))); ok == 0 { return } raw, _, _ := procWindowFromPoint.Call(packPoint(p)) if raw == 0 { e.debugf("mouse fallback: no window under press at (%d,%d)", p.X, p.Y) return } root, _, _ := procGetAncestor.Call(raw, gaRoot) if root == 0 { root = raw } hwnd := windows.Handle(root) if !e.canManage(hwnd) { e.debugf("mouse fallback: window under press is not manageable: hwnd=0x%x app=%q", uintptr(hwnd), executableForWindow(hwnd)) return } var start rect if ok, _, _ := procGetWindowRect.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&start))); ok == 0 { e.debugf("mouse fallback: GetWindowRect failed for hwnd=0x%x", uintptr(hwnd)) return } e.mouseWindow = hwnd e.mouseStart = start e.mouseMoved = false e.mouseShift = keyDown(vkShift) e.debugf("mouse fallback tracking started: hwnd=0x%x app=%q shift=%v", uintptr(hwnd), executableForWindow(hwnd), e.mouseShift) } func (e *engine) updateMouseTracking() { if e.mouseWindow == 0 { return } e.mouseShift = keyDown(vkShift) var current rect if ok, _, _ := procGetWindowRect.Call(uintptr(e.mouseWindow), uintptr(unsafe.Pointer(¤t))); ok != 0 && rectMoved(e.mouseStart, current) { e.mouseMoved = true } } func (e *engine) endMouseTracking() { hwnd := e.mouseWindow moved := e.mouseMoved activated := e.mouseShift || keyDown(vkShift) e.mouseWindow = 0 e.mouseMoved = false e.mouseShift = false if hwnd == 0 { return } if !moved { e.debugf("mouse fallback ignored click: hwnd=0x%x rectangle did not move", uintptr(hwnd)) return } e.mu.RLock() enabled := e.cfg.ShiftDrag e.mu.RUnlock() if !enabled || !activated { e.debugf("mouse fallback move ended without snapping: hwnd=0x%x shift_drag=%v shift=%v", uintptr(hwnd), enabled, activated) return } if e.dragging == hwnd { e.fallbackHandled = hwnd } if ok, reason := e.snapAtCursor(hwnd); !ok { log.Printf("window was not snapped by mouse fallback: %s", reason) } else { e.debugf("window snapped by mouse fallback: hwnd=0x%x", uintptr(hwnd)) } } func rectMoved(a, b rect) bool { const threshold = int32(2) return abs32(a.Left-b.Left) > threshold || abs32(a.Top-b.Top) > threshold || abs32(a.Right-b.Right) > threshold || abs32(a.Bottom-b.Bottom) > threshold } func abs32(v int32) int32 { if v < 0 { return -v } return v } func packPoint(p point) uintptr { return uintptr(uint32(p.X)) | uintptr(uint64(uint32(p.Y))<<32) } func (e *engine) snapAtCursor(hwnd windows.Handle) (bool, string) { var p point if ok, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&p))); ok == 0 { return false, "GetCursorPos failed" } mon, _, _ := procMonitorFromPoint.Call(packPoint(p), monitorDefaultToNull) if mon == 0 { return false, fmt.Sprintf("no monitor contains pointer (%d,%d)", p.X, p.Y) } mi, ok := monitorDetails(windows.Handle(mon)) if !ok { return false, "GetMonitorInfo failed" } e.mu.RLock() cfg := e.cfg e.mu.RUnlock() l, ok := cfg.LayoutFor(mi.device()) if !ok { return false, fmt.Sprintf("active layout %q has no entry for monitor %s and no wildcard fallback", cfg.ActiveLayout, mi.device()) } work := toLayoutRect(mi.Work) zi := layout.ZoneAt(work, l.Zones, 0, layout.Point{X: p.X, Y: p.Y}) if zi < 0 { return false, fmt.Sprintf("pointer (%d,%d) is outside every zone on %s", p.X, p.Y, mi.device()) } e.debugf("snap target: monitor=%s zone=%d name=%q pointer=(%d,%d)", mi.device(), zi+1, l.Zones[zi].Name, p.X, p.Y) if !e.moveTo(hwnd, work, l.Zones[zi], cfg.Gap) { return false, "SetWindowPos failed (the window may be elevated or may reject resizing)" } return true, "" } func (e *engine) handleHotkey(id int) { binding, ok := e.hotkeys[id] if !ok { return } h, _, _ := procGetForegroundWindow.Call() hwnd := windows.Handle(h) if !e.canManage(hwnd) { return } mon, _, _ := procMonitorFromWindow.Call(uintptr(hwnd), monitorDefaultToNearest) if mon == 0 { return } mi, ok := monitorDetails(windows.Handle(mon)) if !ok { return } e.mu.RLock() cfg := e.cfg e.mu.RUnlock() l, ok := cfg.LayoutFor(mi.device()) if !ok || len(l.Zones) == 0 { return } idx := -1 action := strings.ToLower(strings.TrimSpace(binding.action)) if strings.HasPrefix(action, "zone_") { n, err := strconv.Atoi(strings.TrimPrefix(action, "zone_")) if err == nil { idx = n - 1 } } else { var wr rect if r, _, _ := procGetWindowRect.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&wr))); r == 0 { return } center := layout.Point{X: (wr.Left + wr.Right) / 2, Y: (wr.Top + wr.Bottom) / 2} current := layout.Closest(toLayoutRect(mi.Work), l.Zones, center) if action == "next_zone" { idx = (current + 1) % len(l.Zones) } else { idx = (current - 1 + len(l.Zones)) % len(l.Zones) } } if idx >= 0 && idx < len(l.Zones) { e.moveTo(hwnd, toLayoutRect(mi.Work), l.Zones[idx], cfg.Gap) } } func (e *engine) moveTo(hwnd windows.Handle, work layout.Rect, z config.Zone, gap int) bool { r := layout.Resolve(work, z, gap) procShowWindow.Call(uintptr(hwnd), swRestore) r = outerRectForVisibleFrame(hwnd, r) ok, _, err := procSetWindowPos.Call(uintptr(hwnd), 0, uintptr(r.Left), uintptr(r.Top), uintptr(r.Right-r.Left), uintptr(r.Bottom-r.Top), swpNoZOrder|swpNoActivate|swpNoOwnerZOrder) if ok == 0 { log.Printf("cannot move window 0x%x: %v (it may be elevated)", uintptr(hwnd), err) return false } return true } func (e *engine) debugf(format string, args ...any) { if e.debug { log.Printf("debug: "+format, args...) } } // Windows 11 windows have an invisible resize border outside the DWM-rendered // frame. Expand the SetWindowPos rectangle so the visible frame, rather than // that hidden border, aligns with the configured zone. func outerRectForVisibleFrame(hwnd windows.Handle, target layout.Rect) layout.Rect { var outer, frame rect if ok, _, _ := procGetWindowRect.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&outer))); ok == 0 { return target } const dwmwaExtendedFrameBounds = 9 hr, _, _ := procDwmGetWindowAttribute.Call(uintptr(hwnd), dwmwaExtendedFrameBounds, uintptr(unsafe.Pointer(&frame)), unsafe.Sizeof(frame)) if int32(hr) < 0 { return target } return layout.Rect{ Left: target.Left - (frame.Left - outer.Left), Top: target.Top - (frame.Top - outer.Top), Right: target.Right + (outer.Right - frame.Right), Bottom: target.Bottom + (outer.Bottom - frame.Bottom), } } func (e *engine) canManage(hwnd windows.Handle) bool { if hwnd == 0 { return false } visible, _, _ := procIsWindowVisible.Call(uintptr(hwnd)) if visible == 0 { return false } root, _, _ := procGetAncestor.Call(uintptr(hwnd), gaRoot) if root != uintptr(hwnd) { return false } owner, _, _ := procGetWindow.Call(uintptr(hwnd), gwOwner) if owner != 0 { return false } style, _, _ := procGetWindowLongPtr.Call(uintptr(hwnd), ^uintptr(15)) if style&wsChild != 0 { return false } exe := executableForWindow(hwnd) e.mu.RLock() excluded := e.cfg.IsExcluded(exe) e.mu.RUnlock() return !excluded } func executableForWindow(hwnd windows.Handle) string { var pid uint32 procGetWindowThreadProcessID.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&pid))) if pid == 0 { return "" } h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) if err != nil { return "" } defer windows.CloseHandle(h) buf := make([]uint16, 32768) n := uint32(len(buf)) if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &n); err != nil { return "" } return windows.UTF16ToString(buf[:n]) } func monitorDetails(mon windows.Handle) (monitorInfoEx, bool) { mi := monitorInfoEx{Size: uint32(unsafe.Sizeof(monitorInfoEx{}))} ok, _, _ := procGetMonitorInfo.Call(uintptr(mon), uintptr(unsafe.Pointer(&mi))) return mi, ok != 0 } func logMonitors() { callback := windows.NewCallback(func(mon, _, _, _ uintptr) uintptr { if mi, ok := monitorDetails(windows.Handle(mon)); ok { log.Printf("monitor %s work-area=(%d,%d)-(%d,%d)", mi.device(), mi.Work.Left, mi.Work.Top, mi.Work.Right, mi.Work.Bottom) } return 1 }) procEnumDisplayMonitors.Call(0, 0, callback, 0) } func (m monitorInfoEx) device() string { return windows.UTF16ToString(m.Device[:]) } func toLayoutRect(r rect) layout.Rect { return layout.Rect{Left: r.Left, Top: r.Top, Right: r.Right, Bottom: r.Bottom} } func keyDown(vk uintptr) bool { r, _, _ := procGetAsyncKeyState.Call(vk); return r&0x8000 != 0 } func (e *engine) registerHotkeys() error { for i, h := range e.cfg.Hotkeys { mods, vk, err := parseHotkey(h.Keys) if err != nil { e.unregisterHotkeys() return fmt.Errorf("hotkey %q: %w", h.Keys, err) } id := i + 1 ok, _, callErr := procRegisterHotKey.Call(0, uintptr(id), uintptr(mods|modNoRepeat), uintptr(vk)) if ok == 0 { e.unregisterHotkeys() return fmt.Errorf("register hotkey %q (already in use?): %v", h.Keys, callErr) } e.hotkeys[id] = hotkeyBinding{id: id, action: h.Action, keys: h.Keys} } return nil } func (e *engine) unregisterHotkeys() { for id := range e.hotkeys { procUnregisterHotKey.Call(0, uintptr(id)) delete(e.hotkeys, id) } } func parseHotkey(s string) (uint32, uint32, error) { parts := strings.Split(strings.ToLower(strings.ReplaceAll(s, " ", "")), "+") var mods, vk uint32 regularKeys := 0 for _, p := range parts { switch p { case "win", "windows": mods |= modWin case "alt": mods |= modAlt case "ctrl", "control": mods |= modControl case "shift": mods |= modShift case "left": vk = 0x25 regularKeys++ case "up": vk = 0x26 regularKeys++ case "right": vk = 0x27 regularKeys++ case "down": vk = 0x28 regularKeys++ case "pageup", "pgup": vk = 0x21 regularKeys++ case "pagedown", "pgdn": vk = 0x22 regularKeys++ default: if len(p) == 1 && ((p[0] >= 'a' && p[0] <= 'z') || (p[0] >= '0' && p[0] <= '9')) { vk = uint32(strings.ToUpper(p)[0]) regularKeys++ } else { return 0, 0, fmt.Errorf("unknown key %q", p) } } } if mods == 0 || vk == 0 || regularKeys != 1 { return 0, 0, fmt.Errorf("must contain modifiers and exactly one regular key") } return mods, vk, nil } func (e *engine) rememberConfigTime() { if s, err := os.Stat(e.configPath); err == nil { e.configTime = s.ModTime() } } func (e *engine) reloadIfChanged() { s, err := os.Stat(e.configPath) if err != nil || !s.ModTime().After(e.configTime) { return } e.configTime = s.ModTime() cfg, err := config.Load(e.configPath) if err != nil { log.Printf("configuration reload rejected: %v", err) return } e.unregisterHotkeys() e.overlay.hide() e.overlayFailed = false e.mu.Lock() old := e.cfg e.cfg = cfg e.mu.Unlock() if err := e.registerHotkeys(); err != nil { log.Printf("configuration hotkeys rejected: %v; restoring previous configuration", err) e.mu.Lock() e.cfg = old e.mu.Unlock() _ = 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) }