feat: add tray layout controls

This commit is contained in:
Steve Cliff
2026-08-20 09:31:15 +01:00
parent 45ee82692d
commit cb00340c55
10 changed files with 523 additions and 31 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+57
View File
@@ -200,6 +200,63 @@ func (c Config) LayoutFor(device string) (Layout, bool) {
return Layout{}, false
}
func (c Config) LayoutNames() []string {
seen := make(map[string]bool)
var names []string
for _, l := range c.Layouts {
name := strings.TrimSpace(l.Name)
key := strings.ToLower(name)
if name != "" && !seen[key] {
seen[key] = true
names = append(names, name)
}
}
return names
}
// SetActiveLayout updates only the top-level active_layout line, preserving the
// user's comments, layout formatting, and ordering.
func SetActiveLayout(path, name string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
encoded, err := yaml.Marshal(strings.TrimSpace(name))
if err != nil {
return err
}
value := strings.TrimSpace(string(encoded))
newline := "\n"
if strings.Contains(string(data), "\r\n") {
newline = "\r\n"
}
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
replaced := false
for i, line := range lines {
if strings.HasPrefix(line, "active_layout:") {
lines[i] = "active_layout: " + value
replaced = true
break
}
}
if !replaced {
insertAt := 0
for i, line := range lines {
if strings.HasPrefix(line, "version:") || strings.HasPrefix(line, "gap:") || strings.HasPrefix(line, "shift_drag:") {
insertAt = i + 1
}
}
lines = append(lines, "")
copy(lines[insertAt+1:], lines[insertAt:])
lines[insertAt] = "active_layout: " + value
}
info, err := os.Stat(path)
if err != nil {
return err
}
return os.WriteFile(path, []byte(strings.Join(lines, newline)), info.Mode().Perm())
}
func (c Config) IsExcluded(exe string) bool {
// Windows paths may be validated on another OS during cross-compilation.
normalized := strings.ReplaceAll(exe, `\`, "/")
+32
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -64,6 +65,37 @@ func TestMissingActiveLayoutFails(t *testing.T) {
}
}
func TestLayoutNamesAreUniqueAndOrdered(t *testing.T) {
c := Default()
c.Layouts = append(c.Layouts,
Layout{Name: "Columns", Monitor: `\\.\DISPLAY2`},
Layout{Name: "Rows", Monitor: "*"},
)
got := c.LayoutNames()
if len(got) != 2 || got[0] != "columns" || got[1] != "Rows" {
t.Fatalf("unexpected names: %#v", got)
}
}
func TestSetActiveLayoutPreservesConfiguration(t *testing.T) {
path := filepath.Join(t.TempDir(), "fancywin.yaml")
original := "# keep this comment\r\nversion: 1\r\nactive_layout: old # old value\r\nlayouts: []\r\n"
if err := os.WriteFile(path, []byte(original), 0o640); err != nil {
t.Fatal(err)
}
if err := SetActiveLayout(path, "new layout"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(data)
if !strings.Contains(text, "# keep this comment\r\n") || !strings.Contains(text, "active_layout: new layout\r\n") || !strings.Contains(text, "layouts: []\r\n") {
t.Fatalf("unexpected rewritten file: %q", text)
}
}
func TestExcluded(t *testing.T) {
c := Default()
c.Excluded = []string{"notepad", "exact.exe"}
+4
View File
@@ -4,9 +4,13 @@ package platform
import (
"errors"
"github.com/stevec/fancywin/internal/config"
)
func PrepareBackground() {}
func ShowError(_, _ string) {}
func Run(_ string, _ config.Config, _ bool) error {
return errors.New("the window manager can only run on Windows")
}
+49
View File
@@ -76,7 +76,9 @@ var (
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 }
@@ -121,10 +123,20 @@ type engine struct {
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()
@@ -142,6 +154,12 @@ func Run(configPath string, cfg config.Config, debug bool) error {
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 }()
@@ -690,5 +708,36 @@ func (e *engine) reloadIfChanged() {
_ = 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)
}
+295
View File
@@ -0,0 +1,295 @@
//go:build windows
package platform
import (
"bytes"
"fmt"
"image/png"
"strings"
"unsafe"
"github.com/stevec/fancywin/internal/assets"
"golang.org/x/sys/windows"
)
const (
wmAppTray = 0x8001
wmRButtonUp = 0x0205
wmContextMenu = 0x007B
wmNull = 0x0000
nimAdd = 0x00000000
nimModify = 0x00000001
nimDelete = 0x00000002
nifMessage = 0x00000001
nifIcon = 0x00000002
nifTip = 0x00000004
mfString = 0x00000000
mfGray = 0x00000001
mfChecked = 0x00000008
mfSeparator = 0x00000800
tpmRightButton = 0x00000002
tpmReturnCmd = 0x00000100
tpmNonotify = 0x00000080
trayCommandExit = 1001
trayLayoutBase = 2000
idiApplication = 32512
biRGB = 0
dibRGBColors = 0
)
var (
shell32 = windows.NewLazySystemDLL("shell32.dll")
procShellNotifyIcon = shell32.NewProc("Shell_NotifyIconW")
procLoadIcon = user32.NewProc("LoadIconW")
procCreatePopupMenu = user32.NewProc("CreatePopupMenu")
procAppendMenu = user32.NewProc("AppendMenuW")
procTrackPopupMenu = user32.NewProc("TrackPopupMenu")
procDestroyMenu = user32.NewProc("DestroyMenu")
procSetForegroundWindow = user32.NewProc("SetForegroundWindow")
procPostMessage = user32.NewProc("PostMessageW")
procPostQuitMessage = user32.NewProc("PostQuitMessage")
procRegisterWindowMessage = user32.NewProc("RegisterWindowMessageW")
procCreateIconIndirect = user32.NewProc("CreateIconIndirect")
procDestroyIcon = user32.NewProc("DestroyIcon")
procCreateDIBSection = gdi32.NewProc("CreateDIBSection")
procCreateBitmap = gdi32.NewProc("CreateBitmap")
trayClassName, _ = windows.UTF16PtrFromString("FancyWinTrayWindow")
trayWindowProc = windows.NewCallback(trayWndProc)
activeTray *trayIcon
)
type notifyIconData struct {
Size uint32
Window windows.Handle
ID uint32
Flags uint32
CallbackMessage uint32
Icon windows.Handle
Tip [128]uint16
State uint32
StateMask uint32
Info [256]uint16
TimeoutOrVersion uint32
InfoTitle [64]uint16
InfoFlags uint32
GUID [16]byte
BalloonIcon windows.Handle
}
type trayIcon struct {
hwnd windows.Handle
icon windows.Handle
activeLayout string
layouts []string
taskbarMessage uint32
ownsIcon bool
}
type bitmapInfoHeader struct {
Size uint32
Width int32
Height int32
Planes uint16
BitCount uint16
Compression uint32
SizeImage uint32
XPelsPerMeter int32
YPelsPerMeter int32
ClrUsed uint32
ClrImportant uint32
}
type iconInfo struct {
Icon int32
XHotspot uint32
YHotspot uint32
Mask windows.Handle
Color windows.Handle
}
func newTrayIcon(activeLayout string, layouts []string) (*trayIcon, error) {
instance, _, instanceErr := procGetModuleHandle.Call(0)
if instance == 0 {
return nil, fmt.Errorf("tray GetModuleHandleW: %v", instanceErr)
}
wc := windowClassEx{
Size: uint32(unsafe.Sizeof(windowClassEx{})),
WndProc: trayWindowProc,
Instance: windows.Handle(instance),
ClassName: trayClassName,
}
if atom, _, err := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&wc))); atom == 0 {
return nil, fmt.Errorf("register tray window class: %v", err)
}
raw, _, createErr := procCreateWindowEx.Call(
0,
uintptr(unsafe.Pointer(trayClassName)),
uintptr(unsafe.Pointer(trayClassName)),
0, 0, 0, 0, 0, 0, 0, instance, 0,
)
if raw == 0 {
return nil, fmt.Errorf("create tray message window: %v", createErr)
}
icon, iconErr := loadFancyIcon()
ownsIcon := icon != 0
if icon == 0 {
fallback, _, fallbackErr := procLoadIcon.Call(0, idiApplication)
if fallback == 0 {
procDestroyWindow.Call(raw)
return nil, fmt.Errorf("load tray icon: generated icon: %v; fallback: %v", iconErr, fallbackErr)
}
icon = windows.Handle(fallback)
}
taskbarName, _ := windows.UTF16PtrFromString("TaskbarCreated")
taskbarMessage, _, _ := procRegisterWindowMessage.Call(uintptr(unsafe.Pointer(taskbarName)))
t := &trayIcon{
hwnd: windows.Handle(raw), icon: windows.Handle(icon),
activeLayout: activeLayout, layouts: append([]string(nil), layouts...),
taskbarMessage: uint32(taskbarMessage), ownsIcon: ownsIcon,
}
activeTray = t
if err := t.notify(nimAdd); err != nil {
activeTray = nil
procDestroyWindow.Call(raw)
return nil, err
}
return t, nil
}
func (t *trayIcon) close() {
if t == nil || t.hwnd == 0 {
return
}
_ = t.notify(nimDelete)
procDestroyWindow.Call(uintptr(t.hwnd))
if t.ownsIcon && t.icon != 0 {
procDestroyIcon.Call(uintptr(t.icon))
}
if activeTray == t {
activeTray = nil
}
t.hwnd = 0
}
func (t *trayIcon) setLayouts(active string, layouts []string) {
if t == nil {
return
}
t.activeLayout = active
t.layouts = append(t.layouts[:0], layouts...)
_ = t.notify(nimModify)
}
func (t *trayIcon) notify(operation uintptr) error {
nid := notifyIconData{
Size: uint32(unsafe.Sizeof(notifyIconData{})), Window: t.hwnd, ID: 1,
Flags: nifMessage | nifIcon | nifTip, CallbackMessage: wmAppTray, Icon: t.icon,
}
tip, _ := windows.UTF16FromString("FancyWin — " + t.activeLayout)
copy(nid.Tip[:len(nid.Tip)-1], tip)
if ok, _, err := procShellNotifyIcon.Call(operation, uintptr(unsafe.Pointer(&nid))); ok == 0 {
return fmt.Errorf("update notification-area icon: %v", err)
}
return nil
}
func trayWndProc(rawHWND, message, wParam, lParam uintptr) uintptr {
t := activeTray
if t != nil && t.taskbarMessage != 0 && uint32(message) == t.taskbarMessage {
_ = t.notify(nimAdd) // Explorer was restarted; restore the icon.
return 0
}
if message == wmAppTray && (uint32(lParam) == wmRButtonUp || uint32(lParam) == wmContextMenu) {
if t != nil {
t.showMenu()
}
return 0
}
r, _, _ := procDefWindowProc.Call(rawHWND, message, wParam, lParam)
return r
}
func (t *trayIcon) showMenu() {
menu, _, _ := procCreatePopupMenu.Call()
if menu == 0 {
return
}
defer procDestroyMenu.Call(menu)
heading, _ := windows.UTF16PtrFromString("Layouts")
exit, _ := windows.UTF16PtrFromString("Exit")
procAppendMenu.Call(menu, mfString|mfGray, 0, uintptr(unsafe.Pointer(heading)))
for i, name := range t.layouts {
label, _ := windows.UTF16PtrFromString(name)
flags := uintptr(mfString)
if strings.EqualFold(name, t.activeLayout) {
flags |= mfChecked
}
procAppendMenu.Call(menu, flags, uintptr(trayLayoutBase+i), uintptr(unsafe.Pointer(label)))
}
procAppendMenu.Call(menu, mfSeparator, 0, 0)
procAppendMenu.Call(menu, mfString, trayCommandExit, uintptr(unsafe.Pointer(exit)))
var p point
if ok, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&p))); ok == 0 {
return
}
procSetForegroundWindow.Call(uintptr(t.hwnd))
command, _, _ := procTrackPopupMenu.Call(menu, tpmRightButton|tpmReturnCmd|tpmNonotify, uintptr(p.X), uintptr(p.Y), 0, uintptr(t.hwnd), 0)
procPostMessage.Call(uintptr(t.hwnd), wmNull, 0, 0)
if command == trayCommandExit {
procPostQuitMessage.Call(0)
} else if command >= trayLayoutBase && command < uintptr(trayLayoutBase+len(t.layouts)) {
if e := activeEngine; e != nil {
e.selectLayout(t.layouts[int(command)-trayLayoutBase])
}
}
}
func loadFancyIcon() (windows.Handle, error) {
img, err := png.Decode(bytes.NewReader(assets.TrayIconPNG))
if err != nil {
return 0, err
}
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
if w < 1 || h < 1 {
return 0, fmt.Errorf("embedded icon is empty")
}
header := bitmapInfoHeader{
Size: uint32(unsafe.Sizeof(bitmapInfoHeader{})), Width: int32(w), Height: -int32(h),
Planes: 1, BitCount: 32, Compression: biRGB, SizeImage: uint32(w * h * 4),
}
var bits unsafe.Pointer
color, _, dibErr := procCreateDIBSection.Call(0, uintptr(unsafe.Pointer(&header)), dibRGBColors, uintptr(unsafe.Pointer(&bits)), 0, 0)
if color == 0 || bits == nil {
return 0, fmt.Errorf("CreateDIBSection: %v", dibErr)
}
defer procDeleteObject.Call(color)
pixels := unsafe.Slice((*byte)(bits), w*h*4)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, g, b, a := img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
i := (y*w + x) * 4
pixels[i+0] = byte(b >> 8)
pixels[i+1] = byte(g >> 8)
pixels[i+2] = byte(r >> 8)
pixels[i+3] = byte(a >> 8)
}
}
maskStride := ((w + 15) / 16) * 2
maskBits := make([]byte, maskStride*h)
mask, _, maskErr := procCreateBitmap.Call(uintptr(w), uintptr(h), 1, 1, uintptr(unsafe.Pointer(&maskBits[0])))
if mask == 0 {
return 0, fmt.Errorf("CreateBitmap icon mask: %v", maskErr)
}
defer procDeleteObject.Call(mask)
info := iconInfo{Icon: 1, Mask: windows.Handle(mask), Color: windows.Handle(color)}
icon, _, iconErr := procCreateIconIndirect.Call(uintptr(unsafe.Pointer(&info)))
if icon == 0 {
return 0, fmt.Errorf("CreateIconIndirect: %v", iconErr)
}
return windows.Handle(icon), nil
}