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
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,
+51 -7
View File
@@ -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
+1 -1
View File
@@ -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()
+15 -3
View File
@@ -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 }
+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"}
+3 -3
View File
@@ -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)
}