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:
Maksim Totmin
2026-06-24 19:51:18 +07:00
parent ac2bb77113
commit 3fdccadd3c
5 changed files with 223 additions and 54 deletions
+39
View File
@@ -92,6 +92,10 @@ func (h *hyprlandBackend) GetMonitors(ctx context.Context) ([]MonitorInfo, error
// ApplyLayout writes the monitor configuration file and calls hyprctl reload.
// The config is written atomically (temp file + rename) to prevent corruption.
//
// Any physically connected monitor not mentioned in the desired layout is
// explicitly disabled. This prevents external monitors from remaining enabled
// when switching to portable mode (where only eDP-* is listed).
func (h *hyprlandBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig) error {
var err error
monitors, err = resolveMonitorNames(ctx, monitors, h.GetMonitors, h.logger)
@@ -99,6 +103,14 @@ func (h *hyprlandBackend) ApplyLayout(ctx context.Context, monitors []MonitorCon
return fmt.Errorf("resolve monitor names: %w", err)
}
// Fetch the current set of connected monitors and explicitly disable
// any that are not part of the target layout.
current, err := h.GetMonitors(ctx)
if err != nil {
return fmt.Errorf("get current monitors for cleanup: %w", err)
}
monitors = h.ensureCleanLayout(monitors, current)
content := h.generateConf(monitors)
destPath, err := h.resolveOutputPath()
@@ -282,6 +294,33 @@ func (h *hyprlandBackend) generateConf(monitors []MonitorConfig) string {
return buf.String()
}
// ensureCleanLayout adds disabled entries for any physically connected
// monitor that is not present in the target layout. This prevents
// monitors from remaining enabled when switching to a mode that only
// configures a subset of displays (e.g. portable mode).
func (h *hyprlandBackend) ensureCleanLayout(target []MonitorConfig, connected []MonitorInfo) []MonitorConfig {
names := make(map[string]bool, len(target))
for _, m := range target {
names[m.Name] = true
}
var result []MonitorConfig
result = append(result, target...)
for _, mi := range connected {
if !names[mi.Name] {
h.logger.Debug("explicitly disabling unlisted monitor",
"monitor", mi.Name)
result = append(result, MonitorConfig{
Name: mi.Name,
Enabled: false,
})
}
}
return result
}
// formatScale formats a scale float: 1 → "1", 1.5 → "1.5"
func formatScale(s float64) string {
if s == 0 {
+126 -37
View File
@@ -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
}
}