feat: size: prefix, external auto-detection, auto-disable stale monitors
- 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
This commit is contained in:
+126
-37
@@ -12,6 +12,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -103,23 +105,24 @@ type Backend interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// resolveMonitorNames converts desc: prefixed monitor config names to the
|
||||
// actual connector names (e.g. "desc:Xiaomi Corporation Mi Monitor" → "DP-9")
|
||||
// by matching against the current set of connected monitors.
|
||||
// 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:make model" — substring match against description (make + " " + model)
|
||||
// - "desc:serial" — substring match against serial number
|
||||
// - "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 needsDescResolution bool
|
||||
var needsResolution bool
|
||||
for _, m := range monitors {
|
||||
if strings.HasPrefix(m.Name, "desc:") {
|
||||
needsDescResolution = true
|
||||
if strings.HasPrefix(m.Name, "desc:") || strings.HasPrefix(m.Name, "size:") {
|
||||
needsResolution = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !needsDescResolution {
|
||||
if !needsResolution {
|
||||
return monitors, nil
|
||||
}
|
||||
|
||||
@@ -130,37 +133,123 @@ func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonit
|
||||
|
||||
resolved := make([]MonitorConfig, len(monitors))
|
||||
for i, m := range monitors {
|
||||
if !strings.HasPrefix(m.Name, "desc:") {
|
||||
resolved[i] = m
|
||||
continue
|
||||
}
|
||||
|
||||
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 nil, fmt.Errorf("no connected monitor matches desc:%q", needle)
|
||||
case 1:
|
||||
resolved[i] = m
|
||||
resolved[i].Name = matches[0].Name
|
||||
logger.Debug("resolved desc to connector",
|
||||
"desc", needle, "connector", matches[0].Name)
|
||||
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:
|
||||
var names []string
|
||||
for _, mat := range matches {
|
||||
names = append(names, mat.Name)
|
||||
}
|
||||
return nil, fmt.Errorf(
|
||||
"desc:%q matches multiple monitors: %s — use a more specific identifier",
|
||||
needle, strings.Join(names, ", "))
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user