- size:WxH and size:WxH@R resolution for config names - auto-detection of external monitors when external list is empty (any non-internal display) - ensureCleanLayout: explicitly disable connected monitors not in target layout - update docs (README, example.yaml) for all three features
256 lines
7.5 KiB
Go
256 lines
7.5 KiB
Go
// 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
|
|
}
|
|
}
|