// 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"` // OutputPath overrides the generated monitor config file path. // Supports ~ for home directory expansion. // Empty means the default: ~/.config/hypr/monitors.conf. OutputPath string `yaml:"output_path"` // 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 }