feat: add portable Windows zone manager

This commit is contained in:
Steve Cliff
2026-08-20 08:57:41 +01:00
parent 318300ef79
commit 6ddecad74d
15 changed files with 1760 additions and 4 deletions
+179
View File
@@ -0,0 +1,179 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
type Config struct {
Version int `yaml:"version"`
Gap int `yaml:"gap"`
ShiftDrag bool `yaml:"shift_drag"`
Overlay Overlay `yaml:"overlay"`
Layouts []Layout `yaml:"layouts"`
Excluded []string `yaml:"excluded_apps"`
Hotkeys []Hotkey `yaml:"hotkeys"`
}
type Overlay struct {
Enabled bool `yaml:"enabled"`
Color string `yaml:"color"`
Opacity int `yaml:"opacity"`
BorderWidth int `yaml:"border_width"`
}
type Layout struct {
Monitor string `yaml:"monitor"`
Zones []Zone `yaml:"zones"`
}
type Zone struct {
Name string `yaml:"name"`
X float64 `yaml:"x"`
Y float64 `yaml:"y"`
Width float64 `yaml:"width"`
Height float64 `yaml:"height"`
}
type Hotkey struct {
Keys string `yaml:"keys"`
Action string `yaml:"action"`
}
func Default() Config {
return Config{
Version: 1, Gap: 8, ShiftDrag: true,
Overlay: Overlay{Enabled: true, Color: "#00AEEF", Opacity: 90, BorderWidth: 3},
Layouts: []Layout{{Monitor: "*", Zones: []Zone{
{Name: "left", X: 0, Y: 0, Width: 50, Height: 100},
{Name: "right", X: 50, Y: 0, Width: 50, Height: 100},
}}},
Hotkeys: []Hotkey{
{Keys: "win+alt+left", Action: "previous_zone"},
{Keys: "win+alt+right", Action: "next_zone"},
},
}
}
func Load(path string) (Config, error) {
f, err := os.Open(path)
if err != nil {
return Config{}, err
}
defer f.Close()
var c Config
d := yaml.NewDecoder(f)
d.KnownFields(true)
if err := d.Decode(&c); err != nil {
return Config{}, fmt.Errorf("parse YAML: %w", err)
}
if err := c.Validate(); err != nil {
return Config{}, err
}
return c, nil
}
func (c Config) Validate() error {
if c.Version != 1 {
return fmt.Errorf("version must be 1 (got %d)", c.Version)
}
if c.Gap < 0 || c.Gap > 500 {
return errors.New("gap must be between 0 and 500 pixels")
}
if c.Overlay.Enabled {
if !validHexColor(c.Overlay.Color) {
return errors.New("overlay.color must use #RRGGBB format")
}
if c.Overlay.Opacity < 1 || c.Overlay.Opacity > 100 {
return errors.New("overlay.opacity must be between 1 and 100")
}
if c.Overlay.BorderWidth < 1 || c.Overlay.BorderWidth > 20 {
return errors.New("overlay.border_width must be between 1 and 20 pixels")
}
}
if len(c.Layouts) == 0 {
return errors.New("at least one layout is required")
}
seenMonitors := map[string]bool{}
for li, l := range c.Layouts {
name := strings.TrimSpace(l.Monitor)
if name == "" {
return fmt.Errorf("layouts[%d].monitor is required", li)
}
key := strings.ToLower(name)
if seenMonitors[key] {
return fmt.Errorf("duplicate layout for monitor %q", name)
}
seenMonitors[key] = true
if len(l.Zones) == 0 {
return fmt.Errorf("layout %q needs at least one zone", name)
}
for zi, z := range l.Zones {
if z.Width <= 0 || z.Height <= 0 || z.X < 0 || z.Y < 0 || z.X+z.Width > 100.000001 || z.Y+z.Height > 100.000001 {
return fmt.Errorf("layout %q zone %d must fit within the 0..100 percent work area", name, zi+1)
}
}
}
for i, h := range c.Hotkeys {
a := strings.ToLower(strings.TrimSpace(h.Action))
if a != "next_zone" && a != "previous_zone" && !strings.HasPrefix(a, "zone_") {
return fmt.Errorf("hotkeys[%d].action must be next_zone, previous_zone, or zone_N", i)
}
if strings.HasPrefix(a, "zone_") {
n, err := strconv.Atoi(strings.TrimPrefix(a, "zone_"))
if err != nil || n < 1 {
return fmt.Errorf("hotkeys[%d].action must use a positive zone number", i)
}
}
if strings.TrimSpace(h.Keys) == "" {
return fmt.Errorf("hotkeys[%d].keys is required", i)
}
}
return nil
}
func validHexColor(s string) bool {
if len(s) != 7 || s[0] != '#' {
return false
}
_, err := strconv.ParseUint(s[1:], 16, 24)
return err == nil
}
func DefaultPath(exePath string) string {
return filepath.Join(filepath.Dir(exePath), "fancywin.yaml")
}
func (c Config) LayoutFor(device string) (Layout, bool) {
for _, l := range c.Layouts {
monitor := strings.TrimSpace(l.Monitor)
if monitor != "*" && strings.EqualFold(monitor, strings.TrimSpace(device)) {
return l, true
}
}
for _, l := range c.Layouts {
if strings.TrimSpace(l.Monitor) == "*" {
return l, true
}
}
return Layout{}, false
}
func (c Config) IsExcluded(exe string) bool {
// Windows paths may be validated on another OS during cross-compilation.
normalized := strings.ReplaceAll(exe, `\`, "/")
base := strings.ToLower(normalized[strings.LastIndex(normalized, "/")+1:])
for _, item := range c.Excluded {
needle := strings.ToLower(strings.TrimSpace(item))
if needle != "" && (base == needle || (!strings.Contains(needle, ".") && strings.Contains(base, needle))) {
return true
}
}
return false
}
+45
View File
@@ -0,0 +1,45 @@
package config
import "testing"
func TestDefaultValid(t *testing.T) {
if err := Default().Validate(); err != nil {
t.Fatal(err)
}
}
func TestLayoutFallback(t *testing.T) {
c := Default()
if _, ok := c.LayoutFor(`\\.\DISPLAY99`); !ok {
t.Fatal("expected wildcard layout")
}
}
func TestExcluded(t *testing.T) {
c := Default()
c.Excluded = []string{"notepad", "exact.exe"}
if !c.IsExcluded(`C:\Windows\notepad.exe`) || !c.IsExcluded(`C:\x\exact.exe`) || c.IsExcluded(`C:\x\other.exe`) {
t.Fatal("bad exclusion matching")
}
}
func TestInvalidNumberedAction(t *testing.T) {
c := Default()
c.Hotkeys = []Hotkey{{Keys: "win+alt+1", Action: "zone_nope"}}
if err := c.Validate(); err == nil {
t.Fatal("expected invalid numbered action to fail")
}
}
func TestOverlayValidation(t *testing.T) {
c := Default()
c.Overlay.Color = "blue"
if err := c.Validate(); err == nil {
t.Fatal("expected invalid overlay color to fail")
}
c = Default()
c.Overlay.Opacity = 101
if err := c.Validate(); err == nil {
t.Fatal("expected invalid overlay opacity to fail")
}
}
+58
View File
@@ -0,0 +1,58 @@
package layout
import (
"math"
"github.com/stevec/fancywin/internal/config"
)
type Rect struct{ Left, Top, Right, Bottom int32 }
type Point struct{ X, Y int32 }
func Resolve(work Rect, z config.Zone, gap int) Rect {
w, h := float64(work.Right-work.Left), float64(work.Bottom-work.Top)
r := Rect{
Left: work.Left + int32(math.Round(w*z.X/100)),
Top: work.Top + int32(math.Round(h*z.Y/100)),
Right: work.Left + int32(math.Round(w*(z.X+z.Width)/100)),
Bottom: work.Top + int32(math.Round(h*(z.Y+z.Height)/100)),
}
g := int32(gap)
r.Left += g
r.Top += g
r.Right -= g
r.Bottom -= g
if r.Right <= r.Left {
r.Right = r.Left + 1
}
if r.Bottom <= r.Top {
r.Bottom = r.Top + 1
}
return r
}
func Contains(r Rect, p Point) bool {
return p.X >= r.Left && p.X < r.Right && p.Y >= r.Top && p.Y < r.Bottom
}
func ZoneAt(work Rect, zones []config.Zone, gap int, p Point) int {
for i, z := range zones {
if Contains(Resolve(work, z, gap), p) {
return i
}
}
return -1
}
func Closest(work Rect, zones []config.Zone, p Point) int {
best, bestD := -1, math.MaxFloat64
for i, z := range zones {
r := Resolve(work, z, 0)
cx, cy := float64(r.Left+r.Right)/2, float64(r.Top+r.Bottom)/2
dx, dy := float64(p.X)-cx, float64(p.Y)-cy
if d := dx*dx + dy*dy; d < bestD {
best, bestD = i, d
}
}
return best
}
+21
View File
@@ -0,0 +1,21 @@
package layout
import (
"github.com/stevec/fancywin/internal/config"
"testing"
)
func TestResolvePercentageAndGap(t *testing.T) {
got := Resolve(Rect{0, 0, 1920, 1040}, config.Zone{X: 50, Y: 0, Width: 50, Height: 100}, 8)
want := (Rect{968, 8, 1912, 1032})
if got != want {
t.Fatalf("got %+v, want %+v", got, want)
}
}
func TestZoneAt(t *testing.T) {
z := []config.Zone{{X: 0, Y: 0, Width: 50, Height: 100}, {X: 50, Y: 0, Width: 50, Height: 100}}
if got := ZoneAt(Rect{0, 0, 100, 100}, z, 0, Point{75, 20}); got != 1 {
t.Fatalf("got %d", got)
}
}
+300
View File
@@ -0,0 +1,300 @@
//go:build windows
package platform
import (
"fmt"
"strconv"
"strings"
"sync"
"unsafe"
"github.com/stevec/fancywin/internal/config"
"github.com/stevec/fancywin/internal/layout"
"golang.org/x/sys/windows"
)
const (
wmPaint = 0x000F
wmEraseBackground = 0x0014
wmNcHitTest = 0x0084
wsPopup = 0x80000000
wsExTopmost = 0x00000008
wsExTransparent = 0x00000020
wsExToolWindow = 0x00000080
wsExLayered = 0x00080000
wsExNoActivate = 0x08000000
lwaColorKey = 0x00000001
lwaAlpha = 0x00000002
hwndTopmost = ^uintptr(0)
swpShowWindow = 0x0040
psSolid = 0
nullBrush = 5
bkModeTransparent = 1
dtCenter = 0x00000001
dtVCenter = 0x00000004
dtSingleLine = 0x00000020
dtEndEllipsis = 0x00008000
fontWeightBold = 700
nonAntialiased = 3
overlayColorKey = 0x00010001 // RGB(1,0,1), made fully transparent.
)
var (
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
procRegisterClassEx = user32.NewProc("RegisterClassExW")
procCreateWindowEx = user32.NewProc("CreateWindowExW")
procDestroyWindow = user32.NewProc("DestroyWindow")
procDefWindowProc = user32.NewProc("DefWindowProcW")
procBeginPaint = user32.NewProc("BeginPaint")
procEndPaint = user32.NewProc("EndPaint")
procGetClientRect = user32.NewProc("GetClientRect")
procFillRect = user32.NewProc("FillRect")
procDrawText = user32.NewProc("DrawTextW")
procInvalidateRect = user32.NewProc("InvalidateRect")
procUpdateWindow = user32.NewProc("UpdateWindow")
procSetLayeredWindowAttributes = user32.NewProc("SetLayeredWindowAttributes")
procGetModuleHandle = kernel32.NewProc("GetModuleHandleW")
procCreateSolidBrush = gdi32.NewProc("CreateSolidBrush")
procCreatePen = gdi32.NewProc("CreatePen")
procSelectObject = gdi32.NewProc("SelectObject")
procDeleteObject = gdi32.NewProc("DeleteObject")
procRectangle = gdi32.NewProc("Rectangle")
procSetBkMode = gdi32.NewProc("SetBkMode")
procSetTextColor = gdi32.NewProc("SetTextColor")
procCreateFont = gdi32.NewProc("CreateFontW")
procGetStockObject = gdi32.NewProc("GetStockObject")
overlayClassName, _ = windows.UTF16PtrFromString("FancyWinZoneOverlay")
overlayWindowProc = windows.NewCallback(overlayWndProc)
overlayViews = struct {
sync.RWMutex
items map[windows.Handle]*overlayView
}{items: make(map[windows.Handle]*overlayView)}
)
type windowClassEx struct {
Size uint32
Style uint32
WndProc uintptr
ClsExtra int32
WndExtra int32
Instance windows.Handle
Icon windows.Handle
Cursor windows.Handle
Background windows.Handle
MenuName *uint16
ClassName *uint16
IconSmall windows.Handle
}
type paintStruct struct {
DC windows.Handle
Erase int32
Paint rect
Restore int32
IncUpdate int32
Reserved [32]byte
}
type overlayView struct {
hwnd windows.Handle
work layout.Rect
zones []config.Zone
gap int
style config.Overlay
}
type overlayManager struct {
classReady bool
hwnd windows.Handle
monitor windows.Handle
signature string
}
func (m *overlayManager) show(mon windows.Handle, work layout.Rect, zones []config.Zone, gap int, style config.Overlay) error {
signature := overlaySignature(mon, work, zones, gap, style)
if m.hwnd != 0 && m.signature == signature {
return nil
}
m.hide()
if err := m.ensureClass(); err != nil {
return err
}
instance, _, instanceErr := procGetModuleHandle.Call(0)
if instance == 0 {
return fmt.Errorf("GetModuleHandleW: %v", instanceErr)
}
w, h := work.Right-work.Left, work.Bottom-work.Top
raw, _, createErr := procCreateWindowEx.Call(
wsExTopmost|wsExTransparent|wsExToolWindow|wsExLayered|wsExNoActivate,
uintptr(unsafe.Pointer(overlayClassName)),
uintptr(unsafe.Pointer(overlayClassName)),
wsPopup,
uintptr(work.Left), uintptr(work.Top), uintptr(w), uintptr(h),
0, 0, instance, 0,
)
if raw == 0 {
return fmt.Errorf("CreateWindowExW: %v", createErr)
}
hwnd := windows.Handle(raw)
view := &overlayView{hwnd: hwnd, work: work, zones: append([]config.Zone(nil), zones...), gap: gap, style: style}
overlayViews.Lock()
overlayViews.items[hwnd] = view
overlayViews.Unlock()
alpha := byte((style.Opacity*255 + 50) / 100)
if ok, _, err := procSetLayeredWindowAttributes.Call(raw, overlayColorKey, uintptr(alpha), lwaColorKey|lwaAlpha); ok == 0 {
m.destroy(hwnd)
return fmt.Errorf("SetLayeredWindowAttributes: %v", err)
}
procInvalidateRect.Call(raw, 0, 1)
procUpdateWindow.Call(raw)
if ok, _, err := procSetWindowPos.Call(raw, hwndTopmost, uintptr(work.Left), uintptr(work.Top), uintptr(w), uintptr(h), swpNoActivate|swpNoOwnerZOrder|swpShowWindow); ok == 0 {
m.destroy(hwnd)
return fmt.Errorf("show overlay: %v", err)
}
m.hwnd = hwnd
m.monitor = mon
m.signature = signature
return nil
}
func (m *overlayManager) hide() {
if m.hwnd != 0 {
m.destroy(m.hwnd)
}
m.hwnd = 0
m.monitor = 0
m.signature = ""
}
func (m *overlayManager) destroy(hwnd windows.Handle) {
overlayViews.Lock()
delete(overlayViews.items, hwnd)
overlayViews.Unlock()
procDestroyWindow.Call(uintptr(hwnd))
}
func (m *overlayManager) ensureClass() error {
if m.classReady {
return nil
}
instance, _, instanceErr := procGetModuleHandle.Call(0)
if instance == 0 {
return fmt.Errorf("GetModuleHandleW: %v", instanceErr)
}
wc := windowClassEx{
Size: uint32(unsafe.Sizeof(windowClassEx{})),
WndProc: overlayWindowProc,
Instance: windows.Handle(instance),
ClassName: overlayClassName,
}
if atom, _, err := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&wc))); atom == 0 {
return fmt.Errorf("RegisterClassExW: %v", err)
}
m.classReady = true
return nil
}
func overlayWndProc(rawHWND, message, wParam, lParam uintptr) uintptr {
hwnd := windows.Handle(rawHWND)
switch message {
case wmNcHitTest:
return ^uintptr(0) // HTTRANSPARENT: always pass pointer input through.
case wmEraseBackground:
return 1
case wmPaint:
overlayViews.RLock()
view := overlayViews.items[hwnd]
overlayViews.RUnlock()
if view != nil {
view.paint()
return 0
}
}
r, _, _ := procDefWindowProc.Call(rawHWND, message, wParam, lParam)
return r
}
func (v *overlayView) paint() {
var ps paintStruct
dc, _, _ := procBeginPaint.Call(uintptr(v.hwnd), uintptr(unsafe.Pointer(&ps)))
if dc == 0 {
return
}
defer procEndPaint.Call(uintptr(v.hwnd), uintptr(unsafe.Pointer(&ps)))
var client rect
procGetClientRect.Call(uintptr(v.hwnd), uintptr(unsafe.Pointer(&client)))
background, _, _ := procCreateSolidBrush.Call(overlayColorKey)
if background != 0 {
procFillRect.Call(dc, uintptr(unsafe.Pointer(&client)), background)
procDeleteObject.Call(background)
}
color := colorRef(v.style.Color)
pen, _, _ := procCreatePen.Call(psSolid, uintptr(v.style.BorderWidth), uintptr(color))
nullFill, _, _ := procGetStockObject.Call(nullBrush)
oldPen, _, _ := procSelectObject.Call(dc, pen)
oldBrush, _, _ := procSelectObject.Call(dc, nullFill)
defer func() {
procSelectObject.Call(dc, oldPen)
procSelectObject.Call(dc, oldBrush)
if pen != 0 {
procDeleteObject.Call(pen)
}
}()
fontName, _ := windows.UTF16PtrFromString("Segoe UI")
font, _, _ := procCreateFont.Call(^uintptr(55), 0, 0, 0, fontWeightBold, 0, 0, 0, 1, 0, 0, nonAntialiased, 0, uintptr(unsafe.Pointer(fontName)))
oldFont := uintptr(0)
if font != 0 {
oldFont, _, _ = procSelectObject.Call(dc, font)
defer func() { procSelectObject.Call(dc, oldFont); procDeleteObject.Call(font) }()
}
procSetBkMode.Call(dc, bkModeTransparent)
for i, zone := range v.zones {
r := layout.Resolve(v.work, zone, v.gap)
r.Left -= v.work.Left
r.Right -= v.work.Left
r.Top -= v.work.Top
r.Bottom -= v.work.Top
procRectangle.Call(dc, uintptr(r.Left), uintptr(r.Top), uintptr(r.Right), uintptr(r.Bottom))
name := strings.TrimSpace(zone.Name)
if name == "" {
name = fmt.Sprintf("Zone %d", i+1)
}
text, err := windows.UTF16FromString(name)
if err != nil || len(text) == 0 {
continue
}
shadow := rect{Left: r.Left + 2, Top: r.Top + 2, Right: r.Right + 2, Bottom: r.Bottom + 2}
procSetTextColor.Call(dc, 0x00000000)
procDrawText.Call(dc, uintptr(unsafe.Pointer(&text[0])), ^uintptr(0), uintptr(unsafe.Pointer(&shadow)), dtCenter|dtVCenter|dtSingleLine|dtEndEllipsis)
label := rect{Left: r.Left, Top: r.Top, Right: r.Right, Bottom: r.Bottom}
procSetTextColor.Call(dc, uintptr(color))
procDrawText.Call(dc, uintptr(unsafe.Pointer(&text[0])), ^uintptr(0), uintptr(unsafe.Pointer(&label)), dtCenter|dtVCenter|dtSingleLine|dtEndEllipsis)
}
}
func colorRef(value string) uint32 {
rgb, err := strconv.ParseUint(strings.TrimPrefix(value, "#"), 16, 24)
if err != nil {
return 0x00EFAE00
}
r := uint32(rgb >> 16)
g := uint32((rgb >> 8) & 0xff)
b := uint32(rgb & 0xff)
return r | g<<8 | b<<16
}
func overlaySignature(mon windows.Handle, work layout.Rect, zones []config.Zone, gap int, style config.Overlay) string {
var b strings.Builder
fmt.Fprintf(&b, "%x|%v|%d|%v", uintptr(mon), work, gap, style)
for _, zone := range zones {
fmt.Fprintf(&b, "|%s:%g:%g:%g:%g", zone.Name, zone.X, zone.Y, zone.Width, zone.Height)
}
return b.String()
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package platform
import (
"errors"
"github.com/stevec/fancywin/internal/config"
)
func Run(_ string, _ config.Config, _ bool) error {
return errors.New("the window manager can only run on Windows")
}
+694
View File
@@ -0,0 +1,694 @@
//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")
procDwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute")
)
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
}
var activeEngine *engine
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()
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: Shift+drag snapping=%v, overlay=%v, %d hotkeys", 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(&current))); 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("no layout matches monitor %s and no wildcard layout exists", 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
}
log.Printf("configuration reloaded")
}