feat: add tray layout controls
This commit is contained in:
@@ -18,6 +18,8 @@ The project uses pure Go and sets `CGO_ENABLED=0`. No C compiler, Windows SDK,
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
cmd/fancywin/ CLI entry point
|
cmd/fancywin/ CLI entry point
|
||||||
|
assets/fancywin.png Full-resolution generated icon source
|
||||||
|
internal/assets/ Embedded runtime image assets
|
||||||
internal/config/ YAML model, loading, and validation
|
internal/config/ YAML model, loading, and validation
|
||||||
internal/layout/ Platform-neutral zone geometry
|
internal/layout/ Platform-neutral zone geometry
|
||||||
internal/platform/platform_windows.go Win32 backend
|
internal/platform/platform_windows.go Win32 backend
|
||||||
@@ -56,8 +58,10 @@ The script:
|
|||||||
5. Writes `dist\fancywin.exe` and copies the example to
|
5. Writes `dist\fancywin.exe` and copies the example to
|
||||||
`dist\fancywin.yaml`.
|
`dist\fancywin.yaml`.
|
||||||
|
|
||||||
The generated executable is a console subsystem application so users can see
|
The generated executable remains a console-subsystem application so diagnostics
|
||||||
configuration and Win32 errors directly.
|
work without a second binary. Normal execution calls `FreeConsole` and operates
|
||||||
|
through the notification-area icon. `--debug` retains the console, while
|
||||||
|
startup failures in detached mode are shown with `MessageBoxW`.
|
||||||
|
|
||||||
## Cross-compiling
|
## Cross-compiling
|
||||||
|
|
||||||
@@ -138,6 +142,13 @@ Test both x64 and ARM64 where hardware is available:
|
|||||||
same-integrity elevated run can do so.
|
same-integrity elevated run can do so.
|
||||||
11. Disconnect and reconnect a monitor and confirm current work-area information
|
11. Disconnect and reconnect a monitor and confirm current work-area information
|
||||||
is used for subsequent moves.
|
is used for subsequent moves.
|
||||||
|
12. Confirm the custom icon is crisp in the normal and overflow notification
|
||||||
|
areas at 100%, 150%, and 200% scaling.
|
||||||
|
13. Select each named layout from the tray, confirm the check mark and overlay
|
||||||
|
change immediately, and verify `active_layout` changes without disturbing
|
||||||
|
YAML comments or layout formatting.
|
||||||
|
14. Right-click the tray icon, select `Exit`, and confirm the process and icon
|
||||||
|
both disappear cleanly.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -153,13 +164,29 @@ At startup the backend:
|
|||||||
|
|
||||||
1. Requests per-monitor-v2 DPI awareness.
|
1. Requests per-monitor-v2 DPI awareness.
|
||||||
2. Creates a named mutex for single-instance enforcement.
|
2. Creates a named mutex for single-instance enforcement.
|
||||||
3. Installs a `SetWinEventHook` covering `EVENT_SYSTEM_MOVESIZESTART` through
|
3. Creates a hidden window and registers a notification-area icon with
|
||||||
|
`Shell_NotifyIconW`.
|
||||||
|
4. Installs a `SetWinEventHook` covering `EVENT_SYSTEM_MOVESIZESTART` through
|
||||||
`EVENT_SYSTEM_MOVESIZEEND` with `WINEVENT_OUTOFCONTEXT` and
|
`EVENT_SYSTEM_MOVESIZEEND` with `WINEVENT_OUTOFCONTEXT` and
|
||||||
`WINEVENT_SKIPOWNPROCESS`.
|
`WINEVENT_SKIPOWNPROCESS`.
|
||||||
4. Registers configured system-wide hotkeys against the current thread.
|
5. Registers configured system-wide hotkeys against the current thread.
|
||||||
5. Creates a two-second timer for configuration reload checks and a 25 ms timer
|
6. Creates a two-second timer for configuration reload checks and a 25 ms timer
|
||||||
for sampling the mouse button, Shift, and active drag state.
|
for sampling the mouse button, Shift, and active drag state.
|
||||||
6. Enters a standard Win32 `GetMessage` loop.
|
7. Enters a standard Win32 `GetMessage` loop.
|
||||||
|
|
||||||
|
The tray icon is a generated FancyWin mark embedded as a 64×64 RGBA PNG. At
|
||||||
|
startup the standard-library PNG decoder supplies premultiplied BGRA pixels to a
|
||||||
|
32-bit GDI DIB section, which `CreateIconIndirect` converts to an alpha-aware
|
||||||
|
native `HICON`; it never needs to be extracted to disk. A stock Windows icon is
|
||||||
|
used only if conversion unexpectedly fails.
|
||||||
|
|
||||||
|
The tooltip contains the active layout. The context menu deduplicates configured
|
||||||
|
layouts by case-insensitive name, checks the active one, and provides `Exit`.
|
||||||
|
Selecting a layout updates runtime state immediately and rewrites only the
|
||||||
|
top-level `active_layout` YAML line so the choice persists. `Exit` posts
|
||||||
|
`WM_QUIT` to the existing message loop for orderly hook, hotkey, overlay, icon,
|
||||||
|
and tray cleanup. A registered `TaskbarCreated` message restores the icon after
|
||||||
|
Windows Explorer restarts.
|
||||||
|
|
||||||
### Mouse snapping
|
### Mouse snapping
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,14 @@ You need these two files in the same directory:
|
|||||||
The supplied [example configuration](fancywin.example.yaml) can be copied or
|
The supplied [example configuration](fancywin.example.yaml) can be copied or
|
||||||
renamed to `fancywin.yaml`.
|
renamed to `fancywin.yaml`.
|
||||||
|
|
||||||
Double-click `fancywin.exe` to start it. A console window remains open while
|
Double-click `fancywin.exe` to start it. FancyWin runs quietly in the Windows
|
||||||
FancyWin is running and shows its status, detected monitors, configuration
|
notification area without leaving a console or taskbar window open. Hover over
|
||||||
reloads, and any errors. Closing that console stops FancyWin.
|
its tray icon to see the active layout. Right-click the icon to select any
|
||||||
|
configured layout or choose `Exit` to stop FancyWin. The active layout is marked
|
||||||
|
with a check.
|
||||||
|
|
||||||
|
Run `fancywin.exe --debug` from PowerShell when you want a console showing
|
||||||
|
status, detected monitors, drag diagnostics, configuration reloads, and errors.
|
||||||
|
|
||||||
Only one copy of FancyWin can run at a time.
|
Only one copy of FancyWin can run at a time.
|
||||||
|
|
||||||
@@ -127,8 +132,8 @@ hotkeys:
|
|||||||
|
|
||||||
FancyWin checks the file for unknown fields and invalid values. While it is
|
FancyWin checks the file for unknown fields and invalid values. While it is
|
||||||
running, a valid edit takes effect within about two seconds. If an edit is
|
running, a valid edit takes effect within about two seconds. If an edit is
|
||||||
invalid, the error is printed and the previous valid configuration stays in
|
invalid, the previous valid configuration stays in use. Restart with `--debug`
|
||||||
use.
|
to investigate configuration errors.
|
||||||
|
|
||||||
### General settings
|
### General settings
|
||||||
|
|
||||||
@@ -218,8 +223,10 @@ layouts:
|
|||||||
- { name: right, x: 66.667, y: 0, width: 33.333, height: 100 }
|
- { name: right, x: 66.667, y: 0, width: 33.333, height: 100 }
|
||||||
```
|
```
|
||||||
|
|
||||||
Changing `active_layout` to `equal-thirds` is applied automatically within
|
Select a name from the tray menu to switch immediately. FancyWin also updates
|
||||||
about two seconds; FancyWin does not need to be restarted.
|
`active_layout` in the YAML so the choice survives a restart. Alternatively,
|
||||||
|
changing `active_layout` by hand is applied automatically within about two
|
||||||
|
seconds; FancyWin does not need to be restarted.
|
||||||
|
|
||||||
Use `monitor: "*"` as the fallback layout for every display that does not have
|
Use `monitor: "*"` as the fallback layout for every display that does not have
|
||||||
a specific entry. At startup, FancyWin prints detected device names such as
|
a specific entry. At startup, FancyWin prints detected device names such as
|
||||||
@@ -303,7 +310,7 @@ Run these commands from PowerShell or Command Prompt in the FancyWin directory:
|
|||||||
# Print the FancyWin version
|
# Print the FancyWin version
|
||||||
.\fancywin.exe -version
|
.\fancywin.exe -version
|
||||||
|
|
||||||
# Print detailed window-move and snapping diagnostics
|
# Keep a console open and print detailed diagnostics
|
||||||
.\fancywin.exe -debug
|
.\fancywin.exe -debug
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -322,25 +329,17 @@ necessary.
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### FancyWin closes immediately
|
### FancyWin does not appear to start
|
||||||
|
|
||||||
Open PowerShell in the FancyWin directory and run:
|
Open PowerShell in the FancyWin directory and run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\fancywin.exe
|
.\fancywin.exe --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
The console will remain visible so you can read the error. Common causes are a
|
The console remains attached so you can read errors. Common causes are a missing
|
||||||
missing `fancywin.yaml`, invalid YAML, a shortcut conflict, or another running
|
`fancywin.yaml`, invalid YAML, a shortcut conflict, or another running copy of
|
||||||
copy of FancyWin.
|
FancyWin. Debug output also reports whether Windows emitted a move event, whether the
|
||||||
|
|
||||||
For detailed diagnostics, stop FancyWin and restart it with:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\fancywin.exe -debug
|
|
||||||
```
|
|
||||||
|
|
||||||
The debug output reports whether Windows emitted a move event, whether the
|
|
||||||
window passed filtering, whether Shift was active during the drag, which monitor
|
window passed filtering, whether Shift was active during the drag, which monitor
|
||||||
and zone were selected, and whether Windows accepted the final move request.
|
and zone were selected, and whether Windows accepted the final move request.
|
||||||
|
|
||||||
@@ -370,8 +369,25 @@ borders, but it cannot override size rules imposed by the application itself.
|
|||||||
|
|
||||||
### Configuration changes are not applied
|
### Configuration changes are not applied
|
||||||
|
|
||||||
Wait at least two seconds and check the console. FancyWin keeps using the last
|
Wait at least two seconds. FancyWin keeps using the last valid configuration
|
||||||
valid configuration when a new edit cannot be parsed or validated.
|
when a new edit cannot be parsed or validated. Restart with `--debug` and repeat
|
||||||
|
the edit to see the exact error.
|
||||||
|
|
||||||
|
### The tray icon is missing
|
||||||
|
|
||||||
|
- Check the notification-area overflow menu behind the upward arrow; Windows
|
||||||
|
may place new icons there automatically.
|
||||||
|
- Start FancyWin with `--debug` and check for a notification-area error.
|
||||||
|
- Only one FancyWin instance may run at a time.
|
||||||
|
- If Windows Explorer restarts, FancyWin automatically adds its icon again.
|
||||||
|
|
||||||
|
### A layout is missing from the tray menu
|
||||||
|
|
||||||
|
- Confirm every layout entry has a non-empty `name`.
|
||||||
|
- Monitor-specific variants sharing the same name intentionally appear as one
|
||||||
|
menu choice.
|
||||||
|
- Check the YAML with `fancywin.exe -check`.
|
||||||
|
- Layouts added while FancyWin is running appear after the configuration reload.
|
||||||
|
|
||||||
### The zone overlay does not appear
|
### The zone overlay does not appear
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
+14
-2
@@ -11,7 +11,9 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "0.3.0"
|
const version = "0.5.1"
|
||||||
|
|
||||||
|
var backgroundMode bool
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
@@ -37,6 +39,10 @@ func main() {
|
|||||||
_, _ = os.Stdout.Write(b)
|
_, _ = os.Stdout.Write(b)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
backgroundMode = !*check && !*debug
|
||||||
|
if backgroundMode {
|
||||||
|
platform.PrepareBackground()
|
||||||
|
}
|
||||||
cfg, err := config.Load(*configPath)
|
cfg, err := config.Load(*configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal(fmt.Errorf("%s: %w", filepath.Clean(*configPath), err))
|
fatal(fmt.Errorf("%s: %w", filepath.Clean(*configPath), err))
|
||||||
@@ -51,4 +57,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fatal(err error) { fmt.Fprintln(os.Stderr, "fancywin:", err); os.Exit(1) }
|
func fatal(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, "fancywin:", err)
|
||||||
|
if backgroundMode {
|
||||||
|
platform.ShowError("FancyWin", err.Error())
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -200,6 +200,63 @@ func (c Config) LayoutFor(device string) (Layout, bool) {
|
|||||||
return Layout{}, false
|
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 {
|
func (c Config) IsExcluded(exe string) bool {
|
||||||
// Windows paths may be validated on another OS during cross-compilation.
|
// Windows paths may be validated on another OS during cross-compilation.
|
||||||
normalized := strings.ReplaceAll(exe, `\`, "/")
|
normalized := strings.ReplaceAll(exe, `\`, "/")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestExcluded(t *testing.T) {
|
||||||
c := Default()
|
c := Default()
|
||||||
c.Excluded = []string{"notepad", "exact.exe"}
|
c.Excluded = []string{"notepad", "exact.exe"}
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ package platform
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/stevec/fancywin/internal/config"
|
"github.com/stevec/fancywin/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func PrepareBackground() {}
|
||||||
|
func ShowError(_, _ string) {}
|
||||||
|
|
||||||
func Run(_ string, _ config.Config, _ bool) error {
|
func Run(_ string, _ config.Config, _ bool) error {
|
||||||
return errors.New("the window manager can only run on Windows")
|
return errors.New("the window manager can only run on Windows")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ var (
|
|||||||
procSetProcessDPIAwareV2 = user32.NewProc("SetProcessDpiAwarenessContext")
|
procSetProcessDPIAwareV2 = user32.NewProc("SetProcessDpiAwarenessContext")
|
||||||
procEnumDisplayMonitors = user32.NewProc("EnumDisplayMonitors")
|
procEnumDisplayMonitors = user32.NewProc("EnumDisplayMonitors")
|
||||||
procCreateMutex = kernel32.NewProc("CreateMutexW")
|
procCreateMutex = kernel32.NewProc("CreateMutexW")
|
||||||
|
procFreeConsole = kernel32.NewProc("FreeConsole")
|
||||||
procDwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute")
|
procDwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute")
|
||||||
|
procMessageBox = user32.NewProc("MessageBoxW")
|
||||||
)
|
)
|
||||||
|
|
||||||
type point struct{ X, Y int32 }
|
type point struct{ X, Y int32 }
|
||||||
@@ -121,10 +123,20 @@ type engine struct {
|
|||||||
debug bool
|
debug bool
|
||||||
overlay *overlayManager
|
overlay *overlayManager
|
||||||
overlayFailed bool
|
overlayFailed bool
|
||||||
|
tray *trayIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
var activeEngine *engine
|
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 {
|
func Run(configPath string, cfg config.Config, debug bool) error {
|
||||||
runtime.LockOSThread()
|
runtime.LockOSThread()
|
||||||
defer runtime.UnlockOSThread()
|
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{}}
|
e := &engine{configPath: configPath, cfg: cfg, hotkeys: make(map[int]hotkeyBinding), debug: debug, overlay: &overlayManager{}}
|
||||||
defer e.overlay.hide()
|
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()
|
e.rememberConfigTime()
|
||||||
activeEngine = e
|
activeEngine = e
|
||||||
defer func() { activeEngine = nil }()
|
defer func() { activeEngine = nil }()
|
||||||
@@ -690,5 +708,36 @@ func (e *engine) reloadIfChanged() {
|
|||||||
_ = e.registerHotkeys()
|
_ = e.registerHotkeys()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
e.tray.setLayouts(cfg.ActiveLayout, cfg.LayoutNames())
|
||||||
log.Printf("configuration reloaded: active layout=%q", cfg.ActiveLayout)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user