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
+4 -2
View File
@@ -231,8 +231,10 @@ to bypass that boundary or request elevation through a manifest.
### Configuration reload ### Configuration reload
The YAML decoder rejects unknown fields. Semantic validation checks the schema The YAML decoder rejects unknown fields. Semantic validation checks the schema
version, gap range, unique monitor entries, zone bounds, hotkey action syntax, version, gap range, active-layout existence, unique layout-name/monitor pairs,
and required values. 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 The message-loop timer checks the configuration modification time. A valid new
configuration replaces the active one and re-registers hotkeys. If parsing, configuration replaces the active one and re-registers hotkeys. If parsing,
+51 -7
View File
@@ -79,15 +79,17 @@ Here is a complete two-column configuration:
version: 1 version: 1
gap: 8 gap: 8
shift_drag: true shift_drag: true
active_layout: columns
overlay: overlay:
enabled: true enabled: true
color: "#00AEEF" color: "#00AEEF"
opacity: 90 opacity: 90
border_width: 3 border_width: 10
layouts: layouts:
- monitor: "*" - name: columns
monitor: "*"
zones: zones:
- name: left - name: left
x: 0 x: 0
@@ -100,6 +102,15 @@ layouts:
width: 50 width: 50
height: 100 height: 100
- name: fullscreen
monitor: "*"
zones:
- name: full
x: 0
y: 0
width: 100
height: 100
excluded_apps: excluded_apps:
- mstsc.exe - mstsc.exe
@@ -126,6 +137,7 @@ use.
| `version` | Configuration format. This must currently be `1`. | | `version` | Configuration format. This must currently be `1`. |
| `gap` | Inward spacing, in pixels, applied to every edge of every zone. Valid range: `0``500`. | | `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. | | `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. | | `overlay` | Controls the transparent zone guide shown during Shift-dragging. |
| `layouts` | One or more monitor layouts containing zones. | | `layouts` | One or more monitor layouts containing zones. |
| `excluded_apps` | Applications FancyWin must not move. May be empty. | | `excluded_apps` | Applications FancyWin must not move. May be empty. |
@@ -144,7 +156,7 @@ overlay:
enabled: true enabled: true
color: "#00AEEF" color: "#00AEEF"
opacity: 90 opacity: 90
border_width: 3 border_width: 10
``` ```
- `enabled` turns the guide on or off. - `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 Zones may overlap. If the pointer is inside more than one zone, the first
matching zone listed in the file is selected. 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 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
@@ -192,18 +229,25 @@ A specific monitor layout can override the fallback:
```yaml ```yaml
layouts: layouts:
- monitor: "*" - name: focus
monitor: "*"
zones: zones:
- { name: left, x: 0, y: 0, width: 50, height: 100 } - { name: left, x: 0, y: 0, width: 50, height: 100 }
- { name: right, x: 50, y: 0, width: 50, height: 100 } - { name: right, x: 50, y: 0, width: 50, height: 100 }
- monitor: '\\.\DISPLAY2' - name: focus
monitor: '\\.\DISPLAY2'
zones: zones:
- { name: main, x: 0, y: 0, width: 70, height: 100 } - { name: main, x: 0, y: 0, width: 70, height: 100 }
- { name: side, x: 70, y: 0, width: 30, 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 ### Hotkeys
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
const version = "0.2.1" const version = "0.3.0"
func main() { func main() {
exe, err := os.Executable() exe, err := os.Executable()
+15 -3
View File
@@ -3,16 +3,18 @@
version: 1 version: 1
gap: 8 gap: 8
shift_drag: true shift_drag: true
active_layout: focus
overlay: overlay:
enabled: true enabled: true
color: "#00AEEF" color: "#00AEEF"
opacity: 90 opacity: 90
border_width: 3 border_width: 10
layouts: layouts:
# The wildcard is used for any monitor without a more specific entry. # The wildcard is used for any monitor without a more specific entry.
- monitor: "*" - name: focus
monitor: "*"
zones: zones:
- name: left - name: left
x: 0 x: 0
@@ -30,9 +32,19 @@ layouts:
width: 25 width: 25
height: 100 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 # Optional per-display override. Get the name from Windows Display Settings
# or the startup diagnostics, then uncomment and edit this block. # or the startup diagnostics, then uncomment and edit this block.
# - monitor: '\\.\DISPLAY2' # - name: focus
# monitor: '\\.\DISPLAY2'
# zones: # zones:
# - { name: main, x: 0, y: 0, width: 70, height: 100 } # - { name: main, x: 0, y: 0, width: 70, height: 100 }
# - { name: side, x: 70, y: 0, width: 30, height: 100 } # - { name: side, x: 70, y: 0, width: 30, height: 100 }
+41 -6
View File
@@ -15,6 +15,7 @@ type Config struct {
Version int `yaml:"version"` Version int `yaml:"version"`
Gap int `yaml:"gap"` Gap int `yaml:"gap"`
ShiftDrag bool `yaml:"shift_drag"` ShiftDrag bool `yaml:"shift_drag"`
ActiveLayout string `yaml:"active_layout"`
Overlay Overlay `yaml:"overlay"` Overlay Overlay `yaml:"overlay"`
Layouts []Layout `yaml:"layouts"` Layouts []Layout `yaml:"layouts"`
Excluded []string `yaml:"excluded_apps"` Excluded []string `yaml:"excluded_apps"`
@@ -29,6 +30,7 @@ type Overlay struct {
} }
type Layout struct { type Layout struct {
Name string `yaml:"name"`
Monitor string `yaml:"monitor"` Monitor string `yaml:"monitor"`
Zones []Zone `yaml:"zones"` Zones []Zone `yaml:"zones"`
} }
@@ -49,8 +51,9 @@ type Hotkey struct {
func Default() Config { func Default() Config {
return Config{ return Config{
Version: 1, Gap: 8, ShiftDrag: true, Version: 1, Gap: 8, ShiftDrag: true,
Overlay: Overlay{Enabled: true, Color: "#00AEEF", Opacity: 90, BorderWidth: 3}, ActiveLayout: "columns",
Layouts: []Layout{{Monitor: "*", Zones: []Zone{ 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: "left", X: 0, Y: 0, Width: 50, Height: 100},
{Name: "right", X: 50, 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 { if err := d.Decode(&c); err != nil {
return Config{}, fmt.Errorf("parse YAML: %w", err) return Config{}, fmt.Errorf("parse YAML: %w", err)
} }
c.upgradeLegacyLayouts()
if err := c.Validate(); err != nil { if err := c.Validate(); err != nil {
return Config{}, err return Config{}, err
} }
@@ -100,15 +104,27 @@ func (c Config) Validate() error {
if len(c.Layouts) == 0 { if len(c.Layouts) == 0 {
return errors.New("at least one layout is required") 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{} seenMonitors := map[string]bool{}
activeExists := false
for li, l := range c.Layouts { 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) name := strings.TrimSpace(l.Monitor)
if name == "" { if name == "" {
return fmt.Errorf("layouts[%d].monitor is required", li) return fmt.Errorf("layouts[%d].monitor is required", li)
} }
key := strings.ToLower(name) key := strings.ToLower(layoutName) + "\x00" + strings.ToLower(name)
if seenMonitors[key] { 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 seenMonitors[key] = true
if len(l.Zones) == 0 { 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 { for i, h := range c.Hotkeys {
a := strings.ToLower(strings.TrimSpace(h.Action)) a := strings.ToLower(strings.TrimSpace(h.Action))
if a != "next_zone" && a != "previous_zone" && !strings.HasPrefix(a, "zone_") { if a != "next_zone" && a != "previous_zone" && !strings.HasPrefix(a, "zone_") {
@@ -138,6 +157,21 @@ func (c Config) Validate() error {
return nil 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 { func validHexColor(s string) bool {
if len(s) != 7 || s[0] != '#' { if len(s) != 7 || s[0] != '#' {
return false return false
@@ -151,14 +185,15 @@ func DefaultPath(exePath string) string {
} }
func (c Config) LayoutFor(device string) (Layout, bool) { func (c Config) LayoutFor(device string) (Layout, bool) {
active := strings.TrimSpace(c.ActiveLayout)
for _, l := range c.Layouts { for _, l := range c.Layouts {
monitor := strings.TrimSpace(l.Monitor) 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 return l, true
} }
} }
for _, l := range c.Layouts { 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 return l, true
} }
} }
+50 -1
View File
@@ -1,6 +1,10 @@
package config package config
import "testing" import (
"os"
"path/filepath"
"testing"
)
func TestDefaultValid(t *testing.T) { func TestDefaultValid(t *testing.T) {
if err := Default().Validate(); err != nil { 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) { func TestExcluded(t *testing.T) {
c := Default() c := Default()
c.Excluded = []string{"notepad", "exact.exe"} c.Excluded = []string{"notepad", "exact.exe"}
+3 -3
View File
@@ -168,7 +168,7 @@ func Run(configPath string, cfg config.Config, debug bool) error {
} }
defer procKillTimer.Call(0, shiftTimer) 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() logMonitors()
var msg message var msg message
for { for {
@@ -419,7 +419,7 @@ func (e *engine) snapAtCursor(hwnd windows.Handle) (bool, string) {
e.mu.RUnlock() e.mu.RUnlock()
l, ok := cfg.LayoutFor(mi.device()) l, ok := cfg.LayoutFor(mi.device())
if !ok { 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) work := toLayoutRect(mi.Work)
zi := layout.ZoneAt(work, l.Zones, 0, layout.Point{X: p.X, Y: p.Y}) 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() _ = e.registerHotkeys()
return return
} }
log.Printf("configuration reloaded") log.Printf("configuration reloaded: active layout=%q", cfg.ActiveLayout)
} }