diff --git a/BUILD.md b/BUILD.md index 879ba8d..e8cf851 100644 --- a/BUILD.md +++ b/BUILD.md @@ -231,8 +231,10 @@ to bypass that boundary or request elevation through a manifest. ### Configuration reload The YAML decoder rejects unknown fields. Semantic validation checks the schema -version, gap range, unique monitor entries, zone bounds, hotkey action syntax, -and required values. +version, gap range, active-layout existence, unique layout-name/monitor pairs, +zone bounds, hotkey action syntax, and required values. Legacy files in which +all layouts are unnamed and `active_layout` is absent are normalized to a +single layout name of `default` before validation. The message-loop timer checks the configuration modification time. A valid new configuration replaces the active one and re-registers hotkeys. If parsing, diff --git a/README.md b/README.md index a77bde9..05c17bf 100644 --- a/README.md +++ b/README.md @@ -79,15 +79,17 @@ Here is a complete two-column configuration: version: 1 gap: 8 shift_drag: true +active_layout: columns overlay: enabled: true color: "#00AEEF" opacity: 90 - border_width: 3 + border_width: 10 layouts: - - monitor: "*" + - name: columns + monitor: "*" zones: - name: left x: 0 @@ -100,6 +102,15 @@ layouts: width: 50 height: 100 + - name: fullscreen + monitor: "*" + zones: + - name: full + x: 0 + y: 0 + width: 100 + height: 100 + excluded_apps: - mstsc.exe @@ -126,6 +137,7 @@ use. | `version` | Configuration format. This must currently be `1`. | | `gap` | Inward spacing, in pixels, applied to every edge of every zone. Valid range: `0`–`500`. | | `shift_drag` | Enables or disables mouse snapping when Shift is held at the end of a drag. | +| `active_layout` | Name of the layout currently used for snapping and the overlay. | | `overlay` | Controls the transparent zone guide shown during Shift-dragging. | | `layouts` | One or more monitor layouts containing zones. | | `excluded_apps` | Applications FancyWin must not move. May be empty. | @@ -144,7 +156,7 @@ overlay: enabled: true color: "#00AEEF" opacity: 90 - border_width: 3 + border_width: 10 ``` - `enabled` turns the guide on or off. @@ -182,7 +194,32 @@ For example, the bottom-right quarter is: Zones may overlap. If the pointer is inside more than one zone, the first matching zone listed in the file is selected. -### Monitor-specific layouts +### Named layouts and monitor-specific variants + +Every entry under `layouts` has a `name`. Set `active_layout` to that name to +choose which layout FancyWin uses: + +```yaml +active_layout: focus + +layouts: + - name: focus + monitor: "*" + zones: + - { name: left, x: 0, y: 0, width: 25, height: 100 } + - { name: middle, x: 25, y: 0, width: 50, height: 100 } + - { name: right, x: 75, y: 0, width: 25, height: 100 } + + - name: equal-thirds + monitor: "*" + zones: + - { name: left, x: 0, y: 0, width: 33.333, height: 100 } + - { name: middle, x: 33.333, y: 0, width: 33.334, height: 100 } + - { name: right, x: 66.667, y: 0, width: 33.333, height: 100 } +``` + +Changing `active_layout` to `equal-thirds` 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 a specific entry. At startup, FancyWin prints detected device names such as @@ -192,18 +229,25 @@ A specific monitor layout can override the fallback: ```yaml layouts: - - monitor: "*" + - name: focus + monitor: "*" zones: - { name: left, x: 0, y: 0, width: 50, height: 100 } - { name: right, x: 50, y: 0, width: 50, height: 100 } - - monitor: '\\.\DISPLAY2' + - name: focus + monitor: '\\.\DISPLAY2' zones: - { name: main, x: 0, y: 0, width: 70, height: 100 } - { name: side, x: 70, y: 0, width: 30, height: 100 } ``` -Only one layout may be declared for each monitor name. +The monitor-specific entry must use the same layout name as its fallback. Only +one entry may be declared for each combination of layout name and monitor name. +Names and monitor matching are not case-sensitive. + +Older configurations containing no `active_layout` and no layout names are +loaded as one backwards-compatible layout named `default`. ### Hotkeys diff --git a/cmd/fancywin/main.go b/cmd/fancywin/main.go index 4e84a94..b150cfc 100644 --- a/cmd/fancywin/main.go +++ b/cmd/fancywin/main.go @@ -11,7 +11,7 @@ import ( "gopkg.in/yaml.v3" ) -const version = "0.2.1" +const version = "0.3.0" func main() { exe, err := os.Executable() diff --git a/fancywin.example.yaml b/fancywin.example.yaml index 65f9793..777a7c6 100644 --- a/fancywin.example.yaml +++ b/fancywin.example.yaml @@ -3,16 +3,18 @@ version: 1 gap: 8 shift_drag: true +active_layout: focus overlay: enabled: true color: "#00AEEF" opacity: 90 - border_width: 3 + border_width: 10 layouts: # The wildcard is used for any monitor without a more specific entry. - - monitor: "*" + - name: focus + monitor: "*" zones: - name: left x: 0 @@ -30,9 +32,19 @@ layouts: width: 25 height: 100 + # Other layouts remain available by name. Change active_layout above to + # switch layouts; a running FancyWin instance reloads the change. + - name: equal-thirds + monitor: "*" + zones: + - { name: left, x: 0, y: 0, width: 33.333, height: 100 } + - { name: middle, x: 33.333, y: 0, width: 33.334, height: 100 } + - { name: right, x: 66.667, y: 0, width: 33.333, height: 100 } + # Optional per-display override. Get the name from Windows Display Settings # or the startup diagnostics, then uncomment and edit this block. - # - monitor: '\\.\DISPLAY2' + # - name: focus + # monitor: '\\.\DISPLAY2' # zones: # - { name: main, x: 0, y: 0, width: 70, height: 100 } # - { name: side, x: 70, y: 0, width: 30, height: 100 } diff --git a/internal/config/config.go b/internal/config/config.go index 40280ba..186ce61 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 99e9574..e547606 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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"} diff --git a/internal/platform/platform_windows.go b/internal/platform/platform_windows.go index fd8678c..007dd1d 100644 --- a/internal/platform/platform_windows.go +++ b/internal/platform/platform_windows.go @@ -168,7 +168,7 @@ func Run(configPath string, cfg config.Config, debug bool) error { } defer procKillTimer.Call(0, shiftTimer) - log.Printf("ready: Shift+drag snapping=%v, overlay=%v, %d hotkeys", cfg.ShiftDrag, cfg.Overlay.Enabled, len(e.hotkeys)) + log.Printf("ready: layout=%q, Shift+drag snapping=%v, overlay=%v, %d hotkeys", cfg.ActiveLayout, cfg.ShiftDrag, cfg.Overlay.Enabled, len(e.hotkeys)) logMonitors() var msg message for { @@ -419,7 +419,7 @@ func (e *engine) snapAtCursor(hwnd windows.Handle) (bool, string) { e.mu.RUnlock() l, ok := cfg.LayoutFor(mi.device()) if !ok { - return false, fmt.Sprintf("no layout matches monitor %s and no wildcard layout exists", mi.device()) + return false, fmt.Sprintf("active layout %q has no entry for monitor %s and no wildcard fallback", cfg.ActiveLayout, mi.device()) } work := toLayoutRect(mi.Work) zi := layout.ZoneAt(work, l.Zones, 0, layout.Point{X: p.X, Y: p.Y}) @@ -690,5 +690,5 @@ func (e *engine) reloadIfChanged() { _ = e.registerHotkeys() return } - log.Printf("configuration reloaded") + log.Printf("configuration reloaded: active layout=%q", cfg.ActiveLayout) }