From 4927ef5b9b4ff1de7a6830966fbe96a19b390e1c Mon Sep 17 00:00:00 2001 From: purian23 Date: Fri, 30 Oct 2026 09:22:04 -0400 Subject: [PATCH] hyprland: drop legacy conf support and close 0.57 gaps Port 2.0 --- core/cmd/dms/commands_config.go | 61 +-- core/cmd/dms/commands_config_test.go | 37 +- core/cmd/dms/commands_doctor.go | 184 ++++++- core/cmd/dms/commands_doctor_test.go | 72 ++- core/cmd/dms/commands_setup.go | 5 +- core/cmd/dms/shellapp.go | 2 - core/internal/colorpicker/picker.go | 13 +- core/internal/colorpicker/picker_test.go | 21 + core/internal/config/deployer.go | 193 +------ core/internal/config/deployer_test.go | 320 ++---------- .../internal/config/embedded/hypr-outputs.lua | 2 +- core/internal/config/embedded/hyprland.lua | 6 +- core/internal/config/hyprland_lua.go | 166 +----- core/internal/config/mango_deploy.go | 16 +- core/internal/config/session_units.go | 16 + core/internal/configfrag/status.go | 6 +- core/internal/configfrag/status_test.go | 15 +- core/internal/keybinds/providers/hyprland.go | 92 ++-- .../keybinds/providers/hyprland_lua_expand.go | 38 +- .../keybinds/providers/hyprland_parser.go | 381 ++------------ .../providers/hyprland_parser_test.go | 473 ++---------------- .../keybinds/providers/hyprland_test.go | 75 ++- .../keybinds/providers/mangowc_parser.go | 2 +- .../keybinds/providers/niri_parser.go | 2 +- core/internal/keybinds/status_wire_test.go | 8 +- core/internal/keybinds/types.go | 4 - core/internal/luaconfig/hyprland_version.go | 33 ++ core/internal/screenshot/compositor.go | 34 +- core/internal/screenshot/compositor_test.go | 25 + core/internal/screenshot/screenshot.go | 15 +- core/internal/screenshot/scroll.go | 40 +- .../screenshot/scroll_hyprland_test.go | 49 +- core/internal/tui/views_config.go | 4 +- core/internal/utils/dirs.go | 4 + core/internal/utils/dirs_test.go | 11 + .../windowrules/providers/hyprland_parser.go | 397 ++------------- .../providers/hyprland_parser_test.go | 234 ++------- .../windowrules/providers/mango_parser.go | 1 - .../windowrules/providers/niri_parser.go | 2 +- core/internal/windowrules/status_wire_test.go | 8 +- core/internal/windowrules/types.go | 4 - quickshell/Common/KeybindActions.js | 4 + quickshell/Common/OutputModel.js | 55 +- quickshell/Common/SettingsData.qml | 11 +- quickshell/Common/WindowModel.js | 2 +- quickshell/Common/WorkspaceModel.js | 2 +- quickshell/Common/settings/SettingsSpec.js | 47 +- .../Modules/Settings/CompositorLayoutTab.qml | 114 ++++- .../DisplayConfig/DisplayConfigState.qml | 16 +- quickshell/Modules/Settings/KeybindsTab.qml | 4 +- .../Modules/Settings/MouseTouchpadTab.qml | 29 +- .../Modules/Settings/WallpaperColorsTab.qml | 26 + .../Settings/Widgets/ConfigInclude.qml | 17 +- .../Settings/Widgets/IncludeSetupBanner.qml | 6 +- .../Modules/Settings/WindowRulesTab.qml | 10 +- quickshell/Services/CompositorService.qml | 5 + quickshell/Services/HyprlandService.qml | 154 +++--- quickshell/Services/KeybindsService.qml | 23 +- quickshell/tests/hyprland-lua.test.mjs | 50 +- quickshell/tests/keybinds-execute.test.mjs | 27 +- quickshell/tests/output-model.test.mjs | 8 +- .../translations/settings_search_index.json | 266 ++++++++++ 62 files changed, 1472 insertions(+), 2475 deletions(-) create mode 100644 core/internal/colorpicker/picker_test.go create mode 100644 core/internal/luaconfig/hyprland_version.go create mode 100644 core/internal/screenshot/compositor_test.go diff --git a/core/cmd/dms/commands_config.go b/core/cmd/dms/commands_config.go index 094cf3da2..e3d0a37d3 100644 --- a/core/cmd/dms/commands_config.go +++ b/core/cmd/dms/commands_config.go @@ -60,10 +60,8 @@ func init() { } type IncludeResult struct { - Exists bool `json:"exists"` - Included bool `json:"included"` - ConfigFormat string `json:"configFormat,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` + Exists bool `json:"exists"` + Included bool `json:"included"` } func runResolveInclude(cmd *cobra.Command, args []string) { @@ -108,64 +106,13 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) { } mainLua := filepath.Join(configDir, "hyprland.lua") - if _, err := os.Stat(mainLua); err == nil { - result.ConfigFormat = "lua" - result.Included = luaconfig.RequiresTarget(mainLua, targetAbs, make(map[string]bool)) + if _, err := os.Stat(mainLua); err != nil { return result, nil } - - // 0.55/0.56 installs still on hyprland.conf: read-only until migrated. - mainConf := filepath.Join(configDir, "hyprland.conf") - if _, err := os.Stat(mainConf); err != nil { - return result, nil - } - result.ConfigFormat = "hyprlang" - result.ReadOnly = true - result.Included = hyprlandFindIncludeHyprlang(mainConf, targetAbs, make(map[string]bool)) + result.Included = luaconfig.RequiresTarget(mainLua, targetAbs, make(map[string]bool)) return result, nil } -func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]bool) bool { - absPath, err := filepath.Abs(filePath) - if err != nil { - return false - } - - if processed[absPath] { - return false - } - processed[absPath] = true - - data, err := os.ReadFile(absPath) - if err != nil { - return false - } - - baseDir := filepath.Dir(absPath) - for line := range strings.SplitSeq(string(data), "\n") { - trimmed := strings.TrimSpace(line) - if !strings.HasPrefix(trimmed, "source") { - continue - } - - parts := strings.SplitN(trimmed, "=", 2) - if len(parts) < 2 { - continue - } - - fullPath, err := resolveSourcePath(baseDir, strings.TrimSpace(parts[1])) - if err != nil { - continue - } - - if fullPath == target || hyprlandFindIncludeHyprlang(fullPath, target, processed) { - return true - } - } - - return false -} - func checkNiriInclude(filename string) (IncludeResult, error) { configDir := filepath.Join(utils.XDGConfigHome(), "niri") diff --git a/core/cmd/dms/commands_config_test.go b/core/cmd/dms/commands_config_test.go index 647c66d28..7d7dfa879 100644 --- a/core/cmd/dms/commands_config_test.go +++ b/core/cmd/dms/commands_config_test.go @@ -43,32 +43,17 @@ func TestMangowcFindIncludeNestedRelativeToIncludingFile(t *testing.T) { } } -func TestCheckHyprlandIncludeFormat(t *testing.T) { - tests := []struct { - name string - files map[string]string - wantFormat string - wantReadOnly bool - wantIncluded bool - }{ - {"hyprlang sourced", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n"}, "hyprlang", true, true}, - {"hyprlang not sourced", map[string]string{"hyprland.conf": "bind = SUPER, Q, killactive\n"}, "hyprlang", true, false}, - {"lua wins over leftover conf", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n", "hyprland.lua": "\n"}, "lua", false, false}, +func TestCheckHyprlandIncludeIgnoresLegacyConf(t *testing.T) { + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + writeConfigFile(t, filepath.Join(xdg, "hypr", "hyprland.conf"), "source = ./dms/colors.conf\n") + writeConfigFile(t, filepath.Join(xdg, "hypr", "dms", "colors.conf"), "") + + got, err := checkHyprlandInclude("colors.conf") + if err != nil { + t.Fatal(err) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - xdg := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", xdg) - for name, content := range tt.files { - writeConfigFile(t, filepath.Join(xdg, "hypr", name), content) - } - got, err := checkHyprlandInclude("colors.conf") - if err != nil { - t.Fatal(err) - } - if got.ConfigFormat != tt.wantFormat || got.ReadOnly != tt.wantReadOnly || got.Included != tt.wantIncluded { - t.Fatalf("got %+v, want format %q readOnly %v included %v", got, tt.wantFormat, tt.wantReadOnly, tt.wantIncluded) - } - }) + if got.Included { + t.Fatalf("got %+v; a hyprland.conf source must not count once Hyprland reads only Lua", got) } } diff --git a/core/cmd/dms/commands_doctor.go b/core/cmd/dms/commands_doctor.go index b1534ed02..a9899c1a3 100644 --- a/core/cmd/dms/commands_doctor.go +++ b/core/cmd/dms/commands_doctor.go @@ -12,6 +12,7 @@ import ( "regexp" "runtime" "slices" + "strconv" "strings" "github.com/charmbracelet/lipgloss" @@ -215,12 +216,13 @@ func runDoctor(cmd *cobra.Command, args []string) { qsFeatures, qsMissingFeatures := checkQuickshellFeatures() + versions, qsVersion := checkVersions(qsMissingFeatures) results := slices.Concat( checkSystemInfo(), - checkVersions(qsMissingFeatures), + versions, checkDMSInstallation(), checkPackageRepos(), - checkWindowManagers(), + checkWindowManagers(qsVersion), qsFeatures, checkOptionalDependencies(), checkConfigurationFiles(), @@ -685,18 +687,18 @@ func readOSRelease() map[string]string { return result } -func checkVersions(qsMissingFeatures bool) []checkResult { +func checkVersions(qsMissingFeatures bool) (results []checkResult, qsSemver string) { dmsCliPath, _ := os.Executable() dmsCliDetails := "" if doctorVerbose { dmsCliDetails = dmsCliPath } - results := []checkResult{ + results = []checkResult{ {catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails, doctorDocsURL + "#dms-cli"}, } - qsVersion, qsStatus, qsPath, qsDetails := getQuickshellVersionInfo(qsMissingFeatures) + qsVersion, qsStatus, qsPath, qsDetails, qsSemver := getQuickshellVersionInfo(qsMissingFeatures) if doctorVerbose && qsPath != "" && qsDetails == "" { qsDetails = qsPath } @@ -709,7 +711,7 @@ func checkVersions(qsMissingFeatures bool) []checkResult { results = append(results, checkResult{catVersions, "DMS Shell", statusError, "Not installed or not detected", "Run 'dms setup' to install", doctorDocsURL + "#dms-shell"}) } - return results + return results, qsSemver } func getDMSShellVersion() (version, path string) { @@ -732,9 +734,9 @@ func getDMSShellVersion() (version, path string) { return "", "" } -func getQuickshellVersionInfo(missingFeatures bool) (string, status, string, string) { +func getQuickshellVersionInfo(missingFeatures bool) (display string, st status, path, details, semver string) { if !utils.CommandExists("qs") { - return "Not installed", statusError, "", "" + return "Not installed", statusError, "", "", "" } qsPath, _ := exec.LookPath("qs") @@ -746,21 +748,21 @@ func getQuickshellVersionInfo(missingFeatures bool) (string, status, string, str if errors.As(err, &exitErr) { details = quickshellVersionFailureDetails(string(exitErr.Stderr)) } - return "Installed (version check failed)", statusWarn, qsPath, details + return "Installed (version check failed)", statusWarn, qsPath, details, "" } fullVersion := strings.TrimSpace(string(output)) if matches := quickshellVersionRegex.FindStringSubmatch(fullVersion); len(matches) >= 2 { if version.CompareVersions(matches[1], "0.2.0") < 0 { - return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), statusError, qsPath, "" + return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), statusError, qsPath, "", matches[1] } if missingFeatures { - return fullVersion, statusWarn, qsPath, "" + return fullVersion, statusWarn, qsPath, "", matches[1] } - return fullVersion, statusOK, qsPath, "" + return fullVersion, statusOK, qsPath, "", matches[1] } - return fullVersion, statusWarn, qsPath, "" + return fullVersion, statusWarn, qsPath, "", "" } func quickshellVersionFailureDetails(stderr string) string { @@ -835,7 +837,7 @@ func packageReposResult(distro distros.Distribution) []checkResult { return []checkResult{{catInstallation, "Package Repos", statusInfo, "No DMS package repositories", "", url}} } -func checkWindowManagers() []checkResult { +func checkWindowManagers(qsVersion string) []checkResult { compositors := []struct { name, versionCmd, versionArg string parseVersion func(string) string @@ -887,7 +889,8 @@ func checkWindowManagers() []checkResult { }) } if c.name == "Hyprland" { - results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...) + results = append(results, checkHyprlandQuickshellFloor(ver, qsVersion)...) + results = append(results, checkHyprlandDeprecatedConfig()...) } if c.name == "mangowc" && mangoLacksSnakeCase(ver) { results = append(results, checkResult{ @@ -1023,18 +1026,39 @@ func checkCompositorBlurSupport() checkResult { return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"} } -// Same detection as the keybinds parser: a regular hyprland.lua wins, else hyprland.conf is read-only. -func checkHyprlandConfigFormat(hyprDir string) []checkResult { - if st, err := os.Stat(filepath.Join(hyprDir, "hyprland.lua")); err == nil && st.Mode().IsRegular() { - return nil - } - if st, err := os.Stat(filepath.Join(hyprDir, "hyprland.conf")); err != nil || !st.Mode().IsRegular() { +func checkHyprlandQuickshellFloor(hyprVer, qsVer string) []checkResult { + if !hyprlandReadsOnlyLua(hyprVer) || qsVer == "" || version.CompareVersions(qsVer, "0.3.2") >= 0 { return nil } return []checkResult{{ - catCompositor, "Hyprland Lua config", statusWarn, - "Hyprland config is hyprland.conf", - "DMS keybinds, window rules and displays are read-only until you run `dms setup` to migrate to Lua. Hyprland 0.57 drops .conf support.", + catCompositor, "Quickshell for Hyprland 0.57", statusWarn, + fmt.Sprintf("Quickshell %s is older than 0.3.2", qsVer), + "Hyprland 0.57 sends workspace addresses that older Quickshell collapses to workspace 0, breaking named and special workspaces. Upgrade Quickshell.", + doctorDocsURL + "#compositor-checks", + }} +} + +// Hyprland before 0.57 answers `unknown request`, which fails to parse and is skipped. +func checkHyprlandDeprecatedConfig() []checkResult { + if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" { + return nil + } + out, err := exec.Command("hyprctl", "-j", "deprecated-config").Output() + if err != nil { + return nil + } + return hyprlandDeprecatedConfigResults(out) +} + +func hyprlandDeprecatedConfigResults(out []byte) []checkResult { + var notices []string + if err := json.Unmarshal(out, ¬ices); err != nil || len(notices) == 0 { + return nil + } + return []checkResult{{ + catCompositor, "Hyprland deprecated config", statusWarn, + fmt.Sprintf("%d deprecated option(s)", len(notices)), + strings.Join(notices, "; "), doctorDocsURL + "#compositor-checks", }} } @@ -1043,6 +1067,10 @@ func hyprlandLacksLua(ver string) bool { return ver != "" && version.CompareVersions(ver, "0.55.0") < 0 } +func hyprlandReadsOnlyLua(ver string) bool { + return ver != "" && version.CompareVersions(ver, "0.57.0") >= 0 +} + func mangoLacksSnakeCase(ver string) bool { return ver != "" && version.CompareVersions(ver, "0.18.0") < 0 } @@ -1580,6 +1608,8 @@ func checkSystemdServices() []checkResult { status = statusInfo } results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, "", doctorDocsURL + "#services"}) + results = append(results, checkGreetdSession("/etc/greetd/config.toml")...) + results = append(results, checkRetiredGreeterPackages()...) case doctorVerbose: results = append(results, checkResult{catServices, "greetd", statusInfo, "Not installed", "Optional greeter service", doctorDocsURL + "#services"}) } @@ -1587,6 +1617,112 @@ func checkSystemdServices() []checkResult { return results } +func greeterLuaHint() string { + return "Hyprland 0.57 only reads Lua; switch to the dux-greeter package and rerun `dms-greeter install`. " + greeterPackageInstallHint() +} + +func checkGreetdSession(configPath string) []checkResult { + data, err := os.ReadFile(configPath) + if err != nil { + return nil + } + fields := strings.Fields(greetdDefaultSessionCommand(string(data))) + if len(fields) == 0 { + return nil + } + warn := func(message string) []checkResult { + return []checkResult{{catServices, "Greeter session", statusWarn, message, greeterLuaHint(), doctorDocsURL + "#services"}} + } + if bin, err := exec.LookPath(fields[0]); err == nil && isLegacyWrapperScript(bin) { + return warn("greetd runs a pre-1.6 greeter wrapper script") + } + if !strings.EqualFold(greeterFlagValue(fields, "--command"), "hyprland") { + return nil + } + cfg := strings.Trim(greeterFlagValue(fields, "-C", "--config"), `"'`) + if cfg == "" || strings.HasSuffix(cfg, ".lua") { + return nil + } + content, err := os.ReadFile(cfg) + if err != nil || greeterHyprlandLuaContent(content) { + return nil + } + return warn("Greeter Hyprland config is not Lua: " + cfg) +} + +var greeterHyprlandLuaPattern = regexp.MustCompile(`(^|[^[:alnum:]_])hl\.`) + +// Mirrors dux-greeter's launcher.IsHyprlandLuaConfig content check. +func greeterHyprlandLuaContent(content []byte) bool { + for line := range strings.Lines(string(content)) { + if strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + if greeterHyprlandLuaPattern.MatchString(line) { + return true + } + } + return false +} + +func greetdDefaultSessionCommand(configTOML string) string { + inDefault := false + for line := range strings.SplitSeq(configTOML, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[") { + inDefault = line == "[default_session]" + continue + } + key, value, ok := strings.Cut(line, "=") + if !inDefault || !ok || strings.TrimSpace(key) != "command" { + continue + } + value = strings.TrimSpace(value) + if unquoted, err := strconv.Unquote(value); err == nil { + return unquoted + } + return strings.Trim(value, `'`) + } + return "" +} + +func greeterFlagValue(fields []string, flags ...string) string { + for i, f := range fields { + for _, flag := range flags { + if f == flag && i+1 < len(fields) { + return fields[i+1] + } + if value, ok := strings.CutPrefix(f, flag+"="); ok { + return value + } + } + } + return "" +} + +func checkRetiredGreeterPackages() []checkResult { + if !utils.CommandExists("pacman") { + return nil + } + // pacman -Q exits non-zero when any name is missing but still lists the installed ones + out, _ := exec.Command("pacman", "-Q", "greetd-dms-greeter", "greetd-dms-greeter-bin", "greetd-dms-greeter-git").Output() + var installed []string + for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") { + if name, _, _ := strings.Cut(line, " "); name != "" { + installed = append(installed, name) + } + } + if len(installed) == 0 { + return nil + } + return []checkResult{{ + catServices, "Greeter package", statusWarn, + "Retired package installed: " + strings.Join(installed, ", "), + greeterLuaHint(), + doctorDocsURL + "#services", + }} +} + // Chromium, Electron and libadwaita follow light/dark through the settings // portal; without it they fall back to GTK theme colours that never update. func checkSettingsPortal() []checkResult { diff --git a/core/cmd/dms/commands_doctor_test.go b/core/cmd/dms/commands_doctor_test.go index dfeb4d7ba..9cd2eb099 100644 --- a/core/cmd/dms/commands_doctor_test.go +++ b/core/cmd/dms/commands_doctor_test.go @@ -433,24 +433,66 @@ func TestHyprlandLacksLua(t *testing.T) { } } -func TestCheckHyprlandConfigFormat(t *testing.T) { - for name, tc := range map[string]struct { - files []string - warn bool +func TestCheckHyprlandQuickshellFloor(t *testing.T) { + for _, tc := range []struct { + hypr, qs string + warn bool }{ - "lua only": {[]string{"hyprland.lua"}, false}, - "lua wins": {[]string{"hyprland.lua", "hyprland.conf"}, false}, - "conf only": {[]string{"hyprland.conf"}, true}, - "neither": {nil, false}, + {"0.57.0", "0.3.1", true}, + {"0.57.0", "0.3.2", false}, + {"0.56.1", "0.2.1", false}, + {"0.57.0", "", false}, } { - dir := t.TempDir() - for _, f := range tc.files { - if err := os.WriteFile(filepath.Join(dir, f), nil, 0o644); err != nil { - t.Fatal(err) - } + if got := checkHyprlandQuickshellFloor(tc.hypr, tc.qs); (len(got) == 1 && got[0].status == statusWarn) != tc.warn || (!tc.warn && len(got) != 0) { + t.Errorf("hyprland %q quickshell %q: got %+v, want warn=%v", tc.hypr, tc.qs, got, tc.warn) } - got := checkHyprlandConfigFormat(dir) - if warned := len(got) == 1 && got[0].status == statusWarn; warned != tc.warn || (!tc.warn && len(got) != 0) { + } +} + +func TestHyprlandDeprecatedConfigResults(t *testing.T) { + got := hyprlandDeprecatedConfigResults([]byte(`["general:no_border_on_floating", "decoration:shadow:range"]`)) + if len(got) != 1 || got[0].status != statusWarn || !strings.Contains(got[0].details, "decoration:shadow:range") { + t.Fatalf("got %+v, want one warning listing both keys", got) + } + for _, out := range []string{"[]", "unknown request"} { + if got := hyprlandDeprecatedConfigResults([]byte(out)); len(got) != 0 { + t.Errorf("%q: got %+v, want nothing", out, got) + } + } +} + +func TestCheckGreetdSession(t *testing.T) { + dir := t.TempDir() + wrapper := filepath.Join(dir, "dms-greeter") + if err := os.WriteFile(wrapper, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + hyprlang := filepath.Join(dir, "dms-hypr.conf") + if err := os.WriteFile(hyprlang, []byte("# see hl.dsp docs\nexec-once = waybar\n"), 0o644); err != nil { + t.Fatal(err) + } + luaConf := filepath.Join(dir, "lua-hypr.conf") + if err := os.WriteFile(luaConf, []byte("hl.config({ misc = { disable_hyprland_logo = true } })\n"), 0o644); err != nil { + t.Fatal(err) + } + for name, tc := range map[string]struct { + command string + warn bool + }{ + "pre-1.6 wrapper": {wrapper + " --command niri", true}, + "hyprlang greeter config": {"dux-greeter --command hyprland -C " + hyprlang, true}, + "hyprlang via --config=": {"dux-greeter --command hyprland --config='" + hyprlang + "'", true}, + "lua content in a .conf": {"dux-greeter --command hyprland -C " + luaConf, false}, + "lua greeter config": {"dux-greeter --command hyprland -C /etc/greetd/dms-hypr.lua", false}, + "unreadable greeter config": {"dux-greeter --command hyprland -C " + filepath.Join(dir, "missing.conf"), false}, + "niri greeter": {"dux-greeter --command niri -C /etc/greetd/niri.kdl", false}, + } { + config := filepath.Join(t.TempDir(), "config.toml") + toml := "[terminal]\nvt = 1\n\n[default_session]\ncommand = \"" + tc.command + "\"\nuser = \"greeter\"\n" + if err := os.WriteFile(config, []byte(toml), 0o644); err != nil { + t.Fatal(err) + } + if got := checkGreetdSession(config); (len(got) == 1 && got[0].status == statusWarn) != tc.warn || (!tc.warn && len(got) != 0) { t.Errorf("%s: got %+v, want warn=%v", name, got, tc.warn) } } diff --git a/core/cmd/dms/commands_setup.go b/core/cmd/dms/commands_setup.go index 5deb59519..820cec9f8 100644 --- a/core/cmd/dms/commands_setup.go +++ b/core/cmd/dms/commands_setup.go @@ -563,10 +563,7 @@ func compositorPrimaryPaths(wm deps.WindowManager) []string { case deps.WindowManagerNiri: return []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")} case deps.WindowManagerHyprland: - return []string{ - filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"), - filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"), - } + return []string{filepath.Join(config.HyprlandConfigDir(), "hyprland.lua")} case deps.WindowManagerMango: return []string{ filepath.Join(homeDir, ".config", "mango", "config.conf"), diff --git a/core/cmd/dms/shellapp.go b/core/cmd/dms/shellapp.go index b8543e95d..800a2a2a3 100644 --- a/core/cmd/dms/shellapp.go +++ b/core/cmd/dms/shellapp.go @@ -10,7 +10,6 @@ import ( "github.com/duxlabs-dev/dux-go/shellapp" "github.com/duxlabs-dev/dux-shell/core/internal/clipolicy" - "github.com/duxlabs-dev/dux-shell/core/internal/config" "github.com/duxlabs-dev/dux-shell/core/internal/gpu" "github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/server" @@ -58,7 +57,6 @@ func (b *dmsBackend) Close() { func (b *dmsBackend) Done() <-chan error { return b.done } func bootBackend(ctx context.Context) (shellapp.Backend, error) { - config.CleanupStrayHyprlandConfFile(log.Infof) server.CLIVersion = Version srv := server.New() diff --git a/core/internal/colorpicker/picker.go b/core/internal/colorpicker/picker.go index 02d6d5e24..015a7a58e 100644 --- a/core/internal/colorpicker/picker.go +++ b/core/internal/colorpicker/picker.go @@ -11,6 +11,7 @@ import ( "github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_screencopy" "github.com/duxlabs-dev/dux-shell/core/internal/proto/wp_color_management" "github.com/duxlabs-dev/dux-shell/core/internal/proto/wp_viewporter" + "github.com/duxlabs-dev/dux-shell/core/internal/screenshot" "github.com/duxlabs-dev/dux-shell/core/internal/utils" wlhelpers "github.com/duxlabs-dev/dux-shell/core/internal/wayland/client" "github.com/duxlabs-dev/dux-shell/core/internal/wayland/keymap" @@ -535,9 +536,8 @@ func (p *Picker) captureForSurface(ls *LayerSurface) { ls.state.OnScreencopyReady() screenBuf := ls.state.ScreenBuffer() - if screenBuf != nil && ls.output.transform != TransformNormal { - invTransform := InverseTransform(ls.output.transform) - transformed, err := screenBuf.ApplyTransform(invTransform) + if screenBuf != nil { + transformed, err := ls.output.untransformFrame(screenBuf) if err != nil { log.Error("apply transform failed", "err", err) } else if transformed != screenBuf { @@ -562,6 +562,13 @@ func (p *Picker) captureForSurface(ls *LayerSurface) { }) } +func (o *Output) untransformFrame(buf *ShmBuffer) (*ShmBuffer, error) { + if screenshot.FrameInLogicalOrientation(o.transform, o.width, o.height, buf.Width, buf.Height) { + return buf, nil + } + return buf.ApplyTransform(InverseTransform(o.transform)) +} + func (p *Picker) redrawSurface(ls *LayerSurface) { slot := ls.state.FrontIndex() if ls.slotBusy[slot] || ls.framePending { diff --git a/core/internal/colorpicker/picker_test.go b/core/internal/colorpicker/picker_test.go new file mode 100644 index 000000000..f414357af --- /dev/null +++ b/core/internal/colorpicker/picker_test.go @@ -0,0 +1,21 @@ +package colorpicker + +import "testing" + +func TestUntransformFrameLeavesAnUprightFrame(t *testing.T) { + buf, err := CreateShmBuffer(1080, 1920, 1080*4) + if err != nil { + t.Fatal(err) + } + defer buf.Close() + + output := &Output{width: 1920, height: 1080, transform: Transform90} + got, err := output.untransformFrame(buf) + if err != nil { + t.Fatal(err) + } + if got != buf { + defer got.Close() + t.Fatalf("rotated a frame already in logical orientation to %dx%d", got.Width, got.Height) + } +} diff --git a/core/internal/config/deployer.go b/core/internal/config/deployer.go index abeba13dd..2fb1b383b 100644 --- a/core/internal/config/deployer.go +++ b/core/internal/config/deployer.go @@ -70,9 +70,7 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(_ context.Context, wm dep filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"), }, "Hyprland": { - filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"), - // A conf-only install still counts: deploying moves it into backups. - filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"), + filepath.Join(HyprlandConfigDir(), "hyprland.lua"), }, "Mango": { filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"), @@ -262,7 +260,8 @@ func (cd *ConfigDeployer) deployNiriConfig(terminalCommand string, useSystemd bo newConfig := strings.ReplaceAll(NiriConfig, "{{TERMINAL_COMMAND}}", terminalCommand) - if !cd.systemdAutostart(deps.WindowManagerNiri, useSystemd) { + autostart := cd.systemdAutostart(deps.WindowManagerNiri, useSystemd) + if !autostart { newConfig = cd.transformNiriConfigForNonSystemd(newConfig, terminalCommand) } @@ -276,14 +275,19 @@ func (cd *ConfigDeployer) deployNiriConfig(terminalCommand string, useSystemd bo } } + // niri reloads config.kdl as soon as it changes, so its includes must already exist. + if err := cd.deployNiriDmsConfigs(dmsDir, terminalCommand); err != nil { + result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) + return result, result.Error + } + if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil { result.Error = fmt.Errorf("failed to write config: %w", err) return result, result.Error } - if err := cd.deployNiriDmsConfigs(dmsDir, terminalCommand); err != nil { - result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) - return result, result.Error + if autostart { + cd.startInLiveSession(deps.WindowManagerNiri) } result.Deployed = true @@ -604,11 +608,10 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms return builder.String(), nil } -// deployHyprlandConfig handles Hyprland configuration deployment with backup and merging func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystemd bool) (DeploymentResult, error) { result := DeploymentResult{ ConfigType: "Hyprland", - Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"), + Path: filepath.Join(HyprlandConfigDir(), "hyprland.lua"), } if err := hyprlandLuaUnsupported(cd.hyprlandVersion); err != nil { result.Error = err @@ -627,22 +630,14 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystem return result, result.Error } - timestamp := time.Now().Format("2006-01-02_15-04-05") - backupDir := filepath.Join(configDir, hyprlandBackupDirName, timestamp) - var existingConfig string - existingData, existingPath, err := readExistingHyprlandConfig(configDir) - if err != nil { + existing, err := os.ReadFile(result.Path) + if err != nil && !os.IsNotExist(err) { result.Error = err return result, result.Error } - if existingData != "" { - existingConfig = existingData - cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath)) - } - // A legacy hyprland.conf is only backed up once hyprland.lua is in place; Hyprland - // watches the live config and regenerates a stub one if it disappears first. - if existingData != "" && !strings.EqualFold(filepath.Ext(existingPath), ".conf") { - result.BackupPath, err = backupHyprlandConfigFile(filepath.Join(backupDir, filepath.Base(existingPath)), []byte(existingData)) + if err == nil { + backupDir := filepath.Join(configDir, hyprlandBackupDirName, time.Now().Format("2006-01-02_15-04-05")) + result.BackupPath, err = backupHyprlandConfigFile(filepath.Join(backupDir, "hyprland.lua"), existing) if err != nil { result.Error = fmt.Errorf("failed to create backup: %w", err) return result, result.Error @@ -651,19 +646,15 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystem } newConfig := strings.ReplaceAll(HyprlandLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand) - - if !cd.systemdAutostart(deps.WindowManagerHyprland, useSystemd) { + autostart := cd.systemdAutostart(deps.WindowManagerHyprland, useSystemd) + if !autostart { newConfig = transformHyprlandLuaForNonSystemd(newConfig, terminalCommand) } - if existingConfig != "" { - mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir) - if err != nil { - cd.log(fmt.Sprintf("Warning: Failed to merge monitor sections: %v", err)) - } else { - newConfig = mergedConfig - cd.log("Successfully merged existing monitor sections") - } + // Hyprland reloads hyprland.lua as soon as it changes, so its required fragments must already exist. + if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil { + result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) + return result, result.Error } if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil { @@ -671,30 +662,9 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystem return result, result.Error } - movedLegacy, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir) - if err != nil { - result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err) - return result, result.Error + if autostart { + cd.startInLiveSession(deps.WindowManagerHyprland) } - if mainConfBackup != "" && result.BackupPath == "" { - result.BackupPath = mainConfBackup - cd.log(fmt.Sprintf("Backed up existing config to %s", mainConfBackup)) - } - if movedLegacy > 0 { - if result.BackupPath == "" { - result.BackupPath = backupDir - } - cd.log(fmt.Sprintf("Moved %d legacy hyprlang config(s) to %s", movedLegacy, backupDir)) - } - - if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil { - result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) - 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") @@ -725,80 +695,6 @@ func backupHyprlandConfigFile(dst string, data []byte) (string, error) { return dst, nil } -func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, string, error) { - mainConf := filepath.Join(configDir, "hyprland.conf") - legacyPaths := []string{mainConf} - dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf")) - if err != nil { - return 0, "", err - } - legacyPaths = append(legacyPaths, dmsConfPaths...) - backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir) - if err != nil { - return 0, "", err - } - legacyPaths = append(legacyPaths, backupPaths...) - - moved := 0 - mainConfBackup := "" - for _, src := range legacyPaths { - info, err := os.Lstat(src) - if os.IsNotExist(err) { - continue - } - if err != nil { - return moved, mainConfBackup, err - } - if info.IsDir() { - continue - } - - rel, err := filepath.Rel(configDir, src) - if err != nil { - rel = filepath.Base(src) - } - dst, err := moveHyprlandConfigFile(src, filepath.Join(backupDir, rel)) - if err != nil { - return moved, mainConfBackup, err - } - if src == mainConf { - mainConfBackup = dst - } - moved++ - } - - return moved, mainConfBackup, nil -} - -func moveHyprlandConfigFile(src, dst string) (string, error) { - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return "", err - } - dst = uniqueBackupPath(dst) - if err := os.Rename(src, dst); err != nil { - return "", err - } - return dst, nil -} - -func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) { - var paths []string - patterns := []string{ - filepath.Join(configDir, "hyprland.conf.backup.*"), - filepath.Join(configDir, "hyprland.lua.backup.*"), - filepath.Join(dmsDir, "*.conf.backup.*"), - filepath.Join(dmsDir, "*.lua.backup.*"), - } - for _, pattern := range patterns { - matches, err := filepath.Glob(pattern) - if err != nil { - return nil, err - } - paths = append(paths, matches...) - } - return paths, nil -} - func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error { configs := []struct { name string @@ -838,45 +734,6 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman return nil } -func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) { - _ = newConfig - lines := extractHyprlangMonitorLines(existingConfig) - if len(lines) == 0 { - return newConfig, nil - } - - outputsPath := filepath.Join(dmsDir, "outputs.lua") - if info, err := os.Stat(outputsPath); err == nil && info.Size() > 0 { - cd.log("Skipping monitor migration: dms/outputs.lua already exists") - return newConfig, nil - } - - var b strings.Builder - b.WriteString("-- Migrated from existing hyprlang monitor lines\n\n") - ok := 0 - for _, line := range lines { - lua, err := hyprlangMonitorLineToLua(line) - if err != nil { - cd.log(fmt.Sprintf("Warning: could not migrate monitor line %q: %v", line, err)) - continue - } - b.WriteString(lua) - b.WriteByte('\n') - ok++ - } - if ok == 0 { - return newConfig, nil - } - b.WriteByte('\n') - b.WriteString("-- Default fallback\n") - b.WriteString("hl.monitor({ output = \"\", mode = \"preferred\", position = \"auto\", scale = \"auto\" })\n") - if err := os.WriteFile(outputsPath, []byte(b.String()), 0o644); err != nil { - return newConfig, err - } - cd.log("Migrated monitor sections to dms/outputs.lua") - return newConfig, nil -} - func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string { envVars := fmt.Sprintf(`environment { XDG_CURRENT_DESKTOP "niri" diff --git a/core/internal/config/deployer_test.go b/core/internal/config/deployer_test.go index 6894a19e8..fa98193a7 100644 --- a/core/internal/config/deployer_test.go +++ b/core/internal/config/deployer_test.go @@ -16,6 +16,8 @@ import ( // Tests never reach the user's systemd; stubAddWants counts the add-wants calls a deploy makes. func TestMain(m *testing.M) { systemctlUser = func(...string) error { return nil } + // Hyprland paths follow XDG_CONFIG_HOME; tests pin HOME and must not write a real config. + os.Unsetenv("XDG_CONFIG_HOME") os.Exit(m.Run()) } @@ -34,55 +36,6 @@ func stubAddWants(t *testing.T, addWants error) *int { return &calls } -func TestCleanupStrayHyprlandConfFile(t *testing.T) { - if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" { - t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "test-signature") - } - - t.Run("leaves conf alone when no hyprland.lua present", func(t *testing.T) { - td := t.TempDir() - t.Setenv("HOME", td) - configDir := filepath.Join(td, ".config", "hypr") - 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)) - }) - - t.Run("moves stray conf into backup when hyprland.lua exists", func(t *testing.T) { - td := t.TempDir() - t.Setenv("HOME", td) - configDir := filepath.Join(td, ".config", "hypr") - 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")) - }) -} - func TestMergeNiriOutputSections(t *testing.T) { cd := &ConfigDeployer{} @@ -256,61 +209,6 @@ func getGhosttyPath() string { return filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config") } -func TestMergeHyprlandMonitorSections(t *testing.T) { - cd := &ConfigDeployer{} - - t.Run("no monitors in existing", func(t *testing.T) { - tmp := t.TempDir() - out, err := cd.mergeHyprlandMonitorSections(`hl.config({})`, `input { kb_layout = us }`, tmp) - require.NoError(t, err) - assert.Equal(t, `hl.config({})`, out) - _, e := os.Stat(filepath.Join(tmp, "outputs.lua")) - assert.True(t, os.IsNotExist(e)) - }) - - t.Run("writes outputs lua from hyprlang monitors", func(t *testing.T) { - tmp := t.TempDir() - existing := `monitor = DP-1, 1920x1080@144, 0x0, 1 -# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1 -monitor = eDP-1, 2560x1440@165, auto, 1.25` - out, err := cd.mergeHyprlandMonitorSections(`return`, existing, tmp) - require.NoError(t, err) - assert.Equal(t, `return`, out) - b, err := os.ReadFile(filepath.Join(tmp, "outputs.lua")) - require.NoError(t, err) - s := string(b) - assert.Contains(t, s, "hl.monitor") - assert.Contains(t, s, "DP-1") - assert.Contains(t, s, "HDMI-A-1") - assert.Contains(t, s, "eDP-1") - assert.Contains(t, s, "preferred") // fallback rule at end - }) - - t.Run("skips when outputs lua already exists", func(t *testing.T) { - tmp := t.TempDir() - path := filepath.Join(tmp, "outputs.lua") - require.NoError(t, os.WriteFile(path, []byte("-- keep\n"), 0o644)) - _, err := cd.mergeHyprlandMonitorSections(`x`, `monitor = DP-1, 1920x1080@144, 0x0, 1`, tmp) - require.NoError(t, err) - b, err := os.ReadFile(path) - require.NoError(t, err) - assert.Equal(t, "-- keep\n", string(b)) - }) -} - -func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) { - got, err := hyprlangMonitorLineToLua(`monitor = DP-1, 1920x1080@144, 0x0, 1, transform, 1, vrr, 2, bitdepth, 10, cm, hdr, sdrbrightness, 1.2, sdrsaturation, 0.98`) - require.NoError(t, err) - - assert.Contains(t, got, `output = "DP-1"`) - assert.Contains(t, got, `transform = 1`) - assert.Contains(t, got, `vrr = 2`) - assert.Contains(t, got, `bitdepth = 10`) - assert.Contains(t, got, `cm = "hdr"`) - assert.Contains(t, got, `sdrbrightness = 1.2`) - assert.Contains(t, got, `sdrsaturation = 0.98`) -} - func TestHyprlandConfigDeployment(t *testing.T) { tempDir, err := os.MkdirTemp("", "duxinstall-hyprland-test") require.NoError(t, err) @@ -343,91 +241,27 @@ func TestHyprlandConfigDeployment(t *testing.T) { assert.Contains(t, string(content), "hl.config(") }) - t.Run("deploy hyprland config with existing monitors", func(t *testing.T) { - td, err := os.MkdirTemp("", "duxinstall-hyprland-merge") + t.Run("deploy hyprland config backs up the existing hyprland.lua", func(t *testing.T) { + td, err := os.MkdirTemp("", "duxinstall-hyprland-backup") require.NoError(t, err) defer os.RemoveAll(td) os.Setenv("HOME", td) - existingContent := `# My existing Hyprland config -monitor = DP-1, 1920x1080@144, 0x0, 1 -monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5 - -general { - gaps_in = 10 -} -` - hyprPath := filepath.Join(td, ".config", "hypr", "hyprland.conf") - err = os.MkdirAll(filepath.Dir(hyprPath), 0o755) - require.NoError(t, err) - err = os.WriteFile(hyprPath, []byte(existingContent), 0o644) - require.NoError(t, err) - 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)) + existing := "hl.config({ autogenerated = true })\n" + luaPath := filepath.Join(td, ".config", "hypr", "hyprland.lua") + require.NoError(t, os.MkdirAll(filepath.Dir(luaPath), 0o755)) + require.NoError(t, os.WriteFile(luaPath, []byte(existing), 0o644)) result, err := cd.deployHyprlandConfig("kitty", true) require.NoError(t, err) - assert.Equal(t, "Hyprland", result.ConfigType) - assert.True(t, result.Deployed) - assert.NotEmpty(t, result.BackupPath) - assert.FileExists(t, result.Path) - assert.FileExists(t, result.BackupPath) - - backupContent, err := os.ReadFile(result.BackupPath) - require.NoError(t, err) - assert.Equal(t, existingContent, string(backupContent)) 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")) - - newContent, err := os.ReadFile(result.Path) + backup, err := os.ReadFile(result.BackupPath) require.NoError(t, err) - assert.Contains(t, string(newContent), `require("dms.binds")`) - - outputsPath := filepath.Join(td, ".config", "hypr", "dms", "outputs.lua") - outBytes, err := os.ReadFile(outputsPath) + assert.Equal(t, existing, string(backup)) + content, err := os.ReadFile(result.Path) require.NoError(t, err) - outs := string(outBytes) - assert.Contains(t, outs, `hl.monitor`) - assert.Contains(t, outs, "DP-1") - assert.Contains(t, outs, "HDMI-A-1") - }) - - t.Run("deploy hyprland config removes root legacy symlink when lua exists", func(t *testing.T) { - td, err := os.MkdirTemp("", "duxinstall-hyprland-lua-conf-symlink") - require.NoError(t, err) - defer os.RemoveAll(td) - os.Setenv("HOME", td) - - configDir := filepath.Join(td, ".config", "hypr") - require.NoError(t, os.MkdirAll(configDir, 0o755)) - luaPath := filepath.Join(configDir, "hyprland.lua") - confPath := filepath.Join(configDir, "hyprland.conf") - require.NoError(t, os.WriteFile(luaPath, []byte(`require("dms.binds")`+"\n"), 0o644)) - require.NoError(t, os.Symlink(filepath.Join(configDir, "missing-legacy.conf"), confPath)) - - result, err := cd.deployHyprlandConfig("kitty", true) - require.NoError(t, err) - - assert.Equal(t, luaPath, result.Path) - _, err = os.Lstat(confPath) - assert.True(t, os.IsNotExist(err), "root hyprland.conf symlink should be moved out of the live config directory") - _, err = os.Lstat(filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf")) - assert.NoError(t, err) + assert.Contains(t, string(content), `require("dms.binds")`) + assert.NotContains(t, string(content), "autogenerated") }) t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) { @@ -724,66 +558,6 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) { assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true") }) - t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) { - tempDir, err := os.MkdirTemp("", "duxinstall-hyprland-legacy-skip-test") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - originalHome := os.Getenv("HOME") - os.Setenv("HOME", tempDir) - defer os.Setenv("HOME", originalHome) - - hyprConf := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf") - require.NoError(t, os.MkdirAll(filepath.Dir(hyprConf), 0o755)) - require.NoError(t, os.WriteFile(hyprConf, []byte("monitor = , preferred, auto, 1\n"), 0o644)) - - logChan := make(chan string, 100) - cd := NewConfigDeployer(logChan) - results, err := cd.deployConfigurationsInternal( - context.Background(), - deps.WindowManagerHyprland, - deps.TerminalGhostty, - nil, - allFalse, - nil, - true, - ) - require.NoError(t, err) - - for _, r := range results { - if r.ConfigType == "Hyprland" && r.Deployed { - t.Fatalf("expected Hyprland deployment to be skipped when legacy config exists and replace=false") - } - } - }) -} - -func TestBackupLegacyHyprlandConfFilesKeepsExistingBackups(t *testing.T) { - td := t.TempDir() - configDir := filepath.Join(td, ".config", "hypr") - dmsDir := filepath.Join(configDir, "dms") - require.NoError(t, os.MkdirAll(dmsDir, 0o755)) - - userConfig := "# the config the user actually wrote\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n" - backupDir := filepath.Join(configDir, hyprlandBackupDirName, "2026-01-02_03-04-05") - require.NoError(t, os.MkdirAll(backupDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(backupDir, "hyprland.conf"), []byte(userConfig), 0o644)) - - autogenerated := "autogenerated = 1\n$mainMod = SUPER\n" - require.NoError(t, os.WriteFile(filepath.Join(configDir, "hyprland.conf"), []byte(autogenerated), 0o644)) - - moved, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir) - require.NoError(t, err) - assert.Equal(t, 1, moved) - assert.Equal(t, filepath.Join(backupDir, "hyprland.conf.1"), mainConfBackup) - - kept, err := os.ReadFile(filepath.Join(backupDir, "hyprland.conf")) - require.NoError(t, err) - assert.Equal(t, userConfig, string(kept)) - - stray, err := os.ReadFile(mainConfBackup) - require.NoError(t, err) - assert.Equal(t, autogenerated, string(stray)) } func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) { @@ -800,33 +574,6 @@ func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) { assert.Equal(t, "-- first\n", string(first)) } -func TestDeployHyprlandConfigWritesLuaBeforeRemovingLegacyConf(t *testing.T) { - td := t.TempDir() - t.Setenv("HOME", td) - configDir := filepath.Join(td, ".config", "hypr") - require.NoError(t, os.MkdirAll(configDir, 0o755)) - confPath := filepath.Join(configDir, "hyprland.conf") - userConfig := "# user config\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n" - require.NoError(t, os.WriteFile(confPath, []byte(userConfig), 0o644)) - - logChan := make(chan string, 100) - cd := NewConfigDeployer(logChan) - go func() { - for range logChan { - } - }() - defer close(logChan) - - result, err := cd.deployHyprlandConfig("ghostty", true) - require.NoError(t, err) - - assert.NoFileExists(t, confPath) - backup, err := os.ReadFile(result.BackupPath) - require.NoError(t, err) - assert.Equal(t, userConfig, string(backup)) - assert.Equal(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"), result.BackupPath) -} - func TestDeployHyprlandConfigSystemdAutostart(t *testing.T) { cases := []struct { name string @@ -862,6 +609,47 @@ func TestDeployHyprlandConfigSystemdAutostart(t *testing.T) { } } +func TestDeployHyprlandStartsShellInLiveSession(t *testing.T) { + cases := []struct { + name string + instance string + unitActive bool + useSystemd bool + wantStarted bool + }{ + {"live session", "sig", true, true, true}, + {"session unit inactive", "sig", false, true, false}, + {"other session", "", true, true, false}, + {"no systemd", "sig", true, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", tc.instance) + t.Setenv("NIRI_SOCKET", "") + t.Setenv("MANGO_INSTANCE_SIGNATURE", "") + started := false + prev := systemctlUser + systemctlUser = func(args ...string) error { + switch args[0] { + case "is-active": + if !tc.unitActive { + return errors.New("inactive") + } + case "start": + started = true + } + return nil + } + t.Cleanup(func() { systemctlUser = prev }) + + _, err := NewConfigDeployer(nil).deployHyprlandConfig("kitty", tc.useSystemd) + require.NoError(t, err) + assert.Equal(t, tc.wantStarted, started) + }) + } +} + func TestDeployNiriConfigSystemdAutostart(t *testing.T) { cases := []struct { name string diff --git a/core/internal/config/embedded/hypr-outputs.lua b/core/internal/config/embedded/hypr-outputs.lua index b401a0c12..0160324d0 100644 --- a/core/internal/config/embedded/hypr-outputs.lua +++ b/core/internal/config/embedded/hypr-outputs.lua @@ -1,3 +1,3 @@ --- Per-output monitor rules — embedded sibling of the legacy outputs.conf fragment. Deploy writes ~/.config/hypr/dms/outputs.lua +-- Per-output monitor rules. Deploy writes ~/.config/hypr/dms/outputs.lua hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" }) diff --git a/core/internal/config/embedded/hyprland.lua b/core/internal/config/embedded/hyprland.lua index 34205cfbe..ab700c7a8 100644 --- a/core/internal/config/embedded/hyprland.lua +++ b/core/internal/config/embedded/hyprland.lua @@ -1,9 +1,11 @@ --- Hyprland configuration (Lua) — https://wiki.hypr.land/Configuring/Start/ +-- Hyprland configuration (Lua) — https://wiki.hypr.land/configuring/ -- DMS_STARTUP_BEGIN hl.on("hyprland.start", function() hl.exec_cmd("dbus-update-activation-environment --systemd --all") - hl.exec_cmd("systemctl --user start hyprland-session.target") + if not os.getenv("HYPRLAND_NO_SD_TARGET") then + hl.exec_cmd("systemctl --user start hyprland-session.target") + end end) -- DMS_STARTUP_END diff --git a/core/internal/config/hyprland_lua.go b/core/internal/config/hyprland_lua.go index 5f0e1f0e2..33eaced52 100644 --- a/core/internal/config/hyprland_lua.go +++ b/core/internal/config/hyprland_lua.go @@ -2,12 +2,11 @@ package config import ( "fmt" - "os" "path/filepath" - "regexp" "strconv" "strings" - "time" + + "github.com/duxlabs-dev/dux-shell/core/internal/utils" ) const ( @@ -15,93 +14,6 @@ const ( hyprlandStartupEnd = "-- DMS_STARTUP_END" ) -func extractHyprlangMonitorLines(hyprlang string) []string { - re := regexp.MustCompile(`(?m)^\s*#?\s*monitor\s*=.*$`) - return re.FindAllString(hyprlang, -1) -} - -func hyprlangMonitorLineToLua(line string) (string, error) { - re := regexp.MustCompile(`(?i)^\s*#?\s*monitor\s*=\s*(.*)\s*$`) - m := re.FindStringSubmatch(line) - if m == nil { - return "", fmt.Errorf("not a monitor line") - } - rest := strings.TrimSpace(m[1]) - parts := strings.Split(rest, ",") - for i := range parts { - parts[i] = strings.TrimSpace(parts[i]) - } - if len(parts) < 4 { - if len(parts) == 2 && strings.EqualFold(parts[1], "disable") { - return fmt.Sprintf(`hl.monitor({ output = %s, disabled = true })`, strconv.Quote(parts[0])), nil - } - return "", fmt.Errorf("expected at least 4 comma-separated fields") - } - out := parts[0] - mode := parts[1] - pos := parts[2] - scaleStr := parts[3] - - scaleField := formatMonitorScaleLua(scaleStr) - fields := []string{ - fmt.Sprintf("output = %s", strconv.Quote(out)), - fmt.Sprintf("mode = %s", strconv.Quote(mode)), - fmt.Sprintf("position = %s", strconv.Quote(pos)), - scaleField, - } - for i := 4; i < len(parts); i += 2 { - key := strings.ToLower(strings.TrimSpace(parts[i])) - if key == "" { - continue - } - if i+1 >= len(parts) { - fields = append(fields, fmt.Sprintf("%s = true", hyprlangMonitorOptionToLuaKey(key))) - continue - } - val := strings.TrimSpace(parts[i+1]) - if converted, ok := formatMonitorOptionLua(key, val); ok { - fields = append(fields, converted) - } - } - return fmt.Sprintf(`hl.monitor({ %s })`, strings.Join(fields, ", ")), nil -} - -func formatMonitorScaleLua(scaleStr string) string { - if scaleStr == "auto" { - return `scale = "auto"` - } - if f, err := strconv.ParseFloat(scaleStr, 64); err == nil { - return fmt.Sprintf(`scale = %g`, f) - } - return fmt.Sprintf(`scale = %s`, strconv.Quote(scaleStr)) -} - -func hyprlangMonitorOptionToLuaKey(key string) string { - switch strings.ToLower(strings.TrimSpace(key)) { - case "10bit": - return "bitdepth" - default: - return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(key)), "-", "_") - } -} - -func formatMonitorOptionLua(key, val string) (string, bool) { - luaKey := hyprlangMonitorOptionToLuaKey(key) - switch luaKey { - case "transform", "vrr", "bitdepth", "supports_wide_color", "supports_hdr", "sdr_max_luminance", "max_luminance", "max_avg_luminance": - if _, err := strconv.Atoi(val); err == nil { - return fmt.Sprintf("%s = %s", luaKey, val), true - } - case "sdrbrightness", "sdrsaturation", "sdr_min_luminance", "min_luminance": - if _, err := strconv.ParseFloat(val, 64); err == nil { - return fmt.Sprintf("%s = %s", luaKey, val), true - } - case "cm", "sdr_eotf", "icc", "mirror": - return fmt.Sprintf("%s = %s", luaKey, strconv.Quote(val)), true - } - return "", false -} - func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string { start := strings.Index(config, hyprlandStartupBegin) end := strings.Index(config, hyprlandStartupEnd) @@ -122,76 +34,6 @@ func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string { return config[:start] + replacement + config[endClose:] } -func readExistingHyprlandConfig(configDir string) (data string, sourcePath string, err error) { - luaPath := filepath.Join(configDir, "hyprland.lua") - if b, e := os.ReadFile(luaPath); e == nil { - return string(b), luaPath, nil - } else if !os.IsNotExist(e) { - return "", "", e - } - confPath := filepath.Join(configDir, "hyprland.conf") - if b, e := os.ReadFile(confPath); e == nil { - return string(b), confPath, nil - } else if !os.IsNotExist(e) { - return "", "", e - } - return "", "", nil -} - -// 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 - } - home := os.Getenv("HOME") - if home == "" { - return - } - configDir := filepath.Join(home, ".config", "hypr") - luaPath := filepath.Join(configDir, "hyprland.lua") - if _, err := os.Stat(luaPath); err != nil { - return - } - - var strayPaths []string - confPath := filepath.Join(configDir, "hyprland.conf") - if info, err := os.Lstat(confPath); err == nil && !info.IsDir() { - strayPaths = append(strayPaths, confPath) - } - 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 - } - - 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, err := moveHyprlandConfigFile(src, filepath.Join(configDir, hyprlandBackupDirName, ts, rel)) - if 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) - } +func HyprlandConfigDir() string { + return filepath.Join(utils.XDGConfigHome(), "hypr") } diff --git a/core/internal/config/mango_deploy.go b/core/internal/config/mango_deploy.go index f7c99f99c..ff6c8e46c 100644 --- a/core/internal/config/mango_deploy.go +++ b/core/internal/config/mango_deploy.go @@ -110,19 +110,25 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b } newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand) - if cd.systemdAutostart(deps.WindowManagerMango, useSystemd) { + autostart := cd.systemdAutostart(deps.WindowManagerMango, useSystemd) + if autostart { newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "") } - if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil { - result.Error = fmt.Errorf("failed to write config: %w", err) - return result, result.Error - } if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, existing, timestamp); err != nil { result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) return result, result.Error } + if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil { + result.Error = fmt.Errorf("failed to write config: %w", err) + return result, result.Error + } + + if autostart { + cd.startInLiveSession(deps.WindowManagerMango) + } + result.Deployed = true cd.log("Successfully deployed Mango configuration") return result, nil diff --git a/core/internal/config/session_units.go b/core/internal/config/session_units.go index 07b6a22f4..fb365d3ea 100644 --- a/core/internal/config/session_units.go +++ b/core/internal/config/session_units.go @@ -150,3 +150,19 @@ func (cd *ConfigDeployer) systemdAutostart(wm deps.WindowManager, useSystemd boo cd.log("DMS starts as dms.service with " + unit) return true } + +// An already active session unit ignores a new wants link until next login, so a deploy +// from inside that session starts dms.service itself. +func (cd *ConfigDeployer) startInLiveSession(wm deps.WindowManager) { + if running, ok := RunningCompositor(); !ok || running != wm { + return + } + if systemctlUser("is-active", "--quiet", SessionUnitName(wm)) != nil { + return + } + if err := systemctlUser("start", "--no-block", "dms.service"); err != nil { + cd.log(fmt.Sprintf("Warning: failed to start dms.service: %v", err)) + return + } + cd.log("Started dms.service in the running session") +} diff --git a/core/internal/configfrag/status.go b/core/internal/configfrag/status.go index 740c56cf6..128094ad8 100644 --- a/core/internal/configfrag/status.go +++ b/core/internal/configfrag/status.go @@ -16,19 +16,15 @@ type Status struct { Effective bool OverriddenBy int StatusMessage string - ConfigFormat string - ReadOnly bool } -func BuildStatus(scan IncludeScan, exists bool, entriesAfter int, format string, readOnly bool, msgs Messages) Status { +func BuildStatus(scan IncludeScan, exists bool, entriesAfter int, msgs Messages) Status { status := Status{ Exists: exists, Included: scan.DMSSeen, IncludePosition: scan.DMSPosition, TotalIncludes: scan.Count, EntriesAfterDMS: entriesAfter, - ConfigFormat: format, - ReadOnly: readOnly, } if !exists { diff --git a/core/internal/configfrag/status_test.go b/core/internal/configfrag/status_test.go index 5aa80b330..d8ee1b1f3 100644 --- a/core/internal/configfrag/status_test.go +++ b/core/internal/configfrag/status_test.go @@ -47,7 +47,7 @@ var allDomains = map[string]Messages{ func TestMissingFragmentIsNotEffective(t *testing.T) { for domain, msgs := range allDomains { - status := BuildStatus(NewScan(), false, 0, "", false, msgs) + status := BuildStatus(NewScan(), false, 0, msgs) if status.Effective { t.Errorf("%s: a missing fragment must not be effective", domain) } @@ -64,7 +64,7 @@ func TestExistingButNotIncludedIsNotEffective(t *testing.T) { for domain, msgs := range allDomains { scan := NewScan() scan.Count = 2 - status := BuildStatus(scan, true, 0, "", false, msgs) + status := BuildStatus(scan, true, 0, msgs) if status.Effective { t.Errorf("%s: an unincluded fragment must not be effective", domain) } @@ -77,7 +77,7 @@ func TestExistingButNotIncludedIsNotEffective(t *testing.T) { func TestIncludedWithEntriesAfterReportsOverride(t *testing.T) { for domain, msgs := range allDomains { scan := IncludeScan{Count: 3, DMSPosition: 2, DMSSeen: true} - status := BuildStatus(scan, true, 5, "", false, msgs) + status := BuildStatus(scan, true, 5, msgs) if !status.Effective { t.Errorf("%s: an overridden fragment is still effective", domain) } @@ -93,7 +93,7 @@ func TestIncludedWithEntriesAfterReportsOverride(t *testing.T) { func TestIncludedWithNothingAfterIsActive(t *testing.T) { for domain, msgs := range allDomains { scan := IncludeScan{Count: 1, DMSPosition: 1, DMSSeen: true} - status := BuildStatus(scan, true, 0, "", false, msgs) + status := BuildStatus(scan, true, 0, msgs) if !status.Effective { t.Errorf("%s: an included fragment with nothing after it is effective", domain) } @@ -106,9 +106,9 @@ func TestIncludedWithNothingAfterIsActive(t *testing.T) { } } -func TestScanAndFormatCarryThrough(t *testing.T) { +func TestScanCarriesThrough(t *testing.T) { scan := IncludeScan{Count: 4, DMSPosition: 3, DMSSeen: true} - status := BuildStatus(scan, true, 0, "lua", true, hyprlandRules) + status := BuildStatus(scan, true, 0, hyprlandRules) if status.TotalIncludes != 4 || status.IncludePosition != 3 { t.Errorf("includes = %d/%d, want 3/4", status.IncludePosition, status.TotalIncludes) @@ -116,7 +116,4 @@ func TestScanAndFormatCarryThrough(t *testing.T) { if !status.Included { t.Error("DMSSeen must land on Included") } - if status.ConfigFormat != "lua" || !status.ReadOnly { - t.Errorf("format/readOnly = %q/%v, want lua/true", status.ConfigFormat, status.ReadOnly) - } } diff --git a/core/internal/keybinds/providers/hyprland.go b/core/internal/keybinds/providers/hyprland.go index cf3f269b8..632194b0f 100644 --- a/core/internal/keybinds/providers/hyprland.go +++ b/core/internal/keybinds/providers/hyprland.go @@ -1,6 +1,7 @@ package providers import ( + "errors" "fmt" "os" "path/filepath" @@ -17,8 +18,6 @@ type HyprlandProvider struct { configPath string dmsBindsIncluded bool parsed bool - // hyprlang configs (0.55/0.56) reject hl.dsp text, so binds carry no LuaAction there - luaConfig bool } func NewHyprlandProvider(configPath string) *HyprlandProvider { @@ -50,7 +49,6 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { h.dmsBindsIncluded = result.DMSBindsIncluded h.parsed = true - h.luaConfig = result.ConfigFormat == "lua" categorizedBinds := make(map[string][]keybinds.Keybind) h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs, result.DefaultDMSKeys) @@ -92,7 +90,7 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory } for _, kb := range section.Keybinds { - category := h.categorizeByDispatcher(kb.Dispatcher) + category := h.categorizeBind(&kb) bind := h.convertKeybind(&kb, currentSubcat, conflicts, defaultKeys) categorizedBinds[category] = append(categorizedBinds[category], bind) } @@ -102,6 +100,19 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory } } +var luaFunctionActionPattern = regexp.MustCompile(`^(function\s*\(|[A-Za-z_][A-Za-z0-9_.]*$)`) + +// A function or named-function action says nothing about what it does; its description does. +func (h *HyprlandProvider) categorizeBind(kb *HyprlandKeyBinding) string { + if kb.Comment == "" || kb.Params != "" || !luaFunctionActionPattern.MatchString(kb.Dispatcher) { + return h.categorizeByDispatcher(kb.Dispatcher) + } + if _, known := luaActionStringFromHyprlangAction(kb.Dispatcher); known { + return h.categorizeByDispatcher(kb.Dispatcher) + } + return h.categorizeByDispatcher(strings.ToLower(kb.Comment)) +} + func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string { switch { case dispatcher == "exec" || strings.HasPrefix(dispatcher, "hl.dsp.exec_cmd("): @@ -122,7 +133,7 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string { dispatcher == "splitratio" || dispatcher == "resizeactive": return "Window" - case dispatcher == "exit" || strings.Contains(dispatcher, "dpms"): + case dispatcher == "exit" || dispatcher == "reloadconfig" || strings.Contains(dispatcher, "dpms"): return "System" default: return "Other" @@ -159,10 +170,8 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st Flags: kb.Flags, HasDefault: hasDefault, } - if h.luaConfig { - if expr, ok := luaActionStringFromHyprlangAction(rawAction); ok { - bind.LuaAction = expr - } + if expr, ok := luaActionStringFromHyprlangAction(rawAction); ok { + bind.LuaAction = expr } if (source == "dms" || source == "dms-default") && conflicts != nil { @@ -374,31 +383,21 @@ type hyprlandOverrideBind struct { RawLuaAction bool } +// Include repair would write a stub hyprland.lua, replacing a conf-only user's whole config. 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") + _, err := hyprlandMainConfigPath(h.configPath) + switch { + case err == nil: + return nil + case errors.Is(err, os.ErrNotExist): + return fmt.Errorf("hyprland.lua not found in %s; run `dms setup` to migrate before editing keybinds: %w", h.configPath, err) + default: + return fmt.Errorf("cannot read hyprland.lua in %s: %w", h.configPath, err) } - 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()) + return readLuaOverride(h.GetOverridePath()) } func canonicalHyprlandOverrideKey(key string) string { @@ -1098,13 +1097,22 @@ func luaActionStringFromHyprlangAction(action string) (string, bool) { return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params), luaBoolField("group_aware", true)), true } case "lockgroups": - return dispatcherToggleTableCall("hl.dsp.group.lock", params), true + // 0.57 locks only the active group without all; 0.56 ignores the field + return luaDispatcherTableCall("hl.dsp.group.lock", luaBoolField("all", true), luaStringField("action", luaToggleActionValue(params))), true case "lockactivegroup": + lockArg, window := firstParam(params) + if window != "" { + return luaDispatcherTableCall("hl.dsp.group.lock", luaStringField("action", luaToggleActionValue(lockArg)), luaStringField("window", window)), true + } + // lock_active is deprecated on 0.57 but still the only active-group lock on 0.56 return dispatcherToggleTableCall("hl.dsp.group.lock_active", params), true case "denywindowfromgroup": return dispatcherToggleTableCall("hl.dsp.window.deny_from_group", params), true case "forcerendererreload": return `hl.dsp.force_renderer_reload()`, true + case "reloadconfig": + // hl.dsp.reload_config is 0.57-only; calling nil on 0.55/0.56 aborts loading the file + return `hl.dsp.exec_cmd("hyprctl reload")`, true case "forceidle": if params != "" && isBareLuaNumber(params) { return fmt.Sprintf(`hl.dsp.force_idle(%s)`, params), true @@ -1231,7 +1239,7 @@ func luaKeyComboToInternalKey(combo string) string { return strings.Join(parts, "+") } -func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, error) { +func readLuaOverride(path string) (map[string]*hyprlandOverrideBind, error) { binds := make(map[string]*hyprlandOverrideBind) data, err := os.ReadFile(path) if os.IsNotExist(err) { @@ -1241,7 +1249,6 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e return nil, err } lines := expandLuaConfigLines(strings.Split(string(data), "\n")) - parser := NewHyprlandParser("") pendingUnbinds := make(map[string]string) for _, line := range lines { line = strings.TrimSpace(line) @@ -1257,28 +1264,7 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e normalizedKey := hyprlandOverrideMapKey(kb.Key) binds[normalizedKey] = kb delete(pendingUnbinds, normalizedKey) - continue } - if !strings.HasPrefix(line, "bind") { - continue - } - kb := parser.parseBindLine(line) - if kb == nil { - continue - } - action := kb.Dispatcher - if kb.Params != "" { - action = kb.Dispatcher + " " + kb.Params - } - keyStr := canonicalHyprlandOverrideKey(parser.formatBindKey(kb)) - normalizedKey := hyprlandOverrideMapKey(keyStr) - binds[normalizedKey] = &hyprlandOverrideBind{ - Key: keyStr, - Action: action, - Description: kb.Comment, - Flags: kb.Flags, - } - delete(pendingUnbinds, normalizedKey) } for normKey, origKey := range pendingUnbinds { binds[normKey] = &hyprlandOverrideBind{Key: origKey, Unbind: true} diff --git a/core/internal/keybinds/providers/hyprland_lua_expand.go b/core/internal/keybinds/providers/hyprland_lua_expand.go index 7dd619252..fe2ea0ee8 100644 --- a/core/internal/keybinds/providers/hyprland_lua_expand.go +++ b/core/internal/keybinds/providers/hyprland_lua_expand.go @@ -39,7 +39,43 @@ func expandLuaConfigLines(lines []string) []string { func expandLuaConfigLinesEnv(lines []string) ([]string, luaVarEnv) { env := luaVarEnv{} - return expandLuaBlock(lines, env), env + return expandLuaBlock(joinLuaBindSpans(lines), env), env +} + +// The bind parser is line based; fold wrapped hl.bind( calls, keeping only the closing line's comment. +func joinLuaBindSpans(lines []string) []string { + out := make([]string, 0, len(lines)) + for i := 0; i < len(lines); i++ { + masked := luaMaskStrings(lines[i]) + depth := luaParenDelta(masked) + if depth <= 0 || !strings.Contains(masked, "hl.bind") { + out = append(out, lines[i]) + continue + } + parts := []string{strings.TrimSpace(luaStripLineComment(lines[i]))} + j := i + 1 + for ; j < len(lines) && depth > 0; j++ { + depth += luaParenDelta(luaMaskStrings(lines[j])) + part := lines[j] + if depth > 0 { + part = luaStripLineComment(part) + } + if part = strings.TrimSpace(part); part != "" { + parts = append(parts, part) + } + } + if depth > 0 { + out = append(out, lines[i]) + continue + } + out = append(out, strings.Join(parts, " ")) + i = j - 1 + } + return out +} + +func luaParenDelta(masked string) int { + return strings.Count(masked, "(") - strings.Count(masked, ")") } func expandLuaBlock(lines []string, env luaVarEnv) []string { diff --git a/core/internal/keybinds/providers/hyprland_parser.go b/core/internal/keybinds/providers/hyprland_parser.go index abdac8473..d03f7cf86 100644 --- a/core/internal/keybinds/providers/hyprland_parser.go +++ b/core/internal/keybinds/providers/hyprland_parser.go @@ -5,7 +5,6 @@ import ( "os" "path/filepath" "regexp" - "slices" "strconv" "strings" @@ -13,14 +12,6 @@ import ( "github.com/duxlabs-dev/dux-shell/core/internal/utils" ) -const ( - TitleRegex = "#+!" - HideComment = "[hidden]" - CommentBindPattern = "#/#" -) - -var ModSeparators = []rune{'+', ' '} - type HyprlandKeyBinding struct { Mods []string `json:"mods"` Key string `json:"key"` @@ -39,8 +30,6 @@ type HyprlandSection struct { } type HyprlandParser struct { - contentLines []string - readingLine int configDir string currentSource string dmsBindsExists bool @@ -54,20 +43,15 @@ type HyprlandParser struct { processedFiles map[string]bool dmsProcessed bool removedKeys map[string]bool // hl.unbind targets - // rebindSources maps a removed key to the files that unbind and then rebind - // it. Their own bind survives the removal; binds from anywhere else do not. + // Files that unbind then rebind a removed key; only their own bind survives the removal. rebindSources map[string]map[string]bool fileUnbinds map[string]bool // hl.unbind targets seen so far in the file being parsed - defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf} - configFormat string - readOnly bool + defaultDMSKeys map[string]bool // keys present in dms/binds.lua mainMod string } func NewHyprlandParser(configDir string) *HyprlandParser { return &HyprlandParser{ - contentLines: []string{}, - readingLine: 0, configDir: configDir, walker: configfrag.NewWalker(isDMSBindsPrimarySourcePath), dmsBindKeys: make(map[string]bool), @@ -82,47 +66,6 @@ func NewHyprlandParser(configDir string) *HyprlandParser { } } -func (p *HyprlandParser) ReadContent(directory string) error { - expandedDir, err := utils.ExpandPath(directory) - if err != nil { - return err - } - - info, err := os.Stat(expandedDir) - if err != nil { - return err - } - if !info.IsDir() { - return os.ErrNotExist - } - - confFiles, err := filepath.Glob(filepath.Join(expandedDir, "*.conf")) - if err != nil { - return err - } - if len(confFiles) == 0 { - return os.ErrNotExist - } - - var combinedContent []string - for _, confFile := range confFiles { - if fileInfo, err := os.Stat(confFile); err == nil && fileInfo.Mode().IsRegular() { - data, err := os.ReadFile(confFile) - if err == nil { - combinedContent = append(combinedContent, string(data)) - } - } - } - - if len(combinedContent) == 0 { - return os.ErrNotExist - } - - fullContent := strings.Join(combinedContent, "\n") - p.contentLines = strings.Split(fullContent, "\n") - return nil -} - var hyprlandDirectionWords = map[string]string{ "l": "left", "left": "left", "r": "right", "right": "right", @@ -401,95 +344,24 @@ func describeHyprlandMove(params string) string { return "Move window " + strings.Join(parts, ", ") } -func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding { - line := p.contentLines[lineNumber] - return p.parseBindLine(line) -} - -func (p *HyprlandParser) getBindsRecursive(currentContent *HyprlandSection, scope int) *HyprlandSection { - titleRegex := regexp.MustCompile(TitleRegex) - - for p.readingLine < len(p.contentLines) { - line := p.contentLines[p.readingLine] - - loc := titleRegex.FindStringIndex(line) - if loc != nil && loc[0] == 0 { - headingScope := strings.Index(line, "!") - - if headingScope <= scope { - p.readingLine-- - return currentContent - } - - sectionName := strings.TrimSpace(line[headingScope+1:]) - p.readingLine++ - - childSection := &HyprlandSection{ - Children: []HyprlandSection{}, - Keybinds: []HyprlandKeyBinding{}, - Name: sectionName, - } - result := p.getBindsRecursive(childSection, headingScope) - currentContent.Children = append(currentContent.Children, *result) - - } else if strings.HasPrefix(line, CommentBindPattern) { - keybind := p.getKeybindAtLine(p.readingLine) - if keybind != nil { - currentContent.Keybinds = append(currentContent.Keybinds, *keybind) - } - - } else if line == "" || !strings.HasPrefix(strings.TrimSpace(line), "bind") { - - } else { - keybind := p.getKeybindAtLine(p.readingLine) - if keybind != nil { - currentContent.Keybinds = append(currentContent.Keybinds, *keybind) - } - } - - p.readingLine++ - } - - return currentContent -} - -func (p *HyprlandParser) ParseKeys() *HyprlandSection { - p.readingLine = 0 - rootSection := &HyprlandSection{ - Children: []HyprlandSection{}, - Keybinds: []HyprlandKeyBinding{}, - Name: "", - } - return p.getBindsRecursive(rootSection, 0) -} - -func ParseHyprlandKeys(path string) (*HyprlandSection, error) { - parser := NewHyprlandParser(path) - if err := parser.ReadContent(path); err != nil { - return nil, err - } - return parser.ParseKeys(), nil -} - type HyprlandParseResult struct { Section *HyprlandSection DMSBindsIncluded bool DMSStatus *configfrag.Status ConflictingConfigs map[string]*HyprlandKeyBinding - DefaultDMSKeys map[string]bool // keys with a DMS default in binds.{lua,conf} - ConfigFormat string // "lua" or "hyprlang" + DefaultDMSKeys map[string]bool // keys with a DMS default in binds.lua MainMod string // value of the mainMod Lua variable when the config defines one } var hyprlandBindsMessages = configfrag.Messages{ - Missing: "dms/binds.lua (or legacy binds.conf) does not exist", - NotIncluded: "dms binds are not loaded from Hyprland config (require / source)", + Missing: "dms/binds.lua does not exist", + NotIncluded: "dms binds are not loaded from Hyprland config (require)", Overridden: "Some DMS binds may be overridden by config binds", Active: "DMS binds are active", } func (p *HyprlandParser) buildDMSStatus() *configfrag.Status { - status := configfrag.BuildStatus(p.walker.Scan(), p.dmsBindsExists, p.bindsAfterDMS, p.configFormat, p.readOnly, hyprlandBindsMessages) + status := configfrag.BuildStatus(p.walker.Scan(), p.dmsBindsExists, p.bindsAfterDMS, hyprlandBindsMessages) return &status } @@ -543,28 +415,15 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) { return nil, err } - dmsBindsLua := filepath.Join(expandedDir, "dms", "binds.lua") - dmsBindsConf := filepath.Join(expandedDir, "dms", "binds.conf") - dmsBindsPath := "" - if _, err := os.Stat(dmsBindsLua); err == nil { + dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.lua") + if _, err := os.Stat(dmsBindsPath); err == nil { p.dmsBindsExists = true - dmsBindsPath = dmsBindsLua - } else if _, err := os.Stat(dmsBindsConf); err == nil { - p.dmsBindsExists = true - dmsBindsPath = dmsBindsConf } mainConfig, err := hyprlandMainConfigPath(p.configDir) 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 @@ -649,94 +508,16 @@ func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*Hyp return nil, err } - if strings.EqualFold(filepath.Ext(absPath), ".lua") { - return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName) - } - - prevSource := p.currentSource - p.currentSource = absPath - - section := &HyprlandSection{Name: sectionName} - lines := strings.SplitSeq(string(data), "\n") - - for line := range lines { - trimmed := strings.TrimSpace(line) - - if strings.HasPrefix(trimmed, "source") { - p.handleSource(trimmed, section, filepath.Dir(absPath)) - continue - } - - if !strings.HasPrefix(trimmed, "bind") { - continue - } - - kb := p.parseBindLine(line) - if kb == nil { - continue - } - kb.Source = p.currentSource - if p.addBind(kb) { - section.Keybinds = append(section.Keybinds, *kb) - } - } - - p.currentSource = prevSource - return section, nil -} - -func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, baseDir string) { - matched := p.walker.IncludeAssignment(baseDir, line, func(absPath string) error { - includedSection, err := p.parseFileWithSource(absPath, "") - if err != nil { - return err - } - section.Children = append(section.Children, *includedSection) - return nil - }) - if matched { - p.dmsProcessed = true - } + return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName) } func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) { - if strings.EqualFold(filepath.Ext(dmsBindsPath), ".lua") { - sub, err := p.parseLuaLinesFromPath(dmsBindsPath) - if err != nil { - return - } - section.Keybinds = append(section.Keybinds, sub.Keybinds...) - section.Children = append(section.Children, sub.Children...) - p.dmsProcessed = true - return - } - - data, err := os.ReadFile(dmsBindsPath) + sub, err := p.parseLuaLinesFromPath(dmsBindsPath) if err != nil { return } - - prevSource := p.currentSource - p.currentSource = dmsBindsPath - - lines := strings.SplitSeq(string(data), "\n") - for line := range lines { - trimmed := strings.TrimSpace(line) - if !strings.HasPrefix(trimmed, "bind") { - continue - } - - kb := p.parseBindLine(line) - if kb == nil { - continue - } - kb.Source = dmsBindsPath - if p.addBind(kb) { - section.Keybinds = append(section.Keybinds, *kb) - } - } - - p.currentSource = prevSource + section.Keybinds = append(section.Keybinds, sub.Keybinds...) + section.Children = append(section.Children, sub.Children...) p.dmsProcessed = true } @@ -930,110 +711,6 @@ func luaBindOptDescription(optSuffix string) string { return luaTableStringField(optSuffix, "description") } -// Hyprland 0.56 field order: `bind[flags] = MODS, key, [device,] [description,] dispatcher, params`. -func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding { - parts := strings.SplitN(line, "=", 2) - if len(parts) < 2 { - return nil - } - - var flags string - if bindType := strings.TrimSpace(parts[0]); bindType != CommentBindPattern { - var ok bool - if flags, ok = extractBindFlags(bindType); !ok { - return nil - } - } - - keys, comment, _ := strings.Cut(parts[1], "#") - comment = strings.TrimSpace(comment) - - dispatcherIndex := 2 - descIndex := -1 - if strings.Contains(flags, "k") { - dispatcherIndex++ - } - if strings.Contains(flags, "d") { - descIndex = dispatcherIndex - dispatcherIndex++ - } - - keyFields := strings.SplitN(keys, ",", dispatcherIndex+2) - if len(keyFields) <= dispatcherIndex { - return nil - } - - mods := strings.TrimSpace(keyFields[0]) - key := strings.TrimSpace(keyFields[1]) - dispatcher := strings.TrimSpace(keyFields[dispatcherIndex]) - var params string - if len(keyFields) > dispatcherIndex+1 { - params = strings.TrimSpace(keyFields[dispatcherIndex+1]) - } - if descIndex >= 0 && comment == "" { - comment = strings.TrimSpace(keyFields[descIndex]) - } - - if strings.HasPrefix(comment, HideComment) { - return nil - } - - if comment == "" { - comment = hyprlandAutogenerateComment(dispatcher, params) - } - - var modList []string - if mods != "" { - modstring := mods + string(ModSeparators[0]) - idx := 0 - for index, char := range modstring { - if slices.Contains(ModSeparators, char) { - if index-idx > 1 { - modList = append(modList, modstring[idx:index]) - } - idx = index + 1 - } - } - } - - return &HyprlandKeyBinding{ - Mods: modList, - Key: key, - Dispatcher: dispatcher, - Params: params, - Comment: comment, - Flags: flags, - } -} - -// ok is false for `binds:...` and for any letter Hyprland rejects, since that drops the whole bind. -func extractBindFlags(bindType string) (string, bool) { - flags, ok := strings.CutPrefix(strings.TrimSpace(bindType), "bind") - if !ok { - return "", false - } - for i := 0; i < len(flags); i++ { - if !isHyprlangBindFlag(flags[i]) { - return "", false - } - } - return flags, true -} - -// s (multi-key), k (device field) and d (description field) exist only in hyprlang. -func isHyprlangBindFlag(c byte) bool { - switch c { - case 's', 'k', 'd': - return true - } - for _, fo := range hyprlandBindFlagOptions { - if fo.flag == c { - return true - } - } - return false -} - func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) { parser := NewHyprlandParser(path) section, err := parser.ParseWithDMS() @@ -1047,7 +724,6 @@ func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) { DMSStatus: parser.buildDMSStatus(), ConflictingConfigs: parser.conflictingConfigs, DefaultDMSKeys: parser.defaultDMSKeys, - ConfigFormat: parser.configFormat, MainMod: parser.mainMod, }, nil } @@ -1261,6 +937,9 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { if after, ok := strings.CutPrefix(u, "hyprctl dispatch "); ok { return splitDispatchCommand(strings.TrimSpace(after)) } + if strings.TrimSpace(u) == "hyprctl reload" { + return "reloadconfig", "" + } return "exec", u } } @@ -1355,7 +1034,13 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { case strings.HasPrefix(expr, "hl.dsp.group.lock_active("): return "lockactivegroup", luaToggleActionToLockArg(luaTableStringField(expr, "action")) case strings.HasPrefix(expr, "hl.dsp.group.lock("): - return "lockgroups", luaToggleActionToLockArg(luaTableStringField(expr, "action")) + lockArg := luaToggleActionToLockArg(luaTableStringField(expr, "action")) + window := luaTableStringField(expr, "window") + // Before 0.57 group.lock locked every group and ignored all; 0.57 locks only the active one without it + if all, _ := luaTableBoolField(expr, "all"); window == "" && (all || !luaconfig.HyprlandAtLeast(0, 57)) { + return "lockgroups", lockArg + } + return joinDispatcherParams("lockactivegroup", lockArg, window) case strings.HasPrefix(expr, "hl.dsp.window.deny_from_group("): return "denywindowfromgroup", luaToggleActionToLegacy(luaTableStringField(expr, "action")) case strings.HasPrefix(expr, "hl.dsp.focus("): @@ -1536,6 +1221,8 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { return "releaseinputcapture", "" case strings.Contains(expr, "hl.dsp.force_renderer_reload()"): return "forcerendererreload", "" + case strings.Contains(expr, "hl.dsp.reload_config()"): + return "reloadconfig", "" case strings.HasPrefix(expr, "hl.dsp.force_idle("): return "forceidle", luaCallScalarArgValue(expr, "hl.dsp.force_idle") case strings.Contains(expr, "hl.dsp.exit()"): @@ -1767,13 +1454,7 @@ func isDMSBindsUserOverridePath(p string) bool { func isDMSBindsPrimarySourcePath(p string) bool { p = filepath.ToSlash(strings.TrimSpace(p)) - if strings.Contains(p, "/dms/binds.lua") || strings.HasSuffix(p, "dms/binds.lua") || p == "dms/binds.lua" || p == "./dms/binds.lua" { - return true - } - if strings.Contains(p, "/dms/binds.conf") || strings.HasSuffix(p, "dms/binds.conf") { - return true - } - return p == "dms/binds.conf" || p == "./dms/binds.conf" + return strings.Contains(p, "/dms/binds.lua") || strings.HasSuffix(p, "dms/binds.lua") } func hyprlandMainConfigPath(dir string) (string, error) { @@ -1782,12 +1463,12 @@ func hyprlandMainConfigPath(dir string) (string, error) { return "", err } luaPath := filepath.Join(expandedDir, "hyprland.lua") - if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() { - return luaPath, nil + st, err := os.Stat(luaPath) + if err != nil { + return "", err } - confPath := filepath.Join(expandedDir, "hyprland.conf") - if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() { - return confPath, nil + if !st.Mode().IsRegular() { + return "", os.ErrNotExist } - return "", os.ErrNotExist + return luaPath, nil } diff --git a/core/internal/keybinds/providers/hyprland_parser_test.go b/core/internal/keybinds/providers/hyprland_parser_test.go index fc979217f..6065a8087 100644 --- a/core/internal/keybinds/providers/hyprland_parser_test.go +++ b/core/internal/keybinds/providers/hyprland_parser_test.go @@ -1,6 +1,7 @@ package providers import ( + "github.com/duxlabs-dev/dux-shell/core/internal/luaconfig" "os" "path/filepath" "strings" @@ -96,8 +97,11 @@ func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) { {`hl.dsp.group.prev()`, "changegroupactive", "b"}, {`hl.dsp.group.active({ index = 2 })`, "changegroupactive", "2"}, {`hl.dsp.group.move_window({ forward = false })`, "movegroupwindow", "b"}, - {`hl.dsp.group.lock({ action = "on" })`, "lockgroups", "lock"}, + {`hl.dsp.group.lock({ all = true, action = "on" })`, "lockgroups", "lock"}, + {`hl.dsp.group.lock({ action = "off", window = "class:^(kitty)$" })`, "lockactivegroup", "unlock class:^(kitty)$"}, {`hl.dsp.group.lock_active({ action = "off" })`, "lockactivegroup", "unlock"}, + {`hl.dsp.reload_config()`, "reloadconfig", ""}, + {`hl.dsp.exec_cmd("hyprctl reload")`, "reloadconfig", ""}, {`hl.dsp.window.deny_from_group({ action = "toggle" })`, "denywindowfromgroup", "toggle"}, {`function() hl.exec_cmd("hyprctl dispatch splitratio +0.1") end`, "splitratio", "+0.1"}, {`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"}, @@ -202,8 +206,10 @@ func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) { {"moveoutofgroup", `hl.dsp.window.move({ out_of_group = true })`}, {"movewindoworgroup r", `hl.dsp.window.move({ direction = "r", group_aware = true })`}, {"movegroupwindow b", `hl.dsp.group.move_window({ forward = false })`}, - {"lockgroups lock", `hl.dsp.group.lock({ action = "on" })`}, + {"lockgroups lock", `hl.dsp.group.lock({ all = true, action = "on" })`}, {"lockactivegroup unlock", `hl.dsp.group.lock_active({ action = "off" })`}, + {"lockactivegroup lock class:^(kitty)$", `hl.dsp.group.lock({ action = "on", window = "class:^(kitty)$" })`}, + {"reloadconfig", `hl.dsp.exec_cmd("hyprctl reload")`}, {"denywindowfromgroup toggle", `hl.dsp.window.deny_from_group({ action = "toggle" })`}, {"cyclenext prev", `hl.dsp.window.cycle_next({ next = false })`}, {"setfloating", `hl.dsp.window.float({ action = "on" })`}, @@ -306,6 +312,34 @@ func TestParseLuaBindLineUpgradesMappableDispatchWrapper(t *testing.T) { } } +func TestParseLuaBindLineBareGroupLockFollowsHyprlandVersion(t *testing.T) { + prev := luaconfig.HyprlandVersion + t.Cleanup(func() { luaconfig.HyprlandVersion = prev }) + for _, tc := range []struct { + minor int + action string + want string + }{ + {56, "lockgroups toggle", `hl.dsp.group.lock({ all = true, action = "toggle" })`}, + {57, "lockactivegroup toggle", `hl.dsp.group.lock_active({ action = "toggle" })`}, + } { + minor := tc.minor + luaconfig.HyprlandVersion = func() (int, int) { return 0, minor } + got, ok := parseLuaBindOverrideLine(`hl.bind("SUPER + G", hl.dsp.group.lock({ action = "toggle" }))`) + if !ok { + t.Fatalf("0.%d: expected line to parse", minor) + } + if got.Action != tc.action { + t.Fatalf("0.%d: action = %q, want %q", minor, got.Action, tc.action) + } + var sb strings.Builder + writeLuaBindLine(&sb, got) + if !strings.Contains(sb.String(), tc.want) { + t.Fatalf("0.%d: expected %q, got %q", minor, tc.want, sb.String()) + } + } +} + func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) { tmpDir := t.TempDir() overridePath := filepath.Join(tmpDir, "binds-user.lua") @@ -317,7 +351,7 @@ hl.bind("SUPER + H", hl.dsp.exec_cmd("app --help")) t.Fatal(err) } - binds, err := readLuaOrHyprlangOverride(overridePath) + binds, err := readLuaOverride(overridePath) if err != nil { t.Fatal(err) } @@ -401,7 +435,7 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) t.Fatal(err) } - binds, err := readLuaOrHyprlangOverride(overridePath) + binds, err := readLuaOverride(overridePath) if err != nil { t.Fatal(err) } @@ -474,7 +508,7 @@ hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`, } func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := hyprLuaTestDir(t) dmsDir := filepath.Join(tmpDir, "dms") if err := os.MkdirAll(dmsDir, 0o755); err != nil { t.Fatal(err) @@ -519,7 +553,7 @@ func TestIsRawLuaActionText(t *testing.T) { } func TestHyprlandSetBindPreservesRawLuaAction(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := hyprLuaTestDir(t) dmsDir := filepath.Join(tmpDir, "dms") if err := os.MkdirAll(dmsDir, 0o755); err != nil { t.Fatal(err) @@ -548,7 +582,7 @@ func TestHyprlandSetBindPreservesRawLuaAction(t *testing.T) { } func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := hyprLuaTestDir(t) dmsDir := filepath.Join(tmpDir, "dms") if err := os.MkdirAll(dmsDir, 0o755); err != nil { t.Fatal(err) @@ -587,7 +621,7 @@ hl.bind("SUPER + 1", hl.dsp.exec_cmd("hyprctl dispatch workspace 1")) } func TestHyprlandSetBindTranslatesScrollWheelToMouse(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := hyprLuaTestDir(t) dmsDir := filepath.Join(tmpDir, "dms") if err := os.MkdirAll(dmsDir, 0o755); err != nil { t.Fatal(err) @@ -642,7 +676,7 @@ func readFile(t *testing.T, path string) string { } func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := hyprLuaTestDir(t) dmsDir := filepath.Join(tmpDir, "dms") if err := os.MkdirAll(dmsDir, 0o755); err != nil { t.Fatal(err) @@ -672,7 +706,7 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) } func TestHyprlandResetBindRevertsExistingOverrideToDefault(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := hyprLuaTestDir(t) dmsDir := filepath.Join(tmpDir, "dms") if err := os.MkdirAll(dmsDir, 0o755); err != nil { t.Fatal(err) @@ -797,7 +831,7 @@ func TestHyprlandBindFlagsRoundTrip(t *testing.T) { } func TestHyprlandSetBindKeepsDeviceTable(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := hyprLuaTestDir(t) dmsDir := filepath.Join(tmpDir, "dms") if err := os.MkdirAll(dmsDir, 0o755); err != nil { t.Fatal(err) @@ -840,420 +874,19 @@ func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) { 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 !strings.Contains(err.Error(), "dms setup") { + t.Fatalf("expected a run-dms-setup 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 TestHyprlandGetKeybindAtLine(t *testing.T) { - tests := []struct { - name string - line string - expected *HyprlandKeyBinding - }{ - { - name: "basic_keybind", - line: "bind = SUPER, Q, killactive", - expected: &HyprlandKeyBinding{ - Mods: []string{"SUPER"}, - Key: "Q", - Dispatcher: "killactive", - Params: "", - Comment: "Close window", - }, - }, - { - name: "keybind_with_params", - line: "bind = SUPER, left, movefocus, l", - expected: &HyprlandKeyBinding{ - Mods: []string{"SUPER"}, - Key: "left", - Dispatcher: "movefocus", - Params: "l", - Comment: "move focus left", - }, - }, - { - name: "keybind_with_comment", - line: "bind = SUPER, T, exec, kitty # Open terminal", - expected: &HyprlandKeyBinding{ - Mods: []string{"SUPER"}, - Key: "T", - Dispatcher: "exec", - Params: "kitty", - Comment: "Open terminal", - }, - }, - { - name: "keybind_hidden", - line: "bind = SUPER, H, exec, secret # [hidden]", - expected: nil, - }, - { - name: "keybind_multiple_mods", - line: "bind = SUPER+SHIFT, F, fullscreen, 0", - expected: &HyprlandKeyBinding{ - Mods: []string{"SUPER", "SHIFT"}, - Key: "F", - Dispatcher: "fullscreen", - Params: "0", - Comment: "Toggle fullscreen", - }, - }, - { - name: "keybind_no_mods", - line: "bind = , Print, exec, screenshot", - expected: &HyprlandKeyBinding{ - Mods: []string{}, - Key: "Print", - Dispatcher: "exec", - Params: "screenshot", - Comment: "screenshot", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - parser := NewHyprlandParser("") - parser.contentLines = []string{tt.line} - result := parser.getKeybindAtLine(0) - - if tt.expected == nil { - if result != nil { - t.Errorf("expected nil, got %+v", result) - } - return - } - - if result == nil { - t.Errorf("expected %+v, got nil", tt.expected) - return - } - - if result.Key != tt.expected.Key { - t.Errorf("Key = %q, want %q", result.Key, tt.expected.Key) - } - if result.Dispatcher != tt.expected.Dispatcher { - t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expected.Dispatcher) - } - if result.Params != tt.expected.Params { - t.Errorf("Params = %q, want %q", result.Params, tt.expected.Params) - } - if result.Comment != tt.expected.Comment { - t.Errorf("Comment = %q, want %q", result.Comment, tt.expected.Comment) - } - if len(result.Mods) != len(tt.expected.Mods) { - t.Errorf("Mods length = %d, want %d", len(result.Mods), len(tt.expected.Mods)) - } else { - for i := range result.Mods { - if result.Mods[i] != tt.expected.Mods[i] { - t.Errorf("Mods[%d] = %q, want %q", i, result.Mods[i], tt.expected.Mods[i]) - } - } - } - }) - } -} - -func TestHyprlandParseKeysWithSections(t *testing.T) { - tmpDir := t.TempDir() - configFile := filepath.Join(tmpDir, "hyprland.conf") - - content := `##! Window Management -bind = SUPER, Q, killactive -bind = SUPER, F, fullscreen, 0 - -###! Movement -bind = SUPER, left, movefocus, l -bind = SUPER, right, movefocus, r - -##! Applications -bind = SUPER, T, exec, kitty # Terminal -` - - if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { - t.Fatalf("Failed to write test config: %v", err) - } - - section, err := ParseHyprlandKeys(tmpDir) - if err != nil { - t.Fatalf("ParseHyprlandKeys failed: %v", err) - } - - if len(section.Children) != 2 { - t.Errorf("Expected 2 top-level sections, got %d", len(section.Children)) - } - - if len(section.Children) >= 1 { - windowMgmt := section.Children[0] - if windowMgmt.Name != "Window Management" { - t.Errorf("First section name = %q, want %q", windowMgmt.Name, "Window Management") - } - if len(windowMgmt.Keybinds) != 2 { - t.Errorf("Window Management keybinds = %d, want 2", len(windowMgmt.Keybinds)) - } - - if len(windowMgmt.Children) != 1 { - t.Errorf("Window Management children = %d, want 1", len(windowMgmt.Children)) - } else { - movement := windowMgmt.Children[0] - if movement.Name != "Movement" { - t.Errorf("Movement section name = %q, want %q", movement.Name, "Movement") - } - if len(movement.Keybinds) != 2 { - t.Errorf("Movement keybinds = %d, want 2", len(movement.Keybinds)) - } - } - } - - if len(section.Children) >= 2 { - apps := section.Children[1] - if apps.Name != "Applications" { - t.Errorf("Second section name = %q, want %q", apps.Name, "Applications") - } - if len(apps.Keybinds) != 1 { - t.Errorf("Applications keybinds = %d, want 1", len(apps.Keybinds)) - } - if len(apps.Keybinds) > 0 && apps.Keybinds[0].Comment != "Terminal" { - t.Errorf("Applications keybind comment = %q, want %q", apps.Keybinds[0].Comment, "Terminal") - } - } -} - -func TestHyprlandParseKeysWithCommentBinds(t *testing.T) { - tmpDir := t.TempDir() - configFile := filepath.Join(tmpDir, "test.conf") - - content := `#/# = SUPER, A, exec, app1 -bind = SUPER, B, exec, app2 -#/# = SUPER, C, exec, app3 # Custom comment -` - - if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { - t.Fatalf("Failed to write test config: %v", err) - } - - section, err := ParseHyprlandKeys(tmpDir) - if err != nil { - t.Fatalf("ParseHyprlandKeys failed: %v", err) - } - - if len(section.Keybinds) != 3 { - t.Errorf("Expected 3 keybinds, got %d", len(section.Keybinds)) - } - - if len(section.Keybinds) > 0 && section.Keybinds[0].Key != "A" { - t.Errorf("First keybind key = %q, want %q", section.Keybinds[0].Key, "A") - } - if len(section.Keybinds) > 1 && section.Keybinds[1].Key != "B" { - t.Errorf("Second keybind key = %q, want %q", section.Keybinds[1].Key, "B") - } - if len(section.Keybinds) > 2 && section.Keybinds[2].Comment != "Custom comment" { - t.Errorf("Third keybind comment = %q, want %q", section.Keybinds[2].Comment, "Custom comment") - } -} - -func TestHyprlandReadContentMultipleFiles(t *testing.T) { - tmpDir := t.TempDir() - - file1 := filepath.Join(tmpDir, "a.conf") - file2 := filepath.Join(tmpDir, "b.conf") - - content1 := "bind = SUPER, Q, killactive\n" - content2 := "bind = SUPER, T, exec, kitty\n" - - if err := os.WriteFile(file1, []byte(content1), 0o644); err != nil { - t.Fatalf("Failed to write file1: %v", err) - } - if err := os.WriteFile(file2, []byte(content2), 0o644); err != nil { - t.Fatalf("Failed to write file2: %v", err) - } - - parser := NewHyprlandParser("") - if err := parser.ReadContent(tmpDir); err != nil { - t.Fatalf("ReadContent failed: %v", err) - } - - section := parser.ParseKeys() - if len(section.Keybinds) != 2 { - t.Errorf("Expected 2 keybinds from multiple files, got %d", len(section.Keybinds)) - } -} - -func TestHyprlandReadContentErrors(t *testing.T) { - tests := []struct { - name string - path string - }{ - { - name: "nonexistent_directory", - path: "/nonexistent/path/that/does/not/exist", - }, - { - name: "empty_directory", - path: t.TempDir(), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := ParseHyprlandKeys(tt.path) - if err == nil { - t.Error("Expected error, got nil") - } - }) - } -} - -func TestHyprlandKeybindWithParamsContainingCommas(t *testing.T) { - parser := NewHyprlandParser("") - parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"} - - result := parser.getKeybindAtLine(0) - - if result == nil { - t.Fatal("Expected keybind, got nil") - } - - expected := "notify-send 'Title' 'Message, with comma'" - if result.Params != expected { - t.Errorf("Params = %q, want %q", result.Params, expected) - } -} - -func TestHyprlandEmptyAndCommentLines(t *testing.T) { - tmpDir := t.TempDir() - configFile := filepath.Join(tmpDir, "test.conf") - - content := ` -# This is a comment -bind = SUPER, Q, killactive - -# Another comment - -bind = SUPER, T, exec, kitty -` - - if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { - t.Fatalf("Failed to write test config: %v", err) - } - - section, err := ParseHyprlandKeys(tmpDir) - if err != nil { - t.Fatalf("ParseHyprlandKeys failed: %v", err) - } - - if len(section.Keybinds) != 2 { - t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds)) - } -} - -func TestExtractBindFlags(t *testing.T) { - tests := []struct { - bindType string - expected string - }{ - {"bind", ""}, - {"binde", "e"}, - {"bindel", "el"}, - {" binde ", "e"}, - {"bindkd", "kd"}, - {"bindpcg", "pcg"}, - } - - for _, tt := range tests { - t.Run(tt.bindType, func(t *testing.T) { - result, ok := extractBindFlags(tt.bindType) - if !ok || result != tt.expected { - t.Errorf("extractBindFlags(%q) = %q, %v, want %q", tt.bindType, result, ok, tt.expected) - } - }) - } - - for _, bindType := range []string{"notbind", "", "bindq", "binds:allow_workspace_cycles"} { - if flags, ok := extractBindFlags(bindType); ok { - t.Errorf("extractBindFlags(%q) = %q, want rejected", bindType, flags) - } - } -} - -func TestHyprlandBindFlags(t *testing.T) { - tests := []struct { - name string - line string - expectedFlags string - expectedKey string - expectedDisp string - expectedDesc string - }{ - { - name: "binde (repeat on hold)", - line: "binde = , XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+", - expectedFlags: "e", - expectedKey: "XF86AudioRaiseVolume", - expectedDisp: "exec", - expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+", - }, - { - name: "bindd (description)", - line: "bindd = SUPER, Q, Open my favourite terminal, exec, kitty", - expectedFlags: "d", - expectedKey: "Q", - expectedDisp: "exec", - expectedDesc: "Open my favourite terminal", - }, - { - name: "bindk (per-device)", - line: "bindk = SUPER, Q, at-translated-set-2-keyboard, killactive", - expectedFlags: "k", - expectedKey: "Q", - expectedDisp: "killactive", - expectedDesc: "Close window", - }, - { - name: "binddk (device before description)", - line: "binddk = SUPER, T, !my-macropad, Terminal, exec, kitty", - expectedFlags: "dk", - expectedKey: "T", - expectedDisp: "exec", - expectedDesc: "Terminal", - }, - { - name: "bindux (submap-universal, input capture)", - line: "bindux = SUPER, Escape, submap, reset", - expectedFlags: "ux", - expectedKey: "Escape", - expectedDisp: "submap", - expectedDesc: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - parser := NewHyprlandParser("") - parser.contentLines = []string{tt.line} - result := parser.getKeybindAtLine(0) - - if result == nil { - t.Fatal("Expected keybind, got nil") - } - - if result.Flags != tt.expectedFlags { - t.Errorf("Flags = %q, want %q", result.Flags, tt.expectedFlags) - } - if result.Key != tt.expectedKey { - t.Errorf("Key = %q, want %q", result.Key, tt.expectedKey) - } - if result.Dispatcher != tt.expectedDisp { - t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expectedDisp) - } - if result.Comment != tt.expectedDesc { - t.Errorf("Comment = %q, want %q", result.Comment, tt.expectedDesc) - } - }) +func hyprLuaTestDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "hyprland.lua"), nil, 0o644); err != nil { + t.Fatal(err) } + return dir } diff --git a/core/internal/keybinds/providers/hyprland_test.go b/core/internal/keybinds/providers/hyprland_test.go index 360a7cd93..94b227c87 100644 --- a/core/internal/keybinds/providers/hyprland_test.go +++ b/core/internal/keybinds/providers/hyprland_test.go @@ -186,7 +186,6 @@ func TestHyprlandConvertKeybindLuaAction(t *testing.T) { {"hl.dsp.no_op()", "", ""}, } p := NewHyprlandProvider(t.TempDir()) - p.luaConfig = true for _, tt := range tests { t.Run(tt.dispatcher+" "+tt.params, func(t *testing.T) { kb := &HyprlandKeyBinding{Mods: []string{"SUPER"}, Key: "X", Dispatcher: tt.dispatcher, Params: tt.params} @@ -217,45 +216,41 @@ func TestHyprlandLuaBindLabels(t *testing.T) { } } -func TestHyprlandCheatSheetConfigFormat(t *testing.T) { - const conf = "bind = SUPER, Q, killactive\n" - const lua = `hl.bind("SUPER + Q", hl.dsp.window.close())` + "\n" - tests := []struct { - name string - files map[string]string - wantFormat string - wantReadOnly bool - wantLua string - }{ - {"hyprlang only", map[string]string{"hyprland.conf": conf}, "hyprlang", true, ""}, - {"lua only", map[string]string{"hyprland.lua": lua}, "lua", false, `hl.dsp.window.close()`}, - {"lua wins over leftover conf", map[string]string{"hyprland.conf": conf, "hyprland.lua": lua}, "lua", false, `hl.dsp.window.close()`}, +func TestHyprlandCheatSheetIgnoresLegacyConf(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "hyprland.conf"), []byte("bind = SUPER, Q, killactive\n"), 0o644); err != nil { + t.Fatal(err) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "dms", "binds.lua"), nil, 0o644); err != nil { - t.Fatal(err) - } - for name, content := range tt.files { - if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - } - sheet, err := NewHyprlandProvider(dir).GetCheatSheet() - if err != nil { - t.Fatalf("GetCheatSheet: %v", err) - } - if sheet.DMSStatus == nil || sheet.DMSStatus.ConfigFormat != tt.wantFormat || sheet.DMSStatus.ReadOnly != tt.wantReadOnly { - t.Fatalf("DMSStatus = %+v, want format %q readOnly %v", sheet.DMSStatus, tt.wantFormat, tt.wantReadOnly) - } - binds := sheet.Binds["Window"] - if len(binds) != 1 || binds[0].Action != "killactive" || binds[0].LuaAction != tt.wantLua { - t.Fatalf("Window binds = %+v, want killactive with LuaAction %q", binds, tt.wantLua) - } - }) + if sheet, err := NewHyprlandProvider(dir).GetCheatSheet(); err == nil { + t.Fatalf("GetCheatSheet read hyprland.conf: %+v", sheet.Binds) + } +} + +func TestHyprlandCheatSheetReadsWrappedBinds(t *testing.T) { + dir := t.TempDir() + config := `local function cycle_ws() + hl.dispatch("workspace e+1") +end + +hl.bind("SUPER + T", + hl.dsp.exec_cmd("kitty"), -- launcher + { description = "Terminal", repeating = true }) +hl.bind("SUPER + C", cycle_ws, { description = "Next workspace" }) +` + if err := os.WriteFile(filepath.Join(dir, "hyprland.lua"), []byte(config), 0o644); err != nil { + t.Fatal(err) + } + sheet, err := NewHyprlandProvider(dir).GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet: %v", err) + } + + exec := sheet.Binds["Execute"] + if len(exec) != 1 || exec[0].Key != "SUPER+T" || exec[0].Description != "Terminal" || exec[0].Flags != "ed" { + t.Fatalf("Execute binds = %+v, want the wrapped SUPER+T bind with its options", exec) + } + ws := sheet.Binds["Workspace"] + if len(ws) != 1 || ws[0].Key != "SUPER+C" || ws[0].Description != "Next workspace" { + t.Fatalf("Workspace binds = %+v, want the named-function bind filed by its description", ws) } } diff --git a/core/internal/keybinds/providers/mangowc_parser.go b/core/internal/keybinds/providers/mangowc_parser.go index 22862c8e2..7d34ae541 100644 --- a/core/internal/keybinds/providers/mangowc_parser.go +++ b/core/internal/keybinds/providers/mangowc_parser.go @@ -290,7 +290,7 @@ var mangowcBindsMessages = configfrag.Messages{ } func (p *MangoWCParser) buildDMSStatus() *configfrag.Status { - status := configfrag.BuildStatus(p.walker.Scan(), p.dmsBindsExists, p.bindsAfterDMS, "", false, mangowcBindsMessages) + status := configfrag.BuildStatus(p.walker.Scan(), p.dmsBindsExists, p.bindsAfterDMS, mangowcBindsMessages) return &status } diff --git a/core/internal/keybinds/providers/niri_parser.go b/core/internal/keybinds/providers/niri_parser.go index 81a4ca998..ca6d4ecaf 100644 --- a/core/internal/keybinds/providers/niri_parser.go +++ b/core/internal/keybinds/providers/niri_parser.go @@ -569,7 +569,7 @@ var niriBindsMessages = configfrag.Messages{ func (p *NiriParser) buildDMSStatus() *configfrag.Status { scan := configfrag.IncludeScan{Count: p.includeCount, DMSPosition: p.dmsIncludePos, DMSSeen: p.dmsBindsIncluded} - status := configfrag.BuildStatus(scan, p.dmsBindsExists, p.bindsAfterDMS, "", false, niriBindsMessages) + status := configfrag.BuildStatus(scan, p.dmsBindsExists, p.bindsAfterDMS, niriBindsMessages) return &status } diff --git a/core/internal/keybinds/status_wire_test.go b/core/internal/keybinds/status_wire_test.go index 158e79b6b..4efec72d5 100644 --- a/core/internal/keybinds/status_wire_test.go +++ b/core/internal/keybinds/status_wire_test.go @@ -30,11 +30,9 @@ func TestDMSBindsStatusKeySpellings(t *testing.T) { Effective: true, OverriddenBy: 4, StatusMessage: "DMS binds are active", - ConfigFormat: "lua", - ReadOnly: true, }) - want := []string{"exists", "included", "includePosition", "totalIncludes", "bindsAfterDms", "effective", "overriddenBy", "statusMessage", "configFormat", "readOnly"} + want := []string{"exists", "included", "includePosition", "totalIncludes", "bindsAfterDms", "effective", "overriddenBy", "statusMessage"} if len(keys) != len(want) { t.Fatalf("key count = %d, want %d: %v", len(keys), len(want), keys) } @@ -55,8 +53,6 @@ func TestDMSBindsStatusFromCarriesEveryField(t *testing.T) { Effective: true, OverriddenBy: 7, StatusMessage: "DMS binds are active", - ConfigFormat: "lua", - ReadOnly: true, }) want := DMSBindsStatus{ @@ -68,8 +64,6 @@ func TestDMSBindsStatusFromCarriesEveryField(t *testing.T) { Effective: true, OverriddenBy: 7, StatusMessage: "DMS binds are active", - ConfigFormat: "lua", - ReadOnly: true, } if *got != want { t.Errorf("DMSBindsStatusFrom = %+v, want %+v", *got, want) diff --git a/core/internal/keybinds/types.go b/core/internal/keybinds/types.go index 3c49415e0..8271e39ab 100644 --- a/core/internal/keybinds/types.go +++ b/core/internal/keybinds/types.go @@ -32,8 +32,6 @@ 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"` } const ( @@ -127,7 +125,5 @@ func DMSBindsStatusFrom(s configfrag.Status) *DMSBindsStatus { Effective: s.Effective, OverriddenBy: s.OverriddenBy, StatusMessage: s.StatusMessage, - ConfigFormat: s.ConfigFormat, - ReadOnly: s.ReadOnly, } } diff --git a/core/internal/luaconfig/hyprland_version.go b/core/internal/luaconfig/hyprland_version.go new file mode 100644 index 000000000..f0df24e9a --- /dev/null +++ b/core/internal/luaconfig/hyprland_version.go @@ -0,0 +1,33 @@ +package luaconfig + +import ( + "encoding/json" + "fmt" + "os/exec" + "sync" +) + +// HyprlandVersion is the running compositor's major.minor, 0.0 when hyprctl is unavailable. Tests replace it. +var HyprlandVersion = sync.OnceValues(func() (int, int) { + output, err := exec.Command("hyprctl", "-j", "version").Output() + if err != nil { + return 0, 0 + } + var info struct { + Version string `json:"version"` + } + if err := json.Unmarshal(output, &info); err != nil { + return 0, 0 + } + var major, minor int + fmt.Sscanf(info.Version, "%d.%d", &major, &minor) + return major, minor +}) + +func HyprlandAtLeast(major, minor int) bool { + haveMajor, haveMinor := HyprlandVersion() + if haveMajor != major { + return haveMajor > major + } + return haveMinor >= minor +} diff --git a/core/internal/screenshot/compositor.go b/core/internal/screenshot/compositor.go index 0f0053573..fa13b0c89 100644 --- a/core/internal/screenshot/compositor.go +++ b/core/internal/screenshot/compositor.go @@ -124,13 +124,14 @@ func getHyprlandActiveWindow() (*WindowGeometry, error) { } type hyprlandMonitor struct { - Name string `json:"name"` - X int32 `json:"x"` - Y int32 `json:"y"` - Width int32 `json:"width"` - Height int32 `json:"height"` - Scale float64 `json:"scale"` - Focused bool `json:"focused"` + Name string `json:"name"` + X int32 `json:"x"` + Y int32 `json:"y"` + Width int32 `json:"width"` + Height int32 `json:"height"` + Scale float64 `json:"scale"` + Transform int32 `json:"transform"` + Focused bool `json:"focused"` } func GetHyprlandMonitorScale(name string) float64 { @@ -201,18 +202,27 @@ func GetHyprlandMonitorGeometry(name string) (x, y, w, h int32, ok bool) { if err != nil { return 0, 0, 0, 0, false } + return hyprlandMonitorGeometry(output, name) +} +func hyprlandMonitorGeometry(monitorsJSON []byte, name string) (x, y, w, h int32, ok bool) { var monitors []hyprlandMonitor - if err := json.Unmarshal(output, &monitors); err != nil { + if err := json.Unmarshal(monitorsJSON, &monitors); err != nil { return 0, 0, 0, 0, false } for _, m := range monitors { - if m.Name == name { - logicalW := int32(float64(m.Width) / m.Scale) - logicalH := int32(float64(m.Height) / m.Scale) - return m.X, m.Y, logicalW, logicalH, true + if m.Name != name { + continue } + // hyprctl reports width/height before the transform is applied + modeW, modeH := m.Width, m.Height + if m.Transform%2 == 1 { + modeW, modeH = modeH, modeW + } + logicalW := int32(float64(modeW) / m.Scale) + logicalH := int32(float64(modeH) / m.Scale) + return m.X, m.Y, logicalW, logicalH, true } return 0, 0, 0, 0, false } diff --git a/core/internal/screenshot/compositor_test.go b/core/internal/screenshot/compositor_test.go new file mode 100644 index 000000000..e306adb7d --- /dev/null +++ b/core/internal/screenshot/compositor_test.go @@ -0,0 +1,25 @@ +package screenshot + +import "testing" + +func TestHyprlandMonitorGeometryFollowsRotation(t *testing.T) { + monitors := []byte(`[ + {"name": "DP-1", "x": 0, "y": 0, "width": 2560, "height": 1440, "scale": 1.25, "transform": 0}, + {"name": "DP-2", "x": 2048, "y": 0, "width": 2560, "height": 1440, "scale": 1.25, "transform": 1}, + {"name": "DP-3", "x": 3200, "y": 0, "width": 2560, "height": 1440, "scale": 1, "transform": 7} + ]`) + cases := []struct { + name string + want [4]int32 + }{ + {"DP-1", [4]int32{0, 0, 2048, 1152}}, + {"DP-2", [4]int32{2048, 0, 1152, 2048}}, + {"DP-3", [4]int32{3200, 0, 1440, 2560}}, + } + for _, c := range cases { + x, y, w, h, ok := hyprlandMonitorGeometry(monitors, c.name) + if got := [4]int32{x, y, w, h}; !ok || got != c.want { + t.Errorf("%s: got %v ok=%v want %v", c.name, got, ok, c.want) + } + } +} diff --git a/core/internal/screenshot/screenshot.go b/core/internal/screenshot/screenshot.go index f35ac47cb..c2a894635 100644 --- a/core/internal/screenshot/screenshot.go +++ b/core/internal/screenshot/screenshot.go @@ -49,21 +49,24 @@ func (o *WaylandOutput) effectiveScale() float64 { return scale } -// Hyprland renders screencopy frames in the output's logical orientation since -// 901881c9f (v0.57.0); wlroots and niri still hand back the native framebuffer. +// Hyprland >= 0.57 (901881c9f) sends screencopy frames in logical orientation; wlroots and niri send the native framebuffer. // !TODO: drop the version fallback once every distro ships hyprland >= 0.57 -func (o *WaylandOutput) frameInLogicalOrientation(frameW, frameH int) bool { - switch o.transform { +func FrameInLogicalOrientation(transform, outW, outH int32, frameW, frameH int) bool { + switch transform { case TransformNormal: return true case Transform90, Transform270, TransformFlipped90, TransformFlipped270: - if o.width != o.height { - return frameW == int(o.height) && frameH == int(o.width) + if outW != outH { + return frameW == int(outH) && frameH == int(outW) } } return DetectCompositor() == CompositorHyprland && hyprlandVersionAtLeast(0, 57) } +func (o *WaylandOutput) frameInLogicalOrientation(frameW, frameH int) bool { + return FrameInLogicalOrientation(o.transform, o.width, o.height, frameW, frameH) +} + func (o *WaylandOutput) untransformFrame(buf *ShmBuffer) (*ShmBuffer, error) { if o.frameInLogicalOrientation(buf.Width, buf.Height) { return buf, nil diff --git a/core/internal/screenshot/scroll.go b/core/internal/screenshot/scroll.go index 687be9b7c..dc5042472 100644 --- a/core/internal/screenshot/scroll.go +++ b/core/internal/screenshot/scroll.go @@ -74,7 +74,6 @@ type scrollSession struct { sigCh chan os.Signal keysBound bool - hyprLua bool st *stitcher } @@ -357,28 +356,14 @@ func (r *RegionSelector) enterHyprlandScrollInput(osurf *OutputSurface) { } cx := int(float64(osurf.output.x) + float64(s.holeX+s.holeW/2)/scale) cy := int(float64(osurf.output.y) + float64(s.holeY+s.holeH/2)/scale) - s.hyprLua = hyprlandLuaConfigActive() - hyprlandFocusWindowAt(s.hyprLua, cx, cy) + hyprlandFocusWindowAt(cx, cy) s.sigCh = make(chan os.Signal, 2) signal.Notify(s.sigCh, unix.SIGUSR1, unix.SIGUSR2) - s.keysBound = exec.Command("hyprctl", hyprlandBindScrollKeysArgs(s.hyprLua, os.Getpid())...).Run() == nil + s.keysBound = exec.Command("hyprctl", hyprlandBindScrollKeysArgs(os.Getpid())...).Run() == nil } -// `status` exists since 0.55; older builds answer "unknown request" and have no Lua -func hyprlandLuaConfigActive() bool { - out, err := exec.Command("hyprctl", "-j", "status").Output() - return err == nil && hyprlandStatusIsLua(out) -} - -func hyprlandStatusIsLua(out []byte) bool { - var status struct { - ConfigProvider string `json:"configProvider"` - } - return json.Unmarshal(out, &status) == nil && status.ConfigProvider == "lua" -} - -func hyprlandFocusWindowAt(lua bool, x, y int) { +func hyprlandFocusWindowAt(x, y int) { out, err := exec.Command("hyprctl", "-j", "clients").Output() if err != nil { return @@ -410,27 +395,18 @@ func hyprlandFocusWindowAt(lua bool, x, y int) { if best < 0 { return } - _ = exec.Command("hyprctl", hyprlandFocusArgs(lua, clients[best].Address)...).Run() + _ = exec.Command("hyprctl", hyprlandFocusArgs(clients[best].Address)...).Run() } -func hyprlandFocusArgs(lua bool, address string) []string { - if !lua { - return []string{"dispatch", "focuswindow", "address:" + address} - } +func hyprlandFocusArgs(address string) []string { return []string{"dispatch", fmt.Sprintf("hl.dsp.focus({ window = %q })", "address:"+address)} } -func hyprlandBindScrollKeysArgs(lua bool, pid int) []string { - if !lua { - return []string{"--batch", fmt.Sprintf("keyword bind ,Return,exec,kill -USR1 %d ; keyword bind ,Escape,exec,kill -USR2 %d", pid, pid)} - } +func hyprlandBindScrollKeysArgs(pid int) []string { return []string{"eval", fmt.Sprintf(`hl.bind("Return", hl.dsp.exec_cmd("kill -USR1 %d")); hl.bind("Escape", hl.dsp.exec_cmd("kill -USR2 %d"))`, pid, pid)} } -func hyprlandUnbindScrollKeysArgs(lua bool) []string { - if !lua { - return []string{"--batch", "keyword unbind ,Return ; keyword unbind ,Escape"} - } +func hyprlandUnbindScrollKeysArgs() []string { return []string{"eval", `hl.unbind("Return"); hl.unbind("Escape")`} } @@ -701,7 +677,7 @@ func (r *RegionSelector) cleanupScroll() { return } if s.keysBound { - _ = exec.Command("hyprctl", hyprlandUnbindScrollKeysArgs(s.hyprLua)...).Run() + _ = exec.Command("hyprctl", hyprlandUnbindScrollKeysArgs()...).Run() } if s.sigCh != nil { signal.Stop(s.sigCh) diff --git a/core/internal/screenshot/scroll_hyprland_test.go b/core/internal/screenshot/scroll_hyprland_test.go index fc4cc7e64..3e0661812 100644 --- a/core/internal/screenshot/scroll_hyprland_test.go +++ b/core/internal/screenshot/scroll_hyprland_test.go @@ -6,46 +6,17 @@ import ( ) func TestHyprlandScrollCommands(t *testing.T) { - cases := []struct { - name string - status string - focus []string - bind []string - unbind []string - }{ - { - name: "lua", - status: `{"configProvider": "lua", "backend": "drm"}`, - focus: []string{"dispatch", `hl.dsp.focus({ window = "address:0x55f56b093cf0" })`}, - bind: []string{"eval", `hl.bind("Return", hl.dsp.exec_cmd("kill -USR1 4242")); hl.bind("Escape", hl.dsp.exec_cmd("kill -USR2 4242"))`}, - unbind: []string{"eval", `hl.unbind("Return"); hl.unbind("Escape")`}, - }, - { - name: "hyprland.conf on 0.55/0.56", - status: `{"configProvider": "hyprlang", "backend": "drm"}`, - focus: []string{"dispatch", "focuswindow", "address:0x55f56b093cf0"}, - bind: []string{"--batch", "keyword bind ,Return,exec,kill -USR1 4242 ; keyword bind ,Escape,exec,kill -USR2 4242"}, - unbind: []string{"--batch", "keyword unbind ,Return ; keyword unbind ,Escape"}, - }, - { - name: "pre-0.55 has no status request", - status: "unknown request", - focus: []string{"dispatch", "focuswindow", "address:0x55f56b093cf0"}, - bind: []string{"--batch", "keyword bind ,Return,exec,kill -USR1 4242 ; keyword bind ,Escape,exec,kill -USR2 4242"}, - unbind: []string{"--batch", "keyword unbind ,Return ; keyword unbind ,Escape"}, - }, + focus := []string{"dispatch", `hl.dsp.focus({ window = "address:0x55f56b093cf0" })`} + bind := []string{"eval", `hl.bind("Return", hl.dsp.exec_cmd("kill -USR1 4242")); hl.bind("Escape", hl.dsp.exec_cmd("kill -USR2 4242"))`} + unbind := []string{"eval", `hl.unbind("Return"); hl.unbind("Escape")`} + if got := hyprlandFocusArgs("0x55f56b093cf0"); !slices.Equal(got, focus) { + t.Errorf("focus: got %q want %q", got, focus) } - for _, c := range cases { - lua := hyprlandStatusIsLua([]byte(c.status)) - if got := hyprlandFocusArgs(lua, "0x55f56b093cf0"); !slices.Equal(got, c.focus) { - t.Errorf("%s focus: got %q want %q", c.name, got, c.focus) - } - if got := hyprlandBindScrollKeysArgs(lua, 4242); !slices.Equal(got, c.bind) { - t.Errorf("%s bind: got %q want %q", c.name, got, c.bind) - } - if got := hyprlandUnbindScrollKeysArgs(lua); !slices.Equal(got, c.unbind) { - t.Errorf("%s unbind: got %q want %q", c.name, got, c.unbind) - } + if got := hyprlandBindScrollKeysArgs(4242); !slices.Equal(got, bind) { + t.Errorf("bind: got %q want %q", got, bind) + } + if got := hyprlandUnbindScrollKeysArgs(); !slices.Equal(got, unbind) { + t.Errorf("unbind: got %q want %q", got, unbind) } } diff --git a/core/internal/tui/views_config.go b/core/internal/tui/views_config.go index 7ca9060c0..601a2c0bd 100644 --- a/core/internal/tui/views_config.go +++ b/core/internal/tui/views_config.go @@ -270,9 +270,7 @@ func (m Model) wmConfigInfo() ExistingConfigInfo { filepath.Join(configDir, "mango", "config.conf"), filepath.Join(configDir, "mango", "mango.conf")) default: - return statConfig("Hyprland", - filepath.Join(configDir, "hypr", "hyprland.lua"), - filepath.Join(configDir, "hypr", "hyprland.conf")) + return statConfig("Hyprland", filepath.Join(config.HyprlandConfigDir(), "hyprland.lua")) } } diff --git a/core/internal/utils/dirs.go b/core/internal/utils/dirs.go index 553bdc7f1..5c82dcd0a 100644 --- a/core/internal/utils/dirs.go +++ b/core/internal/utils/dirs.go @@ -47,6 +47,10 @@ func migrateLegacyDir(oldDir, newDir string) error { if newExists { return nil } + // A dangling legacy link is usually our own link to a since-deleted dms dir; linking back would loop. + if _, err := os.Stat(oldDir); err != nil { + return nil + } return os.Symlink(legacyDirName, newDir) case !oldInfo.IsDir(): return nil diff --git a/core/internal/utils/dirs_test.go b/core/internal/utils/dirs_test.go index 7c459d166..db6aebf75 100644 --- a/core/internal/utils/dirs_test.go +++ b/core/internal/utils/dirs_test.go @@ -105,6 +105,17 @@ func TestMigrateLegacyDirs(t *testing.T) { assert.Equal(t, "dotfiles", readMigrationFile(t, filepath.Join(home, "config", "dms", "settings.json"))) }, }, + { + name: "dangling legacy link to a deleted dms dir is not linked back", + setup: func(t *testing.T, home string) { + require.NoError(t, os.MkdirAll(filepath.Join(home, "config"), 0o755)) + require.NoError(t, os.Symlink("dms", filepath.Join(home, "config", "DankMaterialShell"))) + }, + verify: func(t *testing.T, home string) { + _, err := os.Lstat(filepath.Join(home, "config", "dms")) + assert.True(t, os.IsNotExist(err)) + }, + }, } for _, tt := range tests { diff --git a/core/internal/windowrules/providers/hyprland_parser.go b/core/internal/windowrules/providers/hyprland_parser.go index 274e5f4af..583da2b28 100644 --- a/core/internal/windowrules/providers/hyprland_parser.go +++ b/core/internal/windowrules/providers/hyprland_parser.go @@ -1,6 +1,7 @@ package providers import ( + "errors" "fmt" "os" "path/filepath" @@ -27,12 +28,10 @@ type HyprlandRulesParser struct { rules []HyprlandWindowRule currentSource string dmsRulesExists bool - dmsPrimaryPath string // dms/windowrules.lua preferred, else dms/windowrules.conf when present + dmsPrimaryPath string walker *configfrag.Walker 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 @@ -54,14 +53,9 @@ func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) { return nil, err } - dmsLua := filepath.Join(expandedDir, "dms", "windowrules.lua") - dmsConf := filepath.Join(expandedDir, "dms", "windowrules.conf") - if _, err := os.Stat(dmsLua); err == nil { + p.dmsPrimaryPath = filepath.Join(expandedDir, "dms", "windowrules.lua") + if _, err := os.Stat(p.dmsPrimaryPath); err == nil { p.dmsRulesExists = true - p.dmsPrimaryPath = dmsLua - } else if _, err := os.Stat(dmsConf); err == nil { - p.dmsRulesExists = true - p.dmsPrimaryPath = dmsConf } mainConfig, err := hyprlandMainConfigPath(expandedDir) @@ -69,15 +63,9 @@ func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) { return nil, err } - if strings.EqualFold(filepath.Ext(mainConfig), ".lua") { - p.configFormat = "lua" - p.probeRequireWindowrulesLine(mainConfig) - if ap, err := filepath.Abs(mainConfig); err == nil { - p.primaryHyprLua = ap - } - } else { - p.configFormat = "hyprlang" - p.readOnly = true + p.probeRequireWindowrulesLine(mainConfig) + if ap, err := filepath.Abs(mainConfig); err == nil { + p.primaryHyprLua = ap } if err := p.parseFile(mainConfig); err != nil { @@ -102,11 +90,7 @@ func (p *HyprlandRulesParser) parseDMSRulesDirectly(dmsRulesPath string) { abs = dmsRulesPath } - if strings.EqualFold(filepath.Ext(abs), ".lua") { - p.parseLuaWindowRules(string(data), filepath.Dir(abs), abs, false) - } else { - p.parseHyprlangContent(string(data), filepath.Dir(abs), abs, false) - } + p.parseLuaWindowRules(string(data), filepath.Dir(abs), abs, false) p.dmsProcessed = true } @@ -126,220 +110,23 @@ func (p *HyprlandRulesParser) parseFile(filePath string) error { return nil } - if strings.EqualFold(filepath.Ext(absPath), ".lua") { - p.parseLuaWindowRules(string(data), filepath.Dir(absPath), absPath, true) - return nil - } - p.parseHyprlangContent(string(data), filepath.Dir(absPath), absPath, true) + p.parseLuaWindowRules(string(data), filepath.Dir(absPath), absPath, true) return nil } -func (p *HyprlandRulesParser) parseHyprlangContent(content, baseDir, absPath string, allowSource bool) { - prevSource := p.currentSource - p.currentSource = absPath - defer func() { p.currentSource = prevSource }() - - lines := strings.Split(content, "\n") - for i := 0; i < len(lines); i++ { - trimmed := strings.TrimSpace(lines[i]) - switch { - case allowSource && strings.HasPrefix(trimmed, "source"): - p.handleSource(trimmed, baseDir) - case windowRuleBlockRegex.MatchString(strings.TrimSpace(stripHyprlangComment(trimmed))): - i = p.parseWindowRuleBlock(lines, i) - default: - p.parseLine(trimmed) - } - } -} - -func (p *HyprlandRulesParser) handleSource(line string, baseDir string) { - matched := p.walker.IncludeAssignment(baseDir, line, func(absPath string) error { - return p.parseFile(absPath) - }) - if matched { - p.dmsProcessed = true - } -} - -func (p *HyprlandRulesParser) parseLine(line string) { - trimmed := strings.TrimSpace(line) - if !strings.HasPrefix(trimmed, "windowrule") { - return - } - rule := p.parseWindowRuleLine(trimmed) - if rule == nil { - return - } - rule.Source = p.currentSource - p.rules = append(p.rules, *rule) -} - -var windowRuleV2Regex = regexp.MustCompile(`^windowrulev?2?\s*=\s*(.+)$`) -var windowRuleBlockRegex = regexp.MustCompile(`^windowrule\s*\{$`) - -// stripHyprlangComment cuts at the first `#` the way hyprlang does; `##` is a literal `#`. -func stripHyprlangComment(line string) string { - if !strings.Contains(line, "#") { - return line - } - var sb strings.Builder - for i := 0; i < len(line); i++ { - if line[i] != '#' { - sb.WriteByte(line[i]) - continue - } - if i+1 < len(line) && line[i+1] == '#' { - sb.WriteByte('#') - i++ - continue - } - break - } - return sb.String() -} - -func (p *HyprlandRulesParser) parseWindowRuleLine(line string) *HyprlandWindowRule { - rule := &HyprlandWindowRule{RawLine: line} - line = strings.TrimSpace(stripHyprlangComment(line)) - matches := windowRuleV2Regex.FindStringSubmatch(line) - if len(matches) < 2 { - return nil - } - - content := strings.TrimSpace(matches[1]) - - switch { - case strings.HasPrefix(line, "windowrulev2"): - p.parseWindowRuleV2(content, rule) - case strings.Contains(content, "match:"): - parseWindowRuleMatchSyntax(content, rule) - default: - p.parseWindowRuleV1(content, rule) - } - - return rule -} - -func (p *HyprlandRulesParser) parseWindowRuleV1(content string, rule *HyprlandWindowRule) { - parts := strings.SplitN(content, ",", 2) - if len(parts) < 2 { - return - } - - rule.Match.AppID = strings.TrimSpace(parts[1]) - applyHyprlandRuleAction(&rule.Actions, strings.TrimSpace(parts[0]), "") -} - -func (p *HyprlandRulesParser) parseWindowRuleV2(content string, rule *HyprlandWindowRule) { - parts := strings.SplitN(content, ",", 2) - if len(parts) < 2 { - return - } - - ruleName, value, _ := strings.Cut(strings.TrimSpace(parts[0]), " ") - applyHyprlandRuleAction(&rule.Actions, ruleName, strings.TrimSpace(value)) - - for pair := range strings.SplitSeq(parts[1], ",") { - key, value, ok := strings.Cut(strings.TrimSpace(pair), ":") - if !ok || key == "" { - continue - } - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - b := value == "1" || value == "true" - - switch key { - case "class": - rule.Match.AppID = value - case "title": - rule.Match.Title = value - case "xwayland": - rule.Match.XWayland = &b - case "floating": - rule.Match.IsFloating = &b - case "fullscreen": - rule.Match.Fullscreen = &b - case "pinned": - rule.Match.Pinned = &b - case "initialised", "initialized": - rule.Match.Initialised = &b - } - } -} - -// 0.53+ hyprlang: `windowrule = match:class kitty, float on`; keys share the Lua names. -func parseWindowRuleMatchSyntax(content string, rule *HyprlandWindowRule) { - for seg := range strings.SplitSeq(content, ",") { - seg = strings.TrimSpace(seg) - key, value, _ := strings.Cut(seg, " ") - applyHyprlangRuleField(rule, key, value) - } -} - -func (p *HyprlandRulesParser) parseWindowRuleBlock(lines []string, start int) int { - rule := HyprlandWindowRule{Source: p.currentSource} - end := start + 1 - for ; end < len(lines); end++ { - trimmed := strings.TrimSpace(stripHyprlangComment(lines[end])) - if trimmed == "}" { - break - } - if key, value, ok := strings.Cut(trimmed, "="); ok { - applyHyprlangRuleField(&rule, key, value) - } - } - rule.RawLine = strings.TrimSpace(strings.Join(lines[start:min(end+1, len(lines))], "\n")) - p.rules = append(p.rules, rule) - return end -} - -func applyHyprlangRuleField(rule *HyprlandWindowRule, key, value string) { - key = strings.ToLower(strings.TrimSpace(key)) - raw := hyprlangValueToLua(key, strings.TrimSpace(value)) - if matchKey, ok := strings.CutPrefix(key, "match:"); ok { - applyLuaMatchKey(&rule.Match, matchKey, raw) - return - } - applyLuaActionKey(&rule.Actions, key, raw) -} - -func hyprlangValueToLua(key, value string) string { - // Actions has one opacity, so `opacity active inactive` keeps only the active value. - if f := strings.Fields(value); len(f) > 1 && key == "opacity" { - value = f[0] - } - switch strings.ToLower(value) { - case "", "on": - return "true" - case "off": - return "false" - } - if _, ok := luaBoolLike(value); ok { - return value - } - if _, err := strconv.ParseFloat(value, 64); err == nil { - return value - } - if f := strings.Fields(value); len(f) == 2 && (key == "size" || key == "move") { - return fmt.Sprintf("{ %s, %s }", strconv.Quote(f[0]), strconv.Quote(f[1])) - } - return strconv.Quote(value) -} - func (p *HyprlandRulesParser) HasDMSRulesIncluded() bool { return p.walker.Included() } var hyprlandRulesMessages = configfrag.Messages{ - Missing: "dms window rules fragment (windowrules.lua / windowrules.conf) does not exist", - NotIncluded: "dms window rules are not loaded (missing require/source for dms/windowrules)", + Missing: "dms/windowrules.lua does not exist", + NotIncluded: "dms window rules are not loaded (missing require for dms/windowrules)", Overridden: "Some DMS rules may be overridden by config rules", Active: "DMS window rules are active", } func (p *HyprlandRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus { - return windowrules.DMSRulesStatusFrom(configfrag.BuildStatus(p.walker.Scan(), p.dmsRulesExists, p.rulesAfterDMS, p.configFormat, p.readOnly, hyprlandRulesMessages)) + return windowrules.DMSRulesStatusFrom(configfrag.BuildStatus(p.walker.Scan(), p.dmsRulesExists, p.rulesAfterDMS, hyprlandRulesMessages)) } type HyprlandRulesParseResult struct { @@ -361,54 +148,6 @@ func ParseHyprlandWindowRules(configDir string) (*HyprlandRulesParseResult, erro }, nil } -func applyHyprlandRuleAction(actions *windowrules.Actions, rule, value string) { - t := true - switch rule { - case "float": - actions.OpenFloating = &t - case "tile": - actions.Tile = &t - case "fullscreen": - actions.OpenFullscreen = &t - case "maximize": - actions.OpenMaximized = &t - case "nofocus": - actions.NoFocus = &t - case "noborder": - actions.NoBorder = &t - case "noshadow": - actions.NoShadow = &t - case "nodim": - actions.NoDim = &t - case "noblur": - actions.NoBlur = &t - case "noanim": - actions.NoAnim = &t - case "norounding": - actions.NoRounding = &t - case "pin": - actions.Pin = &t - case "opaque": - actions.Opaque = &t - case "forcergbx": - actions.ForcergbX = &t - case "opacity": - if f, err := strconv.ParseFloat(value, 64); err == nil { - actions.Opacity = &f - } - case "monitor": - actions.Monitor = value - case "workspace": - actions.Workspace = value - case "idleinhibit": - actions.Idleinhibit = value - case "rounding": - if i, err := strconv.Atoi(value); err == nil { - actions.CornerRadius = &i - } - } -} - func ConvertHyprlandRulesToWindowRules(hyprRules []HyprlandWindowRule) []windowrules.WindowRule { result := make([]windowrules.WindowRule, 0, len(hyprRules)) for i, hr := range hyprRules { @@ -454,11 +193,17 @@ func (p *HyprlandWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) { }, nil } +// Include repair would write a stub hyprland.lua, replacing a conf-only user's whole config. func (p *HyprlandWritableProvider) EnsureWritable() 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") + _, err := hyprlandMainConfigPath(p.configDir) + switch { + case err == nil: + return nil + case errors.Is(err, os.ErrNotExist): + return fmt.Errorf("hyprland.lua not found in %s; run `dms setup` to migrate before editing window rules: %w", p.configDir, err) + default: + return fmt.Errorf("cannot read hyprland.lua in %s: %w", p.configDir, err) } - return nil } func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error { @@ -473,27 +218,15 @@ func (p *HyprlandWritableProvider) ReorderRules(ids []string) error { return windowrules.Reorder(p, ids) } -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=(.*)$`) -// 0.55/0.56 reject rounding above 20 and drop the rule; main allows 100. Raise after 0.57 ships. -const hyprlandMaxRounding = 20 +// 0.57 raised the per-rule cap from 20 to 100. +func hyprlandMaxRounding() int { + if luaconfig.HyprlandAtLeast(0, 57) { + return 100 + } + return 20 +} type luaField[S, V any] struct { key string @@ -646,7 +379,7 @@ func luaAppendActions(a windowrules.Actions, dst *[]string) { *dst = append(*dst, fmt.Sprintf(`workspace = %s`, strconv.Quote(a.Workspace))) } if a.CornerRadius != nil && (a.NoRounding == nil || !*a.NoRounding) { - *dst = append(*dst, fmt.Sprintf(`rounding = %d`, min(*a.CornerRadius, hyprlandMaxRounding))) + *dst = append(*dst, fmt.Sprintf(`rounding = %d`, min(*a.CornerRadius, hyprlandMaxRounding()))) } if a.Idleinhibit != "" { *dst = append(*dst, fmt.Sprintf(`idle_inhibit = %s`, strconv.Quote(a.Idleinhibit))) @@ -688,67 +421,13 @@ func formatLuaManagedHyprRule(rule windowrules.WindowRule) []string { func (p *HyprlandWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) { luaPath := p.GetOverridePath() data, err := os.ReadFile(luaPath) - if err == nil { - return p.loadDMSRulesFromLua(data, luaPath) - } - if !os.IsNotExist(err) { - return nil, err - } - - confPath := filepath.Join(filepath.Dir(luaPath), "windowrules.conf") - data, err = os.ReadFile(confPath) if os.IsNotExist(err) { return []windowrules.WindowRule{}, nil } if err != nil { return nil, err } - return p.loadDMSRulesFromConf(data, confPath) -} - -func (p *HyprlandWritableProvider) loadDMSRulesFromConf(data []byte, rulesPath string) ([]windowrules.WindowRule, error) { - var rules []windowrules.WindowRule - var currentID, currentName string - parser := NewHyprlandRulesParser(p.configDir) - - for line := range strings.SplitSeq(string(data), "\n") { - trimmed := strings.TrimSpace(line) - - if matches := dmsRuleCommentRegex.FindStringSubmatch(trimmed); matches != nil { - currentID = matches[1] - currentName = matches[2] - continue - } - - if !strings.HasPrefix(trimmed, "windowrule") { - continue - } - hrule := parser.parseWindowRuleLine(trimmed) - if hrule == nil { - continue - } - - wr := windowrules.WindowRule{ - ID: currentID, - Name: currentName, - Enabled: true, - Source: rulesPath, - MatchCriteria: hrule.Match, - Actions: hrule.Actions, - } - if wr.ID == "" { - wr.ID = hrule.Match.AppID - if wr.ID == "" { - wr.ID = hrule.Match.Title - } - } - - rules = append(rules, wr) - currentID = "" - currentName = "" - } - - return rules, nil + return p.loadDMSRulesFromLua(data, luaPath) } func (p *HyprlandWritableProvider) loadDMSRulesFromLua(data []byte, rulesPath string) ([]windowrules.WindowRule, error) { @@ -846,21 +525,19 @@ func hyprlandMainConfigPath(dir string) (string, error) { return "", err } luaPath := filepath.Join(expandedDir, "hyprland.lua") - if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() { - return luaPath, nil + st, err := os.Stat(luaPath) + if err != nil { + return "", err } - confPath := filepath.Join(expandedDir, "hyprland.conf") - if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() { - return confPath, nil + if !st.Mode().IsRegular() { + return "", os.ErrNotExist } - return "", os.ErrNotExist + return luaPath, nil } func isDMSWindowRulesSourcePath(sourcePath string) bool { p := filepath.ToSlash(strings.TrimSpace(sourcePath)) - return p == "dms/windowrules.lua" || strings.HasSuffix(p, "/dms/windowrules.lua") || - p == "dms/windowrules.conf" || strings.HasSuffix(p, "/dms/windowrules.conf") || - p == "./dms/windowrules.lua" || p == "./dms/windowrules.conf" + return p == "dms/windowrules.lua" || p == "./dms/windowrules.lua" || strings.HasSuffix(p, "/dms/windowrules.lua") } func isDMSWindowRulesRequireModule(mod string) bool { diff --git a/core/internal/windowrules/providers/hyprland_parser_test.go b/core/internal/windowrules/providers/hyprland_parser_test.go index 486ac6d2c..9ddd434dd 100644 --- a/core/internal/windowrules/providers/hyprland_parser_test.go +++ b/core/internal/windowrules/providers/hyprland_parser_test.go @@ -1,6 +1,7 @@ package providers import ( + "github.com/duxlabs-dev/dux-shell/core/internal/luaconfig" "os" "path/filepath" "reflect" @@ -10,143 +11,7 @@ import ( "github.com/duxlabs-dev/dux-shell/core/internal/windowrules" ) -func TestParseWindowRuleV1(t *testing.T) { - parser := NewHyprlandRulesParser("") - - tests := []struct { - name string - line string - wantClass string - wantFloat bool - wantTile bool - }{ - {name: "basic float rule", line: "windowrule = float, ^(firefox)$", wantClass: "^(firefox)$", wantFloat: true}, - {name: "tile rule", line: "windowrule = tile, steam", wantClass: "steam", wantTile: true}, - {name: "no match returns empty class", line: "windowrule = float"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := parser.parseWindowRuleLine(tt.line) - if result == nil { - t.Fatal("expected non-nil result") - } - if result.Match.AppID != tt.wantClass { - t.Errorf("AppID = %q, want %q", result.Match.AppID, tt.wantClass) - } - if (result.Actions.OpenFloating != nil) != tt.wantFloat { - t.Errorf("OpenFloating = %v, want %v", result.Actions.OpenFloating, tt.wantFloat) - } - if (result.Actions.Tile != nil) != tt.wantTile { - t.Errorf("Tile = %v, want %v", result.Actions.Tile, tt.wantTile) - } - }) - } -} - -func TestParseWindowRuleV2(t *testing.T) { - parser := NewHyprlandRulesParser("") - - tests := []struct { - name string - line string - wantClass string - wantTitle string - wantOpacity float64 - wantMax bool - }{ - {name: "float with class", line: "windowrulev2 = float, class:^(firefox)$", wantClass: "^(firefox)$"}, - {name: "opacity with value", line: "windowrulev2 = opacity 0.8, class:^(code)$", wantClass: "^(code)$", wantOpacity: 0.8}, - {name: "maximize", line: "windowrulev2 = maximize, class:^(steam)$", wantClass: "^(steam)$", wantMax: true}, - {name: "size with value and title", line: "windowrulev2 = size 800 600, class:^(steam)$, title:Settings", wantClass: "^(steam)$", wantTitle: "Settings"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := parser.parseWindowRuleLine(tt.line) - if result == nil { - t.Fatal("expected non-nil result") - } - if result.Match.AppID != tt.wantClass { - t.Errorf("AppID = %q, want %q", result.Match.AppID, tt.wantClass) - } - if result.Match.Title != tt.wantTitle { - t.Errorf("Title = %q, want %q", result.Match.Title, tt.wantTitle) - } - if tt.wantOpacity != 0 && (result.Actions.Opacity == nil || *result.Actions.Opacity != tt.wantOpacity) { - t.Errorf("Opacity = %v, want %v", result.Actions.Opacity, tt.wantOpacity) - } - if (result.Actions.OpenMaximized != nil) != tt.wantMax { - t.Errorf("OpenMaximized = %v, want %v", result.Actions.OpenMaximized, tt.wantMax) - } - }) - } -} - -func TestParseHyprlangMatchSyntax(t *testing.T) { - tmpDir := t.TempDir() - conf := ` -windowrule = match:class ^(kitty)$, match:initial_title ^Login$, match:focus 1, float on, rounding 12 -windowrule = no_initial_focus on, match:fullscreen_state_client 2, match:xdg_tag ^portal$, size 800 600 -windowrule { - name = steam-popups - match:class = ^steam$ - match:modal = true - match:title = ^a##b$ # trailing - border_color = rgb(ff0000) - move = 10 20 - float = on # x -} -windowrule = match:class ^(mpv)$, opacity 0.8 0.9 # x -` - if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte(conf), 0644); err != nil { - t.Fatal(err) - } - - res, err := ParseHyprlandWindowRules(tmpDir) - if err != nil { - t.Fatalf("ParseHyprlandWindowRules: %v", err) - } - got := ConvertHyprlandRulesToWindowRules(res.Rules) - if len(got) != 4 { - t.Fatalf("expected 4 rules, got %d", len(got)) - } - - want := []struct { - match windowrules.MatchCriteria - act windowrules.Actions - }{ - { - windowrules.MatchCriteria{AppID: "^(kitty)$", InitialTitle: "^Login$", IsFocused: new(true)}, - windowrules.Actions{OpenFloating: new(true), CornerRadius: new(12)}, - }, - { - windowrules.MatchCriteria{FullscreenStateClient: new(2), XdgTag: "^portal$"}, - windowrules.Actions{NoInitialFocus: new(true), SizeWidth: "800", SizeHeight: "600"}, - }, - { - windowrules.MatchCriteria{AppID: "^steam$", Title: "^a#b$", Modal: new(true)}, - windowrules.Actions{BorderColor: "rgb(ff0000)", MoveX: "10", MoveY: "20", OpenFloating: new(true)}, - }, - { - windowrules.MatchCriteria{AppID: "^(mpv)$"}, - windowrules.Actions{Opacity: new(0.8)}, - }, - } - for i, w := range want { - if !reflect.DeepEqual(got[i].MatchCriteria, w.match) { - t.Errorf("rule %d match = %+v, want %+v", i, got[i].MatchCriteria, w.match) - } - if !reflect.DeepEqual(got[i].Actions, w.act) { - t.Errorf("rule %d actions = %+v, want %+v", i, got[i].Actions, w.act) - } - } - if res.DMSStatus.ConfigFormat != "hyprlang" || !res.DMSStatus.ReadOnly { - t.Errorf("status format=%q readOnly=%v, want hyprlang/true", res.DMSStatus.ConfigFormat, res.DMSStatus.ReadOnly) - } -} - -func TestHyprlandLuaPreferredOverConf(t *testing.T) { +func TestHyprlandIgnoresConfWhenLuaPresent(t *testing.T) { tmpDir := t.TempDir() if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("windowrule = match:class ^conf$, float on\n"), 0644); err != nil { t.Fatal(err) @@ -162,36 +27,11 @@ func TestHyprlandLuaPreferredOverConf(t *testing.T) { if len(res.Rules) != 1 || res.Rules[0].Match.AppID != "^lua$" { t.Fatalf("expected only the Lua rule, got %+v", res.Rules) } - if res.DMSStatus.ConfigFormat != "lua" || res.DMSStatus.ReadOnly { - t.Errorf("status format=%q readOnly=%v, want lua/false", res.DMSStatus.ConfigFormat, res.DMSStatus.ReadOnly) - } if err := NewHyprlandWritableProvider(tmpDir).EnsureWritable(); err != nil { t.Errorf("EnsureWritable with hyprland.lua present: %v", err) } } -func TestHyprlandLoadDMSRulesFromConfFragment(t *testing.T) { - tmpDir := t.TempDir() - if err := os.MkdirAll(filepath.Join(tmpDir, "dms"), 0755); err != nil { - t.Fatal(err) - } - frag := "# DMS-RULE: id=ff, name=Firefox\nwindowrulev2 = float, class:^(firefox)$\n" - if err := os.WriteFile(filepath.Join(tmpDir, "dms", "windowrules.conf"), []byte(frag), 0644); err != nil { - t.Fatal(err) - } - - rules, err := NewHyprlandWritableProvider(tmpDir).LoadDMSRules() - if err != nil { - t.Fatalf("LoadDMSRules: %v", err) - } - if len(rules) != 1 || rules[0].ID != "ff" || rules[0].Name != "Firefox" || rules[0].MatchCriteria.AppID != "^(firefox)$" { - t.Fatalf("unexpected rules: %+v", rules) - } - if rules[0].Actions.OpenFloating == nil || !*rules[0].Actions.OpenFloating { - t.Errorf("expected OpenFloating, got %+v", rules[0].Actions) - } -} - 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 { @@ -205,8 +45,8 @@ func TestHyprlandSetRuleLeavesConfOnlyInstallReadOnly(t *testing.T) { 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 !strings.Contains(err.Error(), "dms setup") { + t.Fatalf("expected a run-dms-setup 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) @@ -214,7 +54,7 @@ func TestHyprlandSetRuleLeavesConfOnlyInstallReadOnly(t *testing.T) { } func TestHyprlandRemoveRule(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := hyprLuaDir(t) provider := NewHyprlandWritableProvider(tmpDir) rule1 := newTestWindowRule("rule1", "Rule 1", "^(app1)$") @@ -239,7 +79,7 @@ func TestHyprlandRemoveRule(t *testing.T) { } func TestHyprlandReorderRules(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := hyprLuaDir(t) provider := NewHyprlandWritableProvider(tmpDir) rule1 := newTestWindowRule("rule1", "Rule 1", "^(app1)$") @@ -269,35 +109,6 @@ func TestHyprlandReorderRules(t *testing.T) { } } -func TestHyprlandParseConfigWithSource(t *testing.T) { - tmpDir := t.TempDir() - - mainConfig := ` -windowrulev2 = float, class:^(mainapp)$ -source = ./extra.conf -` - extraConfig := ` -windowrulev2 = tile, class:^(extraapp)$ -` - - if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte(mainConfig), 0644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(tmpDir, "extra.conf"), []byte(extraConfig), 0644); err != nil { - t.Fatal(err) - } - - parser := NewHyprlandRulesParser(tmpDir) - rules, err := parser.Parse() - if err != nil { - t.Fatalf("Parse failed: %v", err) - } - - if len(rules) != 2 { - t.Errorf("expected 2 rules, got %d", len(rules)) - } -} - func TestParseHyprlandLuaRequiresFragment(t *testing.T) { tmpDir := t.TempDir() dmsDir := filepath.Join(tmpDir, "dms") @@ -536,15 +347,21 @@ func TestFormatLuaManagedHyprRuleCornerRadiusWithoutNoRounding(t *testing.T) { } } -func TestFormatLuaManagedHyprRuleCornerRadiusCapped(t *testing.T) { +func TestFormatLuaManagedHyprRuleCornerRadiusCappedByVersion(t *testing.T) { + prev := luaconfig.HyprlandVersion + t.Cleanup(func() { luaconfig.HyprlandVersion = prev }) rule := windowrules.WindowRule{ ID: "radius", Enabled: true, - Actions: windowrules.Actions{CornerRadius: new(24)}, + Actions: windowrules.Actions{CornerRadius: new(120)}, } - joined := strings.Join(formatLuaManagedHyprRule(rule), "\n") - if !strings.Contains(joined, "rounding = 20") { - t.Errorf("rounding not capped at 20:\n%s", joined) + for minor, want := range map[int]string{56: "rounding = 20", 57: "rounding = 100"} { + m := minor + luaconfig.HyprlandVersion = func() (int, int) { return 0, m } + joined := strings.Join(formatLuaManagedHyprRule(rule), "\n") + if !strings.Contains(joined, want) { + t.Errorf("0.%d: missing %s:\n%s", minor, want, joined) + } } } @@ -623,7 +440,7 @@ func TestApplyLuaActionKeyBorderAndRounding(t *testing.T) { } func TestHyprlandLuaRoundTripMatchAndActions(t *testing.T) { - provider := NewHyprlandWritableProvider(t.TempDir()) + provider := NewHyprlandWritableProvider(hyprLuaDir(t)) rule := newTestWindowRule("rt", "Round Trip", "^throwaway$") rule.MatchCriteria.IsFloating = new(true) rule.MatchCriteria.Pinned = new(false) @@ -672,7 +489,7 @@ func ruleOrder(t *testing.T, content string, ids ...string) { } func TestHyprlandEndToEndWriterOrderAndKeys(t *testing.T) { - provider := NewHyprlandWritableProvider(t.TempDir()) + provider := NewHyprlandWritableProvider(hyprLuaDir(t)) user1 := newTestWindowRule("user1", "User 1", "^throwaway$") user1.MatchCriteria.IsFloating = new(true) @@ -792,11 +609,11 @@ func TestHyprlandLuaRoundTripCatalogKeys(t *testing.T) { {"suppress_event", `suppress_event = "maximize fullscreen"`, windowrules.MatchCriteria{}, windowrules.Actions{SuppressEvent: "maximize fullscreen"}}, {"monitor silent", `monitor = "DP-1 silent"`, windowrules.MatchCriteria{}, windowrules.Actions{Monitor: "DP-1 silent"}}, {"border_color per focus", `border_color = "rgb(ff0000)"`, windowrules.MatchCriteria{IsFocused: new(true)}, windowrules.Actions{BorderColor: "rgb(ff0000)"}}, - {"rounding at cap", `rounding = 20`, windowrules.MatchCriteria{}, windowrules.Actions{CornerRadius: new(20)}}, + {"rounding at pre-0.57 cap", `rounding = 20`, windowrules.MatchCriteria{}, windowrules.Actions{CornerRadius: new(20)}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - provider := NewHyprlandWritableProvider(t.TempDir()) + provider := NewHyprlandWritableProvider(hyprLuaDir(t)) rule := newTestWindowRule("rt", "Round Trip", "^app$") tt.match.AppID = "^app$" rule.MatchCriteria = tt.match @@ -851,3 +668,12 @@ hl.window_rule({ t.Errorf("no_initial_focus not re-emitted unchanged:\n%s", joined) } } + +func hyprLuaDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "hyprland.lua"), nil, 0o644); err != nil { + t.Fatal(err) + } + return dir +} diff --git a/core/internal/windowrules/providers/mango_parser.go b/core/internal/windowrules/providers/mango_parser.go index 5755ff563..05805e02f 100644 --- a/core/internal/windowrules/providers/mango_parser.go +++ b/core/internal/windowrules/providers/mango_parser.go @@ -125,7 +125,6 @@ func ParseMangoWindowRules(configDir string) (*MangoRulesParseResult, error) { Exists: fileExists(overridePath), Included: included, Effective: included, - ConfigFormat: "conf", StatusMessage: mangoIncludeMessage(included), }, }, nil diff --git a/core/internal/windowrules/providers/niri_parser.go b/core/internal/windowrules/providers/niri_parser.go index 16adee282..47772a47a 100644 --- a/core/internal/windowrules/providers/niri_parser.go +++ b/core/internal/windowrules/providers/niri_parser.go @@ -523,7 +523,7 @@ var niriRulesMessages = configfrag.Messages{ func (p *NiriRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus { scan := configfrag.IncludeScan{Count: p.includeCount, DMSPosition: p.dmsIncludePos, DMSSeen: p.dmsRulesIncluded} - return windowrules.DMSRulesStatusFrom(configfrag.BuildStatus(scan, p.dmsRulesExists, p.rulesAfterDMS, "", false, niriRulesMessages)) + return windowrules.DMSRulesStatusFrom(configfrag.BuildStatus(scan, p.dmsRulesExists, p.rulesAfterDMS, niriRulesMessages)) } type NiriRulesParseResult struct { diff --git a/core/internal/windowrules/status_wire_test.go b/core/internal/windowrules/status_wire_test.go index dacf11420..37283129b 100644 --- a/core/internal/windowrules/status_wire_test.go +++ b/core/internal/windowrules/status_wire_test.go @@ -30,11 +30,9 @@ func TestDMSRulesStatusKeySpellings(t *testing.T) { Effective: true, OverriddenBy: 4, StatusMessage: "DMS window rules are active", - ConfigFormat: "lua", - ReadOnly: true, }) - want := []string{"exists", "included", "includePosition", "totalIncludes", "rulesAfterDms", "effective", "overriddenBy", "statusMessage", "configFormat", "readOnly"} + want := []string{"exists", "included", "includePosition", "totalIncludes", "rulesAfterDms", "effective", "overriddenBy", "statusMessage"} if len(keys) != len(want) { t.Fatalf("key count = %d, want %d: %v", len(keys), len(want), keys) } @@ -55,8 +53,6 @@ func TestDMSRulesStatusFromCarriesEveryField(t *testing.T) { Effective: true, OverriddenBy: 7, StatusMessage: "DMS window rules are active", - ConfigFormat: "lua", - ReadOnly: true, }) want := DMSRulesStatus{ @@ -68,8 +64,6 @@ func TestDMSRulesStatusFromCarriesEveryField(t *testing.T) { Effective: true, OverriddenBy: 7, StatusMessage: "DMS window rules are active", - ConfigFormat: "lua", - ReadOnly: true, } if *got != want { t.Errorf("DMSRulesStatusFrom = %+v, want %+v", *got, want) diff --git a/core/internal/windowrules/types.go b/core/internal/windowrules/types.go index 8e0f8127c..f789dfa4a 100644 --- a/core/internal/windowrules/types.go +++ b/core/internal/windowrules/types.go @@ -116,8 +116,6 @@ 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 { @@ -151,7 +149,5 @@ func DMSRulesStatusFrom(s configfrag.Status) *DMSRulesStatus { Effective: s.Effective, OverriddenBy: s.OverriddenBy, StatusMessage: s.StatusMessage, - ConfigFormat: s.ConfigFormat, - ReadOnly: s.ReadOnly, } } diff --git a/quickshell/Common/KeybindActions.js b/quickshell/Common/KeybindActions.js index 3f7e4fad4..86c913496 100644 --- a/quickshell/Common/KeybindActions.js +++ b/quickshell/Common/KeybindActions.js @@ -1833,6 +1833,10 @@ const HYPRLAND_ACTIONS = { id: "forcerendererreload", label: "Force Renderer Reload" }, + { + id: "reloadconfig", + label: "Reload Config" + }, { id: "dpms on", label: "DPMS On" diff --git a/quickshell/Common/OutputModel.js b/quickshell/Common/OutputModel.js index 0f02907e7..81bde4b49 100644 --- a/quickshell/Common/OutputModel.js +++ b/quickshell/Common/OutputModel.js @@ -255,7 +255,7 @@ function hyprlandVrrMode(settings, saved) { return Number.isInteger(fromFile) && fromFile > 0 ? fromFile : undefined; } -// hardwareDetails exists from Hyprland 0.56; null means unknown and gates nothing. +// hardwareDetails exists from Hyprland 0.57; null means unknown and gates nothing. function hyprlandHardware(ipcObject) { const details = ipcObject?.hardwareDetails; if (!details || typeof details !== "object") @@ -321,33 +321,7 @@ function parseHyprlandDisableLine(line) { "modes": [], "current_mode": -1, "vrr_enabled": false, - "vrr_supported": false, - "hyprlandSettings": { - "disabled": true - } - }; -} - -function hyprlandConfExtras(rest) { - const transformMatch = rest.match(/,\s*transform,\s*(\d+)/); - const vrrMatch = rest.match(/,\s*vrr,\s*(\d+)/); - const bitdepthMatch = rest.match(/,\s*bitdepth,\s*(\d+)/); - const cmMatch = rest.match(/,\s*cm,\s*(\w+)/); - const sdrBrightnessMatch = rest.match(/,\s*sdrbrightness,\s*([\d.]+)/); - const sdrSaturationMatch = rest.match(/,\s*sdrsaturation,\s*([\d.]+)/); - const mirrorMatch = rest.match(/,\s*mirror,\s*([^,\s]+)/); - const vrrMode = vrrMatch ? parseInt(vrrMatch[1]) : undefined; - return { - "transform": transformMatch ? parseInt(transformMatch[1]) : 0, - "vrrMode": vrrMode ?? 0, - "mirror": mirrorMatch ? mirrorMatch[1] : "", - "settings": { - "vrr": vrrMode, - "bitdepth": bitdepthMatch ? parseInt(bitdepthMatch[1]) : undefined, - "colorManagement": cmMatch ? cmMatch[1] : undefined, - "sdrBrightness": sdrBrightnessMatch ? parseFloat(sdrBrightnessMatch[1]) : undefined, - "sdrSaturation": sdrSaturationMatch ? parseFloat(sdrSaturationMatch[1]) : undefined - } + "vrr_supported": false }; } @@ -355,28 +329,30 @@ function parseHyprlandMonitorLine(line) { const match = line.match(/^\s*monitor\s*=\s*([^,]+),\s*(\d+)x(\d+)@([\d.]+),\s*(-?\d+)x(-?\d+),\s*([\d.]+)/); if (!match) return null; - const extras = hyprlandConfExtras(line.substring(line.indexOf(match[7]) + match[7].length)); + const rest = line.substring(match[0].length); + const transformMatch = rest.match(/,\s*transform,\s*(\d+)/); + const vrrMatch = rest.match(/,\s*vrr,\s*(\d+)/); + const mirrorMatch = rest.match(/,\s*mirror,\s*([^,\s]+)/); return { "name": match[1].trim(), "logical": { "x": parseInt(match[5]), "y": parseInt(match[6]), "scale": parseFloat(match[7]), - "transform": transformName(extras.transform) + "transform": transformName(transformMatch ? parseInt(transformMatch[1]) : 0) }, "modes": [parsedMode(match[2], match[3], match[4])], "current_mode": 0, - "vrr_enabled": extras.vrrMode >= 1, + "vrr_enabled": vrrMatch ? parseInt(vrrMatch[1]) >= 1 : false, "vrr_supported": true, - "hyprlandSettings": extras.settings, - "mirror": extras.mirror + "mirror": mirrorMatch ? mirrorMatch[1] : "" }; } -function parseHyprlandOutputs(content) { +function collectOutputs(content, parseLine) { const result = {}; for (const line of content.split("\n")) { - const entry = parseHyprlandLuaMonitorLine(line) || parseHyprlandDisableLine(line) || parseHyprlandMonitorLine(line); + const entry = parseLine(line); if (!entry) continue; result[entry.name] = entry; @@ -384,6 +360,15 @@ function parseHyprlandOutputs(content) { return result; } +function parseHyprlandOutputs(content) { + return collectOutputs(content, parseHyprlandLuaMonitorLine); +} + +// Only for migrating DMS 1.x display profiles saved as hypr/dms/profiles/*.conf +function parseHyprlandConfProfile(content) { + return collectOutputs(content, line => parseHyprlandDisableLine(line) || parseHyprlandMonitorLine(line)); +} + function parseMangoRule(rule) { const params = {}; for (const pair of rule.split(",")) { diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 0f6dca6fc..0197850c7 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -219,6 +219,9 @@ Singleton { property int hyprlandBlurHazeIridescence: Spec.SPEC.hyprlandBlurHazeIridescence.def property int hyprlandBlurRippleStrength: Spec.SPEC.hyprlandBlurRippleStrength.def property int hyprlandBlurWaterStrength: Spec.SPEC.hyprlandBlurWaterStrength.def + property int hyprlandBlurDropsSpeed: Spec.SPEC.hyprlandBlurDropsSpeed.def + property int hyprlandBlurHeatShimmerSpeed: Spec.SPEC.hyprlandBlurHeatShimmerSpeed.def + property bool hyprlandBlurProportional: Spec.SPEC.hyprlandBlurProportional.def property bool hyprlandGlowEnabled: Spec.SPEC.hyprlandGlowEnabled.def property int hyprlandGlowRange: Spec.SPEC.hyprlandGlowRange.def property int hyprlandGlowRenderPower: Spec.SPEC.hyprlandGlowRenderPower.def @@ -242,12 +245,18 @@ Singleton { property string hyprlandScrollingDirection: Spec.SPEC.hyprlandScrollingDirection.def property int hyprlandScrollingColumnWidth: Spec.SPEC.hyprlandScrollingColumnWidth.def property bool hyprlandScrollingFullscreenOneColumn: Spec.SPEC.hyprlandScrollingFullscreenOneColumn.def - property bool hyprlandScrollingFollowFocus: Spec.SPEC.hyprlandScrollingFollowFocus.def + property int hyprlandScrollingFollowFocus: Spec.SPEC.hyprlandScrollingFollowFocus.def + property bool hyprlandDragCenterWindow: Spec.SPEC.hyprlandDragCenterWindow.def + property int hyprlandWorkspaceBackAndForth: Spec.SPEC.hyprlandWorkspaceBackAndForth.def + property int hyprlandExitWindowRetainsFullscreen: Spec.SPEC.hyprlandExitWindowRetainsFullscreen.def + property string hyprlandBellSound: Spec.SPEC.hyprlandBellSound.def property int mangoLayoutGapsOverride: Spec.SPEC.mangoLayoutGapsOverride.def property int mangoLayoutGapsOutOverride: Spec.SPEC.mangoLayoutGapsOutOverride.def property int mangoLayoutRadiusOverride: Spec.SPEC.mangoLayoutRadiusOverride.def property int mangoLayoutBorderSize: Spec.SPEC.mangoLayoutBorderSize.def property int focusFollowsMouse: Spec.SPEC.focusFollowsMouse.def + property bool hyprlandSwallowFocusClick: Spec.SPEC.hyprlandSwallowFocusClick.def + property bool hyprlandTabletEnabled: Spec.SPEC.hyprlandTabletEnabled.def property string mouseAccelProfile: Spec.SPEC.mouseAccelProfile.def property real mouseAccelSpeed: Spec.SPEC.mouseAccelSpeed.def property bool mouseLeftHanded: Spec.SPEC.mouseLeftHanded.def diff --git a/quickshell/Common/WindowModel.js b/quickshell/Common/WindowModel.js index 994f00a2e..f64a6ad35 100644 --- a/quickshell/Common/WindowModel.js +++ b/quickshell/Common/WindowModel.js @@ -134,7 +134,7 @@ function hyprlandWindowPid(hyprToplevels, toplevel) { return hyprToplevels.find(t => t.wayland === toplevel)?.lastIpcObject?.pid || 0; } -// Same identity as WorkspaceModel.hyprlandKey: Quickshell reports -1 for every named workspace on Hyprland main +// Same identity as WorkspaceModel.hyprlandKey: Quickshell reports -1 for every named workspace on Hyprland 0.57 function hyprlandWorkspaceKey(ws) { return ws.id > 0 ? ws.id : ws.address || ws.name; } diff --git a/quickshell/Common/WorkspaceModel.js b/quickshell/Common/WorkspaceModel.js index 2a2bfa168..bc5219e52 100644 --- a/quickshell/Common/WorkspaceModel.js +++ b/quickshell/Common/WorkspaceModel.js @@ -108,7 +108,7 @@ function hyprlandSpecial(ws) { return !(ws.id > 0) && hyprlandSpecialName(ws.name ?? ""); } -// Hyprland main gives named workspaces no id (Quickshell reports -1 for all of them), so the address is their identity +// Hyprland 0.57 gives named workspaces no id (Quickshell reports -1 for all of them), so the address is their identity function hyprlandKey(ws) { if (ws?.id > 0) return ws.id; diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 6413a37f3..dd5b1ab0d 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -19,6 +19,13 @@ function greeterWidgetDefaults() { return Shared.greeterWidgetDefaults(); } +// Hyprland 0.57 turned scrolling:follow_focus from a bool into modes 0-2; stored bools stay readable +function followFocusMode(v) { + if (typeof v === "boolean") + return v ? 1 : 0; + return Number.isInteger(v) && v >= 0 && v <= 2 ? v : undefined; +} + var LOCAL_SPEC = { dockConfigs: { def: [DockConfig.create("dock", "Dock")] @@ -203,6 +210,18 @@ var LOCAL_SPEC = { def: 32, onChange: "updateCompositorLayout" }, + hyprlandBlurDropsSpeed: { + def: 3, + onChange: "updateCompositorLayout" + }, + hyprlandBlurHeatShimmerSpeed: { + def: 1, + onChange: "updateCompositorLayout" + }, + hyprlandBlurProportional: { + def: false, + onChange: "updateCompositorLayout" + }, hyprlandGlowEnabled: { def: false, onChange: "updateCompositorLayout" @@ -296,9 +315,26 @@ var LOCAL_SPEC = { onChange: "updateCompositorLayout" }, hyprlandScrollingFollowFocus: { + def: 1, + coerce: followFocusMode, + onChange: "updateCompositorLayout" + }, + hyprlandDragCenterWindow: { def: true, onChange: "updateCompositorLayout" }, + hyprlandWorkspaceBackAndForth: { + def: 0, + onChange: "updateCompositorLayout" + }, + hyprlandExitWindowRetainsFullscreen: { + def: 0, + onChange: "updateCompositorLayout" + }, + hyprlandBellSound: { + def: "default", + onChange: "updateCompositorLayout" + }, mangoLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" @@ -319,6 +355,14 @@ var LOCAL_SPEC = { def: -1, onChange: "updateCompositorInput" }, + hyprlandSwallowFocusClick: { + def: false, + onChange: "updateCompositorInput" + }, + hyprlandTabletEnabled: { + def: true, + onChange: "updateCompositorInput" + }, mouseAccelProfile: { def: "default", onChange: "updateCompositorInput" @@ -963,7 +1007,8 @@ var LOCAL_SPEC = { hyprland: { hideOnKeyPress: false, hideOnTouch: false, - inactiveTimeout: 0 + inactiveTimeout: 0, + warpOnMonitorChange: -1 }, dwl: { cursorHideTimeout: 0 diff --git a/quickshell/Modules/Settings/CompositorLayoutTab.qml b/quickshell/Modules/Settings/CompositorLayoutTab.qml index 292fdfa10..92de3bf4f 100644 --- a/quickshell/Modules/Settings/CompositorLayoutTab.qml +++ b/quickshell/Modules/Settings/CompositorLayoutTab.qml @@ -347,13 +347,24 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; onToggled: checked => SettingsData.set("hyprlandScrollingFullscreenOneColumn", checked) } - SettingsToggleRow { - tags: ["hyprland", "scrolling", "follow", "focus", "scroll"] + SettingsDropdownRow { + tags: ["hyprland", "scrolling", "follow", "focus", "scroll", "click", "follow_focus"] settingKey: "hyprlandScrollingFollowFocus" visible: SettingsData.hyprlandTilingLayout === "scrolling" text: I18n.tr("Follow focus") - checked: SettingsData.hyprlandScrollingFollowFocus - onToggled: checked => SettingsData.set("hyprlandScrollingFollowFocus", checked) + options: { + const modes = [I18n.tr("Off"), I18n.tr("On")]; + if (HyprlandService.hyprIntModes()) + modes.push(I18n.tr("Not on click", "Hyprland scrolling follow focus option, focus scrolls the view but clicking a window does not")); + return modes; + } + currentValue: options[Math.min(SettingsData.hyprlandScrollingFollowFocus, options.length - 1)] ?? "" + onValueChanged: value => { + const index = options.indexOf(value); + if (index < 0) + return; + SettingsData.set("hyprlandScrollingFollowFocus", index); + } } } @@ -452,6 +463,16 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; onSliderValueChanged: newValue => SettingsData.set("hyprlandBorderGrabArea", newValue) } + SettingsToggleRow { + tags: ["hyprland", "drag", "float", "center", "cursor", "mouse", "drag_center_window"] + settingKey: "hyprlandDragCenterWindow" + visible: HyprlandService.hyprSupports("binds:drag_center_window") + text: I18n.tr("Center dragged windows", "Hyprland toggle, a tiled or fullscreen window dragged loose centers on the cursor") + description: I18n.tr("A tiled or fullscreen window dragged into floating centers on the cursor") + checked: SettingsData.hyprlandDragCenterWindow + onToggled: checked => SettingsData.set("hyprlandDragCenterWindow", checked) + } + SettingsSliderRow { tags: ["hyprland", "opacity", "transparency", "window"] settingKey: "hyprlandWindowOpacity" @@ -491,7 +512,7 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; title: I18n.tr("Visual effects") settingKey: "hyprlandEffects" iconName: "auto_awesome" - visible: CompositorService.isHyprland && (HyprlandService.hyprSupports("decoration:blur:variant") || HyprlandService.hyprSupports("decoration:glow:enabled") || HyprlandService.hyprSupports("decoration:wobble:enabled") || HyprlandService.hyprSupports("decoration:motion_blur:enabled")) + visible: CompositorService.isHyprland && (HyprlandService.hyprSupports("decoration:blur:variant") || HyprlandService.hyprSupports("decoration:blur:proportional") || HyprlandService.hyprSupports("decoration:glow:enabled") || HyprlandService.hyprSupports("decoration:wobble:enabled") || HyprlandService.hyprSupports("decoration:motion_blur:enabled")) readonly property var blurVariantIds: ["kawase", "frost", "ripple", "drops", "water", "fluid_jar", "prism", "heat_shimmer", "acrylic", "aurora", "haze"] @@ -506,6 +527,40 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; onValueChanged: value => SettingsData.set("hyprlandBlurVariant", hyprEffectsCard.blurVariantIds[Math.max(0, options.indexOf(value))]) } + SettingsToggleRow { + tags: ["hyprland", "blur", "proportional", "opacity", "transparency"] + settingKey: "hyprlandBlurProportional" + visible: HyprlandService.hyprSupports("decoration:blur:proportional") + text: I18n.tr("Blur follows opacity", "Hyprland toggle, blur strength scales with how opaque each pixel is") + description: I18n.tr("More transparent areas get less blur") + checked: SettingsData.hyprlandBlurProportional + onToggled: checked => SettingsData.set("hyprlandBlurProportional", checked) + } + + SettingsSliderRow { + tags: ["hyprland", "blur", "drops", "speed", "animation"] + settingKey: "hyprlandBlurDropsSpeed" + visible: SettingsData.hyprlandBlurVariant === "drops" && HyprlandService.hyprSupports("decoration:blur:drops:speed") + text: I18n.tr("Speed") + description: I18n.tr("0 stops the animation and saves GPU", "Hyprland animated blur speed slider description") + value: SettingsData.hyprlandBlurDropsSpeed + minimum: 0 + maximum: 10 + onSliderValueChanged: newValue => SettingsData.set("hyprlandBlurDropsSpeed", newValue) + } + + SettingsSliderRow { + tags: ["hyprland", "blur", "heat", "shimmer", "speed", "animation"] + settingKey: "hyprlandBlurHeatShimmerSpeed" + visible: SettingsData.hyprlandBlurVariant === "heat_shimmer" && HyprlandService.hyprSupports("decoration:blur:heat_shimmer:speed") + text: I18n.tr("Speed") + description: I18n.tr("0 stops the animation and saves GPU", "Hyprland animated blur speed slider description") + value: SettingsData.hyprlandBlurHeatShimmerSpeed + minimum: 0 + maximum: 10 + onSliderValueChanged: newValue => SettingsData.set("hyprlandBlurHeatShimmerSpeed", newValue) + } + SettingsSliderRow { tags: ["hyprland", "blur", "ripple", "strength", "refraction"] settingKey: "hyprlandBlurRippleStrength" @@ -702,6 +757,55 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; } } + SettingsCard { + width: parent.width + tags: ["hyprland", "behavior", "workspace", "fullscreen", "bell", "binds", "misc"] + title: I18n.tr("Behavior") + settingKey: "hyprlandBehavior" + iconName: "tune" + visible: CompositorService.isHyprland && (HyprlandService.hyprSupports("binds:workspace_back_and_forth") || HyprlandService.hyprSupports("misc:exit_window_retains_fullscreen") || HyprlandService.hyprSupports("misc:bell_sound")) + + SettingsDropdownRow { + tags: ["hyprland", "workspace", "back", "forth", "previous", "toggle", "workspace_back_and_forth"] + settingKey: "hyprlandWorkspaceBackAndForth" + visible: HyprlandService.hyprSupports("binds:workspace_back_and_forth") + text: I18n.tr("Workspace back and forth", "Hyprland option, switching to the current workspace goes to the previous one") + description: I18n.tr("Switching to the current workspace returns to the previous one") + options: HyprlandService.hyprIntModes() ? [I18n.tr("Off"), I18n.tr("All displays"), I18n.tr("Per display", "Hyprland workspace back and forth option, each display remembers its own previous workspace")] : [I18n.tr("Off"), I18n.tr("On")] + currentValue: options[Math.min(SettingsData.hyprlandWorkspaceBackAndForth, options.length - 1)] ?? "" + onValueChanged: value => { + const index = options.indexOf(value); + if (index < 0) + return; + SettingsData.set("hyprlandWorkspaceBackAndForth", index); + } + } + + SettingsDropdownRow { + tags: ["hyprland", "fullscreen", "close", "exit", "retain", "group", "exit_window_retains_fullscreen"] + settingKey: "hyprlandExitWindowRetainsFullscreen" + visible: HyprlandService.hyprSupports("misc:exit_window_retains_fullscreen") + text: I18n.tr("Keep fullscreen on close", "Hyprland option, closing a fullscreen window makes the next focused window fullscreen") + options: HyprlandService.hyprIntModes() ? [I18n.tr("Off"), I18n.tr("On"), I18n.tr("Only in groups", "Hyprland keep fullscreen on close option, only inside tabbed window groups"), I18n.tr("Only outside groups", "Hyprland keep fullscreen on close option, only for windows not in a tabbed group")] : [I18n.tr("Off"), I18n.tr("On")] + currentValue: options[Math.min(SettingsData.hyprlandExitWindowRetainsFullscreen, options.length - 1)] ?? "" + onValueChanged: value => { + const index = options.indexOf(value); + if (index < 0) + return; + SettingsData.set("hyprlandExitWindowRetainsFullscreen", index); + } + } + + SettingsToggleRow { + tags: ["hyprland", "bell", "sound", "beep", "mute", "bell_sound"] + settingKey: "hyprlandBellSound" + visible: HyprlandService.hyprSupports("misc:bell_sound") + text: I18n.tr("System bell", "Hyprland toggle, play a sound when an app rings the terminal bell") + checked: SettingsData.hyprlandBellSound !== "" && SettingsData.hyprlandBellSound !== "none" + onToggled: checked => SettingsData.set("hyprlandBellSound", checked ? "default" : "") + } + } + SettingsCard { width: parent.width tags: ["mangowc", "mango", "dwl", "layout", "gaps", "radius", "window", "border"] diff --git a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml index 37605ebf0..4294bae93 100644 --- a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml +++ b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml @@ -376,7 +376,7 @@ Singleton { parsed = OutputModel.parseNiriOutputs(content); break; case "hyprland": - parsed = OutputModel.parseHyprlandOutputs(content); + parsed = OutputModel.parseHyprlandConfProfile(content); break; case "dwl": case "mango": @@ -587,13 +587,13 @@ Singleton { } function applyConfigEntry(configEntry, configId, profileName, isManual) { - if (CompositorService.isHyprland && readOnly) { + if (readOnly) { if (isManual) { profilesLoading = false; manualActivation = false; profileError(I18n.tr("Hyprland conf mode is read-only in Settings")); } - showHyprlandReadOnlyWarning(); + include.showReadOnlyWarning(); return; } const onWriteFailed = () => { @@ -1149,10 +1149,6 @@ Singleton { WlrOutputService.requestState(); } - function showHyprlandReadOnlyWarning() { - ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using %1. Run %2 to migrate before changing these settings.", "hyprland legacy config warning, %1 is the config file, %2 a command").arg("hyprland.conf").arg("dms setup"), "dms setup", "display-config"); - } - function buildOutputsMap() { const liveMonitors = {}; for (const output of wlrOutputs) { @@ -1200,7 +1196,7 @@ Singleton { case "hyprland": { if (readOnly) { - showHyprlandReadOnlyWarning(); + include.showReadOnlyWarning(); finish(false); return; } @@ -1611,8 +1607,8 @@ Singleton { function applyChanges() { if (!hasPendingChanges) return; - if (CompositorService.isHyprland && readOnly) { - showHyprlandReadOnlyWarning(); + if (readOnly) { + include.showReadOnlyWarning(); return; } const changeDescriptions = []; diff --git a/quickshell/Modules/Settings/KeybindsTab.qml b/quickshell/Modules/Settings/KeybindsTab.qml index 8ce9d5e5c..f28d1999e 100644 --- a/quickshell/Modules/Settings/KeybindsTab.qml +++ b/quickshell/Modules/Settings/KeybindsTab.qml @@ -724,7 +724,7 @@ Item { StyledText { text: { if (warningBox.showLegacy) - return I18n.tr("Hyprland conf mode"); + return I18n.tr("%1 not found").arg("hyprland.lua"); if (warningBox.showSetup) return I18n.tr("First Time Setup"); if (warningBox.showWarning) @@ -741,7 +741,7 @@ Item { StyledText { text: { if (warningBox.showLegacy) - return I18n.tr("This install is still using %1. Run %2 to migrate before changing these settings.", "hyprland legacy config warning, %1 is the config file, %2 a command").arg("hyprland.conf").arg("dms setup"); + return I18n.tr("Run %1 to create it before changing these settings.", "hyprland.lua missing warning, %1 is a command").arg("dms setup"); if (warningBox.showSetup) return I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.", "include setup banner, %1 is the dms config file name").arg("dms/binds"); if (warningBox.showWarning) { diff --git a/quickshell/Modules/Settings/MouseTouchpadTab.qml b/quickshell/Modules/Settings/MouseTouchpadTab.qml index 7a7eafdd5..7045216a5 100644 --- a/quickshell/Modules/Settings/MouseTouchpadTab.qml +++ b/quickshell/Modules/Settings/MouseTouchpadTab.qml @@ -36,7 +36,7 @@ Item { // Only Hyprland can report the live mode; untouched niri and Mango show their defaults readonly property int inheritedMode: CompositorService.isHyprland ? HyprlandService.followMouseMode : CompositorService.isMango ? 1 : 0 visible: CompositorService.supportsInputConfig && (!CompositorService.isHyprland || HyprlandService.hyprSupports("input:follow_mouse")) - enabled: inputInclude.included && !inputInclude.readOnly && (!CompositorService.isHyprland || (HyprlandService.luaConfigActive && HyprlandService.followMouseMode >= 0)) + enabled: inputInclude.included && (!CompositorService.isHyprland || (HyprlandService.luaConfigActive && HyprlandService.followMouseMode >= 0)) tags: ["mouse", "focus", "follow", "pointer", "window", "sloppy"] settingKey: "focusFollowsMouse" resetKeys: ["focusFollowsMouse"] @@ -45,6 +45,16 @@ Item { onToggled: checked => SettingsData.set("focusFollowsMouse", checked ? 1 : 0) } + SettingsToggleRow { + visible: CompositorService.isHyprland && HyprlandService.hyprSupports("input:swallow_focus_click") + tags: ["mouse", "focus", "click", "swallow", "window", "swallow_focus_click"] + settingKey: "hyprlandSwallowFocusClick" + text: I18n.tr("Swallow focus click", "Hyprland toggle, the click that focuses a window is not passed to the app") + description: I18n.tr("The click that focuses a window does not reach the app") + checked: SettingsData.hyprlandSwallowFocusClick + onToggled: checked => SettingsData.set("hyprlandSwallowFocusClick", checked) + } + SettingsSliderRow { tags: ["mouse", "sensitivity", "speed", "accel"] settingKey: "mouseAccelSpeed" @@ -282,5 +292,22 @@ Item { onToggled: checked => SettingsData.set("touchpadMiddleEmulation", checked) } } + + SettingsCard { + width: parent.width + visible: CompositorService.isHyprland && HyprlandService.hyprSupports("input:tablet:enabled") + tags: ["tablet", "pen", "stylus", "drawing", "input"] + title: I18n.tr("Tablet", "drawing tablet input settings card title") + settingKey: "tabletSettings" + iconName: "stylus" + + SettingsToggleRow { + tags: ["tablet", "pen", "stylus", "drawing", "enable", "disable"] + settingKey: "hyprlandTabletEnabled" + text: I18n.tr("Tablet input", "Hyprland toggle, accept input from drawing tablets") + checked: SettingsData.hyprlandTabletEnabled + onToggled: checked => SettingsData.set("hyprlandTabletEnabled", checked) + } + } } } diff --git a/quickshell/Modules/Settings/WallpaperColorsTab.qml b/quickshell/Modules/Settings/WallpaperColorsTab.qml index 73bd83c04..050ec5958 100644 --- a/quickshell/Modules/Settings/WallpaperColorsTab.qml +++ b/quickshell/Modules/Settings/WallpaperColorsTab.qml @@ -226,6 +226,13 @@ Column { SettingsData.set("cursorSettings", updated); } + function setHyprlandCursorWarp(mode) { + const updated = JSON.parse(JSON.stringify(SettingsData.cursorSettings)); + updated.hyprland = updated.hyprland || {}; + updated.hyprland.warpOnMonitorChange = mode; + SettingsData.set("cursorSettings", updated); + } + function warnIfMissingQtTheme() { if (Quickshell.env("QT_QPA_PLATFORMTHEME") === "gtk3" || Quickshell.env("QT_QPA_PLATFORMTHEME") === "qt6ct" || Quickshell.env("QT_QPA_PLATFORMTHEME_QT6") === "qt6ct" || SettingsData.qtengineActive || Quickshell.env("QT_QPA_PLATFORMTHEME") === "kde") return; @@ -1048,6 +1055,25 @@ Column { } } + SettingsDropdownRow { + readonly property var modes: [-1, 0, 1, 2] + tab: "theme" + tags: ["cursor", "warp", "monitor", "display", "focus", "warp_on_monitor_change"] + settingKey: "cursorWarpOnMonitorChange" + text: I18n.tr("Warp on display change", "Hyprland cursor option, move the pointer to the last focused window when focus moves to another display") + visible: CompositorService.isHyprland && HyprlandService.hyprSupports("cursor:warp_on_monitor_change") + options: [I18n.tr("Same as workspace change", "Hyprland cursor warp option, follow the workspace change warp setting"), I18n.tr("Off"), I18n.tr("On"), I18n.tr("Force", "Hyprland cursor warp option, warp even when cursor warps are disabled")] + currentValue: options[Math.max(0, modes.indexOf(SettingsData.cursorSettings.hyprland?.warpOnMonitorChange ?? -1))] + modified: (SettingsData.cursorSettings.hyprland?.warpOnMonitorChange ?? -1) !== -1 + onResetRequested: root.setHyprlandCursorWarp(-1) + onValueChanged: value => { + const index = options.indexOf(value); + if (index < 0) + return; + root.setHyprlandCursorWarp(modes[index]); + } + } + SettingsSliderRow { tab: "theme" tags: ["cursor", "hide", "timeout", "inactive"] diff --git a/quickshell/Modules/Settings/Widgets/ConfigInclude.qml b/quickshell/Modules/Settings/Widgets/ConfigInclude.qml index 50d008502..0a0a993d4 100644 --- a/quickshell/Modules/Settings/Widgets/ConfigInclude.qml +++ b/quickshell/Modules/Settings/Widgets/ConfigInclude.qml @@ -17,7 +17,7 @@ QtObject { readonly property bool compositorSupported: ConfigIncludeResolve.includeSpec(includeKind, CompositorService.compositor) !== null readonly property bool included: status.included === true - readonly property bool readOnly: CompositorService.isHyprland && status.readOnly === true + readonly property bool readOnly: CompositorService.isHyprland && !HyprlandService.luaConfigActive readonly property string fragmentLabel: "dms/" + includeKind signal fixed @@ -25,9 +25,7 @@ QtObject { function defaultStatus() { return { "exists": false, - "included": false, - "configFormat": "", - "readOnly": false + "included": false }; } @@ -36,9 +34,7 @@ QtObject { return; status = { "exists": dmsStatus.exists, - "included": dmsStatus.included, - "configFormat": dmsStatus.configFormat ?? "", - "readOnly": dmsStatus.readOnly === true + "included": dmsStatus.included }; } @@ -68,7 +64,7 @@ QtObject { } function showReadOnlyWarning() { - ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using %1. Run %2 to migrate before changing these settings.", "hyprland legacy config warning, %1 is the config file, %2 a command").arg("hyprland.conf").arg("dms setup"), "dms setup", warningCategory); + ToastService.showWarning(I18n.tr("%1 not found").arg("hyprland.lua"), I18n.tr("Run %1 to create it before changing these settings.", "hyprland.lua missing warning, %1 is a command").arg("dms setup"), "dms setup", warningCategory); } function fix() { @@ -76,11 +72,6 @@ QtObject { showReadOnlyWarning(); return; } - if (CompositorService.isHyprland && !HyprlandService.luaConfigActive) { - showReadOnlyWarning(); - check(); - return; - } const compositor = CompositorService.compositor; const dir = configDir(); const paths = ConfigIncludeResolve.includePaths(includeKind, compositor, dir); diff --git a/quickshell/Modules/Settings/Widgets/IncludeSetupBanner.qml b/quickshell/Modules/Settings/Widgets/IncludeSetupBanner.qml index 4996a477f..53b18e6df 100644 --- a/quickshell/Modules/Settings/Widgets/IncludeSetupBanner.qml +++ b/quickshell/Modules/Settings/Widgets/IncludeSetupBanner.qml @@ -38,7 +38,7 @@ StyledRect { anchors.verticalCenter: parent.verticalCenter StyledText { - text: root.showLegacy ? I18n.tr("Hyprland conf mode") : I18n.tr("First Time Setup") + text: root.showLegacy ? I18n.tr("%1 not found").arg("hyprland.lua") : I18n.tr("First Time Setup") font.pixelSize: Theme.fontSizeMedium font.weight: Theme.fontWeightMedium color: Theme.primary @@ -47,7 +47,7 @@ StyledRect { } StyledText { - text: root.showLegacy ? I18n.tr("This install is still using %1. Run %2 to migrate before changing these settings.", "hyprland legacy config warning, %1 is the config file, %2 a command").arg("hyprland.conf").arg("dms setup") : I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg(root.include.fragmentLabel) + text: root.showLegacy ? I18n.tr("Run %1 to create it before changing these settings.", "hyprland.lua missing warning, %1 is a command").arg("dms setup") : I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg(root.include.fragmentLabel) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText wrapMode: Text.WordWrap @@ -62,7 +62,7 @@ StyledRect { text: root.include.fixing ? I18n.tr("Setting up...") : I18n.tr("Setup") backgroundColor: Theme.primary textColor: Theme.primaryText - enabled: !root.include.fixing + enabled: !root.include.fixing && !root.include.readOnly anchors.verticalCenter: parent.verticalCenter onClicked: root.include.fix() } diff --git a/quickshell/Modules/Settings/WindowRulesTab.qml b/quickshell/Modules/Settings/WindowRulesTab.qml index 09585aa16..60bbcb833 100644 --- a/quickshell/Modules/Settings/WindowRulesTab.qml +++ b/quickshell/Modules/Settings/WindowRulesTab.qml @@ -232,7 +232,7 @@ Item { function removeRule(ruleId) { if (readOnly) { - showHyprlandReadOnlyWarning(); + windowRulesInclude.showReadOnlyWarning(); return; } const compositor = CompositorService.compositor; @@ -252,7 +252,7 @@ Item { if (savingRuleOrder) return; if (readOnly) { - showHyprlandReadOnlyWarning(); + windowRulesInclude.showReadOnlyWarning(); return; } const compositor = CompositorService.compositor; @@ -294,7 +294,7 @@ Item { function openEditor(mode, payload) { if (readOnly) { - showHyprlandReadOnlyWarning(); + windowRulesInclude.showReadOnlyWarning(); return; } if (editorOpen) @@ -334,10 +334,6 @@ Item { editorLoader.item.opened = false; } - function showHyprlandReadOnlyWarning() { - ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using %1. Run %2 to migrate before changing these settings.", "hyprland legacy config warning, %1 is the config file, %2 a command").arg("hyprland.conf").arg("dms setup"), "dms setup", "hyprland-migration"); - } - Component.onCompleted: { componentReady = true; Qt.callLater(() => { diff --git a/quickshell/Services/CompositorService.qml b/quickshell/Services/CompositorService.qml index a13e16004..fbe943e25 100644 --- a/quickshell/Services/CompositorService.qml +++ b/quickshell/Services/CompositorService.qml @@ -500,6 +500,11 @@ Singleton { root.refreshHyprlandMonitorLayout(); return; } + // Hyprland 0.57 emits this when a workspace id changes (e.g. after a rename); Quickshell 0.3.2 ignores it + if (event.name === "changeworkspaceid") { + Hyprland.refreshWorkspaces(); + return; + } if (event.name === "openwindow" || event.name === "closewindow" || event.name === "movewindow" || event.name === "movewindowv2" || event.name === "workspace" || event.name === "workspacev2" || event.name === "focusedmon" || event.name === "focusedmonv2" || event.name === "activewindow" || event.name === "activewindowv2" || event.name === "changefloatingmode" || event.name === "fullscreen" || event.name === "moveintogroup" || event.name === "moveoutofgroup" || event.name === "activespecial") { try { Hyprland.refreshToplevels(); diff --git a/quickshell/Services/HyprlandService.qml b/quickshell/Services/HyprlandService.qml index 0e477de99..a4cb9df5a 100644 --- a/quickshell/Services/HyprlandService.qml +++ b/quickshell/Services/HyprlandService.qml @@ -6,7 +6,6 @@ import QtQuick import Quickshell import Quickshell.Hyprland import qs.Common -import "../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve import qs.Services import "../Common/OutputModel.js" as OutputModel import "../Common/BlurStrength.js" as BlurStrength @@ -33,7 +32,6 @@ Singleton { property bool luaConfigDetected: false property bool luaConfigStatusReady: false property bool luaConfigStatusLoading: false - property string luaConfigFormat: "" property bool layoutGenerationPending: false property bool layoutGenerationRunning: false // Effect options come and go between Hyprland releases; an unknown key is a config error, so writers and rows both gate on this @@ -42,6 +40,7 @@ Singleton { property bool hyprOptionsLoading: false property bool inputGenerationPending: false property bool inputGenerationRunning: false + property bool cursorGenerationPending: false property int followMouseMode: -1 // Hyprland only has per-device touchpad speed; probed once per compositor, a hotplugged touchpad waits for the next shell start property var hyprTouchpadNames: [] @@ -157,7 +156,6 @@ Singleton { luaConfigDetected = false; luaConfigStatusReady = false; luaConfigStatusLoading = false; - luaConfigFormat = ""; hyprOptionNames = {}; hyprOptionsReady = false; hyprTouchpadNames = []; @@ -175,34 +173,27 @@ Singleton { return JSON.stringify(String(str ?? "")); } + // Hyprland prefers hyprland.lua whenever it exists, so the file probe covers old Quickshell builds and a lost j/status reply function refreshLuaConfigStatus() { if (!CompositorService.isHyprland) { luaConfigDetected = false; luaConfigStatusReady = false; luaConfigStatusLoading = false; - luaConfigFormat = ""; return; } - if (luaConfigStatusLoading) + if (luaConfigStatusReady || luaConfigStatusLoading) return; + // usingLua starts false until the async j/status reply lands, so only true is a decision + if (Hyprland.usingLua === true) { + luaConfigStatusReady = true; + return; + } luaConfigStatusLoading = true; - Proc.runCommand("hypr-lua-config-status", [Proc.dmsBin, "config", "resolve-include", ...ConfigIncludeResolve.resolveIncludeArgs("outputs", "hyprland")], (output, exitCode) => { - luaConfigStatusLoading = false; + Proc.runCommand("hypr-lua-config-status", ["test", "-f", configDir + "/hypr/hyprland.lua"], (output, exitCode) => { + luaConfigDetected = exitCode === 0; luaConfigStatusReady = true; - if (exitCode !== 0) { - luaConfigDetected = false; - luaConfigFormat = ""; - return; - } - try { - const status = JSON.parse(output.trim()); - luaConfigFormat = status.configFormat ?? ""; - luaConfigDetected = luaConfigFormat === "lua" && status.readOnly !== true; - } catch (e) { - luaConfigDetected = false; - luaConfigFormat = ""; - } + luaConfigStatusLoading = false; }); } @@ -223,6 +214,15 @@ Singleton { return hyprOptionNames[name] === true; } + // hyprctl descriptions only gives us names, so a 0.57-only option marks the release where these bools became modes + function hyprIntModes() { + return hyprSupports("input:swallow_focus_click"); + } + + function hyprMode(value) { + return hyprIntModes() ? value : value !== 0; + } + function refreshFollowMouse() { if (!CompositorService.isHyprland) return; @@ -260,6 +260,8 @@ Singleton { layoutGenerationAction.schedule(); if (inputGenerationPending) inputGenerationAction.schedule(); + if (cursorGenerationPending) + generateCursorConfig(); }); } @@ -562,6 +564,12 @@ Singleton { const groupLines = luaTable("groupbar", groupbarLines(SettingsData)); if (groupLines.length) sections.push(luaSection("group", groupLines)); + const binds = bindsLines(SettingsData); + if (binds.length) + sections.push(luaSection("binds", binds)); + const misc = miscLines(SettingsData); + if (misc.length) + sections.push(luaSection("misc", misc)); let content = `-- Auto-generated by DMS — do not edit manually @@ -623,7 +631,7 @@ hl.layer_rule({ case "master": return [`orientation = ${luaString(SettingsData.hyprlandMasterOrientation)},`, `new_status = ${luaString(SettingsData.hyprlandMasterNewStatus)},`, `new_on_top = ${SettingsData.hyprlandMasterNewOnTop},`, `mfact = ${SettingsData.hyprlandMasterSize / 100},`]; case "scrolling": - return [`direction = ${luaString(SettingsData.hyprlandScrollingDirection)},`, `column_width = ${SettingsData.hyprlandScrollingColumnWidth / 100},`, `fullscreen_on_one_column = ${SettingsData.hyprlandScrollingFullscreenOneColumn},`, `follow_focus = ${SettingsData.hyprlandScrollingFollowFocus},`]; + return [`direction = ${luaString(SettingsData.hyprlandScrollingDirection)},`, `column_width = ${SettingsData.hyprlandScrollingColumnWidth / 100},`, `fullscreen_on_one_column = ${SettingsData.hyprlandScrollingFullscreenOneColumn},`, `follow_focus = ${hyprMode(SettingsData.hyprlandScrollingFollowFocus)},`]; default: return []; } @@ -640,6 +648,8 @@ hl.layer_rule({ lines.push(`size = ${blur.size},`); if (blur.passes !== 1) lines.push(`passes = ${blur.passes},`); + if (s.hyprlandBlurProportional && hyprSupports("decoration:blur:proportional")) + lines.push("proportional = true,"); const variant = s.hyprlandBlurVariant; if (!variant || variant === "kawase" || !hyprSupports("decoration:blur:variant")) return lines; @@ -650,6 +660,14 @@ hl.layer_rule({ return lines.concat(luaTable("ripple", [`strength = ${s.hyprlandBlurRippleStrength},`])); case "water": return lines.concat(luaTable("water", [`strength = ${s.hyprlandBlurWaterStrength},`])); + case "drops": + if (s.hyprlandBlurDropsSpeed === 3 || !hyprSupports("decoration:blur:drops:speed")) + return lines; + return lines.concat(luaTable("drops", [`speed = ${s.hyprlandBlurDropsSpeed},`])); + case "heat_shimmer": + if (s.hyprlandBlurHeatShimmerSpeed === 1 || !hyprSupports("decoration:blur:heat_shimmer:speed")) + return lines; + return lines.concat(luaTable("heat_shimmer", [`speed = ${s.hyprlandBlurHeatShimmerSpeed},`])); case "acrylic": return lines.concat(luaTable("acrylic", [`clarity = ${s.hyprlandBlurAcrylicClarity / 100},`])); case "aurora": @@ -690,11 +708,30 @@ hl.layer_rule({ return lines; } + function bindsLines(s) { + const lines = []; + if (!s.hyprlandDragCenterWindow && hyprSupports("binds:drag_center_window")) + lines.push("drag_center_window = false,"); + if (s.hyprlandWorkspaceBackAndForth > 0 && hyprSupports("binds:workspace_back_and_forth")) + lines.push(`workspace_back_and_forth = ${hyprMode(s.hyprlandWorkspaceBackAndForth)},`); + return lines; + } + + function miscLines(s) { + const lines = []; + if (s.hyprlandExitWindowRetainsFullscreen > 0 && hyprSupports("misc:exit_window_retains_fullscreen")) + lines.push(`exit_window_retains_fullscreen = ${hyprMode(s.hyprlandExitWindowRetainsFullscreen)},`); + if (s.hyprlandBellSound !== "default" && hyprSupports("misc:bell_sound")) + lines.push(`bell_sound = ${luaString(s.hyprlandBellSound)},`); + return lines; + } + function generateCursorConfig() { if (!CompositorService.isHyprland) return; if (!canWriteLuaConfig("cursor")) return; + cursorGenerationPending = false; const settings = typeof SettingsData !== "undefined" ? SettingsData.cursorSettings : null; if (!settings) { @@ -710,10 +747,17 @@ hl.layer_rule({ const hideOnKeyPress = settings.hyprland?.hideOnKeyPress || false; const hideOnTouch = settings.hyprland?.hideOnTouch || false; const inactiveTimeout = settings.hyprland?.inactiveTimeout || 0; + const warpOnMonitorChange = settings.hyprland?.warpOnMonitorChange ?? -1; + if (warpOnMonitorChange !== -1 && !hyprOptionsReady) { + cursorGenerationPending = true; + loadHyprOptionNames(); + return; + } + const writeWarp = warpOnMonitorChange !== -1 && hyprSupports("cursor:warp_on_monitor_change"); const hasTheme = themeName && themeName.length > 0; const hasNonDefaultSize = size !== 24; - const hasCursorSettings = hideOnKeyPress || hideOnTouch || inactiveTimeout > 0; + const hasCursorSettings = hideOnKeyPress || hideOnTouch || inactiveTimeout > 0 || writeWarp; if (!hasTheme && !hasNonDefaultSize && !hasCursorSettings) { Proc.runCommand("hypr-write-cursor", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && printf '%s\\n' "-- Auto-generated by DMS — do not edit manually" "" > "${cursorPath}"`], (output, exitCode) => { @@ -742,6 +786,8 @@ hl.layer_rule({ lines.push("\t\thide_on_touch = true,"); if (inactiveTimeout > 0) lines.push(`\t\tinactive_timeout = ${inactiveTimeout},`); + if (writeWarp) + lines.push(`\t\twarp_on_monitor_change = ${warpOnMonitorChange},`); lines.push("\t},"); lines.push("})"); } @@ -850,6 +896,8 @@ hl.layer_rule({ add("input:natural_scroll", "natural_scroll = true,"); if (s.mouseLeftHanded) add("input:left_handed", "left_handed = true,"); + if (s.hyprlandSwallowFocusClick) + add("input:swallow_focus_click", "swallow_focus_click = true,"); if (s.mouseScrollFactor !== 1) add("input:scroll_factor", `scroll_factor = ${s.mouseScrollFactor.toFixed(2)},`); const scrollMethod = hyprScrollMethod(s.mouseScrollMethod); @@ -877,6 +925,8 @@ hl.layer_rule({ if (s.touchpadClickMethod !== "default") addTouchpad("clickfinger_behavior", `clickfinger_behavior = ${s.touchpadClickMethod === "clickfinger"},`); lines.push(...luaTable("touchpad", touchpad)); + if (!s.hyprlandTabletEnabled && hyprSupports("input:tablet:enabled")) + lines.push(...luaTable("tablet", ["enabled = false,"])); // Unconfigured keyboard keeps the user's own kb_* keys and XKB_DEFAULT_LAYOUT const keyboardConfigured = s.keyboardKeymapFile || s.keyboardLayouts || s.keyboardVariants || s.keyboardModel || s.keyboardOptions || s.keyboardRepeatDelay > 0 || s.keyboardRepeatRate > 0 || s.keyboardNumlock; @@ -931,23 +981,18 @@ hl.layer_rule({ } function renameWorkspace(newName) { + if (!luaConfigActive) + return; const ws = Hyprland.focusedWorkspace; if (!ws) return; const name = ws.id > 0 ? ws.id + " " + newName : newName; - if (!luaConfigActive) { - if (ws.id) - Hyprland.dispatch(`renameworkspace ${ws.id} ${name}`); - return; - } Hyprland.dispatch(`hl.dsp.workspace.rename({ workspace = ${luaValue(WorkspaceModel.hyprlandSelector(ws))}, name = ${luaString(name)} })`); } function focusWorkspace(workspace) { - if (!luaConfigActive) { - Hyprland.dispatch(`workspace ${workspace}`); + if (!luaConfigActive) return; - } Hyprland.dispatch(`hl.dsp.focus({ workspace = ${luaValue(workspace)} })`); } @@ -957,7 +1002,8 @@ hl.layer_rule({ function luaValue(value) { const text = String(value ?? ""); - return /^[-+]?\d+$/.test(text) ? text : luaString(text); + // Signed selectors are relative ("+0", "-1") and Lua has no unary plus, so only plain ids stay bare + return /^\d+$/.test(text) ? text : luaString(text); } function windowSelector(windowAddress) { @@ -972,83 +1018,65 @@ hl.layer_rule({ } function focusWindow(windowAddress) { + if (!luaConfigActive) + return; const selector = windowSelector(windowAddress); if (!selector) return; - if (!luaConfigActive) { - Hyprland.dispatch(`focuswindow ${selector}`); - return; - } Hyprland.dispatch(`hl.dsp.focus({ window = ${luaString(selector)} })`); } function closeWindow(windowAddress) { + if (!luaConfigActive) + return; const selector = windowSelector(windowAddress); if (!selector) return; - if (!luaConfigActive) { - Hyprland.dispatch(`closewindow ${selector}`); - return; - } - Hyprland.dispatch(`hl.dsp.window.close(${luaString(selector)})`); + Hyprland.dispatch(`hl.dsp.window.close({ window = ${luaString(selector)} })`); } function moveToWorkspace(workspace, windowAddress, follow = true) { + if (!luaConfigActive) + return; const selector = windowSelector(windowAddress); if (!selector) return; - if (!luaConfigActive) { - Hyprland.dispatch(`${follow ? "movetoworkspace" : "movetoworkspacesilent"} ${workspace},${selector}`); - return; - } Hyprland.dispatch(`hl.dsp.window.move({ workspace = ${luaValue(workspace)}, window = ${luaString(selector)}, follow = ${follow ? "true" : "false"} })`); } function cycleWindow(forward) { - if (!luaConfigActive) { - Hyprland.dispatch(forward ? "cyclenext" : "cyclenext prev"); + if (!luaConfigActive) return; - } Hyprland.dispatch(`hl.dsp.window.cycle_next({ next = ${forward ? "true" : "false"} })`); } function focusMonitor(monitor) { - if (!luaConfigActive) { - Hyprland.dispatch(`focusmonitor ${monitor}`); + if (!luaConfigActive) return; - } Hyprland.dispatch(`hl.dsp.focus({ monitor = ${luaString(monitor)} })`); } function toggleSpecial(specialName) { - if (!luaConfigActive) { - Hyprland.dispatch("togglespecialworkspace " + specialName); + if (!luaConfigActive) return; - } - Hyprland.dispatch(`hl.dsp.workspace.toggle_special(${luaString(specialName)})`); + Hyprland.dispatch(specialName ? `hl.dsp.workspace.toggle_special(${luaString(specialName)})` : "hl.dsp.workspace.toggle_special()"); } function exit() { - if (!luaConfigActive) { - Hyprland.dispatch("exit"); + if (!luaConfigActive) return; - } Hyprland.dispatch("hl.dsp.exit()"); } function dpmsOff() { - if (!luaConfigActive) { - Hyprland.dispatch("dpms off"); + if (!luaConfigActive) return; - } Hyprland.dispatch(`hl.dsp.dpms({ action = "disable" })`); } function dpmsOn() { - if (!luaConfigActive) { - Hyprland.dispatch("dpms on"); + if (!luaConfigActive) return; - } Hyprland.dispatch(`hl.dsp.dpms({ action = "enable" })`); } } diff --git a/quickshell/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml index dad8acc01..48fbae792 100644 --- a/quickshell/Services/KeybindsService.qml +++ b/quickshell/Services/KeybindsService.qml @@ -58,9 +58,7 @@ Singleton { "bindsAfterDms": 0, "effective": true, "overriddenBy": 0, - "statusMessage": "", - "configFormat": "", - "readOnly": false + "statusMessage": "" }) // What the active layout puts on the first level of every physical key, @@ -112,7 +110,7 @@ Singleton { readonly property var includePaths: ConfigIncludeResolve.includePaths("binds", includeCompositor, includeCompositor === "mango" ? MangoService.configDir : configDir) readonly property string dmsBindsPath: includePaths?.fragmentFiles[0] ?? "" readonly property string mainConfigPath: includePaths?.configFile ?? "" - readonly property bool readOnly: currentProvider === "hyprland" && dmsStatus.readOnly === true + readonly property bool readOnly: currentProvider === "hyprland" && CompositorService.isHyprland && !HyprlandService.luaConfigActive readonly property var actionTypes: Actions.getActionTypes() readonly property var dmsActions: getDmsActions() @@ -380,7 +378,7 @@ Singleton { // Mouse-drag dispatchers do nothing without a pointer, and exit would end the session. if (provider === "hyprland" && (action.trim() === "movewindow" || action.trim() === "resizewindow" || base === "exit")) return false; - if (provider === "hyprland" && !luaAction && HyprlandService.luaConfigActive) + if (provider === "hyprland" && (!luaAction || !HyprlandService.luaConfigActive)) return false; return provider === "niri" || provider === "hyprland" || provider === "mangowc"; } @@ -434,12 +432,7 @@ Singleton { return true; } if (provider === "hyprland") { - if (!HyprlandService.luaConfigActive) { - Quickshell.execDetached(["sh", "-c", "hyprctl dispatch " + action]); - return true; - } - // Lua-config Hyprland rejects legacy dispatcher text as a syntax error. - if (!luaAction) + if (!luaAction || !HyprlandService.luaConfigActive) return false; Quickshell.execDetached(["hyprctl", "dispatch", luaAction]); return true; @@ -785,9 +778,7 @@ Singleton { "bindsAfterDms": status.bindsAfterDms ?? 0, "effective": status.effective ?? true, "overriddenBy": status.overriddenBy ?? 0, - "statusMessage": status.statusMessage ?? "", - "configFormat": status.configFormat ?? "", - "readOnly": status.readOnly === true + "statusMessage": status.statusMessage ?? "" }; } _maybeWarnHyprlandLegacyConf(); @@ -965,7 +956,7 @@ Singleton { function _maybeWarnHyprlandLegacyConf() { if (_hyprlandLegacyWarnShown) return; - if (currentProvider !== "hyprland") + if (currentProvider !== "hyprland" || !HyprlandService.luaConfigStatusReady) return; if (readOnly) { _hyprlandLegacyWarnShown = true; @@ -979,7 +970,7 @@ Singleton { } function showHyprlandReadOnlyWarning() { - ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using %1. Run %2 to migrate before changing these settings.", "hyprland legacy config warning, %1 is the config file, %2 a command").arg("hyprland.conf").arg("dms setup"), "dms setup", "hyprland-migration"); + ToastService.showWarning(I18n.tr("%1 not found").arg("hyprland.lua"), I18n.tr("Run %1 to create it before changing these settings.", "hyprland.lua missing warning, %1 is a command").arg("dms setup"), "dms setup", "hyprland-migration"); } function removeBind(key, draft, callback) { diff --git a/quickshell/tests/hyprland-lua.test.mjs b/quickshell/tests/hyprland-lua.test.mjs index 810adb07c..e785d491f 100644 --- a/quickshell/tests/hyprland-lua.test.mjs +++ b/quickshell/tests/hyprland-lua.test.mjs @@ -24,7 +24,7 @@ const scope = vm.createContext({ liveMonitor: name => live[name] ?? null, hyprOptionNames: new Proxy({}, { get: (names, key) => key in names ? names[key] : true }) }); -for (const fn of ["getOutputIdentifier", "luaQuoted", "forceFlagValue", "buildOutputsLua", "luaString", "luaTable", "hyprSupports", "blurLines", "decorationLines", "groupbarLines", "hyprScrollMethod", "touchpadNames", "inputLines", "touchpadDeviceLines", "refreshFollowMouse"]) +for (const fn of ["getOutputIdentifier", "luaQuoted", "forceFlagValue", "buildOutputsLua", "luaString", "luaTable", "hyprSupports", "hyprIntModes", "hyprMode", "blurLines", "decorationLines", "groupbarLines", "bindsLines", "miscLines", "tilingLayoutLines", "hyprScrollMethod", "touchpadNames", "inputLines", "touchpadDeviceLines", "refreshFollowMouse"]) vm.runInContext(extract(fn), scope); const withOptions = (names, run) => { const previous = scope.hyprOptionNames; @@ -46,7 +46,7 @@ const settingsFor = prefix => { return overrides => Object.assign({}, defaults, overrides); }; const layout = settingsFor(/^(hyprland|blurStrength)/); -const input = settingsFor(/^(mouse|touchpad|keyboard)[A-Z]/); +const input = settingsFor(/^(mouse|touchpad|keyboard|hyprlandSwallowFocusClick|hyprlandTabletEnabled)/); const table = (lines, name) => { const start = lines.indexOf(`${name} = {`); return start < 0 ? null : lines.slice(start + 1, lines.indexOf("},", start)).map(line => line.trim()); @@ -140,6 +140,52 @@ test("layout: effects the running Hyprland does not know are not written even wh }); }); +const hypr056 = { "scrolling:follow_focus": true, "binds:workspace_back_and_forth": true, "misc:exit_window_retains_fullscreen": true }; +const lines057 = s => [...scope.bindsLines(s), ...scope.miscLines(s), ...scope.blurLines(s), ...inputLines(s)].map(line => line.trim()); + +test("layout: 0.57 options write nothing at Hyprland's defaults and only their own line once changed", () => { + const defaults = layout(input()); + assert.deepEqual([...scope.bindsLines(defaults), ...scope.miscLines(defaults)], []); + const cases = [ + [{ hyprlandDragCenterWindow: false }, "drag_center_window = false,"], + [{ hyprlandWorkspaceBackAndForth: 2 }, "workspace_back_and_forth = 2,"], + [{ hyprlandExitWindowRetainsFullscreen: 3 }, "exit_window_retains_fullscreen = 3,"], + [{ hyprlandBellSound: "" }, 'bell_sound = "",'], + [{ hyprlandBlurProportional: true }, "proportional = true,"], + [{ hyprlandSwallowFocusClick: true }, "swallow_focus_click = true,"], + [{ hyprlandTabletEnabled: false }, "enabled = false,"], + [{ hyprlandBlurVariant: "drops", hyprlandBlurDropsSpeed: 0 }, "speed = 0,"], + [{ hyprlandBlurVariant: "heat_shimmer", hyprlandBlurHeatShimmerSpeed: 0 }, "speed = 0,"] + ]; + const base = lines057(defaults); + for (const [overrides, want] of cases) { + const s = layout(input(overrides)); + const added = lines057(s).filter(line => !base.includes(line)); + assert.deepEqual(added.filter(line => !/^(variant = |enabled = true,|\w+ = \{$)/.test(line)), [want], want); + assert.equal(withOptions({}, () => lines057(s)).includes(want), false, want); + } + assert.equal(lines057(layout(input({ hyprlandBlurVariant: "drops" }))).some(line => line.startsWith("speed")), false); +}); + +test("layout: follow focus and the other bools that became modes stay bools before 0.57", () => { + const previous = scope.SettingsData; + const s = scope.SettingsData = layout({ hyprlandScrollingFollowFocus: 2, hyprlandWorkspaceBackAndForth: 2, hyprlandExitWindowRetainsFullscreen: 2 }); + const written = () => [scope.tilingLayoutLines("scrolling").at(-1), ...scope.bindsLines(s), ...scope.miscLines(s)]; + try { + assert.deepEqual(written(), ["follow_focus = 2,", "workspace_back_and_forth = 2,", "exit_window_retains_fullscreen = 2,"]); + assert.deepEqual(withOptions(hypr056, written), ["follow_focus = true,", "workspace_back_and_forth = true,", "exit_window_retains_fullscreen = true,"]); + s.hyprlandScrollingFollowFocus = 0; + assert.equal(withOptions(hypr056, () => scope.tilingLayoutLines("scrolling").at(-1)), "follow_focus = false,"); + } finally { + scope.SettingsData = previous; + } +}); + +test("settings: a stored follow focus bool loads as the matching mode", () => { + const coerce = SPEC.hyprlandScrollingFollowFocus.coerce; + assert.deepEqual([true, false, 0, 1, 2, 3, "1"].map(coerce), [1, 0, 0, 1, 2, undefined, undefined]); +}); + test("input: turning natural scroll off is written so the template's true cannot shadow it", () => { assert.ok(table(inputLines(input({ touchpadNaturalScroll: false })), "touchpad").includes("natural_scroll = false,")); }); diff --git a/quickshell/tests/keybinds-execute.test.mjs b/quickshell/tests/keybinds-execute.test.mjs index e7b6acf29..3c6d1ed13 100644 --- a/quickshell/tests/keybinds-execute.test.mjs +++ b/quickshell/tests/keybinds-execute.test.mjs @@ -34,22 +34,19 @@ test("hyprland runs the Lua dispatch as one argv entry and refuses legacy text w assert.deepEqual(calls, []); }); -test("hyprland on a conf config dispatches the legacy text", () => { - scope.HyprlandService.luaConfigActive = false; - calls.length = 0; - assert.equal(scope.executeAction("movefocus l"), true); - assert.deepEqual(calls, [["sh", "-c", "hyprctl dispatch movefocus l"]]); -}); - -test("hyprland rows without a Lua action run only on a conf config", () => { +test("hyprland runs nothing without an active Lua config", () => { + const lua = 'hl.dsp.focus({ direction = "l" })'; const cases = [ - [true, "movefocus l", 'hl.dsp.focus({ direction = "l" })', true], - [true, "movefocus l", "", false], - [false, "movefocus l", "", true], - [false, "movewindow", "", false] + [true, lua, true], + [true, "", false], + [false, lua, false], + [false, "", false] ]; - for (const [lua, action, luaAction, expected] of cases) { - scope.HyprlandService.luaConfigActive = lua; - assert.equal(scope.canExecuteAction(action, luaAction), expected, `${lua} ${action}`); + for (const [active, luaAction, expected] of cases) { + scope.HyprlandService.luaConfigActive = active; + calls.length = 0; + assert.equal(scope.canExecuteAction("movefocus l", luaAction), expected, `can ${active} ${luaAction}`); + assert.equal(scope.executeAction("movefocus l", luaAction), expected, `run ${active} ${luaAction}`); + assert.equal(calls.length, expected ? 1 : 0); } }); diff --git a/quickshell/tests/output-model.test.mjs b/quickshell/tests/output-model.test.mjs index 473a67128..927fed922 100644 --- a/quickshell/tests/output-model.test.mjs +++ b/quickshell/tests/output-model.test.mjs @@ -55,13 +55,13 @@ test("niri kdl parser: top-level off disables, nested off does not, on-demand vr assert.deepEqual(parsed["Dell Inc. DELL U2720Q ABC123"].logical, { x: -2560, y: 0, scale: 1.5, transform: "flipped-90" }); }); -test("hyprland conf parser: extras, disable line, desc name, trailing space, mirror, bare line skipped", () => { - const parsed = plain(model.parseHyprlandOutputs(text("hyprland-monitors.conf"))); +test("hyprland conf lines only parse for 1.x profile migration: extras, disable line, desc name, trailing space, mirror, bare line skipped", () => { + assert.deepEqual(Object.keys(plain(model.parseHyprlandOutputs(text("hyprland-monitors.conf")))), []); + const parsed = plain(model.parseHyprlandConfProfile(text("hyprland-monitors.conf"))); assert.deepEqual(Object.keys(parsed), ["DP-1", "HDMI-A-1", "desc:Dell Inc. DELL U2720Q ABC123", "eDP-1", "DP-2"]); assert.deepEqual(parsed["DP-1"].logical, { x: 0, y: 0, scale: 1.25, transform: "90" }); - assert.deepEqual(parsed["DP-1"].hyprlandSettings, { vrr: 2, bitdepth: 10, colorManagement: "hdr", sdrBrightness: 1.2, sdrSaturation: 0.9 }); assert.equal(parsed["DP-1"].vrr_enabled, true); - assert.deepEqual(parsed["HDMI-A-1"], { name: "HDMI-A-1", logical: { x: 0, y: 0, scale: 1, transform: "Normal" }, modes: [], current_mode: -1, vrr_enabled: false, vrr_supported: false, hyprlandSettings: { disabled: true } }); + assert.deepEqual(parsed["HDMI-A-1"], { name: "HDMI-A-1", logical: { x: 0, y: 0, scale: 1, transform: "Normal" }, modes: [], current_mode: -1, vrr_enabled: false, vrr_supported: false }); assert.equal(parsed["desc:Dell Inc. DELL U2720Q ABC123"].logical.transform, "Flipped90"); assert.equal(parsed["eDP-1"].mirror, "DP-1"); assert.deepEqual(parsed["DP-2"].modes, [{ width: 1920, height: 1080, refresh_rate: 60000 }]); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index ed8fc4b33..cb36c4463 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -2880,6 +2880,37 @@ "page": "personalization", "icon": "restore" }, + { + "section": "cursorWarpOnMonitorChange", + "label": "Warp on display change", + "tabIndex": -1, + "category": "Wallpaper & colors", + "keywords": [ + "background", + "bg", + "change", + "colors", + "colour", + "colours", + "cursor", + "desktop", + "display", + "focus", + "hue", + "image", + "monitor", + "output", + "palette", + "picture", + "screen", + "tint", + "wallpaper", + "warp", + "warp_on_monitor_change" + ], + "page": "personalization", + "conditionKey": "isHyprland" + }, { "section": "blurXrayLink", "label": "Xray options are in Compositor → Layout", @@ -12763,6 +12794,25 @@ ], "conditionKey": "layoutCapable" }, + { + "section": "hyprlandBehavior", + "label": "Behavior", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "behavior", + "bell", + "binds", + "fullscreen", + "hyprland", + "integrations", + "misc", + "system", + "workspace" + ], + "icon": "tune", + "conditionKey": "isHyprland" + }, { "section": "hyprlandBlurVariant", "label": "Blur", @@ -12787,6 +12837,54 @@ "description": "Styles outside of Default use more GPU", "conditionKey": "layoutCapable" }, + { + "section": "hyprlandBlurProportional", + "label": "Blur follows opacity", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "areas", + "blur", + "follows", + "hyprland", + "integrations", + "less", + "more", + "opacity", + "proportional", + "system", + "transparency", + "transparent" + ], + "description": "More transparent areas get less blur", + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandDragCenterWindow", + "label": "Center dragged windows", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "center", + "centers", + "cursor", + "drag", + "drag_center_window", + "dragged", + "float", + "floating", + "fullscreen", + "hyprland", + "integrations", + "mouse", + "system", + "tiled", + "window", + "windows" + ], + "description": "A tiled or fullscreen window dragged into floating centers on the cursor", + "conditionKey": "layoutCapable" + }, { "section": "hyprlandBlurAcrylicClarity", "label": "Clarity", @@ -12861,8 +12959,10 @@ "tabIndex": 37, "category": "System & integrations", "keywords": [ + "click", "focus", "follow", + "follow_focus", "hyprland", "integrations", "scroll", @@ -13170,6 +13270,25 @@ ], "conditionKey": "layoutCapable" }, + { + "section": "hyprlandExitWindowRetainsFullscreen", + "label": "Keep fullscreen on close", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "close", + "exit", + "exit_window_retains_fullscreen", + "fullscreen", + "group", + "hyprland", + "integrations", + "keep", + "retain", + "system" + ], + "conditionKey": "layoutCapable" + }, { "section": "hyprlandLayout", "label": "Layout overrides", @@ -13588,6 +13707,70 @@ ], "conditionKey": "layoutCapable" }, + { + "section": "hyprlandBlurDropsSpeed", + "label": "Speed", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "animate", + "animation", + "animations", + "blur", + "drops", + "hyprland", + "integrations", + "motion", + "saves", + "speed", + "stops", + "system", + "transition" + ], + "description": "0 stops the animation and saves GPU", + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandBlurHeatShimmerSpeed", + "label": "Speed", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "animate", + "animation", + "animations", + "blur", + "heat", + "hyprland", + "integrations", + "motion", + "saves", + "shimmer", + "speed", + "stops", + "system", + "transition" + ], + "description": "0 stops the animation and saves GPU", + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandBellSound", + "label": "System bell", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "beep", + "bell", + "bell_sound", + "hyprland", + "integrations", + "mute", + "sound", + "system" + ], + "conditionKey": "layoutCapable" + }, { "section": "hyprlandTilingLayout", "label": "Tiling layout", @@ -13723,6 +13906,30 @@ ], "conditionKey": "layoutCapable" }, + { + "section": "hyprlandWorkspaceBackAndForth", + "label": "Workspace back and forth", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "back", + "current", + "desktop", + "forth", + "hyprland", + "integrations", + "previous", + "returns", + "switching", + "system", + "toggle", + "virtual", + "workspace", + "workspace_back_and_forth" + ], + "description": "Switching to the current workspace returns to the previous one", + "conditionKey": "layoutCapable" + }, { "section": "hyprlandLayoutXrayEnabled", "label": "Xray blur", @@ -14920,6 +15127,65 @@ "parentLabel": "Input", "conditionKey": "pointerCapable" }, + { + "section": "hyprlandSwallowFocusClick", + "label": "Swallow focus click", + "tabIndex": 44, + "category": "Mouse & touchpad", + "keywords": [ + "click", + "does", + "focus", + "focuses", + "input", + "mouse", + "reach", + "swallow", + "swallow_focus_click", + "touchpad", + "window" + ], + "parentLabel": "Input", + "description": "The click that focuses a window does not reach the app", + "conditionKey": "isHyprland" + }, + { + "section": "tabletSettings", + "label": "Tablet", + "tabIndex": 44, + "category": "Mouse & touchpad", + "keywords": [ + "drawing", + "input", + "mouse", + "pen", + "stylus", + "tablet", + "touchpad" + ], + "parentLabel": "Input", + "icon": "stylus", + "conditionKey": "isHyprland" + }, + { + "section": "hyprlandTabletEnabled", + "label": "Tablet input", + "tabIndex": 44, + "category": "Mouse & touchpad", + "keywords": [ + "disable", + "drawing", + "enable", + "input", + "mouse", + "pen", + "stylus", + "tablet", + "touchpad" + ], + "parentLabel": "Input", + "conditionKey": "pointerCapable" + }, { "section": "touchpadTapAndDrag", "label": "Tap and drag",