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
+2
View File
@@ -0,0 +1,2 @@
/dist/
*.exe
+276
View File
@@ -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.
+45 -4
View File
@@ -10,6 +10,8 @@ menu entry, or registry settings.
## What FancyWin does ## What FancyWin does
- Snaps a dragged window into the zone beneath the mouse pointer. - 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. - Moves the focused window between zones with keyboard shortcuts.
- Supports different layouts for different monitors. - Supports different layouts for different monitors.
- Uses percentages, so layouts adapt to resolution, orientation, display - 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. - Reloads its configuration automatically after the YAML file changes.
- Lets you exclude applications that should never be moved. - Lets you exclude applications that should never be moved.
FancyWin is a window-moving backend rather than a complete PowerToys FancyWin is a focused window manager rather than a complete PowerToys
replacement. It does not display a zone overlay or include a visual layout replacement. It does not include a visual layout editor and does not currently
editor. It also does not currently remember application positions, combine remember application positions, combine multiple zones, or assign layouts per
multiple zones, or assign layouts per virtual desktop. virtual desktop.
## Getting started ## Getting started
@@ -78,6 +80,12 @@ version: 1
gap: 8 gap: 8
shift_drag: true shift_drag: true
overlay:
enabled: true
color: "#00AEEF"
opacity: 90
border_width: 3
layouts: layouts:
- monitor: "*" - monitor: "*"
zones: zones:
@@ -118,10 +126,35 @@ use.
| `version` | Configuration format. This must currently be `1`. | | `version` | Configuration format. This must currently be `1`. |
| `gap` | Inward spacing, in pixels, applied to every edge of every zone. Valid range: `0``500`. | | `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. | | `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. | | `layouts` | One or more monitor layouts containing zones. |
| `excluded_apps` | Applications FancyWin must not move. May be empty. | | `excluded_apps` | Applications FancyWin must not move. May be empty. |
| `hotkeys` | Global keyboard shortcuts. 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 ### Zone coordinates
Each zone is a rectangle described as percentages of the monitor's usable work 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 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. 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 ## Project relationship
FancyWin is an independent implementation and does not contain Microsoft FancyWin is an independent implementation and does not contain Microsoft
+11
View File
@@ -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"
+54
View File
@@ -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) }
+49
View File
@@ -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 }
+8
View File
@@ -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
)
+6
View File
@@ -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=
+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")
}