Initial commit: monitor-lets-go — automatic monitor layout daemon
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
// Package config handles parsing, validation, and defaults for the
|
||||
// monitor-lets-go YAML configuration file.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Duration is a time.Duration that supports YAML string unmarshaling.
|
||||
type Duration time.Duration
|
||||
|
||||
// UnmarshalYAML parses a duration string like "1200ms", "5s", "2m".
|
||||
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
|
||||
var s string
|
||||
if err := value.Decode(&s); err != nil {
|
||||
return err
|
||||
}
|
||||
dur, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q: %w", s, err)
|
||||
}
|
||||
*d = Duration(dur)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Duration) String() string { return time.Duration(d).String() }
|
||||
|
||||
// Config holds the complete monitor-lets-go configuration.
|
||||
type Config struct {
|
||||
// Backend selects the window manager backend.
|
||||
// "auto" (default) probes for an available backend.
|
||||
Backend string `yaml:"backend"`
|
||||
|
||||
// Debounce is the quiet period after the last hotplug event before
|
||||
// querying and applying a new layout. Defaults to 1200ms.
|
||||
Debounce Duration `yaml:"debounce"`
|
||||
|
||||
// PollInterval is the fallback polling period when socket events
|
||||
// are unavailable. Set to 0 to disable. Defaults to 5s.
|
||||
PollInterval Duration `yaml:"poll_interval"`
|
||||
|
||||
// RestoreOnExit, if true, applies the portable layout before the
|
||||
// daemon shuts down (on SIGTERM). Defaults to true.
|
||||
// Uses *bool so we can distinguish "not set" from explicit false.
|
||||
RestoreOnExit *bool `yaml:"restore_on_exit"`
|
||||
|
||||
// External lists monitor identifiers that trigger docked mode.
|
||||
// Each entry is a name (DP-1) or a desc: prefix (desc:Dell U2723QE).
|
||||
External []string `yaml:"external"`
|
||||
|
||||
// Modes maps mode names to monitor layouts.
|
||||
// Required keys: "portable" and "docked".
|
||||
Modes map[string]Mode `yaml:"modes"`
|
||||
|
||||
// Hooks maps event names to shell commands executed after a layout change.
|
||||
// Supported keys: "on_dock", "on_undock".
|
||||
Hooks map[string][]string `yaml:"hooks"`
|
||||
}
|
||||
|
||||
// Mode describes a single monitor layout.
|
||||
type Mode struct {
|
||||
Monitors []MonitorEntry `yaml:"monitors"`
|
||||
}
|
||||
|
||||
// MonitorEntry defines the desired state of one monitor in a mode.
|
||||
type MonitorEntry struct {
|
||||
Name string `yaml:"name"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Mode string `yaml:"mode"`
|
||||
Position string `yaml:"position"`
|
||||
Scale float64 `yaml:"scale"`
|
||||
}
|
||||
|
||||
// Load reads and validates a configuration file.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
if err := cfg.applyDefaults(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// applyDefaults fills in default values for unset fields.
|
||||
func (c *Config) applyDefaults() error {
|
||||
if c.Backend == "" {
|
||||
c.Backend = "auto"
|
||||
}
|
||||
if c.Debounce == 0 {
|
||||
c.Debounce = Duration(1200 * time.Millisecond)
|
||||
}
|
||||
if c.PollInterval == 0 {
|
||||
c.PollInterval = Duration(5 * time.Second)
|
||||
}
|
||||
if c.RestoreOnExit == nil {
|
||||
t := true
|
||||
c.RestoreOnExit = &t
|
||||
}
|
||||
if c.Modes == nil {
|
||||
c.Modes = make(map[string]Mode)
|
||||
}
|
||||
if c.Hooks == nil {
|
||||
c.Hooks = make(map[string][]string)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validate checks that the configuration is usable.
|
||||
func (c *Config) validate() error {
|
||||
if c.Backend != "auto" && c.Backend != "hyprland" && c.Backend != "sway" {
|
||||
return fmt.Errorf("unknown backend %q", c.Backend)
|
||||
}
|
||||
|
||||
portable, ok := c.Modes["portable"]
|
||||
if !ok {
|
||||
return errors.New("missing required mode 'portable'")
|
||||
}
|
||||
docked, ok := c.Modes["docked"]
|
||||
if !ok {
|
||||
return errors.New("missing required mode 'docked'")
|
||||
}
|
||||
|
||||
// Portable mode must have at least one enabled monitor.
|
||||
if countEnabled(portable.Monitors) == 0 {
|
||||
return errors.New("portable mode must have at least one enabled monitor")
|
||||
}
|
||||
// Docked mode must have at least one enabled monitor.
|
||||
if countEnabled(docked.Monitors) == 0 {
|
||||
return errors.New("docked mode must have at least one enabled monitor")
|
||||
}
|
||||
// At least one external monitor must be listed.
|
||||
if len(c.External) == 0 {
|
||||
return errors.New("at least one external monitor must be specified")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// countEnabled returns how many MonitorEntries are enabled.
|
||||
func countEnabled(entries []MonitorEntry) int {
|
||||
n := 0
|
||||
for _, e := range entries {
|
||||
if e.Enabled {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// MatchesExternal checks whether a monitor name or description matches any
|
||||
// entry in the External list. Supports two match modes:
|
||||
//
|
||||
// - Plain name: exact match against MonitorEntry.Name
|
||||
// - desc: prefix: substring match against MonitorEntry.Description
|
||||
func (c *Config) MatchesExternal(name, description string) bool {
|
||||
for _, ext := range c.External {
|
||||
if strings.HasPrefix(ext, "desc:") {
|
||||
desc := strings.TrimPrefix(ext, "desc:")
|
||||
if strings.Contains(description, desc) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if name == ext {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user