mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-30 23:17:51 -04:00
hyprland: drop legacy conf support and close 0.57 gaps
Port 2.0
This commit is contained in:
1 parent
46a12d9152
commit
4927ef5b9b
62 files changed
+1472
-2475
No files matched your search
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+160
-24
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" })
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/<timestamp>/ 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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -125,7 +125,6 @@ func ParseMangoWindowRules(configDir string) (*MangoRulesParseResult, error) {
|
||||
Exists: fileExists(overridePath),
|
||||
Included: included,
|
||||
Effective: included,
|
||||
ConfigFormat: "conf",
|
||||
StatusMessage: mangoIncludeMessage(included),
|
||||
},
|
||||
}, nil
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1833,6 +1833,10 @@ const HYPRLAND_ACTIONS = {
|
||||
id: "forcerendererreload",
|
||||
label: "Force Renderer Reload"
|
||||
},
|
||||
{
|
||||
id: "reloadconfig",
|
||||
label: "Reload Config"
|
||||
},
|
||||
{
|
||||
id: "dpms on",
|
||||
label: "DPMS On"
|
||||
|
||||
@@ -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(",")) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" })`);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,"));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 }]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user