From 3515608fd3410ca459a02b8215820c2d33d36fb4 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Sun, 2 Aug 2026 12:50:53 +0700 Subject: [PATCH] feat(hyprland): generate Lua monitor config instead of hyprlang Write hl.monitor({...}) calls to ~/.config/hypr/monitors.lua (loaded via require("monitors") in hyprland.lua) instead of monitor= lines in monitors.conf. Prepare now strips hl.monitor lines with disabled = true, and generateConf omits mode/position so Hyprland uses its defaults. Update README and example config to match. --- README.md | 18 +++---- internal/backend/hyprland.go | 93 +++++++++++++++++++++--------------- internal/config/config.go | 2 +- monitor-lets-go.example.yaml | 4 +- 4 files changed, 68 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 2908b40..9967562 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Plugs into your dock — external monitors turn on, built-in turns off. Unplug 1. **Startup** — daemon queries connected monitors, determines portable/docked, applies layout 2. **Hotplug events** — listens to compositor socket for monitor connect/disconnect events 3. **Debounce** — waits 1200ms after the last event (docks fire multiple events) -4. **Apply** — Hyprland: writes `monitors.conf` + `hyprctl reload`; Sway: `swaymsg 'output ...'` batch commands +4. **Apply** — Hyprland: writes `monitors.lua` + `hyprctl reload`; Sway: `swaymsg 'output ...'` batch commands 5. **Hooks** — runs shell commands after layout change (waybar, wallpapers, etc.) 6. **Fallback polling** — every 5s checks monitor state (catches missed events) @@ -91,13 +91,15 @@ Configure `backend` in `config.yaml`: ### Hyprland setup -Add one line to `~/.config/hypr/hyprland.conf` (or `hyprland.lua`): +Add one line to `~/.config/hypr/hyprland.lua`: -``` -source = ~/.config/hypr/monitors.conf +```lua +require("monitors") ``` Remove any static `monitor=...` lines — monitor-lets-go manages monitors now. +The daemon writes `~/.config/hypr/monitors.lua` (Lua, `hl.monitor` calls) and +reloads Hyprland automatically. Then reload: @@ -215,12 +217,12 @@ Use the `backend_config` section for compositor-specific options: ```yaml backend_config: - output_path: ~/.config/hypr/custom-monitors.conf # override generated config path + output_path: ~/.config/hypr/custom-monitors.lua # override generated config path ``` | Backend | Key | Type | Default | Description | |---------|-----|------|---------|-------------| -| Hyprland | `output_path` | string | `~/.config/hypr/monitors.conf` | Path to the generated monitor config file. Supports `~` expansion. | +| Hyprland | `output_path` | string | `~/.config/hypr/monitors.lua` | Path to the generated monitor config file. Supports `~` expansion. | | Sway | _(none)_ | — | — | Layout is applied via `swaymsg` commands directly — no config files needed. | The deprecated top-level `output_path` key still works — `backend_config` takes priority if both are set. @@ -339,9 +341,9 @@ The daemon couldn't find any supported compositor. **Sway:** `SWAYSOCK` is not set and no sway IPC socket found in `$XDG_RUNTIME_DIR`. Make sure Sway is running and the socket is accessible. Verify with: `ls $XDG_RUNTIME_DIR/sway-ipc.*.sock`. -### "source file not found" (Hyprland only) +### "module not found: monitors" (Hyprland only) -The `source = ~/.config/hypr/monitors.conf` line must be added to hyprland.conf. The daemon creates this file on first run. +The `require("monitors")` line must be added to `~/.config/hypr/hyprland.lua`. The daemon creates `~/.config/hypr/monitors.lua` on first run. ### Hooks not running diff --git a/internal/backend/hyprland.go b/internal/backend/hyprland.go index d8eaf13..0c2cda7 100644 --- a/internal/backend/hyprland.go +++ b/internal/backend/hyprland.go @@ -19,17 +19,17 @@ import ( // hyprlandBackend implements Backend for the Hyprland compositor. // // Monitor queries: hyprctl -j monitors all -// Layout application: write ~/.config/hypr/monitors.conf + hyprctl reload +// Layout application: write ~/.config/hypr/monitors.lua + hyprctl reload // Hotplug events: socket2 unix socket ($XDG_RUNTIME_DIR/hypr/$HIS/.socket2.sock) // -// The user must add the following line to their hyprland.lua: +// The daemon writes a Lua config file that hyprland.lua loads via: // -// source = os.getenv("HOME") .. "/.config/hypr/monitors.conf" +// require("monitors") type hyprlandBackend struct { logger *slog.Logger // outputPath overrides the generated monitor config file path. - // Empty means the default: ~/.config/hypr/monitors.conf. + // Empty means the default: ~/.config/hypr/monitors.lua. // Read from backend_config.output_path at construction time. outputPath string @@ -45,7 +45,7 @@ type hyprlandBackend struct { // backend_config section. Hyprland supports: // // output_path — path to the generated monitor config file -// (default: ~/.config/hypr/monitors.conf) +// (default: ~/.config/hypr/monitors.lua) func NewHyprland(logger *slog.Logger, opts map[string]any) (Backend, error) { sig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") if sig == "" { @@ -120,7 +120,7 @@ func (h *hyprlandBackend) ApplyLayout(ctx context.Context, monitors []MonitorCon // Atomic write: temp file, write, fsync, rename. if err := atomicWrite(destPath, []byte(content)); err != nil { - return fmt.Errorf("write monitors.conf: %w", err) + return fmt.Errorf("write monitor config: %w", err) } // Reload Hyprland config to apply changes atomically. @@ -171,10 +171,10 @@ func (h *hyprlandBackend) WriteConfig(ctx context.Context, monitors []MonitorCon return nil } -// Prepare removes all monitor=...,disabled lines from the monitors.conf -// file. This prevents the "no active displays" issue when Hyprland starts -// with a stale config. If the resulting file would be empty (or doesn't -// exist), a generic built-in entry is written as a fallback. +// Prepare removes all hl.monitor({...disabled = true...}) lines from the +// monitor config file. This prevents the "no active displays" issue when +// Hyprland starts with a stale config. If the resulting file would be empty +// (or doesn't exist), a generic built-in entry is written as a fallback. func (h *hyprlandBackend) Prepare(ctx context.Context) error { destPath, err := h.resolveOutputPath() if err != nil { @@ -197,7 +197,7 @@ func (h *hyprlandBackend) Prepare(ctx context.Context) error { if trimmed == "" { continue } - if strings.HasPrefix(trimmed, "monitor=") && strings.HasSuffix(trimmed, ",disabled") { + if strings.Contains(trimmed, "disabled = true") { continue } cleaned = append(cleaned, line) @@ -205,7 +205,7 @@ func (h *hyprlandBackend) Prepare(ctx context.Context) error { content := strings.Join(cleaned, "\n") if strings.TrimSpace(content) == "" { - content = "monitor=eDP-1,preferred,auto,1\n" + content = "hl.monitor({ output = \"eDP-1\" })\n" } if err := atomicWrite(destPath, []byte(content)); err != nil { @@ -330,12 +330,21 @@ func eventData(line string) string { return line[idx+2:] } -// generateConf produces the content of a Hyprland-compatible monitor config file. -// Old-style syntax: monitor=name,mode,pos,scale -// Disabled monitors: monitor=name,disabled +// generateConf produces the content of a Hyprland Lua monitor config file. +// Enabled monitors: hl.monitor({ output = "eDP-1", mode = "1920x1200@60", position = "0x0", scale = 1 }) +// Disabled monitors: hl.monitor({ output = "DP-1", disabled = true }) +// +// The file is meant to be loaded from hyprland.lua via: +// +// require("monitors") +// +// mode and position are omitted when empty so Hyprland uses its defaults +// ("preferred" and "auto" respectively). func (h *hyprlandBackend) generateConf(monitors []MonitorConfig) string { var buf strings.Builder + buf.WriteString("-- Generated by monitor-lets-go. Do not edit.\n") + // Ensure disabled monitors appear last so Hyprland migrates // workspaces to enabled ones first. var disabled []MonitorConfig @@ -346,36 +355,44 @@ func (h *hyprlandBackend) generateConf(monitors []MonitorConfig) string { continue } - mode := m.Mode - if mode == "" { - mode = "preferred" - } - pos := m.Position - if pos == "" { - pos = "auto" - } - scale := formatScale(m.Scale) + buf.WriteString("hl.monitor({ output = ") + buf.WriteString(luaQuote(m.Name)) - buf.WriteString("monitor=") - buf.WriteString(m.Name) - buf.WriteString(",") - buf.WriteString(mode) - buf.WriteString(",") - buf.WriteString(pos) - buf.WriteString(",") - buf.WriteString(scale) - buf.WriteString("\n") + if m.Mode != "" { + buf.WriteString(", mode = ") + buf.WriteString(luaQuote(m.Mode)) + } + if m.Position != "" { + buf.WriteString(", position = ") + buf.WriteString(luaQuote(m.Position)) + } + buf.WriteString(", scale = ") + buf.WriteString(formatScale(m.Scale)) + + buf.WriteString(" })\n") } for _, m := range disabled { - buf.WriteString("monitor=") - buf.WriteString(m.Name) - buf.WriteString(",disabled\n") + buf.WriteString("hl.monitor({ output = ") + buf.WriteString(luaQuote(m.Name)) + buf.WriteString(", disabled = true })\n") } return buf.String() } +// luaQuote wraps s in double quotes, escaping characters that would break +// a Lua string literal. +func luaQuote(s string) string { + replacer := strings.NewReplacer( + "\\", "\\\\", + "\"", "\\\"", + "\n", "\\n", + "\r", "\\r", + ) + return "\"" + replacer.Replace(s) + "\"" +} + // 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 @@ -431,7 +448,7 @@ func (h *hyprlandBackend) hyprConfigDir() (string, error) { // resolveOutputPath returns the path for the generated monitor config file. // When outputPath is set (via constructor), it is used after ~ expansion. -// Otherwise the default ~/.config/hypr/monitors.conf is returned. +// Otherwise the default ~/.config/hypr/monitors.lua is returned. func (h *hyprlandBackend) resolveOutputPath() (string, error) { if h.outputPath != "" { p := h.outputPath @@ -452,7 +469,7 @@ func (h *hyprlandBackend) resolveOutputPath() (string, error) { if err != nil { return "", err } - return filepath.Join(configDir, "monitors.conf"), nil + return filepath.Join(configDir, "monitors.lua"), nil } func (h *hyprlandBackend) Close() error { diff --git a/internal/config/config.go b/internal/config/config.go index d947da6..c2a8b16 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,7 +53,7 @@ type Config struct { // OutputPath overrides the generated monitor config file path. // Supports ~ for home directory expansion. // Deprecated: use backend_config.output_path instead. - // Empty means the default: ~/.config/hypr/monitors.conf. + // Empty means the default: ~/.config/hypr/monitors.lua. OutputPath string `yaml:"output_path"` // BackendConfig holds backend-specific configuration options. diff --git a/monitor-lets-go.example.yaml b/monitor-lets-go.example.yaml index 89d2c39..e559ea2 100644 --- a/monitor-lets-go.example.yaml +++ b/monitor-lets-go.example.yaml @@ -18,10 +18,10 @@ restore_on_exit: true # Backend-specific options. Keys depend on the selected backend. # Hyprland supports: # output_path — path to the generated monitor config file -# (default: ~/.config/hypr/monitors.conf) +# (default: ~/.config/hypr/monitors.lua) # Sway supports no backend-specific options (uses swaymsg commands directly). # backend_config: -# output_path: ~/.config/hypr/custom-monitors.conf +# output_path: ~/.config/hypr/custom-monitors.lua # External monitors that trigger docked mode. # Plain name: matches the connector name (e.g. DP-1, HDMI-A-1).