feat: add portable Windows zone manager
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/dist/
|
||||
*.exe
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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) }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user