diff --git a/core/cmd/dms/commands_config.go b/core/cmd/dms/commands_config.go index fc2fe4af..e467ae49 100644 --- a/core/cmd/dms/commands_config.go +++ b/core/cmd/dms/commands_config.go @@ -54,8 +54,10 @@ func init() { } type IncludeResult struct { - Exists bool `json:"exists"` - Included bool `json:"included"` + Exists bool `json:"exists"` + Included bool `json:"included"` + ConfigFormat string `json:"configFormat,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` } func runResolveInclude(cmd *cobra.Command, args []string) { @@ -106,6 +108,8 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) { mainLua := filepath.Join(configDir, "hyprland.lua") if _, err := os.Stat(mainLua); err == nil { + result.ConfigFormat = "lua" + result.ReadOnly = false processedLua := make(map[string]bool) if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) { result.Included = true @@ -115,6 +119,10 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) { mainConf := filepath.Join(configDir, "hyprland.conf") if _, err := os.Stat(mainConf); err == nil { + if result.ConfigFormat == "" { + result.ConfigFormat = "hyprlang" + result.ReadOnly = true + } processed := make(map[string]bool) if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) { result.Included = true diff --git a/core/internal/config/deployer.go b/core/internal/config/deployer.go index fdbb09b1..2494e8ba 100644 --- a/core/internal/config/deployer.go +++ b/core/internal/config/deployer.go @@ -600,6 +600,10 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem return result, result.Error } + CleanupStrayHyprlandConfFile(func(format string, v ...any) { + cd.log(fmt.Sprintf(format, v...)) + }) + result.Deployed = true cd.log("Successfully deployed Hyprland configuration") return result, nil diff --git a/core/internal/config/deployer_test.go b/core/internal/config/deployer_test.go index 20a51ab2..8d772a98 100644 --- a/core/internal/config/deployer_test.go +++ b/core/internal/config/deployer_test.go @@ -20,13 +20,17 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) { td := t.TempDir() t.Setenv("HOME", td) configDir := filepath.Join(td, ".config", "hypr") - require.NoError(t, os.MkdirAll(configDir, 0o755)) + dmsDir := filepath.Join(configDir, "dms") + require.NoError(t, os.MkdirAll(dmsDir, 0o755)) confPath := filepath.Join(configDir, "hyprland.conf") + dmsConfPath := filepath.Join(dmsDir, "colors.conf") require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644)) + require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644)) CleanupStrayHyprlandConfFile(nil) assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated") + assert.FileExists(t, dmsConfPath, "must not touch dms/*.conf when user has not migrated") assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName)) }) @@ -34,20 +38,25 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) { td := t.TempDir() t.Setenv("HOME", td) configDir := filepath.Join(td, ".config", "hypr") - require.NoError(t, os.MkdirAll(configDir, 0o755)) + dmsDir := filepath.Join(configDir, "dms") + require.NoError(t, os.MkdirAll(dmsDir, 0o755)) luaPath := filepath.Join(configDir, "hyprland.lua") require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644)) confPath := filepath.Join(configDir, "hyprland.conf") + dmsConfPath := filepath.Join(dmsDir, "colors.conf") require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644)) + require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644)) CleanupStrayHyprlandConfFile(nil) assert.NoFileExists(t, confPath) + assert.NoFileExists(t, dmsConfPath) assert.FileExists(t, luaPath) entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName)) require.NoError(t, err) require.Len(t, entries, 1) assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "hyprland.conf")) + assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "dms", "colors.conf")) }) } @@ -404,6 +413,7 @@ general { dmsDir := filepath.Join(td, ".config", "hypr", "dms") require.NoError(t, os.MkdirAll(dmsDir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf"), []byte("bind = SUPER, T, exec, foot\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "colors.conf"), []byte("$primary = rgba(d0bcffFF)\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "cursor.conf"), []byte("env = XCURSOR_SIZE,24\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"), []byte("old backup\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf.backup.old"), []byte("old dms backup\n"), 0o644)) @@ -423,10 +433,12 @@ general { assert.Contains(t, result.BackupPath, hyprlandBackupDirName) assert.NoFileExists(t, hyprPath) assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf")) + assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "colors.conf")) assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "cursor.conf")) assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf.backup.old")) assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf.backup.old")) assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf")) + assert.NoFileExists(t, filepath.Join(dmsDir, "colors.conf")) assert.NoFileExists(t, filepath.Join(dmsDir, "cursor.conf")) assert.NoFileExists(t, filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old")) assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old")) @@ -485,7 +497,7 @@ general { managed, err := os.ReadFile(filepath.Join(dmsDir, "binds.lua")) require.NoError(t, err) assert.Contains(t, string(managed), `hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))`) - assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })`) + assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })`) user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua")) require.NoError(t, err) diff --git a/core/internal/config/embedded/hypr-binds.lua b/core/internal/config/embedded/hypr-binds.lua index 37f7ee97..6c48a90d 100644 --- a/core/internal/config/embedded/hypr-binds.lua +++ b/core/internal/config/embedded/hypr-binds.lua @@ -140,7 +140,7 @@ hl.bind("SUPER + bracketright", hl.dsp.layout("preselect r")) -- === Sizing & Layout === hl.bind("SUPER + R", hl.dsp.layout("togglesplit")) -hl.bind("SUPER + CTRL + F", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive exact 100% 100%]])) +hl.bind("SUPER + CTRL + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "set" })) -- === Move/resize windows with mainMod + LMB/RMB and dragging === hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" }) @@ -150,10 +150,10 @@ hl.bind("SUPER + code:20", hl.dsp.window.resize({ x = -100, y = 0, relative = tr hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" }) -- === Manual Sizing === -hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true }) -hl.bind("SUPER + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 10% 0]]), { repeating = true }) -hl.bind("SUPER + SHIFT + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 -10%]]), { repeating = true }) -hl.bind("SUPER + SHIFT + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 10%]]), { repeating = true }) +hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true }) +hl.bind("SUPER + equal", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { repeating = true }) +hl.bind("SUPER + SHIFT + minus", hl.dsp.window.resize({ x = 0, y = -100, relative = true }), { repeating = true }) +hl.bind("SUPER + SHIFT + equal", hl.dsp.window.resize({ x = 0, y = 100, relative = true }), { repeating = true }) -- === Screenshots === hl.bind("Print", hl.dsp.exec_cmd("dms screenshot")) diff --git a/core/internal/config/hyprland_lua.go b/core/internal/config/hyprland_lua.go index 30d5e24c..2af66761 100644 --- a/core/internal/config/hyprland_lua.go +++ b/core/internal/config/hyprland_lua.go @@ -138,11 +138,9 @@ func readExistingHyprlandConfig(configDir string) (data string, sourcePath strin return "", "", nil } -// CleanupStrayHyprlandConfFile moves a stray ~/.config/hypr/hyprland.conf -// into .dms-backups// only when hyprland.lua also exists, which -// proves Lua is the live config and the .conf is an autogen Hyprland 0.55 -// produced when launched without -c. If only hyprland.conf exists, the user -// has not migrated and we must leave their config alone. +// CleanupStrayHyprlandConfFile moves stray ~/.config/hypr/hyprland.conf and +// top-level ~/.config/hypr/dms/*.conf files into .dms-backups// only +// when hyprland.lua also exists as the live config. func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) { if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" { return @@ -156,19 +154,44 @@ func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) { if _, err := os.Stat(luaPath); err != nil { return } + + var strayPaths []string confPath := filepath.Join(configDir, "hyprland.conf") - if _, err := os.Stat(confPath); err != nil { - return + if info, err := os.Lstat(confPath); err == nil && !info.IsDir() { + strayPaths = append(strayPaths, confPath) } - ts := time.Now().Format("2006-01-02_15-04-05") - dst := filepath.Join(configDir, hyprlandBackupDirName, ts, "hyprland.conf") - if err := moveHyprlandConfigFile(confPath, dst); err != nil { - if logFn != nil { - logFn("Could not move stray hyprland.conf: %v", err) + dmsConfPaths, err := filepath.Glob(filepath.Join(configDir, "dms", "*.conf")) + if err == nil { + for _, p := range dmsConfPaths { + if info, err := os.Lstat(p); err == nil && !info.IsDir() { + strayPaths = append(strayPaths, p) + } } + } + if len(strayPaths) == 0 { return } - if logFn != nil { - logFn("Moved stray hyprland.conf to %s", dst) + + ts := time.Now().Format("2006-01-02_15-04-05") + moved := 0 + for _, src := range strayPaths { + rel, err := filepath.Rel(configDir, src) + if err != nil { + rel = filepath.Base(src) + } + dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel) + if err := moveHyprlandConfigFile(src, dst); err != nil { + if logFn != nil { + logFn("Could not move stray Hyprland conf file %s: %v", src, err) + } + continue + } + moved++ + if logFn != nil { + logFn("Moved stray Hyprland conf file to %s", dst) + } + } + if moved > 0 && logFn != nil { + logFn("Moved %d stray Hyprland conf file(s) out of the active Lua config tree", moved) } } diff --git a/core/internal/keybinds/providers/hyprland.go b/core/internal/keybinds/providers/hyprland.go index 30858218..9d0ee673 100644 --- a/core/internal/keybinds/providers/hyprland.go +++ b/core/internal/keybinds/providers/hyprland.go @@ -68,6 +68,8 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { Effective: result.DMSStatus.Effective, OverriddenBy: result.DMSStatus.OverriddenBy, StatusMessage: result.DMSStatus.StatusMessage, + ConfigFormat: result.DMSStatus.ConfigFormat, + ReadOnly: result.DMSStatus.ReadOnly, } } @@ -219,6 +221,9 @@ func (h *HyprlandProvider) validateAction(action string) error { } func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error { + if err := h.ensureWritableConfig(); err != nil { + return err + } if err := h.validateAction(action); err != nil { return err } @@ -242,9 +247,10 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[ } } - normalizedKey := strings.ToLower(key) + canonicalKey := canonicalHyprlandOverrideKey(key) + normalizedKey := hyprlandOverrideMapKey(canonicalKey) existingBinds[normalizedKey] = &hyprlandOverrideBind{ - Key: key, + Key: canonicalKey, Action: action, Description: description, Flags: flags, @@ -255,21 +261,28 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[ } func (h *HyprlandProvider) RemoveBind(key string) error { + if err := h.ensureWritableConfig(); err != nil { + return err + } existingBinds, err := h.loadOverrideBinds() if err != nil { return nil } - normalizedKey := strings.ToLower(key) - existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: key, Unbind: true} + canonicalKey := canonicalHyprlandOverrideKey(key) + normalizedKey := hyprlandOverrideMapKey(canonicalKey) + existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: canonicalKey, Unbind: true} return h.writeOverrideBinds(existingBinds) } func (h *HyprlandProvider) ResetBind(key string) error { + if err := h.ensureWritableConfig(); err != nil { + return err + } existingBinds, err := h.loadOverrideBinds() if err != nil { return nil } - normalizedKey := strings.ToLower(key) + normalizedKey := hyprlandOverrideMapKey(key) delete(existingBinds, normalizedKey) return h.writeOverrideBinds(existingBinds) } @@ -284,10 +297,46 @@ type hyprlandOverrideBind struct { Unbind bool } +func (h *HyprlandProvider) ensureWritableConfig() error { + if h.isLegacyConfigReadOnly() { + return fmt.Errorf("hyprland legacy conf configs are read-only; run dms setup to migrate to Lua before editing keybinds") + } + return nil +} + +func (h *HyprlandProvider) isLegacyConfigReadOnly() bool { + expanded, err := utils.ExpandPath(h.configPath) + if err != nil { + expanded = h.configPath + } + luaPath := filepath.Join(expanded, "hyprland.lua") + if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() { + return false + } + confPath := filepath.Join(expanded, "hyprland.conf") + if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() { + return true + } + return false +} + func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) { return readLuaOrHyprlangOverride(h.GetOverridePath()) } +func canonicalHyprlandOverrideKey(key string) string { + trimmed := strings.TrimSpace(key) + normalized := luaKeyComboToInternalKey(trimmed) + if normalized == "" { + return trimmed + } + return normalized +} + +func hyprlandOverrideMapKey(key string) string { + return strings.ToLower(canonicalHyprlandOverrideKey(key)) +} + func (h *HyprlandProvider) getBindSortPriority(action string) int { switch { case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"): @@ -368,24 +417,354 @@ func normalizeLuaBindKeyPart(part string) string { return part } +type luaField struct { + name string + value string +} + +func luaDispatcherTableCall(funcName string, fields ...luaField) string { + parts := make([]string, 0, len(fields)) + for _, field := range fields { + if field.name == "" || field.value == "" { + continue + } + parts = append(parts, field.name+" = "+field.value) + } + return fmt.Sprintf(`%s({ %s })`, funcName, strings.Join(parts, ", ")) +} + +func luaStringField(name, value string) luaField { + return luaField{name: name, value: strconv.Quote(strings.TrimSpace(value))} +} + +func luaBoolField(name string, value bool) luaField { + if value { + return luaField{name: name, value: "true"} + } + return luaField{name: name, value: "false"} +} + +func luaNumberOrStringField(name, value string) luaField { + value = strings.TrimSpace(value) + if isBareLuaNumber(value) { + return luaField{name: name, value: value} + } + return luaStringField(name, value) +} + +func isBareLuaNumber(value string) bool { + if value == "" || strings.HasPrefix(value, "+") { + return false + } + if value[0] == '-' { + value = value[1:] + } + if value == "" { + return false + } + digitsBeforeDot := 0 + i := 0 + for i < len(value) && value[i] >= '0' && value[i] <= '9' { + digitsBeforeDot++ + i++ + } + digitsAfterDot := 0 + if i < len(value) && value[i] == '.' { + i++ + for i < len(value) && value[i] >= '0' && value[i] <= '9' { + digitsAfterDot++ + i++ + } + } + return i == len(value) && (digitsBeforeDot > 0 || digitsAfterDot > 0) +} + +func splitHyprlandAction(action string) (dispatcher, params string) { + action = strings.TrimSpace(action) + if action == "" { + return "", "" + } + idx := strings.IndexFunc(action, func(r rune) bool { + return r == ' ' || r == '\t' || r == '\r' || r == '\n' + }) + if idx < 0 { + return strings.ToLower(action), "" + } + return strings.ToLower(strings.TrimSpace(action[:idx])), strings.TrimSpace(action[idx+1:]) +} + +func firstParam(params string) (head, rest string) { + params = strings.TrimSpace(params) + if params == "" { + return "", "" + } + fields := strings.Fields(params) + if len(fields) == 0 { + return "", "" + } + head = fields[0] + rest = strings.TrimSpace(strings.TrimPrefix(params, head)) + return head, rest +} + +func xyParams(params string) (x, y string, relative bool, ok bool) { + fields := strings.Fields(params) + if len(fields) > 0 && strings.EqualFold(fields[0], "exact") { + relative = false + fields = fields[1:] + } else { + relative = true + } + if len(fields) < 2 { + return "", "", relative, false + } + return fields[0], fields[1], relative, true +} + +func dispatcherWorkspaceMove(params string, follow *bool) string { + workspace, window := firstParam(params) + if workspace == "" { + return "" + } + fields := []luaField{luaStringField("workspace", workspace)} + if follow != nil { + fields = append(fields, luaBoolField("follow", *follow)) + } + if window != "" { + fields = append(fields, luaStringField("window", window)) + } + return luaDispatcherTableCall("hl.dsp.window.move", fields...) +} + +func dispatcherActiveMoveResize(funcName, params string) string { + x, y, relative, ok := xyParams(params) + if !ok { + return "" + } + if !isBareLuaNumber(x) || !isBareLuaNumber(y) { + return "" + } + return luaDispatcherTableCall(funcName, + luaNumberOrStringField("x", x), + luaNumberOrStringField("y", y), + luaBoolField("relative", relative), + ) +} + +func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { + dispatcher, params := splitHyprlandAction(action) + switch dispatcher { + case "spawn", "exec": + return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(params)), true + case "killactive": + return `hl.dsp.window.kill()`, true + case "closewindow": + if params == "" { + return `hl.dsp.window.close()`, true + } + return fmt.Sprintf(`hl.dsp.window.close(%s)`, strconv.Quote(params)), true + case "killwindow": + if params == "" { + return `hl.dsp.window.kill()`, true + } + return fmt.Sprintf(`hl.dsp.window.kill(%s)`, strconv.Quote(params)), true + case "togglefloating": + return `hl.dsp.window.float({ action = "toggle" })`, true + case "setfloating": + return `hl.dsp.window.float({ action = "set" })`, true + case "settiled": + return `hl.dsp.window.float({ action = "unset" })`, true + case "fullscreen": + mode := strings.TrimSpace(params) + switch mode { + case "", "0": + return `hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })`, true + case "1": + return `hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, true + } + case "fullscreenstate": + internal, rest := firstParam(params) + client, _ := firstParam(rest) + if internal != "" && client != "" { + return luaDispatcherTableCall("hl.dsp.window.fullscreen_state", + luaNumberOrStringField("internal", internal), + luaNumberOrStringField("client", client), + ), true + } + case "pin": + if params == "" { + return `hl.dsp.window.pin()`, true + } + case "centerwindow": + return `hl.dsp.window.center()`, true + case "resizewindow": + return `hl.dsp.window.resize()`, true + case "movewindow": + if params == "" { + return `hl.dsp.window.drag()`, true + } + if monitor, ok := strings.CutPrefix(params, "mon:"); ok { + return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("monitor", monitor)), true + } + return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params)), true + case "swapwindow": + if params == "" { + return "", false + } + return luaDispatcherTableCall("hl.dsp.window.swap", luaStringField("direction", params)), true + case "resizeactive": + if expr := dispatcherActiveMoveResize("hl.dsp.window.resize", params); expr != "" { + return expr, true + } + case "moveactive": + if expr := dispatcherActiveMoveResize("hl.dsp.window.move", params); expr != "" { + return expr, true + } + case "workspace": + if params == "" { + return "", false + } + return luaDispatcherTableCall("hl.dsp.focus", luaStringField("workspace", params)), true + case "focusworkspaceoncurrentmonitor": + if params == "" { + return "", false + } + return luaDispatcherTableCall("hl.dsp.focus", luaStringField("workspace", params), luaBoolField("on_current_monitor", true)), true + case "movetoworkspace": + if expr := dispatcherWorkspaceMove(params, nil); expr != "" { + return expr, true + } + case "movetoworkspacesilent": + follow := false + if expr := dispatcherWorkspaceMove(params, &follow); expr != "" { + return expr, true + } + case "togglespecialworkspace": + if params == "" { + return `hl.dsp.workspace.toggle_special()`, true + } + return fmt.Sprintf(`hl.dsp.workspace.toggle_special(%s)`, strconv.Quote(params)), true + case "renameworkspace": + workspace, name := firstParam(params) + if workspace != "" { + fields := []luaField{luaStringField("workspace", workspace)} + if name != "" { + fields = append(fields, luaStringField("name", name)) + } + return luaDispatcherTableCall("hl.dsp.workspace.rename", fields...), true + } + case "movecurrentworkspacetomonitor": + if params != "" { + return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("monitor", params)), true + } + case "moveworkspacetomonitor": + workspace, monitor := firstParam(params) + if workspace != "" && monitor != "" { + return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("workspace", workspace), luaStringField("monitor", monitor)), true + } + case "swapactiveworkspaces": + monitor1, rest := firstParam(params) + monitor2, _ := firstParam(rest) + if monitor1 != "" && monitor2 != "" { + return luaDispatcherTableCall("hl.dsp.workspace.swap_monitors", luaStringField("monitor1", monitor1), luaStringField("monitor2", monitor2)), true + } + case "movefocus": + if params != "" { + return luaDispatcherTableCall("hl.dsp.focus", luaStringField("direction", params)), true + } + case "focusmonitor": + if params != "" { + return luaDispatcherTableCall("hl.dsp.focus", luaStringField("monitor", params)), true + } + case "focuswindow": + if params != "" { + return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", params)), true + } + case "focuscurrentorlast": + return `hl.dsp.focus({ last = true })`, true + case "focusurgentorlast": + return `hl.dsp.focus({ urgent_or_last = true })`, true + case "layoutmsg": + if params != "" { + return fmt.Sprintf(`hl.dsp.layout(%s)`, strconv.Quote(params)), true + } + case "alterzorder": + mode, window := firstParam(params) + if mode != "" { + fields := []luaField{luaStringField("mode", mode)} + if window != "" { + fields = append(fields, luaStringField("window", window)) + } + return luaDispatcherTableCall("hl.dsp.window.alter_zorder", fields...), true + } + case "setprop": + window, rest := firstParam(params) + prop, value := firstParam(rest) + if window != "" && prop != "" && value != "" { + return luaDispatcherTableCall("hl.dsp.window.set_prop", + luaStringField("window", window), + luaStringField("prop", prop), + luaStringField("value", value), + ), true + } + case "dpms": + dpmsAction := strings.TrimSpace(params) + switch dpmsAction { + case "on": + dpmsAction = "enable" + case "off": + dpmsAction = "disable" + } + if dpmsAction == "" { + return `hl.dsp.dpms({})`, true + } + return luaDispatcherTableCall("hl.dsp.dpms", luaStringField("action", dpmsAction)), true + case "exit": + return `hl.dsp.exit()`, true + case "submap": + return fmt.Sprintf(`hl.dsp.submap(%s)`, strconv.Quote(params)), true + case "global": + return fmt.Sprintf(`hl.dsp.global(%s)`, strconv.Quote(params)), true + case "event": + return fmt.Sprintf(`hl.dsp.event(%s)`, strconv.Quote(params)), true + case "pass": + if params == "" { + return `hl.dsp.pass({})`, true + } + return luaDispatcherTableCall("hl.dsp.pass", luaStringField("window", params)), true + case "sendshortcut": + mod, rest := firstParam(params) + key, window := firstParam(rest) + if mod != "" && key != "" { + fields := []luaField{luaStringField("mods", mod), luaStringField("key", key)} + if window != "" { + fields = append(fields, luaStringField("window", window)) + } + return luaDispatcherTableCall("hl.dsp.send_shortcut", fields...), true + } + case "sendkeystate": + mod, rest := firstParam(params) + key, rest := firstParam(rest) + state, window := firstParam(rest) + if mod != "" && key != "" && state != "" { + fields := []luaField{luaStringField("mods", mod), luaStringField("key", key), luaStringField("state", state)} + if window != "" { + fields = append(fields, luaStringField("window", window)) + } + return luaDispatcherTableCall("hl.dsp.send_key_state", fields...), true + } + case "togglegroup": + return `hl.dsp.group.toggle()`, true + } + return "", false +} + func luaActionStringFromHyprlangAction(action string) string { action = strings.TrimSpace(action) - if strings.HasPrefix(action, "spawn ") { - return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimSpace(strings.TrimPrefix(action, "spawn ")))) - } - if strings.HasPrefix(action, "exec ") { - return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimPrefix(action, "exec "))) - } - switch action { - case "killactive": - return `hl.dsp.window.kill()` - case "togglefloating": - return `hl.dsp.window.float({ action = "toggle" })` - case "exit": - return `hl.dsp.exit()` - default: - return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action)) + if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok { + return expr } + return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action)) } func luaExprToInternalAction(expr string) string { @@ -498,11 +877,12 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e continue } if key, ok := parseLuaUnbindLine(line); ok { - pendingUnbinds[strings.ToLower(key)] = key + pendingUnbinds[hyprlandOverrideMapKey(key)] = canonicalHyprlandOverrideKey(key) continue } if kb, ok := parseLuaBindOverrideLine(line); ok { - normalizedKey := strings.ToLower(kb.Key) + kb.Key = canonicalHyprlandOverrideKey(kb.Key) + normalizedKey := hyprlandOverrideMapKey(kb.Key) binds[normalizedKey] = kb delete(pendingUnbinds, normalizedKey) continue @@ -520,7 +900,8 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e action = kb.Dispatcher + " " + kb.Params } flags := kb.Flags - normalizedKey := strings.ToLower(keyStr) + keyStr = canonicalHyprlandOverrideKey(keyStr) + normalizedKey := hyprlandOverrideMapKey(keyStr) binds[normalizedKey] = &hyprlandOverrideBind{ Key: keyStr, Action: action, diff --git a/core/internal/keybinds/providers/hyprland_parser.go b/core/internal/keybinds/providers/hyprland_parser.go index 4b8dc885..fb2cd06c 100644 --- a/core/internal/keybinds/providers/hyprland_parser.go +++ b/core/internal/keybinds/providers/hyprland_parser.go @@ -54,6 +54,8 @@ type HyprlandParser struct { dmsProcessed bool removedKeys map[string]bool // bare hl.unbind targets (negative overrides) defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf} + configFormat string + readOnly bool } func NewHyprlandParser(configDir string) *HyprlandParser { @@ -310,6 +312,8 @@ type HyprlandDMSStatus struct { Effective bool OverriddenBy int StatusMessage string + ConfigFormat string + ReadOnly bool } func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus { @@ -319,6 +323,8 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus { IncludePosition: p.dmsIncludePos, TotalIncludes: p.includeCount, BindsAfterDMS: p.bindsAfterDMS, + ConfigFormat: p.configFormat, + ReadOnly: p.readOnly, } switch { @@ -398,6 +404,13 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) { if err != nil { return nil, err } + if strings.EqualFold(filepath.Ext(mainConfig), ".lua") { + p.configFormat = "lua" + p.readOnly = false + } else { + p.configFormat = "hyprlang" + p.readOnly = true + } section, err := p.parseFileWithSource(mainConfig, "") if err != nil { return nil, err @@ -1004,7 +1017,18 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { } } return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd")) - case strings.Contains(expr, "hl.dsp.window.kill()"): + case strings.HasPrefix(expr, "hl.dsp.window.close("): + if arg := luaCallStringArgValue(expr, "hl.dsp.window.close"); arg != "" { + return "closewindow", arg + } + return "closewindow", "" + case strings.HasPrefix(expr, "hl.dsp.window.kill("): + if luaTableBoolFieldValue(expr, "force") { + return "forcekillactive", "" + } + if arg := luaCallStringArgValue(expr, "hl.dsp.window.kill"); arg != "" { + return "killwindow", arg + } return "killactive", "" case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("): switch luaTableStringField(expr, "mode") { @@ -1014,8 +1038,26 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { return "fullscreen", "0" } return "fullscreen", luaTableStringField(expr, "mode") + case strings.HasPrefix(expr, "hl.dsp.window.fullscreen_state("): + internal := luaStringValue(luaTableScalarField(expr, "internal")) + client := luaStringValue(luaTableScalarField(expr, "client")) + return joinDispatcherParams("fullscreenstate", internal, client) case strings.HasPrefix(expr, "hl.dsp.window.float("): - return "togglefloating", "" + switch luaTableStringField(expr, "action") { + case "set": + return "setfloating", "" + case "unset": + return "settiled", "" + default: + return "togglefloating", "" + } + case strings.HasPrefix(expr, "hl.dsp.window.pin("): + if action := luaTableStringField(expr, "action"); action != "" && action != "toggle" { + return "pin", action + } + return "pin", "" + case strings.Contains(expr, "hl.dsp.window.center()"): + return "centerwindow", "" case strings.Contains(expr, "hl.dsp.group.toggle()"): return "togglegroup", "" case strings.HasPrefix(expr, "hl.dsp.focus("): @@ -1025,18 +1067,43 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { case luaTableStringField(expr, "monitor") != "": return "focusmonitor", luaTableStringField(expr, "monitor") case luaTableStringField(expr, "workspace") != "": + if luaTableBoolFieldValue(expr, "on_current_monitor") { + return "focusworkspaceoncurrentmonitor", luaTableStringField(expr, "workspace") + } return "workspace", luaTableStringField(expr, "workspace") case luaTableStringField(expr, "window") != "": return "focuswindow", luaTableStringField(expr, "window") + case luaTableBoolFieldValue(expr, "urgent_or_last"): + return "focusurgentorlast", "" + case luaTableBoolFieldValue(expr, "last"): + return "focuscurrentorlast", "" } case strings.HasPrefix(expr, "hl.dsp.window.move("): switch { + case luaTableScalarField(expr, "x") != "" || luaTableScalarField(expr, "y") != "": + x := luaStringValue(luaTableScalarField(expr, "x")) + y := luaStringValue(luaTableScalarField(expr, "y")) + if x == "" { + x = "0" + } + if y == "" { + y = "0" + } + prefix := "" + if raw, ok := luaTableBoolField(expr, "relative"); ok && !raw { + prefix = "exact " + } + return "moveactive", prefix + x + " " + y case luaTableStringField(expr, "direction") != "": return "movewindow", luaTableStringField(expr, "direction") case luaTableStringField(expr, "monitor") != "": return "movewindow", "mon:" + luaTableStringField(expr, "monitor") case luaTableStringField(expr, "workspace") != "": - return "movetoworkspace", luaTableStringField(expr, "workspace") + action := "movetoworkspace" + if follow, ok := luaTableBoolField(expr, "follow"); ok && !follow { + action = "movetoworkspacesilent" + } + return joinDispatcherParams(action, luaTableStringField(expr, "workspace"), luaTableStringField(expr, "window")) } case expr == "hl.dsp.window.drag()": return "movewindow", "" @@ -1052,19 +1119,69 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { if y == "" { y = "0" } - return "resizeactive", x + " " + y - } - case strings.HasPrefix(expr, "hl.dsp.layout("): - arg := extractLuaCallStringArg(expr, "hl.dsp.layout") - if arg != "" { - if u, err := strconv.Unquote(arg); err == nil { - return "layoutmsg", u + prefix := "" + if relative, ok := luaTableBoolField(expr, "relative"); ok && !relative { + prefix = "exact " } + return "resizeactive", prefix + x + " " + y + } + case strings.HasPrefix(expr, "hl.dsp.window.swap("): + return "swapwindow", luaTableStringField(expr, "direction") + case strings.HasPrefix(expr, "hl.dsp.window.alter_zorder("): + mode := luaTableStringField(expr, "mode") + if mode == "" { + mode = luaTableStringField(expr, "zheight") + } + return joinDispatcherParams("alterzorder", mode, luaTableStringField(expr, "window")) + case strings.HasPrefix(expr, "hl.dsp.window.set_prop("): + prop := luaTableStringField(expr, "prop") + if prop == "" { + prop = luaTableStringField(expr, "property") + } + return joinDispatcherParams("setprop", luaTableStringField(expr, "window"), prop, luaTableStringField(expr, "value")) + case strings.HasPrefix(expr, "hl.dsp.workspace.rename("): + return joinDispatcherParams("renameworkspace", luaTableStringField(expr, "workspace"), luaTableStringField(expr, "name")) + case strings.HasPrefix(expr, "hl.dsp.workspace.move("): + workspace := luaTableStringField(expr, "workspace") + monitor := luaTableStringField(expr, "monitor") + if workspace != "" { + return joinDispatcherParams("moveworkspacetomonitor", workspace, monitor) + } + return "movecurrentworkspacetomonitor", monitor + case strings.HasPrefix(expr, "hl.dsp.workspace.swap_monitors("): + return joinDispatcherParams("swapactiveworkspaces", luaTableStringField(expr, "monitor1"), luaTableStringField(expr, "monitor2")) + case strings.HasPrefix(expr, "hl.dsp.workspace.toggle_special("): + return "togglespecialworkspace", luaCallStringArgValue(expr, "hl.dsp.workspace.toggle_special") + case strings.HasPrefix(expr, "hl.dsp.layout("): + if arg := luaCallStringArgValue(expr, "hl.dsp.layout"); arg != "" { + return "layoutmsg", arg } case strings.HasPrefix(expr, "hl.dsp.dpms("): if action := luaTableStringField(expr, "action"); action != "" { + switch action { + case "enable": + return "dpms", "on" + case "disable": + return "dpms", "off" + } return "dpms", action } + return "dpms", "" + case strings.HasPrefix(expr, "hl.dsp.submap("): + return "submap", luaCallStringArgValue(expr, "hl.dsp.submap") + case strings.HasPrefix(expr, "hl.dsp.global("): + return "global", luaCallStringArgValue(expr, "hl.dsp.global") + case strings.HasPrefix(expr, "hl.dsp.event("): + return "event", luaCallStringArgValue(expr, "hl.dsp.event") + case strings.HasPrefix(expr, "hl.dsp.pass("): + if window := luaTableStringField(expr, "window"); window != "" { + return "pass", window + } + return "pass", luaCallStringArgValue(expr, "hl.dsp.pass") + case strings.HasPrefix(expr, "hl.dsp.send_shortcut("): + return joinDispatcherParams("sendshortcut", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "window")) + case strings.HasPrefix(expr, "hl.dsp.send_key_state("): + return joinDispatcherParams("sendkeystate", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "state"), luaTableStringField(expr, "window")) case strings.Contains(expr, "hl.dsp.exit()"): return "exit", "" default: @@ -1073,6 +1190,17 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { return "exec", "hyprctl dispatch lua:" + expr } +func joinDispatcherParams(dispatcher string, values ...string) (string, string) { + parts := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + parts = append(parts, value) + } + } + return dispatcher, strings.Join(parts, " ") +} + func extractLuaCallStringArg(callExpr, funcName string) string { callExpr = strings.TrimSpace(callExpr) prefix := funcName + "(" @@ -1100,10 +1228,46 @@ func extractLuaCallStringArg(callExpr, funcName string) string { return "" } +func luaCallStringArgValue(callExpr, funcName string) string { + arg := extractLuaCallStringArg(callExpr, funcName) + if arg == "" { + return "" + } + u, err := strconv.Unquote(arg) + if err != nil { + return "" + } + return u +} + func luaTableStringField(expr, field string) string { return luaStringValue(luaTableScalarField(expr, field)) } +func luaTableModsField(expr string) string { + if mods := luaTableStringField(expr, "mods"); mods != "" { + return mods + } + return luaTableStringField(expr, "mod") +} + +func luaTableBoolFieldValue(expr, field string) bool { + value, ok := luaTableBoolField(expr, field) + return ok && value +} + +func luaTableBoolField(expr, field string) (bool, bool) { + raw := strings.ToLower(luaTableScalarField(expr, field)) + switch raw { + case "true": + return true, true + case "false": + return false, true + default: + return false, false + } +} + func luaTableScalarField(expr, field string) string { re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`) m := re.FindStringSubmatch(expr) diff --git a/core/internal/keybinds/providers/hyprland_parser_test.go b/core/internal/keybinds/providers/hyprland_parser_test.go index 1b7be712..2446fbe8 100644 --- a/core/internal/keybinds/providers/hyprland_parser_test.go +++ b/core/internal/keybinds/providers/hyprland_parser_test.go @@ -70,12 +70,17 @@ func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) { wantParams string }{ {`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`}, + {`hl.dsp.exec_cmd([[hyprctl dispatch workspace 1]])`, "workspace", "1"}, {`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"}, {`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"}, + {`hl.dsp.focus({ workspace = "2", on_current_monitor = true })`, "focusworkspaceoncurrentmonitor", "2"}, {`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"}, - {`hl.dsp.window.resize({ x = "-10%", y = 0, relative = true })`, "resizeactive", "-10% 0"}, + {`hl.dsp.window.move({ workspace = "special:magic", follow = false })`, "movetoworkspacesilent", "special:magic"}, + {`hl.dsp.window.resize({ x = -100, y = 0, relative = true })`, "resizeactive", "-100 0"}, + {`hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`, "resizeactive", "exact 1280 720"}, {`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"}, {`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"}, + {`hl.dsp.workspace.rename({ workspace = "1", name = "work" })`, "renameworkspace", "1 work"}, } for _, tt := range tests { @@ -119,6 +124,41 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: } } +func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) { + tests := []struct { + action string + want string + }{ + {"workspace 1", `hl.dsp.focus({ workspace = "1" })`}, + {"movetoworkspace 2", `hl.dsp.window.move({ workspace = "2" })`}, + {"movetoworkspacesilent special:magic", `hl.dsp.window.move({ workspace = "special:magic", follow = false })`}, + {"focusmonitor DP-1", `hl.dsp.focus({ monitor = "DP-1" })`}, + {"resizeactive exact 1280 720", `hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`}, + {"dpms toggle", `hl.dsp.dpms({ action = "toggle" })`}, + {"renameworkspace 1 work", `hl.dsp.workspace.rename({ workspace = "1", name = "work" })`}, + } + + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + got := luaActionStringFromHyprlangAction(tt.action) + if got != tt.want { + t.Fatalf("luaActionStringFromHyprlangAction(%q) = %q, want %q", tt.action, got, tt.want) + } + if strings.Contains(got, "hyprctl dispatch") { + t.Fatalf("expected native Lua dispatcher, got legacy dispatch wrapper: %q", got) + } + }) + } +} + +func TestLuaActionStringFallsBackForUnsupportedResizePercentages(t *testing.T) { + got := luaActionStringFromHyprlangAction("resizeactive exact 100% 100%") + want := `hl.dsp.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%")` + if got != want { + t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want) + } +} + func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) { tmpDir := t.TempDir() dmsDir := filepath.Join(tmpDir, "dms") @@ -283,6 +323,64 @@ func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) { } } +func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) { + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("bind = SUPER, T, exec, kitty\n"), 0o644); err != nil { + t.Fatal(err) + } + + provider := NewHyprlandProvider(tmpDir) + err := provider.SetBind("SUPER+N", "workspace 1", "Workspace 1", nil) + if err == nil { + t.Fatal("expected SetBind to reject conf-only Hyprland config") + } + if !strings.Contains(err.Error(), "read-only") { + t.Fatalf("expected read-only error, got %v", err) + } + if _, err := os.Stat(filepath.Join(tmpDir, "dms", "binds-user.lua")); !os.IsNotExist(err) { + t.Fatalf("expected no Lua override to be created for conf-only config, stat err=%v", err) + } +} + +func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) { + tmpDir := t.TempDir() + dmsDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(dmsDir, 0o755); err != nil { + t.Fatal(err) + } + override := `-- DMS user keybind overrides + +hl.unbind("SUPER + SHIFT + S") +hl.bind("SUPER + 1", hl.dsp.exec_cmd("hyprctl dispatch workspace 1")) +` + if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil { + t.Fatal(err) + } + + provider := NewHyprlandProvider(tmpDir) + if err := provider.SetBind("SUPER + 1", "workspace 1", "", nil); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua")) + if err != nil { + t.Fatal(err) + } + got := string(data) + if strings.Count(got, `hl.unbind("SUPER + 1")`) != 1 { + t.Fatalf("expected one SUPER+1 unbind, got:\n%s", got) + } + if strings.Count(got, `hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))`) != 1 { + t.Fatalf("expected one native SUPER+1 bind, got:\n%s", got) + } + if strings.Contains(got, "hyprctl dispatch workspace 1") { + t.Fatalf("expected old hyprctl workspace dispatcher to be replaced, got:\n%s", got) + } + if !strings.Contains(got, `hl.unbind("SUPER + SHIFT + S")`) { + t.Fatalf("expected unrelated override to be preserved, got:\n%s", got) + } +} + func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) { tmpDir := t.TempDir() dmsDir := filepath.Join(tmpDir, "dms") diff --git a/core/internal/keybinds/types.go b/core/internal/keybinds/types.go index c480474f..6cf53f5d 100644 --- a/core/internal/keybinds/types.go +++ b/core/internal/keybinds/types.go @@ -25,6 +25,8 @@ type DMSBindsStatus struct { Effective bool `json:"effective"` OverriddenBy int `json:"overriddenBy"` StatusMessage string `json:"statusMessage"` + ConfigFormat string `json:"configFormat,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` } type CheatSheet struct { diff --git a/core/internal/windowrules/providers/hyprland_parser.go b/core/internal/windowrules/providers/hyprland_parser.go index 7ed17939..3c31663d 100644 --- a/core/internal/windowrules/providers/hyprland_parser.go +++ b/core/internal/windowrules/providers/hyprland_parser.go @@ -44,6 +44,8 @@ type HyprlandRulesParser struct { dmsIncludePos int rulesAfterDMS int dmsProcessed bool + configFormat string + readOnly bool requireLineInMain int // hyprland.lua line (1-based) where require("dms.windowrules") occurs; else -1 primaryHyprLua string // absolute path to ~/.config/hypr/hyprland.lua when that is the main config @@ -82,10 +84,15 @@ func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) { } if strings.EqualFold(filepath.Ext(mainConfig), ".lua") { + p.configFormat = "lua" + p.readOnly = false p.probeRequireWindowrulesLine(mainConfig) if ap, err := filepath.Abs(mainConfig); err == nil { p.primaryHyprLua = ap } + } else { + p.configFormat = "hyprlang" + p.readOnly = true } if err := p.parseFile(mainConfig); err != nil { @@ -300,6 +307,8 @@ func (p *HyprlandRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus { IncludePosition: p.dmsIncludePos, TotalIncludes: p.includeCount, RulesAfterDMS: p.rulesAfterDMS, + ConfigFormat: p.configFormat, + ReadOnly: p.readOnly, } switch { @@ -451,6 +460,9 @@ func (p *HyprlandWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) { } func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error { + if err := p.ensureWritableConfig(); err != nil { + return err + } rules, err := p.LoadDMSRules() if err != nil { rules = []windowrules.WindowRule{} @@ -472,6 +484,9 @@ func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error { } func (p *HyprlandWritableProvider) RemoveRule(id string) error { + if err := p.ensureWritableConfig(); err != nil { + return err + } rules, err := p.LoadDMSRules() if err != nil { return err @@ -488,6 +503,9 @@ func (p *HyprlandWritableProvider) RemoveRule(id string) error { } func (p *HyprlandWritableProvider) ReorderRules(ids []string) error { + if err := p.ensureWritableConfig(); err != nil { + return err + } rules, err := p.LoadDMSRules() if err != nil { return err @@ -513,6 +531,29 @@ func (p *HyprlandWritableProvider) ReorderRules(ids []string) error { return p.writeDMSRules(newRules) } +func (p *HyprlandWritableProvider) ensureWritableConfig() error { + if p.isLegacyConfigReadOnly() { + return fmt.Errorf("hyprland legacy conf configs are read-only; run dms setup to migrate to Lua before editing window rules") + } + return nil +} + +func (p *HyprlandWritableProvider) isLegacyConfigReadOnly() bool { + expanded, err := utils.ExpandPath(p.configDir) + if err != nil { + expanded = p.configDir + } + luaPath := filepath.Join(expanded, "hyprland.lua") + if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() { + return false + } + confPath := filepath.Join(expanded, "hyprland.conf") + if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() { + return true + } + return false +} + var dmsRuleCommentRegex = regexp.MustCompile(`^#\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`) var dmsRuleLuaHDRRegex = regexp.MustCompile(`^\s*--\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`) diff --git a/core/internal/windowrules/providers/hyprland_parser_test.go b/core/internal/windowrules/providers/hyprland_parser_test.go index 3f42703a..92a8d381 100644 --- a/core/internal/windowrules/providers/hyprland_parser_test.go +++ b/core/internal/windowrules/providers/hyprland_parser_test.go @@ -188,6 +188,27 @@ func TestHyprlandSetAndLoadDMSRules(t *testing.T) { } } +func TestHyprlandSetRuleLeavesConfOnlyInstallReadOnly(t *testing.T) { + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("windowrulev2 = float, class:^(kitty)$\n"), 0o644); err != nil { + t.Fatal(err) + } + provider := NewHyprlandWritableProvider(tmpDir) + rule := newTestWindowRule("test_id", "Test Rule", "^(firefox)$") + rule.Actions.OpenFloating = boolPtr(true) + + err := provider.SetRule(rule) + if err == nil { + t.Fatal("expected SetRule to reject conf-only Hyprland config") + } + if !strings.Contains(err.Error(), "read-only") { + t.Fatalf("expected read-only error, got %v", err) + } + if _, err := os.Stat(filepath.Join(tmpDir, "dms", "windowrules.lua")); !os.IsNotExist(err) { + t.Fatalf("expected no Lua windowrules file to be created for conf-only config, stat err=%v", err) + } +} + func TestHyprlandRemoveRule(t *testing.T) { tmpDir := t.TempDir() provider := NewHyprlandWritableProvider(tmpDir) diff --git a/core/internal/windowrules/types.go b/core/internal/windowrules/types.go index 556acb7e..512acada 100644 --- a/core/internal/windowrules/types.go +++ b/core/internal/windowrules/types.go @@ -79,6 +79,8 @@ type DMSRulesStatus struct { Effective bool `json:"effective"` OverriddenBy int `json:"overriddenBy"` StatusMessage string `json:"statusMessage"` + ConfigFormat string `json:"configFormat,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` } type RuleSet struct { diff --git a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml index ed9003f7..4b91ffa1 100644 --- a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml +++ b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml @@ -21,8 +21,11 @@ Singleton { property var includeStatus: ({ "exists": false, - "included": false + "included": false, + "configFormat": "", + "readOnly": false }) + readonly property bool readOnly: CompositorService.isHyprland && includeStatus.readOnly === true property bool checkingInclude: false property bool fixingInclude: false @@ -481,6 +484,15 @@ Singleton { // Write compositor config from a neutral config entry and optionally reload function applyConfigEntry(configEntry, configId, profileName, isManual) { + if (CompositorService.isHyprland && readOnly) { + if (isManual) { + profilesLoading = false; + manualActivation = false; + profileError(I18n.tr("Hyprland conf mode is read-only in Settings")); + } + showHyprlandReadOnlyWarning(); + return; + } ensureEnabledOutput(configEntry); // Capture the entry being applied so disabled-output settings fields can read // scale/position/transform back even when wlr reports no logical viewport. @@ -1372,7 +1384,9 @@ Singleton { if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "dwl") { includeStatus = { "exists": false, - "included": false + "included": false, + "configFormat": "", + "readOnly": false }; return; } @@ -1386,7 +1400,9 @@ Singleton { if (exitCode !== 0) { includeStatus = { "exists": false, - "included": false + "included": false, + "configFormat": "", + "readOnly": false }; return; } @@ -1395,13 +1411,19 @@ Singleton { } catch (e) { includeStatus = { "exists": false, - "included": false + "included": false, + "configFormat": "", + "readOnly": false }; } }); } function fixOutputsInclude() { + if (readOnly) { + showHyprlandReadOnlyWarning(); + return; + } const paths = getConfigPaths(); if (!paths) return; @@ -1426,6 +1448,10 @@ Singleton { }); } + function showHyprlandReadOnlyWarning() { + ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing display settings."), "dms setup", "display-config"); + } + function buildOutputsMap() { const map = {}; for (const output of wlrOutputs) { @@ -1514,6 +1540,10 @@ Singleton { NiriService.generateOutputsConfig(outputsData); break; case "hyprland": + if (readOnly) { + showHyprlandReadOnlyWarning(); + return false; + } HyprlandService.generateOutputsConfig(outputsData, buildMergedHyprlandSettings()); break; case "dwl": @@ -1523,6 +1553,7 @@ Singleton { WlrOutputService.applyOutputsConfig(outputsData, outputs); break; } + return true; } function normalizeOutputPositions(outputsData) { @@ -1830,6 +1861,10 @@ Singleton { function applyChanges() { if (!hasPendingChanges) return; + if (CompositorService.isHyprland && readOnly) { + showHyprlandReadOnlyWarning(); + return; + } const changeDescriptions = []; if (formatChanged) { diff --git a/quickshell/Modules/Settings/DisplayConfig/IncludeWarningBox.qml b/quickshell/Modules/Settings/DisplayConfig/IncludeWarningBox.qml index f5f270ba..b5d8f29b 100644 --- a/quickshell/Modules/Settings/DisplayConfig/IncludeWarningBox.qml +++ b/quickshell/Modules/Settings/DisplayConfig/IncludeWarningBox.qml @@ -12,13 +12,14 @@ StyledRect { height: warningContent.implicitHeight + Theme.spacingL * 2 radius: Theme.cornerRadius - readonly property bool showError: DisplayConfigState.includeStatus.exists && !DisplayConfigState.includeStatus.included - readonly property bool showSetup: !DisplayConfigState.includeStatus.exists && !DisplayConfigState.includeStatus.included + readonly property bool showLegacy: DisplayConfigState.readOnly + readonly property bool showError: !showLegacy && DisplayConfigState.includeStatus.exists && !DisplayConfigState.includeStatus.included + readonly property bool showSetup: !showLegacy && !DisplayConfigState.includeStatus.exists && !DisplayConfigState.includeStatus.included - color: (showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.15) : "transparent" - border.color: (showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent" + color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.15) : "transparent" + border.color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent" border.width: 1 - visible: (showError || showSetup) && DisplayConfigState.hasOutputBackend && !DisplayConfigState.checkingInclude + visible: (showLegacy || showError || showSetup) && DisplayConfigState.hasOutputBackend && !DisplayConfigState.checkingInclude Column { id: warningContent @@ -44,6 +45,8 @@ StyledRect { StyledText { text: { + if (root.showLegacy) + return I18n.tr("Hyprland conf mode"); if (root.showSetup) return I18n.tr("First Time Setup"); if (root.showError) @@ -59,6 +62,8 @@ StyledRect { StyledText { text: { + if (root.showLegacy) + return I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing display settings."); if (root.showSetup) return I18n.tr("Click 'Setup' to create the outputs config and add include to your compositor config."); if (root.showError) @@ -75,7 +80,7 @@ StyledRect { DankButton { id: fixButton - visible: root.showError || root.showSetup + visible: !root.showLegacy && (root.showError || root.showSetup) text: { if (DisplayConfigState.fixingInclude) return I18n.tr("Fixing..."); diff --git a/quickshell/Modules/Settings/KeybindsTab.qml b/quickshell/Modules/Settings/KeybindsTab.qml index af3eff7a..e5704fed 100644 --- a/quickshell/Modules/Settings/KeybindsTab.qml +++ b/quickshell/Modules/Settings/KeybindsTab.qml @@ -84,6 +84,10 @@ Item { } function startNewBind() { + if (KeybindsService.readOnly) { + KeybindsService.showHyprlandReadOnlyWarning(); + return; + } showingNewBind = true; expandedKey = ""; } @@ -292,7 +296,7 @@ Item { StyledText { readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf" - text: I18n.tr("Click any shortcut to edit. Changes save to %1").arg(bindsFile) + text: KeybindsService.readOnly ? I18n.tr("Hyprland conf mode is read-only in Settings") : I18n.tr("Click any shortcut to edit. Changes save to %1").arg(bindsFile) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText wrapMode: Text.WordWrap @@ -326,7 +330,7 @@ Item { iconSize: Theme.iconSize iconColor: Theme.primary anchors.verticalCenter: parent.verticalCenter - enabled: !keybindsTab.showingNewBind + enabled: !keybindsTab.showingNewBind && !KeybindsService.readOnly opacity: enabled ? 1 : 0.5 onClicked: keybindsTab.startNewBind() } @@ -342,14 +346,15 @@ Item { radius: Theme.cornerRadius readonly property var status: KeybindsService.dmsStatus - readonly property bool showError: !status.included && status.exists - readonly property bool showWarning: status.included && status.overriddenBy > 0 - readonly property bool showSetup: !status.exists + readonly property bool showLegacy: KeybindsService.readOnly + readonly property bool showError: !showLegacy && !status.included && status.exists + readonly property bool showWarning: !showLegacy && status.included && status.overriddenBy > 0 + readonly property bool showSetup: !showLegacy && !status.exists - color: (showError || showWarning || showSetup) ? Theme.withAlpha(Theme.primary, 0.15) : "transparent" - border.color: (showError || showWarning || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent" + color: (showLegacy || showError || showWarning || showSetup) ? Theme.withAlpha(Theme.primary, 0.15) : "transparent" + border.color: (showLegacy || showError || showWarning || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent" border.width: 1 - visible: (showError || showWarning || showSetup) && !KeybindsService.loading + visible: (showLegacy || showError || showWarning || showSetup) && !KeybindsService.loading Column { id: warningSection @@ -375,6 +380,8 @@ Item { StyledText { text: { + if (warningBox.showLegacy) + return I18n.tr("Hyprland conf mode"); if (warningBox.showSetup) return I18n.tr("First Time Setup"); if (warningBox.showError) @@ -391,6 +398,8 @@ Item { StyledText { readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf" text: { + if (warningBox.showLegacy) + return I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing shortcuts in Settings."); if (warningBox.showSetup) return I18n.tr("Click 'Setup' to create %1 and add include to config.").arg(bindsFile); if (warningBox.showError) @@ -411,7 +420,7 @@ Item { DankButton { id: fixButton - visible: warningBox.showError || warningBox.showSetup + visible: !warningBox.showLegacy && (warningBox.showError || warningBox.showSetup) text: { if (KeybindsService.fixing) return I18n.tr("Fixing..."); @@ -559,6 +568,7 @@ Item { desc: "" }) panelWindow: keybindsTab.parentModal + readOnly: KeybindsService.readOnly onSaveBind: (originalKey, newData) => keybindsTab.saveNewBind(newData) onCancelEdit: keybindsTab.cancelNewBind() } @@ -668,6 +678,7 @@ Item { bindData: modelData isExpanded: keybindsTab.expandedKey === modelData.action panelWindow: keybindsTab.parentModal + readOnly: KeybindsService.readOnly onToggleExpand: keybindsTab.toggleExpanded(modelData.action) onSaveBind: (originalKey, newData) => { KeybindsService.saveBind(originalKey, newData); diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index b025c1bd..f3758a04 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -23,8 +23,11 @@ Item { property var cursorIncludeStatus: ({ "exists": false, - "included": false + "included": false, + "configFormat": "", + "readOnly": false }) + readonly property bool cursorReadOnly: CompositorService.isHyprland && cursorIncludeStatus.readOnly === true property bool checkingCursorInclude: false property bool fixingCursorInclude: false @@ -62,7 +65,9 @@ Item { if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "dwl") { cursorIncludeStatus = { "exists": false, - "included": false + "included": false, + "configFormat": "", + "readOnly": false }; return; } @@ -76,7 +81,9 @@ Item { if (exitCode !== 0) { cursorIncludeStatus = { "exists": false, - "included": false + "included": false, + "configFormat": "", + "readOnly": false }; return; } @@ -85,13 +92,19 @@ Item { } catch (e) { cursorIncludeStatus = { "exists": false, - "included": false + "included": false, + "configFormat": "", + "readOnly": false }; } }); } function fixCursorInclude() { + if (cursorReadOnly) { + ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing cursor settings."), "dms setup", "hyprland-migration"); + return; + } const paths = getCursorConfigPaths(); if (!paths) return; diff --git a/quickshell/Modules/Settings/WindowRulesTab.qml b/quickshell/Modules/Settings/WindowRulesTab.qml index ed731674..541b6768 100644 --- a/quickshell/Modules/Settings/WindowRulesTab.qml +++ b/quickshell/Modules/Settings/WindowRulesTab.qml @@ -19,8 +19,11 @@ Item { property var parentModal: null property var windowRulesIncludeStatus: ({ "exists": false, - "included": false + "included": false, + "configFormat": "", + "readOnly": false }) + readonly property bool readOnly: CompositorService.isHyprland && windowRulesIncludeStatus.readOnly === true property bool checkingInclude: false property bool fixingInclude: false property var windowRules: [] @@ -84,7 +87,9 @@ Item { if (result.dmsStatus) { windowRulesIncludeStatus = { "exists": result.dmsStatus.exists, - "included": result.dmsStatus.included + "included": result.dmsStatus.included, + "configFormat": result.dmsStatus.configFormat ?? "", + "readOnly": result.dmsStatus.readOnly === true }; } } catch (e) { @@ -94,6 +99,10 @@ Item { } function removeRule(ruleId) { + if (readOnly) { + showHyprlandReadOnlyWarning(); + return; + } const compositor = CompositorService.compositor; if (compositor !== "niri" && compositor !== "hyprland") return; @@ -107,6 +116,10 @@ Item { } function reorderRules(fromIndex, toIndex) { + if (readOnly) { + showHyprlandReadOnlyWarning(); + return; + } if (fromIndex === toIndex) return; @@ -131,7 +144,9 @@ Item { if (compositor !== "niri" && compositor !== "hyprland") { windowRulesIncludeStatus = { "exists": false, - "included": false + "included": false, + "configFormat": "", + "readOnly": false }; return; } @@ -143,7 +158,9 @@ Item { if (exitCode !== 0) { windowRulesIncludeStatus = { "exists": false, - "included": false + "included": false, + "configFormat": "", + "readOnly": false }; return; } @@ -152,13 +169,19 @@ Item { } catch (e) { windowRulesIncludeStatus = { "exists": false, - "included": false + "included": false, + "configFormat": "", + "readOnly": false }; } }); } function fixWindowRulesInclude() { + if (readOnly) { + showHyprlandReadOnlyWarning(); + return; + } const paths = getWindowRulesConfigPaths(); if (!paths) return; @@ -182,6 +205,10 @@ Item { } function openRuleModal(window) { + if (readOnly) { + showHyprlandReadOnlyWarning(); + return; + } if (!PopoutService.windowRuleModalLoader) return; PopoutService.windowRuleModalLoader.active = true; @@ -192,6 +219,10 @@ Item { } function editRule(rule) { + if (readOnly) { + showHyprlandReadOnlyWarning(); + return; + } if (!PopoutService.windowRuleModalLoader) return; PopoutService.windowRuleModalLoader.active = true; @@ -201,6 +232,10 @@ Item { } } + function showHyprlandReadOnlyWarning() { + ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing window rules in Settings."), "dms setup", "hyprland-migration"); + } + Component.onCompleted: { if (CompositorService.isNiri || CompositorService.isHyprland) { checkWindowRulesIncludeStatus(); @@ -274,6 +309,8 @@ Item { iconName: "add" iconSize: Theme.iconSize iconColor: Theme.primary + enabled: !root.readOnly + opacity: enabled ? 1 : 0.5 onClicked: root.openRuleModal() } } @@ -322,13 +359,14 @@ Item { anchors.horizontalCenter: parent.horizontalCenter radius: Theme.cornerRadius - readonly property bool showError: root.windowRulesIncludeStatus.exists && !root.windowRulesIncludeStatus.included - readonly property bool showSetup: !root.windowRulesIncludeStatus.exists && !root.windowRulesIncludeStatus.included + readonly property bool showLegacy: root.readOnly + readonly property bool showError: !showLegacy && root.windowRulesIncludeStatus.exists && !root.windowRulesIncludeStatus.included + readonly property bool showSetup: !showLegacy && !root.windowRulesIncludeStatus.exists && !root.windowRulesIncludeStatus.included - color: (showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.15) : "transparent" - border.color: (showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.3) : "transparent" + color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.15) : "transparent" + border.color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.3) : "transparent" border.width: 1 - visible: (showError || showSetup) && !root.checkingInclude && (CompositorService.isNiri || CompositorService.isHyprland) + visible: (showLegacy || showError || showSetup) && !root.checkingInclude && (CompositorService.isNiri || CompositorService.isHyprland) Row { id: warningSection @@ -349,7 +387,7 @@ Item { anchors.verticalCenter: parent.verticalCenter StyledText { - text: warningBox.showSetup ? I18n.tr("Window Rules Not Configured") : I18n.tr("Window Rules Include Missing") + text: warningBox.showLegacy ? I18n.tr("Hyprland conf mode") : (warningBox.showSetup ? I18n.tr("Window Rules Not Configured") : I18n.tr("Window Rules Include Missing")) font.pixelSize: Theme.fontSizeMedium font.weight: Font.Medium color: Theme.warning @@ -359,7 +397,7 @@ Item { StyledText { readonly property string rulesFile: CompositorService.isNiri ? "dms/windowrules.kdl" : "dms/windowrules.lua" - text: warningBox.showSetup ? I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg(rulesFile) : I18n.tr("%1 exists but is not included. Window rules won't apply.").arg(rulesFile) + text: warningBox.showLegacy ? I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing window rules in Settings.") : (warningBox.showSetup ? I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg(rulesFile) : I18n.tr("%1 exists but is not included. Window rules won't apply.").arg(rulesFile)) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText wrapMode: Text.WordWrap @@ -370,7 +408,7 @@ Item { DankButton { id: fixButton - visible: warningBox.showError || warningBox.showSetup + visible: !warningBox.showLegacy && (warningBox.showError || warningBox.showSetup) text: root.fixingInclude ? I18n.tr("Fixing...") : (warningBox.showSetup ? I18n.tr("Setup") : I18n.tr("Fix Now")) backgroundColor: Theme.warning textColor: Theme.background @@ -611,6 +649,8 @@ Item { iconSize: 16 backgroundColor: "transparent" iconColor: Theme.surfaceVariantText + enabled: !root.readOnly + opacity: enabled ? 1 : 0.5 onClicked: root.editRule(ruleDelegateItem.liveRuleData) } @@ -621,12 +661,14 @@ Item { iconSize: 16 backgroundColor: "transparent" iconColor: deleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText + enabled: !root.readOnly + opacity: enabled ? 1 : 0.5 MouseArea { id: deleteArea anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor + hoverEnabled: !root.readOnly + cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.PointingHandCursor onClicked: root.removeRule(ruleDelegateItem.ruleIdRef) } } @@ -641,8 +683,8 @@ Item { width: 40 height: ruleCard.height hoverEnabled: true - cursorShape: Qt.SizeVerCursor - drag.target: ruleDelegateItem.held ? ruleDelegateItem : undefined + cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.SizeVerCursor + drag.target: !root.readOnly && ruleDelegateItem.held ? ruleDelegateItem : undefined drag.axis: Drag.YAxis preventStealing: true diff --git a/quickshell/Services/HyprlandService.qml b/quickshell/Services/HyprlandService.qml index 080ddd49..8b59e4ef 100644 --- a/quickshell/Services/HyprlandService.qml +++ b/quickshell/Services/HyprlandService.qml @@ -18,9 +18,17 @@ Singleton { readonly property string layoutPath: hyprDmsDir + "/layout.lua" readonly property string cursorPath: hyprDmsDir + "/cursor.lua" readonly property string windowrulesPath: hyprDmsDir + "/windowrules.lua" + readonly property bool luaConfigActive: CompositorService.isHyprland && Hyprland.usingLua === true property int _lastGapValue: -1 + onLuaConfigActiveChanged: { + if (luaConfigActive) { + Qt.callLater(generateLayoutConfig); + Qt.callLater(ensureWindowrulesConfig); + } + } + Component.onCompleted: { if (CompositorService.isHyprland) { Qt.callLater(generateLayoutConfig); @@ -29,6 +37,8 @@ Singleton { } function ensureWindowrulesConfig() { + if (!canWriteLuaConfig("windowrules")) + return; Proc.runCommand("hypr-ensure-windowrules", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && [ ! -f "${windowrulesPath}" ] && touch "${windowrulesPath}" || true`], (output, exitCode) => { if (exitCode !== 0) log.warn("Failed to ensure windowrules.lua:", output); @@ -66,6 +76,13 @@ Singleton { return JSON.stringify(String(str ?? "")); } + function canWriteLuaConfig(name) { + if (luaConfigActive) + return true; + log.info("Skipping Hyprland", name || "config", "Lua write because the active Hyprland config is not Lua"); + return false; + } + function forceFlagValue(value) { if (value === true) return 1; @@ -75,6 +92,11 @@ Singleton { } function generateOutputsConfig(outputsData, hyprlandSettings, callback) { + if (!canWriteLuaConfig("outputs")) { + if (callback) + callback(false); + return; + } if (!outputsData || Object.keys(outputsData).length === 0) { if (callback) callback(false); @@ -172,6 +194,8 @@ Singleton { function generateLayoutConfig() { if (!CompositorService.isHyprland) return; + if (!canWriteLuaConfig("layout")) + return; const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12; const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4; @@ -254,6 +278,8 @@ hl.config({ function generateCursorConfig() { if (!CompositorService.isHyprland) return; + if (!canWriteLuaConfig("cursor")) + return; const settings = typeof SettingsData !== "undefined" ? SettingsData.cursorSettings : null; if (!settings) { diff --git a/quickshell/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml index 7010fbca..d273507f 100644 --- a/quickshell/Services/KeybindsService.qml +++ b/quickshell/Services/KeybindsService.qml @@ -52,7 +52,9 @@ Singleton { "bindsAfterDms": 0, "effective": true, "overriddenBy": 0, - "statusMessage": "" + "statusMessage": "", + "configFormat": "", + "readOnly": false }) property var _rawData: null @@ -102,6 +104,7 @@ Singleton { return ""; } } + readonly property bool readOnly: currentProvider === "hyprland" && dmsStatus.readOnly === true readonly property var actionTypes: Actions.getActionTypes() readonly property var dmsActions: getDmsActions() @@ -258,6 +261,10 @@ Singleton { function fixDmsBindsInclude() { if (fixing || dmsBindsIncluded || !compositorConfigDir) return; + if (readOnly) { + showHyprlandReadOnlyWarning(); + return; + } fixing = true; const timestamp = Math.floor(Date.now() / 1000); const backupPath = `${mainConfigPath}.dmsbackup${timestamp}`; @@ -343,7 +350,9 @@ Singleton { "bindsAfterDms": status.bindsAfterDms ?? 0, "effective": status.effective ?? true, "overriddenBy": status.overriddenBy ?? 0, - "statusMessage": status.statusMessage ?? "" + "statusMessage": status.statusMessage ?? "", + "configFormat": status.configFormat ?? "", + "readOnly": status.readOnly === true }; } _maybeWarnHyprlandLegacyConf(); @@ -482,6 +491,10 @@ Singleton { } function saveBind(originalKey, bindData) { + if (readOnly) { + showHyprlandReadOnlyWarning(); + return; + } if (!bindData.key || !Actions.isValidAction(bindData.action)) return; saving = true; @@ -510,13 +523,26 @@ Singleton { return; if (currentProvider !== "hyprland") return; + if (readOnly) { + _hyprlandLegacyWarnShown = true; + showHyprlandReadOnlyWarning(); + return; + } if (!dmsStatus.exists || dmsStatus.included) return; _hyprlandLegacyWarnShown = true; - ToastService.showWarning(I18n.tr("Hyprland config still uses hyprlang"), I18n.tr("DMS Settings now writes Lua. Edits won't apply until you migrate."), "dms setup", "hyprland-migration"); + ToastService.showWarning(I18n.tr("Hyprland config include missing"), I18n.tr("DMS Settings writes Lua keybinds. Add the DMS include so edits apply."), "dms setup", "hyprland-migration"); + } + + function showHyprlandReadOnlyWarning() { + ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing shortcuts in Settings."), "dms setup", "hyprland-migration"); } function removeBind(key) { + if (readOnly) { + showHyprlandReadOnlyWarning(); + return; + } if (!key) return; removeProcess.command = ["dms", "keybinds", "remove", currentProvider, key]; @@ -525,6 +551,10 @@ Singleton { } function resetBind(key) { + if (readOnly) { + showHyprlandReadOnlyWarning(); + return; + } if (!key) return; removeProcess.command = ["dms", "keybinds", "reset", currentProvider, key]; diff --git a/quickshell/Widgets/KeybindItem.qml b/quickshell/Widgets/KeybindItem.qml index cd62753a..b86f239b 100644 --- a/quickshell/Widgets/KeybindItem.qml +++ b/quickshell/Widgets/KeybindItem.qml @@ -21,6 +21,7 @@ Item { property var panelWindow: null property bool recording: false property bool isNew: false + property bool readOnly: false property string restoreKey: "" property int editingKeyIndex: -1 @@ -160,6 +161,10 @@ Item { } function startAddingNewKey() { + if (readOnly) { + KeybindsService.showHyprlandReadOnlyWarning(); + return; + } addingNewKey = true; editingKeyIndex = -1; editKey = ""; @@ -181,6 +186,8 @@ Item { } function updateEdit(changes) { + if (readOnly) + return; if (changes.key !== undefined) editKey = changes.key; if (changes.action !== undefined) @@ -208,6 +215,8 @@ Item { } function canSave() { + if (readOnly) + return false; if (!editKey) return false; if (!Actions.isValidAction(editAction)) @@ -216,6 +225,10 @@ Item { } function doSave() { + if (readOnly) { + KeybindsService.showHyprlandReadOnlyWarning(); + return; + } if (!canSave()) return; const origKey = addingNewKey ? "" : _originalKey; @@ -247,6 +260,10 @@ Item { } function startRecording() { + if (readOnly) { + KeybindsService.showHyprlandReadOnlyWarning(); + return; + } recording = true; } @@ -438,6 +455,7 @@ Item { anchors.top: parent.top anchors.margins: Theme.spacingL spacing: Theme.spacingM + enabled: !root.readOnly Rectangle { Layout.fillWidth: true @@ -554,7 +572,7 @@ Item { height: root._chipHeight radius: root._chipHeight / 4 color: root.addingNewKey ? Theme.primary : Theme.surfaceVariant - visible: !root.isNew + visible: !root.isNew && !root.readOnly Rectangle { anchors.fill: parent @@ -644,6 +662,7 @@ Item { iconName: root.recording ? "close" : "radio_button_checked" iconSize: Theme.iconSizeSmall iconColor: root.recording ? Theme.error : Theme.primary + enabled: !root.readOnly onClicked: root.recording ? root.stopRecording() : root.startRecording() } } @@ -746,7 +765,7 @@ Item { Layout.preferredHeight: root._inputHeight radius: Theme.cornerRadius color: root.addingNewKey ? Theme.primary : Theme.surfaceVariant - visible: root.keys.length === 1 && !root.isNew + visible: root.keys.length === 1 && !root.isNew && !root.readOnly Rectangle { anchors.fill: parent @@ -861,6 +880,8 @@ Item { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { + if (root.readOnly) + return; switch (typeDelegate.modelData.id) { case "dms": root.updateEdit({ @@ -926,6 +947,8 @@ Item { enableFuzzySearch: true maxPopupHeight: 300 onValueChanged: value => { + if (root.readOnly) + return; const actions = KeybindsService.getDmsActions(); for (const act of actions) { if (act.label === value) { @@ -1176,8 +1199,12 @@ Item { id: customToggleArea anchors.fill: parent hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.useCustomCompositor = true + cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.PointingHandCursor + onClicked: { + if (root.readOnly) + return; + root.useCustomCompositor = true; + } } } } @@ -1418,8 +1445,10 @@ Item { id: presetToggleArea anchors.fill: parent hoverEnabled: true - cursorShape: Qt.PointingHandCursor + cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.PointingHandCursor onClicked: { + if (root.readOnly) + return; root.useCustomCompositor = false; root.updateEdit({ "action": "close-window", @@ -1768,7 +1797,7 @@ Item { iconName: "delete" iconSize: Theme.iconSize - 4 iconColor: Theme.error - visible: root.editingKeyIndex >= 0 && root.editingKeyIndex < root.keys.length && (root.keys[root.editingKeyIndex].isDMSManaged || root.keys[root.editingKeyIndex].isOverride) && !root.isNew + visible: root.editingKeyIndex >= 0 && root.editingKeyIndex < root.keys.length && (root.keys[root.editingKeyIndex].isDMSManaged || root.keys[root.editingKeyIndex].isOverride) && !root.isNew && !root.readOnly onClicked: root.removeBind(root._originalKey) } @@ -1777,7 +1806,7 @@ Item { buttonHeight: root._buttonHeight backgroundColor: Theme.surfaceContainer textColor: Theme.primary - visible: root.editingKeyIndex >= 0 && root.editingKeyIndex < root.keys.length && root.keys[root.editingKeyIndex].isOverride === true && root.keys[root.editingKeyIndex].hasDefault === true && !root.isNew + visible: root.editingKeyIndex >= 0 && root.editingKeyIndex < root.keys.length && root.keys[root.editingKeyIndex].isOverride === true && root.keys[root.editingKeyIndex].hasDefault === true && !root.isNew && !root.readOnly onClicked: root.resetBind(root._originalKey) } @@ -1786,7 +1815,7 @@ Item { } StyledText { - text: !root.canSave() ? I18n.tr("Set key and action to save") : (root.hasChanges ? I18n.tr("Unsaved changes") : I18n.tr("No changes")) + text: root.readOnly ? I18n.tr("Read-only legacy config") : (!root.canSave() ? I18n.tr("Set key and action to save") : (root.hasChanges ? I18n.tr("Unsaved changes") : I18n.tr("No changes"))) font.pixelSize: Theme.fontSizeSmall color: root.hasChanges ? Theme.surfaceText : Theme.surfaceVariantText visible: !root.isNew