// Package config handles parsing, validation, and defaults for the // monitor-lets-go YAML configuration file. package config import ( "errors" "fmt" "math" "os" "strconv" "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. // Deprecated: use backend_config.output_path instead. // Empty means the default: ~/.config/hypr/monitors.lua. OutputPath string `yaml:"output_path"` // BackendConfig holds backend-specific configuration options. // Keys and values vary by the selected backend. // Hyprland supports: // output_path — path to the generated monitor config file BackendConfig map[string]any `yaml:"backend_config"` // 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") } // External list is optional. When empty, the daemon auto-detects // external monitors (any non-internal display with non-zero dimensions). return nil } // EffectiveBackendConfig merges top-level deprecated backend keys // (OutputPath) into the BackendConfig map. backend_config values // take priority over deprecated top-level keys. // // The caller receives a non-nil map even when no backend_config is // set and no deprecated keys are present. func (c *Config) EffectiveBackendConfig() map[string]any { bc := c.BackendConfig if bc == nil { bc = make(map[string]any) } if c.OutputPath != "" { if _, exists := bc["output_path"]; !exists { bc["output_path"] = c.OutputPath } } return bc } // countEnabled returns how many MonitorEntries are enabled. func countEnabled(entries []MonitorEntry) int { n := 0 for _, e := range entries { if e.Enabled { n++ } } return n } // isInternalConnector returns true if the connector name matches a known // internal display pattern (eDP, LVDS, DSI) or Hyprland's synthetic // "FALLBACK" output. Internal and synthetic displays are always part of the // laptop/tablet (or a no-display fallback) and should never trigger docked // mode. func isInternalConnector(name string) bool { if strings.EqualFold(name, "fallback") { return true } prefixes := []string{"eDP-", "LVDS-", "DSI-", "EDP-"} for _, p := range prefixes { if strings.HasPrefix(name, p) { return true } } return false } // ExternalMonitor carries the identity of a detected monitor so the // external list can match by name, description, serial, or resolution. type ExternalMonitor struct { Name string Description string Serial string Width int Height int RefreshRate float64 } // MatchesExternal checks whether a monitor matches the External list. // Supports match modes: // // - Plain name: exact match against the connector name // - desc: prefix: substring match against description or serial // - serial: prefix: substring match against serial // - size:WxH / size:WxH@R: exact pixel dimensions, optionally plus // refresh rate (within ±1 Hz) // // When the External list is empty, any monitor that is not an internal // display connector (eDP-, LVDS-, DSI-) or synthetic output (FALLBACK) // is automatically external. func (c *Config) MatchesExternal(m ExternalMonitor) bool { if len(c.External) == 0 { return !isInternalConnector(m.Name) } for _, ext := range c.External { switch { case strings.HasPrefix(ext, "desc:"): needle := strings.TrimPrefix(ext, "desc:") if strings.Contains(m.Description, needle) || strings.Contains(m.Serial, needle) { return true } case strings.HasPrefix(ext, "serial:"): needle := strings.TrimPrefix(ext, "serial:") if strings.Contains(m.Serial, needle) { return true } case strings.HasPrefix(ext, "size:"): if matchSize(ext, m.Width, m.Height, m.RefreshRate) { return true } default: if m.Name == ext { return true } } } return false } // matchSize reports whether a monitor matches a "size:WxH" or "size:WxH@R" // spec by exact pixel dimensions and, when a refresh rate is given, by // refresh rate within ±1 Hz. func matchSize(spec string, width, height int, refresh float64) bool { spec = strings.TrimPrefix(spec, "size:") parts := strings.Split(spec, "@") dimParts := strings.Split(parts[0], "x") if len(dimParts) != 2 { return false } w, errW := strconv.Atoi(strings.TrimSpace(dimParts[0])) h, errH := strconv.Atoi(strings.TrimSpace(dimParts[1])) if errW != nil || errH != nil { return false } if w != width || h != height { return false } if len(parts) == 2 { r, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) if err != nil || math.Abs(r-refresh) > 1.0 { return false } } return true }