feat: add portable Windows zone manager
This commit is contained in:
@@ -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