Files
fancywin/internal/platform/platform_windows.go
T
2026-08-20 09:31:15 +01:00

744 lines
22 KiB
Go

//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")
procFreeConsole = kernel32.NewProc("FreeConsole")
procDwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute")
procMessageBox = user32.NewProc("MessageBoxW")
)
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
tray *trayIcon
}
var activeEngine *engine
func PrepareBackground() { procFreeConsole.Call() }
func ShowError(title, message string) {
t, _ := windows.UTF16PtrFromString(title)
m, _ := windows.UTF16PtrFromString(message)
const mbOKIconErrorSetForeground = 0x00000000 | 0x00000010 | 0x00010000
procMessageBox.Call(0, uintptr(unsafe.Pointer(m)), uintptr(unsafe.Pointer(t)), mbOKIconErrorSetForeground)
}
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()
tray, err := newTrayIcon(cfg.ActiveLayout, cfg.LayoutNames())
if err != nil {
return err
}
e.tray = tray
defer e.tray.close()
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: layout=%q, Shift+drag snapping=%v, overlay=%v, %d hotkeys", cfg.ActiveLayout, cfg.ShiftDrag, cfg.Overlay.Enabled, len(e.hotkeys))
logMonitors()
var msg message
for {
r, _, err := procGetMessage.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0)
if int32(r) == -1 {
return fmt.Errorf("message loop: %v", err)
}
if r == 0 || msg.Message == wmQuit {
return nil
}
switch msg.Message {
case wmHotkey:
e.handleHotkey(int(msg.WParam))
case wmTimer:
switch msg.WParam {
case configTimer:
e.reloadIfChanged()
case shiftTimer:
e.pollInput()
}
}
procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg)))
procDispatchMessage.Call(uintptr(unsafe.Pointer(&msg)))
}
}
func winEventCallback(_, event, rawHWND, idObject, idChild, _, _ uintptr) uintptr {
e := activeEngine
hwnd := windows.Handle(rawHWND)
if e == nil || hwnd == 0 || idObject != objectIDWindow || idChild != 0 {
return 0
}
switch event {
case eventSystemMoveSizeStart:
if e.canManage(hwnd) {
e.fallbackHandled = 0
e.dragging = hwnd
e.dragShift = keyDown(vkShift)
e.debugf("move started: hwnd=0x%x app=%q shift=%v", uintptr(hwnd), executableForWindow(hwnd), e.dragShift)
} else {
e.debugf("move ignored: hwnd=0x%x app=%q is not a manageable top-level window", uintptr(hwnd), executableForWindow(hwnd))
}
case eventSystemMoveSizeEnd:
if e.dragging == hwnd {
if e.fallbackHandled == hwnd {
e.dragging = 0
e.dragShift = false
e.fallbackHandled = 0
e.debugf("native move end arrived after mouse fallback: hwnd=0x%x", uintptr(hwnd))
break
}
activated := e.dragShift || keyDown(vkShift)
e.dragging = 0
e.dragShift = false
if e.mouseWindow == hwnd {
e.mouseWindow = 0 // The native event path owns this drag.
e.mouseMoved = false
}
e.mu.RLock()
enabled := e.cfg.ShiftDrag
e.mu.RUnlock()
if enabled && activated {
if ok, reason := e.snapAtCursor(hwnd); !ok {
log.Printf("window was not snapped: %s", reason)
} else {
e.debugf("window snapped: hwnd=0x%x", uintptr(hwnd))
}
} else {
e.debugf("move ended without snapping: hwnd=0x%x shift_drag=%v shift=%v", uintptr(hwnd), enabled, activated)
}
} else {
e.debugf("move end ignored: hwnd=0x%x did not match active drag 0x%x", uintptr(hwnd), uintptr(e.dragging))
}
}
return 0
}
func (e *engine) pollInput() {
if e.dragging != 0 {
e.dragShift = keyDown(vkShift)
}
down := keyDown(vkLButton)
switch {
case down && !e.mouseDown:
e.beginMouseTracking()
case down && e.mouseDown:
e.updateMouseTracking()
case !down && e.mouseDown:
e.endMouseTracking()
}
e.mouseDown = down
e.updateOverlay()
}
func (e *engine) updateOverlay() {
hide := func() {
if e.overlay.hwnd != 0 {
e.debugf("zone overlay hidden")
}
e.overlay.hide()
}
e.mu.RLock()
cfg := e.cfg
e.mu.RUnlock()
activeDrag := e.mouseDown && (e.dragging != 0 || (e.mouseWindow != 0 && e.mouseMoved))
if !cfg.Overlay.Enabled || !activeDrag || !keyDown(vkShift) {
hide()
return
}
var p point
if ok, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&p))); ok == 0 {
hide()
return
}
mon, _, _ := procMonitorFromPoint.Call(packPoint(p), monitorDefaultToNull)
if mon == 0 {
hide()
return
}
mi, ok := monitorDetails(windows.Handle(mon))
if !ok {
hide()
return
}
selected, ok := cfg.LayoutFor(mi.device())
if !ok {
hide()
return
}
wasVisible := e.overlay.hwnd != 0
if err := e.overlay.show(windows.Handle(mon), toLayoutRect(mi.Work), selected.Zones, cfg.Gap, cfg.Overlay); err != nil {
hide()
if !e.overlayFailed {
log.Printf("cannot show zone overlay: %v", err)
e.overlayFailed = true
}
} else if !wasVisible {
e.debugf("zone overlay shown: monitor=%s zones=%d", mi.device(), len(selected.Zones))
}
}
func (e *engine) beginMouseTracking() {
var p point
if ok, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&p))); ok == 0 {
return
}
raw, _, _ := procWindowFromPoint.Call(packPoint(p))
if raw == 0 {
e.debugf("mouse fallback: no window under press at (%d,%d)", p.X, p.Y)
return
}
root, _, _ := procGetAncestor.Call(raw, gaRoot)
if root == 0 {
root = raw
}
hwnd := windows.Handle(root)
if !e.canManage(hwnd) {
e.debugf("mouse fallback: window under press is not manageable: hwnd=0x%x app=%q", uintptr(hwnd), executableForWindow(hwnd))
return
}
var start rect
if ok, _, _ := procGetWindowRect.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&start))); ok == 0 {
e.debugf("mouse fallback: GetWindowRect failed for hwnd=0x%x", uintptr(hwnd))
return
}
e.mouseWindow = hwnd
e.mouseStart = start
e.mouseMoved = false
e.mouseShift = keyDown(vkShift)
e.debugf("mouse fallback tracking started: hwnd=0x%x app=%q shift=%v", uintptr(hwnd), executableForWindow(hwnd), e.mouseShift)
}
func (e *engine) updateMouseTracking() {
if e.mouseWindow == 0 {
return
}
e.mouseShift = keyDown(vkShift)
var current rect
if ok, _, _ := procGetWindowRect.Call(uintptr(e.mouseWindow), uintptr(unsafe.Pointer(&current))); ok != 0 && rectMoved(e.mouseStart, current) {
e.mouseMoved = true
}
}
func (e *engine) endMouseTracking() {
hwnd := e.mouseWindow
moved := e.mouseMoved
activated := e.mouseShift || keyDown(vkShift)
e.mouseWindow = 0
e.mouseMoved = false
e.mouseShift = false
if hwnd == 0 {
return
}
if !moved {
e.debugf("mouse fallback ignored click: hwnd=0x%x rectangle did not move", uintptr(hwnd))
return
}
e.mu.RLock()
enabled := e.cfg.ShiftDrag
e.mu.RUnlock()
if !enabled || !activated {
e.debugf("mouse fallback move ended without snapping: hwnd=0x%x shift_drag=%v shift=%v", uintptr(hwnd), enabled, activated)
return
}
if e.dragging == hwnd {
e.fallbackHandled = hwnd
}
if ok, reason := e.snapAtCursor(hwnd); !ok {
log.Printf("window was not snapped by mouse fallback: %s", reason)
} else {
e.debugf("window snapped by mouse fallback: hwnd=0x%x", uintptr(hwnd))
}
}
func rectMoved(a, b rect) bool {
const threshold = int32(2)
return abs32(a.Left-b.Left) > threshold || abs32(a.Top-b.Top) > threshold ||
abs32(a.Right-b.Right) > threshold || abs32(a.Bottom-b.Bottom) > threshold
}
func abs32(v int32) int32 {
if v < 0 {
return -v
}
return v
}
func packPoint(p point) uintptr {
return uintptr(uint32(p.X)) | uintptr(uint64(uint32(p.Y))<<32)
}
func (e *engine) snapAtCursor(hwnd windows.Handle) (bool, string) {
var p point
if ok, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&p))); ok == 0 {
return false, "GetCursorPos failed"
}
mon, _, _ := procMonitorFromPoint.Call(packPoint(p), monitorDefaultToNull)
if mon == 0 {
return false, fmt.Sprintf("no monitor contains pointer (%d,%d)", p.X, p.Y)
}
mi, ok := monitorDetails(windows.Handle(mon))
if !ok {
return false, "GetMonitorInfo failed"
}
e.mu.RLock()
cfg := e.cfg
e.mu.RUnlock()
l, ok := cfg.LayoutFor(mi.device())
if !ok {
return false, fmt.Sprintf("active layout %q has no entry for monitor %s and no wildcard fallback", cfg.ActiveLayout, 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
}
e.tray.setLayouts(cfg.ActiveLayout, cfg.LayoutNames())
log.Printf("configuration reloaded: active layout=%q", cfg.ActiveLayout)
}
func (e *engine) selectLayout(name string) {
e.mu.RLock()
cfg := e.cfg
e.mu.RUnlock()
found := false
for _, candidate := range cfg.LayoutNames() {
if strings.EqualFold(candidate, name) {
name = candidate
found = true
break
}
}
if !found || strings.EqualFold(cfg.ActiveLayout, name) {
return
}
if err := config.SetActiveLayout(e.configPath, name); err != nil {
log.Printf("cannot select layout %q: %v", name, err)
ShowError("FancyWin", fmt.Sprintf("Could not select layout %q:\n\n%v", name, err))
return
}
cfg.ActiveLayout = name
e.mu.Lock()
e.cfg = cfg
e.mu.Unlock()
e.rememberConfigTime()
e.overlay.hide()
e.tray.setLayouts(name, cfg.LayoutNames())
log.Printf("active layout changed from tray: %q", name)
}