feat: support named switchable layouts

This commit is contained in:
Steve Cliff
2026-08-20 09:06:37 +01:00
parent 6ddecad74d
commit 45ee82692d
7 changed files with 172 additions and 30 deletions
+48 -13
View File
@@ -12,13 +12,14 @@ import (
)
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"`
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 {
@@ -29,6 +30,7 @@ type Overlay struct {
}
type Layout struct {
Name string `yaml:"name"`
Monitor string `yaml:"monitor"`
Zones []Zone `yaml:"zones"`
}
@@ -49,8 +51,9 @@ type Hotkey struct {
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{
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},
}}},
@@ -73,6 +76,7 @@ func Load(path string) (Config, error) {
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
}
@@ -100,15 +104,27 @@ func (c Config) Validate() error {
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(name)
key := strings.ToLower(layoutName) + "\x00" + strings.ToLower(name)
if seenMonitors[key] {
return fmt.Errorf("duplicate layout for monitor %q", name)
return fmt.Errorf("duplicate layout named %q for monitor %q", layoutName, name)
}
seenMonitors[key] = true
if len(l.Zones) == 0 {
@@ -120,6 +136,9 @@ func (c Config) Validate() error {
}
}
}
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_") {
@@ -138,6 +157,21 @@ func (c Config) Validate() error {
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
@@ -151,14 +185,15 @@ func DefaultPath(exePath string) string {
}
func (c Config) LayoutFor(device string) (Layout, bool) {
active := strings.TrimSpace(c.ActiveLayout)
for _, l := range c.Layouts {
monitor := strings.TrimSpace(l.Monitor)
if monitor != "*" && strings.EqualFold(monitor, strings.TrimSpace(device)) {
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.TrimSpace(l.Monitor) == "*" {
if strings.EqualFold(strings.TrimSpace(l.Name), active) && strings.TrimSpace(l.Monitor) == "*" {
return l, true
}
}
+50 -1
View File
@@ -1,6 +1,10 @@
package config
import "testing"
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultValid(t *testing.T) {
if err := Default().Validate(); err != nil {
@@ -15,6 +19,51 @@ func TestLayoutFallback(t *testing.T) {
}
}
func TestActiveLayoutSelection(t *testing.T) {
c := Default()
c.Layouts = append(c.Layouts, Layout{Name: "wide", Monitor: "*", Zones: []Zone{{Name: "wide", Width: 100, Height: 100}}})
c.ActiveLayout = "wide"
got, ok := c.LayoutFor(`\\.\DISPLAY1`)
if !ok || got.Name != "wide" || len(got.Zones) != 1 {
t.Fatalf("wrong active layout: %+v, %v", got, ok)
}
}
func TestLegacyLayoutUpgrade(t *testing.T) {
c := Default()
c.ActiveLayout = ""
for i := range c.Layouts {
c.Layouts[i].Name = ""
}
c.upgradeLegacyLayouts()
if c.ActiveLayout != "default" || c.Layouts[0].Name != "default" {
t.Fatalf("legacy layout was not upgraded: %+v", c)
}
}
func TestLoadLegacyUnnamedLayout(t *testing.T) {
path := filepath.Join(t.TempDir(), "legacy.yaml")
data := []byte("version: 1\ngap: 8\nshift_drag: true\nlayouts:\n - monitor: '*'\n zones:\n - {x: 0, y: 0, width: 100, height: 100}\n")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
c, err := Load(path)
if err != nil {
t.Fatal(err)
}
if c.ActiveLayout != "default" || c.Layouts[0].Name != "default" {
t.Fatalf("legacy file was not upgraded: %+v", c)
}
}
func TestMissingActiveLayoutFails(t *testing.T) {
c := Default()
c.ActiveLayout = "missing"
if err := c.Validate(); err == nil {
t.Fatal("expected missing active layout to fail")
}
}
func TestExcluded(t *testing.T) {
c := Default()
c.Excluded = []string{"notepad", "exact.exe"}