From 3fdccadd3cbb2611310551773ef1420ed6a45712 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Wed, 24 Jun 2026 19:51:18 +0700 Subject: [PATCH] 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 --- README.md | 24 +++-- internal/backend/hyprland.go | 39 ++++++++ internal/backend/interface.go | 163 ++++++++++++++++++++++++++-------- internal/config/config.go | 39 +++++--- monitor-lets-go.example.yaml | 12 ++- 5 files changed, 223 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 30b999a..2908b40 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Plugs into your dock — external monitors turn on, built-in turns off. Unplug | 5 | **Graceful shutdown** — optionally restores portable layout on SIGTERM | | 6 | **Socket reconnect** — exponential backoff if compositor socket drops | | 7 | **Atomic config writes (Hyprland)** — temp file + rename prevents config corruption | +| 8 | **Auto-disable stale monitors** — monitors connected but absent from the target layout are explicitly disabled (prevents external displays from staying on after switching to portable mode) | --- @@ -142,13 +143,17 @@ restore_on_exit: true # External monitors that trigger docked mode. # Plain name: matches connector (DP-1, HDMI-A-1). # desc: prefix: matches by monitor description (survives port rename). -external: +# Optional — if omitted or empty, the daemon auto-detects external monitors: +# any display whose connector is not eDP-/LVDS-/DSI- is treated as external. +external: # optional - desc:Dell Inc. DELL U2723QE - DP-9 - DP-10 # Monitor layouts for each mode. # "portable" and "docked" are required. +# Monitors not listed in a mode are automatically disabled (prevents +# external displays from staying on when switching to portable mode). modes: portable: monitors: @@ -172,6 +177,11 @@ modes: scale: 1.0 - name: eDP-1 enabled: false # turn off laptop screen when docked + - name: size:1920x1080@60 # resolve by resolution + refresh rate + enabled: true + mode: preferred + position: auto + scale: 1.0 # Shell commands run after a layout change. # Commands run concurrently, failures are logged but never crash the daemon. @@ -185,17 +195,19 @@ hooks: ### Monitor matching -Two match modes, supported in both the `external` list and the `modes` section: +Three match modes for the `modes` section (the `external` list supports plain names and `desc:`): | Syntax | Matches | Use case | |---|---|---| | `DP-1` | Exact connector name | Simple setups, built-in displays | -| `desc:Dell U2723QE` | Substring in monitor description | Survives port rename across different docks | +| `desc:Dell U2723QE` | Substring in monitor description or serial | Survives port rename across different docks | +| `size:2560x1440` | Exact pixel dimensions | Two identical monitors with same resolution | +| `size:2560x1440@165` | Dimensions + refresh rate | Disambiguate identical models | Sway description format: `make model serial_widthxheight` (with serial omitted if `Unknown`). Run `hyprctl monitors all` (Hyprland) or `swaymsg -t get_outputs` (Sway) to see your monitor names and descriptions. -**desc: in modes** — when a monitor name in `modes` uses the `desc:` prefix, the daemon resolves it to the actual connector name at runtime. Ambiguous matches (a desc matching multiple monitors) cause an error. +**desc: and size: in modes** — when a monitor name in `modes` uses `desc:` or `size:`, the daemon resolves it to the actual connector name at runtime. Ambiguous matches (a prefix matching multiple monitors with identical resolution) cause an error. For `size:`, add `@R` (refresh rate) to disambiguate monitors with the same resolution. Unresolvable names also cause an error. ### Backend-specific configuration @@ -337,7 +349,7 @@ Check the hook command works from a terminal first. Hooks run via `sh -c`, so sh ### Monitor names changed after reboot -Use `desc:` prefix matching instead of connector names. This survives port renames across different docks and reboots. +Use `desc:` or `size:` prefix matching instead of connector names. Both survive port renames across different docks and reboots. **Hyprland:** `hyprctl monitors all` to see descriptions. **Sway:** `swaymsg -t get_outputs` to see names, make/model, serial, and native resolution. @@ -351,6 +363,8 @@ modes: monitors: - name: desc:Dell Inc. DELL U2723QE # also works here enabled: true + - name: size:2560x1440@165 # match by dimensions + refresh + enabled: true ``` --- diff --git a/internal/backend/hyprland.go b/internal/backend/hyprland.go index 1b1da39..0a178b2 100644 --- a/internal/backend/hyprland.go +++ b/internal/backend/hyprland.go @@ -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 { diff --git a/internal/backend/interface.go b/internal/backend/interface.go index 44a0886..b2b919d 100644 --- a/internal/backend/interface.go +++ b/internal/backend/interface.go @@ -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 + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 1b70676..d947da6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -157,10 +157,8 @@ func (c *Config) validate() error { if countEnabled(docked.Monitors) == 0 { return errors.New("docked mode must have at least one enabled monitor") } - // At least one external monitor must be listed. - if len(c.External) == 0 { - return errors.New("at least one external monitor must be specified") - } + // External list is optional. When empty, the daemon auto-detects + // external monitors (any non-internal display with non-zero dimensions). return nil } @@ -195,19 +193,40 @@ func countEnabled(entries []MonitorEntry) int { return n } -// MatchesExternal checks whether a monitor name or description matches any -// entry in the External list. Supports two match modes: +// isInternalConnector returns true if the connector name matches a known +// internal display pattern (eDP, LVDS, DSI). These are always part of the +// laptop or tablet and should never trigger docked mode. +func isInternalConnector(name string) bool { + prefixes := []string{"eDP-", "LVDS-", "DSI-", "EDP-"} + for _, p := range prefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +// MatchesExternal checks whether a monitor matches the External list. +// Supports match modes: // -// - Plain name: exact match against MonitorEntry.Name -// - desc: prefix: substring match against MonitorEntry.Description +// - Plain name: exact match against name +// - desc: prefix: substring match against description +// +// When the External list is empty, any monitor that is not an internal +// display connector (eDP-, LVDS-, DSI-) is automatically external. func (c *Config) MatchesExternal(name, description string) bool { + if len(c.External) == 0 { + return !isInternalConnector(name) + } + for _, ext := range c.External { - if strings.HasPrefix(ext, "desc:") { + switch { + case strings.HasPrefix(ext, "desc:"): desc := strings.TrimPrefix(ext, "desc:") if strings.Contains(description, desc) { return true } - } else { + default: if name == ext { return true } diff --git a/monitor-lets-go.example.yaml b/monitor-lets-go.example.yaml index eb25167..89d2c39 100644 --- a/monitor-lets-go.example.yaml +++ b/monitor-lets-go.example.yaml @@ -26,6 +26,9 @@ restore_on_exit: true # External monitors that trigger docked mode. # Plain name: matches the connector name (e.g. DP-1, HDMI-A-1). # desc: prefix: matches by monitor description (survives rename). +# Optional — if omitted or left empty, the daemon auto-detects external +# monitors: any display whose connector is not eDP-/LVDS-/DSI- is treated +# as external. external: - DP-1 - DP-2 @@ -33,10 +36,10 @@ external: # Monitor layouts for each mode. # "portable" and "docked" are required. -# Each mode lists monitors with their desired configuration. +# Monitors connected but not listed in the mode are automatically disabled. # # Fields: -# name — connector name or desc:description +# name — connector name, desc:description, size:WxH, or size:WxH@R # enabled — true to show, false to disable # mode — "preferred" (auto-detect), "1920x1080@60", etc. # position — "auto", "0x0", "1920x0", etc. @@ -70,6 +73,11 @@ modes: mode: "3840x2160@60" position: "0x0" scale: 1.5 + - name: size:1920x1080@60 # resolve by resolution + refresh rate + enabled: true + mode: preferred + position: auto + scale: 1.0 # Shell commands run after a layout change. # Commands run concurrently; failures are logged but never crash the daemon.