diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee46411 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/dist/ +*.exe diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..879ba8d --- /dev/null +++ b/BUILD.md @@ -0,0 +1,276 @@ +# Building and developing FancyWin + +This document covers source builds, cross-compilation, verification, release +artifacts, dependencies, and the Windows implementation. End-user instructions +are in [README.md](README.md). + +## Requirements + +- Go 1.24 or later +- PowerShell for the supplied Windows build script, or a POSIX shell for manual + cross-compilation +- Windows 11 for runtime integration testing + +The project uses pure Go and sets `CGO_ENABLED=0`. No C compiler, Windows SDK, +.NET SDK, Visual Studio, or PowerToys checkout is required. + +## Repository layout + +```text +cmd/fancywin/ CLI entry point +internal/config/ YAML model, loading, and validation +internal/layout/ Platform-neutral zone geometry +internal/platform/platform_windows.go Win32 backend +internal/platform/platform_other.go Non-Windows diagnostic stub +fancywin.example.yaml Example user configuration +build.ps1 Native Windows build script +dist/ Generated portable artifacts +``` + +## Dependencies + +Runtime functionality uses the Windows system DLLs available with Windows 11. +There are two pinned Go module dependencies: + +- `golang.org/x/sys/windows` for Windows handles, callbacks, process queries, + and UTF-16 helpers +- `gopkg.in/yaml.v3` for strict YAML parsing + +Both modules are statically linked into the executable. Exact versions and +checksums are recorded in `go.mod` and `go.sum`. + +## Native Windows build + +From PowerShell in the repository root: + +```powershell +.\build.ps1 +``` + +The script: + +1. Selects `amd64` or `arm64` from `PROCESSOR_ARCHITECTURE`. +2. Sets `GOOS=windows` and disables CGO. +3. Runs the complete Go test suite. +4. Builds a stripped, reproducible-path executable. +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. + +## Cross-compiling + +Build Windows x64 from Linux, macOS, or another Go-supported host: + +```sh +mkdir -p dist +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build \ + -buildvcs=false \ + -trimpath \ + -ldflags='-s -w' \ + -o dist/fancywin.exe \ + ./cmd/fancywin +cp fancywin.example.yaml dist/fancywin.yaml +``` + +For Windows on ARM: + +```sh +CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build \ + -buildvcs=false \ + -trimpath \ + -ldflags='-s -w' \ + -o dist/fancywin-arm64.exe \ + ./cmd/fancywin +``` + +`-trimpath` removes local source paths. `-s -w` removes symbol and DWARF tables +to reduce the portable binary size. `-buildvcs=false` permits builds from source +archives or workspaces without complete Git metadata. + +## Verification + +Run platform-neutral tests and race detection on the development host: + +```sh +go test -buildvcs=false ./... +go test -buildvcs=false -race ./... +``` + +Validate Windows-only code without executing it: + +```sh +GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go vet -buildvcs=false ./... +GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -buildvcs=false ./cmd/fancywin +GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -buildvcs=false ./cmd/fancywin +``` + +Validate the example configuration on any supported development host: + +```sh +go run -buildvcs=false ./cmd/fancywin \ + -config fancywin.example.yaml \ + -check +``` + +Cross-compilation proves that the Windows API bindings type-check; it does not +replace runtime testing on a Windows desktop. + +## Windows smoke-test checklist + +Test both x64 and ARM64 where hardware is available: + +1. Start FancyWin without elevation and confirm the monitor names and work areas + are printed. +2. Confirm a second instance exits with a single-instance error. +3. Shift-drag Win32, UWP/Windows App SDK, and Chromium-based windows into each + zone. +4. Exercise all configured hotkey actions, including wrap-around. +5. Test monitors with negative virtual-screen coordinates, different DPI + scaling, portrait orientation, and taskbars on different edges. +6. Maximize a window and then move it by hotkey, checking visible-frame + alignment. +7. Edit valid YAML and confirm it reloads within roughly two seconds. +8. Introduce invalid YAML and confirm the previous configuration stays active. +9. Verify exact and substring exclusions. +10. Confirm a normal process cannot move an elevated application and that a + same-integrity elevated run can do so. +11. Disconnect and reconnect a monitor and confirm current work-area information + is used for subsequent moves. + +## Architecture + +### Process model + +FancyWin is a single process and a single portable executable. The main +goroutine is locked to its OS thread because Windows delivers the out-of-context +accessibility callback and thread hotkey messages through that thread's message +queue. The program does not inject code into other processes or install a hook +DLL, service, shell extension, or driver. + +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 + `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 + for sampling the mouse button, Shift, and active drag state. +6. Enters a standard Win32 `GetMessage` loop. + +### Mouse snapping + +The move-start callback records a manageable top-level window. On move-end, the +backend uses the most recently sampled asynchronous Shift-key state, obtains the +pointer's monitor, resolves the applicable layout, and finds the first zone +containing the pointer. Sampling throughout the drag avoids losing activation +when Windows delivers the move-end accessibility event just after a key-state +transition. The backend then converts that percentage zone into the monitor work +area's physical coordinates and calls `SetWindowPos`. + +The configured gap affects the destination rectangle but not zone hit testing. +This avoids dead pointer strips between adjacent zones. + +Some Windows environments do not deliver the move/size accessibility events +reliably. The 25 ms input timer provides an independent fallback: at the initial +left-button press it records the manageable top-level window beneath the +pointer, observes whether that window's rectangle actually changes while the +button remains down, tracks Shift, and snaps on release. Ordinary clicks are +discarded because the recorded rectangle did not move. Coordination state +prevents the accessibility and polling paths from snapping the same drag twice. + +### Keyboard snapping + +`RegisterHotKey` posts `WM_HOTKEY` to the message-loop thread. The backend obtains +the foreground window and its nearest monitor. Direct `zone_N` actions use a +one-based index. Previous/next actions find the zone whose center is closest to +the current window center and wrap through the monitor's ordered zone list. + +`MOD_NOREPEAT` prevents a held shortcut from generating repeated moves. + +### Coordinates and visible frames + +Zone percentages are resolved against `MONITORINFOEX.rcWork`, not the complete +monitor rectangle, so taskbar and app-bar space is excluded. Per-monitor-v2 DPI +awareness keeps pointer, monitor, and window coordinates in one physical-pixel +coordinate system across mixed-DPI displays. + +Windows 11 commonly includes an invisible resize border in `GetWindowRect`. +Before moving a window, FancyWin compares that rectangle with +`DWMWA_EXTENDED_FRAME_BOUNDS` and expands the outer `SetWindowPos` rectangle so +the visible frame aligns with the requested zone. + +### Overlay rendering + +The overlay is implemented directly with User32 and GDI. While an active mouse +drag and Shift are both detected, FancyWin creates one borderless popup window +covering the usable work area of the monitor beneath the pointer. The window +uses `WS_EX_LAYERED`, `WS_EX_TRANSPARENT`, `WS_EX_TOOLWINDOW`, +`WS_EX_NOACTIVATE`, and `WS_EX_TOPMOST` so it remains visible without receiving +input, taking focus, or appearing in Alt+Tab. + +The background is painted with a color key made transparent by +`SetLayeredWindowAttributes`. GDI then draws the gap-adjusted zone rectangles +and centered Segoe UI labels in the configured color and opacity. The overlay is +destroyed when Shift or the left mouse button is released. Moving the pointer to +another monitor recreates it with that monitor's work area and selected layout. + +### Window filtering and permissions + +The backend ignores invisible windows, child windows, owned windows, and +configured executable exclusions. Process names are obtained with +`PROCESS_QUERY_LIMITED_INFORMATION` and `QueryFullProcessImageName`. + +Windows User Interface Privilege Isolation prevents a lower-integrity process +from reliably controlling a higher-integrity window. FancyWin does not attempt +to bypass that boundary or request elevation through a manifest. + +### Configuration reload + +The YAML decoder rejects unknown fields. Semantic validation checks the schema +version, gap range, unique monitor entries, zone bounds, hotkey action syntax, +and required values. + +The message-loop timer checks the configuration modification time. A valid new +configuration replaces the active one and re-registers hotkeys. If parsing, +validation, or hotkey registration fails, the previous valid configuration and +hotkeys are restored. + +## Current scope + +The current backend intentionally excludes: + +- A visual layout editor +- Low-level keyboard interception of Windows-reserved snap shortcuts +- Multi-zone selection and expansion +- Moving windows across monitors as part of next/previous traversal +- Persistence of application-to-zone history +- Automatic placement of newly created windows +- Virtual-desktop-specific layouts +- Layout switching shortcuts +- Rounded-corner control + +These should be treated as separate features because several require additional +state, UI surfaces, or lower-level input handling. + +## Release preparation + +For a distributable release: + +1. Run all tests, vet, both architecture builds, and the Windows smoke-test + checklist. +2. Update the `version` constant in `cmd/fancywin/main.go`. +3. Build x64 and ARM64 artifacts with `CGO_ENABLED=0`, `-trimpath`, and + `-ldflags='-s -w'`. +4. Include an example renamed to `fancywin.yaml` beside each executable. +5. Record SHA-256 hashes with `Get-FileHash` or `sha256sum`. +6. Code-sign public binaries if a trusted signing certificate and release + process are available. Signing is not required for portability, but unsigned + downloads may receive stronger Microsoft Defender SmartScreen warnings. + +PowerToys was used as behavioral and architectural reference material only. +FancyWin is an independent implementation and contains no copied PowerToys +source code. diff --git a/README.md b/README.md index b4d1057..a77bde9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ menu entry, or registry settings. ## What FancyWin does - Snaps a dragged window into the zone beneath the mouse pointer. +- Shows a transparent, click-through guide with every zone's outline and name + while Shift-dragging. - Moves the focused window between zones with keyboard shortcuts. - Supports different layouts for different monitors. - Uses percentages, so layouts adapt to resolution, orientation, display @@ -17,10 +19,10 @@ menu entry, or registry settings. - Reloads its configuration automatically after the YAML file changes. - Lets you exclude applications that should never be moved. -FancyWin is a window-moving backend rather than a complete PowerToys -replacement. It does not display a zone overlay or include a visual layout -editor. It also does not currently remember application positions, combine -multiple zones, or assign layouts per virtual desktop. +FancyWin is a focused window manager rather than a complete PowerToys +replacement. It does not include a visual layout editor and does not currently +remember application positions, combine multiple zones, or assign layouts per +virtual desktop. ## Getting started @@ -78,6 +80,12 @@ version: 1 gap: 8 shift_drag: true +overlay: + enabled: true + color: "#00AEEF" + opacity: 90 + border_width: 3 + layouts: - monitor: "*" zones: @@ -118,10 +126,35 @@ use. | `version` | Configuration format. This must currently be `1`. | | `gap` | Inward spacing, in pixels, applied to every edge of every zone. Valid range: `0`–`500`. | | `shift_drag` | Enables or disables mouse snapping when Shift is held at the end of a drag. | +| `overlay` | Controls the transparent zone guide shown during Shift-dragging. | | `layouts` | One or more monitor layouts containing zones. | | `excluded_apps` | Applications FancyWin must not move. May be empty. | | `hotkeys` | Global keyboard shortcuts. May be empty. | +### Zone overlay + +The overlay appears after a window has begun moving and Shift is held. It shows +all zones on the monitor beneath the pointer, using the same gap-adjusted +rectangles that windows snap into. Each outline contains the zone's `name`, or +`Zone N` when its name is empty. Releasing Shift or the mouse button immediately +hides it. + +```yaml +overlay: + enabled: true + color: "#00AEEF" + opacity: 90 + border_width: 3 +``` + +- `enabled` turns the guide on or off. +- `color` is an outline and label color in `#RRGGBB` format. +- `opacity` is from `1` to `100` percent. +- `border_width` is from `1` to `20` physical pixels. + +The guide is transparent between its outlines, does not receive mouse input, +does not take keyboard focus, and does not appear in Alt+Tab. + ### Zone coordinates Each zone is a rectangle described as percentages of the monitor's usable work @@ -296,6 +329,14 @@ borders, but it cannot override size rules imposed by the application itself. 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. +### The zone overlay does not appear + +- Confirm `overlay.enabled` and `shift_drag` are both `true`. +- Begin moving the window before expecting the guide; a stationary Shift-click + is intentionally ignored. +- Keep Shift held during the drag. +- Check the console for `cannot show zone overlay` errors. + ## Project relationship FancyWin is an independent implementation and does not contain Microsoft diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..ffd179f --- /dev/null +++ b/build.ps1 @@ -0,0 +1,11 @@ +$ErrorActionPreference = "Stop" +$env:CGO_ENABLED = "0" +$env:GOOS = "windows" +$env:GOARCH = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "amd64" } + +New-Item -ItemType Directory -Force -Path dist | Out-Null +go test -buildvcs=false ./... +go build -buildvcs=false -trimpath -ldflags="-s -w" -o dist/fancywin.exe ./cmd/fancywin +Copy-Item fancywin.example.yaml dist/fancywin.yaml + +Write-Host "Built dist/fancywin.exe for windows/$env:GOARCH" diff --git a/cmd/fancywin/main.go b/cmd/fancywin/main.go new file mode 100644 index 0000000..4e84a94 --- /dev/null +++ b/cmd/fancywin/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/stevec/fancywin/internal/config" + "github.com/stevec/fancywin/internal/platform" + "gopkg.in/yaml.v3" +) + +const version = "0.2.1" + +func main() { + exe, err := os.Executable() + if err != nil { + fatal(err) + } + configPath := flag.String("config", config.DefaultPath(exe), "path to YAML configuration") + check := flag.Bool("check", false, "validate configuration and exit") + printDefault := flag.Bool("print-default", false, "print a default configuration and exit") + showVersion := flag.Bool("version", false, "print version and exit") + debug := flag.Bool("debug", false, "log window move and snap diagnostics") + flag.Parse() + + if *showVersion { + fmt.Println("fancywin", version) + return + } + if *printDefault { + b, err := yaml.Marshal(config.Default()) + if err != nil { + fatal(err) + } + _, _ = os.Stdout.Write(b) + return + } + cfg, err := config.Load(*configPath) + if err != nil { + fatal(fmt.Errorf("%s: %w", filepath.Clean(*configPath), err)) + } + if *check { + fmt.Printf("configuration OK: %s\n", filepath.Clean(*configPath)) + return + } + fmt.Printf("fancywin %s using %s\n", version, filepath.Clean(*configPath)) + if err := platform.Run(*configPath, cfg, *debug); err != nil { + fatal(err) + } +} + +func fatal(err error) { fmt.Fprintln(os.Stderr, "fancywin:", err); os.Exit(1) } diff --git a/fancywin.example.yaml b/fancywin.example.yaml new file mode 100644 index 0000000..65f9793 --- /dev/null +++ b/fancywin.example.yaml @@ -0,0 +1,49 @@ +# FancyWin configuration. Percentages are relative to each monitor's usable +# work area, so the Windows taskbar is automatically excluded. +version: 1 +gap: 8 +shift_drag: true + +overlay: + enabled: true + color: "#00AEEF" + opacity: 90 + border_width: 3 + +layouts: + # The wildcard is used for any monitor without a more specific entry. + - monitor: "*" + zones: + - name: left + x: 0 + y: 0 + width: 25 + height: 100 + - name: middle + x: 25 + y: 0 + width: 50 + height: 100 + - name: right + x: 75 + y: 0 + width: 25 + height: 100 + + # Optional per-display override. Get the name from Windows Display Settings + # or the startup diagnostics, then uncomment and edit this block. + # - monitor: '\\.\DISPLAY2' + # zones: + # - { name: main, x: 0, y: 0, width: 70, height: 100 } + # - { name: side, x: 70, y: 0, width: 30, height: 100 } + +excluded_apps: + - mstsc.exe + - games + +hotkeys: + - { keys: "win+alt+left", action: previous_zone } + - { keys: "win+alt+right", action: next_zone } + - { keys: "win+alt+1", action: zone_1 } + - { keys: "win+alt+2", action: zone_2 } + - { keys: "win+alt+3", action: zone_3 } diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4eeb5dd --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/stevec/fancywin + +go 1.24 + +require ( + golang.org/x/sys v0.35.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f9193bf --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..40280ba --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..99e9574 --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/layout/layout.go b/internal/layout/layout.go new file mode 100644 index 0000000..4a13aa5 --- /dev/null +++ b/internal/layout/layout.go @@ -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 +} diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go new file mode 100644 index 0000000..76c2e5f --- /dev/null +++ b/internal/layout/layout_test.go @@ -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) + } +} diff --git a/internal/platform/overlay_windows.go b/internal/platform/overlay_windows.go new file mode 100644 index 0000000..24a6ab2 --- /dev/null +++ b/internal/platform/overlay_windows.go @@ -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() +} diff --git a/internal/platform/platform_other.go b/internal/platform/platform_other.go new file mode 100644 index 0000000..4facc46 --- /dev/null +++ b/internal/platform/platform_other.go @@ -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") +} diff --git a/internal/platform/platform_windows.go b/internal/platform/platform_windows.go new file mode 100644 index 0000000..fd8678c --- /dev/null +++ b/internal/platform/platform_windows.go @@ -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(¤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("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") +}