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.
This commit is contained in:
Maksim Totmin
2026-08-02 12:50:53 +07:00
parent ac82e3e82a
commit 3515608fd3
4 changed files with 68 additions and 49 deletions
+55 -38
View File
@@ -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 {
+1 -1
View File
@@ -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.