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:
@@ -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
|
1. **Startup** — daemon queries connected monitors, determines portable/docked, applies layout
|
||||||
2. **Hotplug events** — listens to compositor socket for monitor connect/disconnect events
|
2. **Hotplug events** — listens to compositor socket for monitor connect/disconnect events
|
||||||
3. **Debounce** — waits 1200ms after the last event (docks fire multiple 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.)
|
5. **Hooks** — runs shell commands after layout change (waybar, wallpapers, etc.)
|
||||||
6. **Fallback polling** — every 5s checks monitor state (catches missed events)
|
6. **Fallback polling** — every 5s checks monitor state (catches missed events)
|
||||||
|
|
||||||
@@ -91,13 +91,15 @@ Configure `backend` in `config.yaml`:
|
|||||||
|
|
||||||
### Hyprland setup
|
### Hyprland setup
|
||||||
|
|
||||||
Add one line to `~/.config/hypr/hyprland.conf` (or `hyprland.lua`):
|
Add one line to `~/.config/hypr/hyprland.lua`:
|
||||||
|
|
||||||
```
|
```lua
|
||||||
source = ~/.config/hypr/monitors.conf
|
require("monitors")
|
||||||
```
|
```
|
||||||
|
|
||||||
Remove any static `monitor=...` lines — monitor-lets-go manages monitors now.
|
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:
|
Then reload:
|
||||||
|
|
||||||
@@ -215,12 +217,12 @@ Use the `backend_config` section for compositor-specific options:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
backend_config:
|
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 |
|
| 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. |
|
| 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.
|
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`.
|
**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
|
### Hooks not running
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,17 @@ import (
|
|||||||
// hyprlandBackend implements Backend for the Hyprland compositor.
|
// hyprlandBackend implements Backend for the Hyprland compositor.
|
||||||
//
|
//
|
||||||
// Monitor queries: hyprctl -j monitors all
|
// 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)
|
// 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 {
|
type hyprlandBackend struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
||||||
// outputPath overrides the generated monitor config file path.
|
// 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.
|
// Read from backend_config.output_path at construction time.
|
||||||
outputPath string
|
outputPath string
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ type hyprlandBackend struct {
|
|||||||
// backend_config section. Hyprland supports:
|
// backend_config section. Hyprland supports:
|
||||||
//
|
//
|
||||||
// output_path — path to the generated monitor config file
|
// 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) {
|
func NewHyprland(logger *slog.Logger, opts map[string]any) (Backend, error) {
|
||||||
sig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
|
sig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
|
||||||
if sig == "" {
|
if sig == "" {
|
||||||
@@ -120,7 +120,7 @@ func (h *hyprlandBackend) ApplyLayout(ctx context.Context, monitors []MonitorCon
|
|||||||
|
|
||||||
// Atomic write: temp file, write, fsync, rename.
|
// Atomic write: temp file, write, fsync, rename.
|
||||||
if err := atomicWrite(destPath, []byte(content)); err != nil {
|
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.
|
// Reload Hyprland config to apply changes atomically.
|
||||||
@@ -171,10 +171,10 @@ func (h *hyprlandBackend) WriteConfig(ctx context.Context, monitors []MonitorCon
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare removes all monitor=...,disabled lines from the monitors.conf
|
// Prepare removes all hl.monitor({...disabled = true...}) lines from the
|
||||||
// file. This prevents the "no active displays" issue when Hyprland starts
|
// monitor config file. This prevents the "no active displays" issue when
|
||||||
// with a stale config. If the resulting file would be empty (or doesn't
|
// Hyprland starts with a stale config. If the resulting file would be empty
|
||||||
// exist), a generic built-in entry is written as a fallback.
|
// (or doesn't exist), a generic built-in entry is written as a fallback.
|
||||||
func (h *hyprlandBackend) Prepare(ctx context.Context) error {
|
func (h *hyprlandBackend) Prepare(ctx context.Context) error {
|
||||||
destPath, err := h.resolveOutputPath()
|
destPath, err := h.resolveOutputPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -197,7 +197,7 @@ func (h *hyprlandBackend) Prepare(ctx context.Context) error {
|
|||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(trimmed, "monitor=") && strings.HasSuffix(trimmed, ",disabled") {
|
if strings.Contains(trimmed, "disabled = true") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cleaned = append(cleaned, line)
|
cleaned = append(cleaned, line)
|
||||||
@@ -205,7 +205,7 @@ func (h *hyprlandBackend) Prepare(ctx context.Context) error {
|
|||||||
|
|
||||||
content := strings.Join(cleaned, "\n")
|
content := strings.Join(cleaned, "\n")
|
||||||
if strings.TrimSpace(content) == "" {
|
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 {
|
if err := atomicWrite(destPath, []byte(content)); err != nil {
|
||||||
@@ -330,12 +330,21 @@ func eventData(line string) string {
|
|||||||
return line[idx+2:]
|
return line[idx+2:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateConf produces the content of a Hyprland-compatible monitor config file.
|
// generateConf produces the content of a Hyprland Lua monitor config file.
|
||||||
// Old-style syntax: monitor=name,mode,pos,scale
|
// Enabled monitors: hl.monitor({ output = "eDP-1", mode = "1920x1200@60", position = "0x0", scale = 1 })
|
||||||
// Disabled monitors: monitor=name,disabled
|
// 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 {
|
func (h *hyprlandBackend) generateConf(monitors []MonitorConfig) string {
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
|
|
||||||
|
buf.WriteString("-- Generated by monitor-lets-go. Do not edit.\n")
|
||||||
|
|
||||||
// Ensure disabled monitors appear last so Hyprland migrates
|
// Ensure disabled monitors appear last so Hyprland migrates
|
||||||
// workspaces to enabled ones first.
|
// workspaces to enabled ones first.
|
||||||
var disabled []MonitorConfig
|
var disabled []MonitorConfig
|
||||||
@@ -346,36 +355,44 @@ func (h *hyprlandBackend) generateConf(monitors []MonitorConfig) string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
mode := m.Mode
|
buf.WriteString("hl.monitor({ output = ")
|
||||||
if mode == "" {
|
buf.WriteString(luaQuote(m.Name))
|
||||||
mode = "preferred"
|
|
||||||
}
|
|
||||||
pos := m.Position
|
|
||||||
if pos == "" {
|
|
||||||
pos = "auto"
|
|
||||||
}
|
|
||||||
scale := formatScale(m.Scale)
|
|
||||||
|
|
||||||
buf.WriteString("monitor=")
|
if m.Mode != "" {
|
||||||
buf.WriteString(m.Name)
|
buf.WriteString(", mode = ")
|
||||||
buf.WriteString(",")
|
buf.WriteString(luaQuote(m.Mode))
|
||||||
buf.WriteString(mode)
|
}
|
||||||
buf.WriteString(",")
|
if m.Position != "" {
|
||||||
buf.WriteString(pos)
|
buf.WriteString(", position = ")
|
||||||
buf.WriteString(",")
|
buf.WriteString(luaQuote(m.Position))
|
||||||
buf.WriteString(scale)
|
}
|
||||||
buf.WriteString("\n")
|
buf.WriteString(", scale = ")
|
||||||
|
buf.WriteString(formatScale(m.Scale))
|
||||||
|
|
||||||
|
buf.WriteString(" })\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range disabled {
|
for _, m := range disabled {
|
||||||
buf.WriteString("monitor=")
|
buf.WriteString("hl.monitor({ output = ")
|
||||||
buf.WriteString(m.Name)
|
buf.WriteString(luaQuote(m.Name))
|
||||||
buf.WriteString(",disabled\n")
|
buf.WriteString(", disabled = true })\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return buf.String()
|
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
|
// ensureCleanLayout adds disabled entries for any physically connected
|
||||||
// monitor that is not present in the target layout. This prevents
|
// monitor that is not present in the target layout. This prevents
|
||||||
// monitors from remaining enabled when switching to a mode that only
|
// 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.
|
// resolveOutputPath returns the path for the generated monitor config file.
|
||||||
// When outputPath is set (via constructor), it is used after ~ expansion.
|
// 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) {
|
func (h *hyprlandBackend) resolveOutputPath() (string, error) {
|
||||||
if h.outputPath != "" {
|
if h.outputPath != "" {
|
||||||
p := h.outputPath
|
p := h.outputPath
|
||||||
@@ -452,7 +469,7 @@ func (h *hyprlandBackend) resolveOutputPath() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return filepath.Join(configDir, "monitors.conf"), nil
|
return filepath.Join(configDir, "monitors.lua"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *hyprlandBackend) Close() error {
|
func (h *hyprlandBackend) Close() error {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ type Config struct {
|
|||||||
// OutputPath overrides the generated monitor config file path.
|
// OutputPath overrides the generated monitor config file path.
|
||||||
// Supports ~ for home directory expansion.
|
// Supports ~ for home directory expansion.
|
||||||
// Deprecated: use backend_config.output_path instead.
|
// 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"`
|
OutputPath string `yaml:"output_path"`
|
||||||
|
|
||||||
// BackendConfig holds backend-specific configuration options.
|
// BackendConfig holds backend-specific configuration options.
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ restore_on_exit: true
|
|||||||
# Backend-specific options. Keys depend on the selected backend.
|
# Backend-specific options. Keys depend on the selected backend.
|
||||||
# Hyprland supports:
|
# Hyprland supports:
|
||||||
# output_path — path to the generated monitor config file
|
# 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).
|
# Sway supports no backend-specific options (uses swaymsg commands directly).
|
||||||
# backend_config:
|
# backend_config:
|
||||||
# output_path: ~/.config/hypr/custom-monitors.conf
|
# output_path: ~/.config/hypr/custom-monitors.lua
|
||||||
|
|
||||||
# External monitors that trigger docked mode.
|
# External monitors that trigger docked mode.
|
||||||
# Plain name: matches the connector name (e.g. DP-1, HDMI-A-1).
|
# Plain name: matches the connector name (e.g. DP-1, HDMI-A-1).
|
||||||
|
|||||||
Reference in New Issue
Block a user