// Package backend defines the abstraction layer for window-manager-specific // monitor management. Each supported compositor (Hyprland, Sway, etc.) // implements the Backend interface, encapsulating how monitors are detected, // configured, and how hotplug events are received. // // This is the only WM-dependent code in the project. To add support for a // new window manager, implement this interface and register the backend in // cmd/monitor-lets-go/main.go. package backend import ( "context" "fmt" "log/slog" "math" "strconv" "strings" ) // MonitorInfo represents a physical display as reported by the compositor. // Fields use JSON tags matching hyprctl -j output; other compositors // populate equivalent fields. type MonitorInfo struct { Name string `json:"name"` Description string `json:"description"` Make string `json:"make"` Model string `json:"model"` Serial string `json:"serial"` Width int `json:"width"` Height int `json:"height"` RefreshRate float64 `json:"refreshRate"` X int `json:"x"` Y int `json:"y"` Scale float64 `json:"scale"` Enabled bool `json:"enabled"` } // MonitorConfig describes the desired state of a single monitor. // Used in configuration and passed to ApplyLayout. type MonitorConfig struct { Name string `yaml:"name"` Enabled bool `yaml:"enabled"` Mode string `yaml:"mode"` // "1920x1080@144", "preferred", or empty Position string `yaml:"position"` // "0x0", "auto", or empty Scale float64 `yaml:"scale"` // 1.0, 1.5, etc. } // State represents the current operational mode of the daemon. type State int const ( StateUnknown State = iota StatePortable // built-in display only StateDocked // external monitors connected ) // String returns a human-readable state name used for config keys and logging. func (s State) String() string { switch s { case StatePortable: return "portable" case StateDocked: return "docked" default: return "unknown" } } // EventType identifies the kind of monitor hotplug event. type EventType int const ( EventMonitorAdded EventType = iota EventMonitorRemoved ) // Event carries a single monitor hotplug notification from the compositor. type Event struct { Type EventType MonitorName string } // Backend is the abstraction over a window manager's monitor management. // Each implementation handles compositor-specific APIs for querying monitors, // applying layouts, and subscribing to hotplug events. type Backend interface { // Name returns a human-readable backend identifier (e.g. "hyprland"). Name() string // GetMonitors returns all monitors known to the compositor, // both active and inactive (physically connected but disabled). GetMonitors(ctx context.Context) ([]MonitorInfo, error) // ApplyLayout applies a list of monitor configurations atomically. // The backend must ensure no intermediate state where zero monitors // are enabled is ever visible. ApplyLayout(ctx context.Context, monitors []MonitorConfig) error // Events returns a channel of hotplug events and a channel of errors. // The caller must read from both channels. When ctx is cancelled, // the backend closes both channels and stops listening. Events(ctx context.Context) (<-chan Event, <-chan error) // Close releases any resources held by the backend. Close() error } // resolveMonitorNames converts desc: or size: prefixed monitor config names // to the actual connector names by matching against the current set of // connected monitors. // // Matching logic: // - "desc:text" — substring match against description or serial // - "size:WxH" — match by exact pixel dimensions (e.g. "size:2560x1440") // - "size:WxH@R" — match by dimensions + refresh rate (e.g. "size:2560x1440@165") // - plain names are returned as-is func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonitors func(context.Context) ([]MonitorInfo, error), logger *slog.Logger) ([]MonitorConfig, error) { var needsResolution bool for _, m := range monitors { if strings.HasPrefix(m.Name, "desc:") || strings.HasPrefix(m.Name, "size:") { needsResolution = true break } } if !needsResolution { return monitors, nil } current, err := getMonitors(ctx) if err != nil { return nil, fmt.Errorf("get current monitors: %w", err) } resolved := make([]MonitorConfig, len(monitors)) for i, m := range monitors { switch { case strings.HasPrefix(m.Name, "desc:"): resolved[i] = resolveDesc(m, current, logger) case strings.HasPrefix(m.Name, "size:"): resolved[i] = resolveSize(m, current, logger) default: resolved[i] = m } if resolved[i].Name == "" { return nil, fmt.Errorf("cannot resolve monitor name %q", m.Name) } } return resolved, nil } // resolveDesc resolves a desc: prefixed name to a connector name. func resolveDesc(m MonitorConfig, current []MonitorInfo, logger *slog.Logger) MonitorConfig { needle := strings.TrimPrefix(m.Name, "desc:") var matches []MonitorInfo for _, mi := range current { if strings.Contains(mi.Description, needle) || strings.Contains(mi.Serial, needle) { matches = append(matches, mi) } } switch len(matches) { case 0: // Return as-is so the caller sees the empty name. m.Name = "" return m case 1: logger.Debug("resolved desc to connector", "desc", needle, "connector", matches[0].Name) m.Name = matches[0].Name return m default: var names []string for _, mat := range matches { names = append(names, mat.Name) } logger.Warn("desc:%q matches multiple monitors: %s — use a more specific identifier", needle, strings.Join(names, ", ")) m.Name = "" return m } } // resolveSize resolves a size: prefixed name to a connector name. // Format: "size:WxH" or "size:WxH@R" where W and H are pixel dimensions // and R is the refresh rate in Hz. func resolveSize(m MonitorConfig, current []MonitorInfo, logger *slog.Logger) MonitorConfig { spec := strings.TrimPrefix(m.Name, "size:") parts := strings.Split(spec, "@") dimParts := strings.Split(parts[0], "x") if len(dimParts) != 2 { logger.Warn("invalid size spec", "spec", spec) m.Name = "" return m } targetW, errW := strconv.Atoi(strings.TrimSpace(dimParts[0])) targetH, errH := strconv.Atoi(strings.TrimSpace(dimParts[1])) if errW != nil || errH != nil { logger.Warn("invalid size dimensions", "spec", spec) m.Name = "" return m } var targetR float64 hasRefresh := len(parts) == 2 if hasRefresh { var err error targetR, err = strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) if err != nil { logger.Warn("invalid refresh rate in size spec", "spec", spec) m.Name = "" return m } } var matches []MonitorInfo for _, mi := range current { if mi.Width != targetW || mi.Height != targetH { continue } if hasRefresh { if math.Abs(mi.RefreshRate-targetR) > 1.0 { continue } } matches = append(matches, mi) } switch len(matches) { case 0: m.Name = "" return m case 1: logger.Debug("resolved size to connector", "size", spec, "connector", matches[0].Name) m.Name = matches[0].Name return m default: var names []string for _, mat := range matches { names = append(names, mat.Name) } if hasRefresh { logger.Warn("size:%q matches multiple monitors: %s — add @R to disambiguate", spec, strings.Join(names, ", ")) } else { logger.Warn("size:%q matches multiple monitors: %s — use size:WxH@R for disambiguation", spec, strings.Join(names, ", ")) } m.Name = "" return m } }