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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user