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"` ActiveLayout string `yaml:"active_layout"` 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 { Name string `yaml:"name"` 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, ActiveLayout: "columns", Overlay: Overlay{Enabled: true, Color: "#00AEEF", Opacity: 90, BorderWidth: 10}, Layouts: []Layout{{Name: "columns", 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) } c.upgradeLegacyLayouts() 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") } active := strings.TrimSpace(c.ActiveLayout) if active == "" { return errors.New("active_layout is required") } seenMonitors := map[string]bool{} activeExists := false for li, l := range c.Layouts { layoutName := strings.TrimSpace(l.Name) if layoutName == "" { return fmt.Errorf("layouts[%d].name is required", li) } if strings.EqualFold(layoutName, active) { activeExists = true } name := strings.TrimSpace(l.Monitor) if name == "" { return fmt.Errorf("layouts[%d].monitor is required", li) } key := strings.ToLower(layoutName) + "\x00" + strings.ToLower(name) if seenMonitors[key] { return fmt.Errorf("duplicate layout named %q for monitor %q", layoutName, 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) } } } if !activeExists { return fmt.Errorf("active_layout %q does not match any layout name", c.ActiveLayout) } 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 (c *Config) upgradeLegacyLayouts() { if strings.TrimSpace(c.ActiveLayout) != "" || len(c.Layouts) == 0 { return } for _, l := range c.Layouts { if strings.TrimSpace(l.Name) != "" { return } } c.ActiveLayout = "default" for i := range c.Layouts { c.Layouts[i].Name = "default" } } 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) { active := strings.TrimSpace(c.ActiveLayout) for _, l := range c.Layouts { monitor := strings.TrimSpace(l.Monitor) if strings.EqualFold(strings.TrimSpace(l.Name), active) && monitor != "*" && strings.EqualFold(monitor, strings.TrimSpace(device)) { return l, true } } for _, l := range c.Layouts { if strings.EqualFold(strings.TrimSpace(l.Name), active) && 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 }