diff --git a/core/cmd/dms/commands_config.go b/core/cmd/dms/commands_config.go index e3e53b342..f2bb155c5 100644 --- a/core/cmd/dms/commands_config.go +++ b/core/cmd/dms/commands_config.go @@ -36,6 +36,7 @@ var resolveIncludeCmd = &cobra.Command{ "layout.lua", "outputs.lua", "cursor.lua", + "input.lua", "windowrules.lua", "cursor.kdl", "layout.kdl", @@ -132,27 +133,18 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) { mainLua := filepath.Join(configDir, "hyprland.lua") if _, err := os.Stat(mainLua); err == nil { result.ConfigFormat = "lua" - result.ReadOnly = false - processedLua := make(map[string]bool) - if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) { - result.Included = true - return result, nil - } + result.Included = luaconfig.RequiresTarget(mainLua, targetAbs, make(map[string]bool)) + 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 { - if result.ConfigFormat == "" { - result.ConfigFormat = "hyprlang" - result.ReadOnly = true - } - processed := make(map[string]bool) - if hyprlandFindIncludeHyprlang(mainConf, targetAbs, processed) { - result.Included = true - return result, nil - } + 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)) return result, nil } @@ -173,14 +165,8 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b } baseDir := filepath.Dir(absPath) - lines := strings.SplitSeq(string(data), "\n") - - for line := range lines { + for line := range strings.SplitSeq(string(data), "\n") { trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "#") || trimmed == "" { - continue - } - if !strings.HasPrefix(trimmed, "source") { continue } diff --git a/core/cmd/dms/commands_config_test.go b/core/cmd/dms/commands_config_test.go index 4a0ff4a8b..647c66d28 100644 --- a/core/cmd/dms/commands_config_test.go +++ b/core/cmd/dms/commands_config_test.go @@ -42,3 +42,33 @@ func TestMangowcFindIncludeNestedRelativeToIncludingFile(t *testing.T) { t.Error("binds.conf sourced through dms/dms.conf not detected") } } + +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}, + } + 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) + } + }) + } +} diff --git a/core/cmd/dms/commands_doctor.go b/core/cmd/dms/commands_doctor.go index 763802286..b2b2bb786 100644 --- a/core/cmd/dms/commands_doctor.go +++ b/core/cmd/dms/commands_doctor.go @@ -90,7 +90,6 @@ func (ds *DoctorStatus) OKCount() int { var ( quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`) - hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`) niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`) swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`) riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`) @@ -735,20 +734,20 @@ func checkDMSInstallation() []checkResult { func checkWindowManagers() []checkResult { compositors := []struct { name, versionCmd, versionArg string - versionRegex *regexp.Regexp + parseVersion func(string) string commands []string }{ - {"Hyprland", "Hyprland", "--version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}}, - {"niri", "niri", "--version", niriVersionRegex, []string{"niri"}}, - {"Sway", "sway", "--version", swayVersionRegex, []string{"sway"}}, - {"River", "river", "-version", riverVersionRegex, []string{"river"}}, - {"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}}, - {"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}}, - {"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}}, - {"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}}, - {"Scroll", "scroll", "--version", scrollVersionRegex, []string{"scroll"}}, - {"Aqueous", "aqueous", "-version", aqueousVersionRegex, []string{"aqueous"}}, - {"Umbriel", "umbriel", "--version", umbrielVersionRegex, []string{"umbriel"}}, + {"Hyprland", "Hyprland", "--version", distros.ParseHyprlandVersion, []string{"hyprland", "Hyprland"}}, + {"niri", "niri", "--version", firstSubmatch(niriVersionRegex), []string{"niri"}}, + {"Sway", "sway", "--version", firstSubmatch(swayVersionRegex), []string{"sway"}}, + {"River", "river", "-version", firstSubmatch(riverVersionRegex), []string{"river"}}, + {"Wayfire", "wayfire", "--version", firstSubmatch(wayfireVersionRegex), []string{"wayfire"}}, + {"labwc", "labwc", "--version", firstSubmatch(labwcVersionRegex), []string{"labwc"}}, + {"mangowc", "mango", "-v", firstSubmatch(mangowcVersionRegex), []string{"mango"}}, + {"Miracle WM", "miracle-wm", "--version", firstSubmatch(miracleVersionRegex), []string{"miracle-wm"}}, + {"Scroll", "scroll", "--version", firstSubmatch(scrollVersionRegex), []string{"scroll"}}, + {"Aqueous", "aqueous", "-version", firstSubmatch(aqueousVersionRegex), []string{"aqueous"}}, + {"Umbriel", "umbriel", "--version", firstSubmatch(umbrielVersionRegex), []string{"umbriel"}}, } var results []checkResult @@ -770,11 +769,22 @@ func checkWindowManagers() []checkResult { if doctorVerbose && compositorPath != "" { details = compositorPath } + display, ver := getVersionFromCommand(c.versionCmd, c.versionArg, c.parseVersion) results = append(results, checkResult{ - catCompositor, c.name, statusOK, - getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details, + catCompositor, c.name, statusOK, display, details, doctorDocsURL + "#compositor-checks", }) + if c.name == "Hyprland" && hyprlandLacksLua(ver) { + results = append(results, checkResult{ + catCompositor, "Hyprland Lua config", statusWarn, + fmt.Sprintf("Hyprland %s is older than 0.55", ver), + "DMS writes Hyprland config in Lua only, which needs Hyprland 0.55 or newer", + doctorDocsURL + "#compositor-checks", + }) + } + if c.name == "Hyprland" { + results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...) + } } if !foundAny { @@ -869,21 +879,50 @@ func checkCompositorBlurSupport() checkResult { return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"} } -func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string { +// 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() { + 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.", + doctorDocsURL + "#compositor-checks", + }} +} + +func hyprlandLacksLua(ver string) bool { + return ver != "" && version.CompareVersions(ver, "0.55.0") < 0 +} + +func firstSubmatch(re *regexp.Regexp) func(string) string { + return func(s string) string { + if m := re.FindStringSubmatch(s); len(m) > 1 { + return m[1] + } + return "" + } +} + +func getVersionFromCommand(cmd, arg string, parse func(string) string) (display, ver string) { output, err := exec.Command(cmd, arg).CombinedOutput() if err != nil && len(output) == 0 { - return "installed" + return "installed", "" } outStr := string(output) - if matches := regex.FindStringSubmatch(outStr); len(matches) > 1 { - ver := matches[1] - if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") { - return ver + " (git)" - } - return ver + ver = parse(outStr) + if ver == "" { + return strings.TrimSpace(outStr), "" } - return strings.TrimSpace(outStr) + if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") { + return ver + " (git)", ver + } + return ver, ver } func detectRunningWM() string { diff --git a/core/cmd/dms/commands_doctor_test.go b/core/cmd/dms/commands_doctor_test.go index e60e1f1f0..5dc659266 100644 --- a/core/cmd/dms/commands_doctor_test.go +++ b/core/cmd/dms/commands_doctor_test.go @@ -253,3 +253,41 @@ func TestCheckMangoConfigFlagsWrongDialectAndOverviewBinds(t *testing.T) { t.Fatalf("snake: %+v", got) } } + +func TestHyprlandLacksLua(t *testing.T) { + for ver, want := range map[string]bool{ + "0.41.2": true, + "0.54.9": true, + "0.55.0": false, + "0.56.2": false, + "1.0.0": false, + "": false, + } { + if got := hyprlandLacksLua(ver); got != want { + t.Errorf("hyprlandLacksLua(%q) = %v, want %v", ver, got, want) + } + } +} + +func TestCheckHyprlandConfigFormat(t *testing.T) { + for name, tc := range map[string]struct { + files []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}, + } { + dir := t.TempDir() + for _, f := range tc.files { + if err := os.WriteFile(filepath.Join(dir, f), nil, 0o644); err != nil { + t.Fatal(err) + } + } + got := checkHyprlandConfigFormat(dir) + if warned := len(got) == 1 && got[0].status == statusWarn; warned != tc.warn || (!tc.warn && len(got) != 0) { + t.Errorf("%s: got %+v, want warn=%v", name, got, tc.warn) + } + } +} diff --git a/core/cmd/dms/commands_keybinds.go b/core/cmd/dms/commands_keybinds.go index 3fed0293d..268c468fe 100644 --- a/core/cmd/dms/commands_keybinds.go +++ b/core/cmd/dms/commands_keybinds.go @@ -92,7 +92,7 @@ func init() { keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat") keybindsSetCmd.Flags().Bool("no-inhibiting", false, "Keep bind active when shortcuts are inhibited (allow-inhibiting=false)") keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)") - keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)") + keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags as letters: e repeat, l locked, r release, o long-press, n non-consuming, m mouse, t transparent, i ignore-mods, u submap-universal, a auto-consuming, x allow-input-capture") keybindsCmd.AddCommand(keybindsListCmd) keybindsCmd.AddCommand(keybindsShowCmd) diff --git a/core/cmd/dms/commands_setup.go b/core/cmd/dms/commands_setup.go index d5d50c7d6..900c45a9d 100644 --- a/core/cmd/dms/commands_setup.go +++ b/core/cmd/dms/commands_setup.go @@ -146,7 +146,9 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{ }, "input": { niriFile: "input.kdl", + hyprFile: "input.lua", niriContent: func(_ string) string { return config.NiriInputConfig }, + hyprContent: func(_ string) string { return config.DMSInputLuaConfig }, }, "outputs": { niriFile: "outputs.kdl", @@ -190,6 +192,11 @@ func installedTerminals() []string { return found } +func installedHyprlandVersion() string { + _, ver := getVersionFromCommand("Hyprland", "--version", distros.ParseHyprlandVersion) + return ver +} + func defaultTerminalCommand() string { if env := terminalFromEnv(); env != "" { return env @@ -397,6 +404,9 @@ func runSetup() error { if terminalSelected { terminalCommand = terminal.Command() } + if wm == deps.WindowManagerHyprland { + deployer.SetHyprlandVersion(installedHyprlandVersion()) + } var result config.DeploymentResult result, err = deployer.DeployCompositor(wm, terminalCommand, useSystemd) results = append(results, result) diff --git a/core/cmd/dms/commands_setup_headless.go b/core/cmd/dms/commands_setup_headless.go index 889202022..cd1b7e340 100644 --- a/core/cmd/dms/commands_setup_headless.go +++ b/core/cmd/dms/commands_setup_headless.go @@ -90,6 +90,9 @@ func runSetupHeadless(compositor, terminal string, noSystemd, force, skipExistin if termSelected { terminalCommand = term.Command() } + if wm == deps.WindowManagerHyprland { + deployer.SetHyprlandVersion(installedHyprlandVersion()) + } result, err := deployer.DeployCompositor(wm, terminalCommand, headlessUseSystemd(wm, noSystemd)) results = append(results, result) deployErr = err diff --git a/core/internal/config/deployer.go b/core/internal/config/deployer.go index facc3e35d..479107bf2 100644 --- a/core/internal/config/deployer.go +++ b/core/internal/config/deployer.go @@ -10,6 +10,7 @@ import ( "time" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/version" ) const hyprlandBackupDirName = ".dms-backups" @@ -17,6 +18,7 @@ const hyprlandBackupDirName = ".dms-backups" type ConfigDeployer struct { logChan chan<- string replaceMangoBinds bool + hyprlandVersion string } type DeploymentResult struct { @@ -39,6 +41,18 @@ func (cd *ConfigDeployer) log(message string) { } } +// SetHyprlandVersion gates the Lua deploy; empty means unknown (e.g. no session on a TTY) and deploys anyway. +func (cd *ConfigDeployer) SetHyprlandVersion(ver string) { + cd.hyprlandVersion = ver +} + +func hyprlandLuaUnsupported(ver string) error { + if ver == "" || version.CompareVersions(ver, "0.55.0") >= 0 { + return nil + } + return fmt.Errorf("hyprland.lua is not loaded by Hyprland %s; DMS needs 0.55 or newer", ver) +} + func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool) ([]DeploymentResult, error) { return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, true) } @@ -47,7 +61,7 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstallsAndSystemd( return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, useSystemd) } -func (cd *ConfigDeployer) deployConfigurationsInternal(_ context.Context, wm deps.WindowManager, terminal deps.Terminal, _ []deps.Dependency, replaceConfigs map[string]bool, _ map[string]bool, useSystemd bool) ([]DeploymentResult, error) { +func (cd *ConfigDeployer) deployConfigurationsInternal(_ context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, _ map[string]bool, useSystemd bool) ([]DeploymentResult, error) { var results []DeploymentResult // Primary config file paths used to detect fresh installs. @@ -57,6 +71,7 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(_ context.Context, wm dep }, "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"), }, "Mango": { @@ -110,6 +125,11 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(_ context.Context, wm dep } } case deps.WindowManagerHyprland: + for _, dep := range installedDeps { + if dep.Name == "hyprland" && cd.hyprlandVersion == "" { + cd.hyprlandVersion = dep.Version + } + } if shouldReplaceConfig("Hyprland") { result, err := cd.deployHyprlandConfig(terminalCommand, useSystemd) results = append(results, result) @@ -538,6 +558,10 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystem ConfigType: "Hyprland", Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"), } + if err := hyprlandLuaUnsupported(cd.hyprlandVersion); err != nil { + result.Error = err + return result, err + } configDir := filepath.Dir(result.Path) if err := os.MkdirAll(configDir, 0o755); err != nil { @@ -743,6 +767,7 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman {name: "binds-user.lua", content: DMSBindsUserLuaConfig}, {name: "outputs.lua", content: DMSOutputsLuaConfig}, {name: "cursor.lua", content: DMSCursorLuaConfig}, + {name: "input.lua", content: DMSInputLuaConfig}, {name: "windowrules.lua", content: DMSWindowRulesLuaConfig}, } diff --git a/core/internal/config/deployer_test.go b/core/internal/config/deployer_test.go index fdd4435ec..f8c412f4b 100644 --- a/core/internal/config/deployer_test.go +++ b/core/internal/config/deployer_test.go @@ -875,3 +875,16 @@ func TestDeployHyprlandConfigWritesLuaBeforeRemovingLegacyConf(t *testing.T) { assert.Equal(t, userConfig, string(backup)) assert.Equal(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"), result.BackupPath) } + +func TestHyprlandLuaUnsupported(t *testing.T) { + for ver, refuse := range map[string]bool{ + "0.54.2": true, + "0.41.0": true, + "0.55.0": false, + "0.56.1": false, + "": false, + } { + err := hyprlandLuaUnsupported(ver) + assert.Equal(t, refuse, err != nil, ver) + } +} diff --git a/core/internal/config/embedded/hypr-colors.lua b/core/internal/config/embedded/hypr-colors.lua index f0f451b09..7a53b251d 100644 --- a/core/internal/config/embedded/hypr-colors.lua +++ b/core/internal/config/embedded/hypr-colors.lua @@ -25,3 +25,18 @@ hl.config({ }, }, }) + +-- Effect colors exist only on newer Hyprland; skip them elsewhere instead of raising config errors +local function themed(key, value) + local ok, current = pcall(hl.get_config, key) + if ok and current ~= nil then + hl.config({ [key] = value }) + end +end + +themed("decoration.glow.color", "rgba(d0bcffee)") +themed("decoration.glow.color_inactive", "rgba(948f99ee)") +themed("decoration.blur.acrylic.tint", "rgba(d0bcff14)") +themed("decoration.blur.aurora.color1", "rgba(d0bcff29)") +themed("decoration.blur.aurora.color2", "rgba(efb8c87a)") +themed("decoration.blur.fluid_jar.color", "rgba(d0bcffcc)") diff --git a/core/internal/config/embedded/hypr-input.lua b/core/internal/config/embedded/hypr-input.lua new file mode 100644 index 000000000..761b3ea26 --- /dev/null +++ b/core/internal/config/embedded/hypr-input.lua @@ -0,0 +1 @@ +-- Mouse, touchpad and keyboard settings. DMS rewrites this file from Settings. diff --git a/core/internal/config/embedded/hyprland.lua b/core/internal/config/embedded/hyprland.lua index 7ecb814d2..34205cfbe 100644 --- a/core/internal/config/embedded/hyprland.lua +++ b/core/internal/config/embedded/hyprland.lua @@ -1,7 +1,5 @@ -- Hyprland configuration (Lua) — https://wiki.hypr.land/Configuring/Start/ -hl.config({ autogenerated = false }) - -- DMS_STARTUP_BEGIN hl.on("hyprland.start", function() hl.exec_cmd("dbus-update-activation-environment --systemd --all") @@ -15,6 +13,7 @@ hl.config({ kb_layout = "", numlock_by_default = true, follow_mouse = 0, + -- dms/input.lua is required later and wins per key touchpad = { tap_to_click = true, natural_scroll = true, @@ -33,7 +32,7 @@ hl.config({ shadow = { enabled = true, range = 30, - render_power = 5, + render_power = 4, offset = "0 5", color = "rgba(00000070)", }, @@ -84,6 +83,7 @@ require("dms.colors") require("dms.outputs") require("dms.layout") require("dms.cursor") +require("dms.input") require("dms.binds") require("dms.binds-user") require("dms.windowrules") diff --git a/core/internal/config/hyprland.go b/core/internal/config/hyprland.go index 27f9db6b8..4f369e152 100644 --- a/core/internal/config/hyprland.go +++ b/core/internal/config/hyprland.go @@ -20,6 +20,9 @@ var DMSOutputsLuaConfig string //go:embed embedded/hypr-cursor.lua var DMSCursorLuaConfig string +//go:embed embedded/hypr-input.lua +var DMSInputLuaConfig string + //go:embed embedded/hypr-windowrules.lua var DMSWindowRulesLuaConfig string diff --git a/core/internal/config/hyprland_session_target.go b/core/internal/config/hyprland_session_target.go index e5aa545e4..63b9195df 100644 --- a/core/internal/config/hyprland_session_target.go +++ b/core/internal/config/hyprland_session_target.go @@ -30,27 +30,57 @@ After=graphical-session-pre.target `, } -// Only a missing or dankinstall-written unit is replaced; hand-written units are left alone. +var packagedHyprlandSessionTargetDirs = []string{ + "/usr/lib/systemd/user", + "/usr/local/lib/systemd/user", +} + +// EnsureHyprlandSessionTarget returns the unit path that provides hyprland-session.target. +// Hyprland 0.57+ ships the unit; ours would shadow it, so it is removed then. Hand-written units are left alone. func EnsureHyprlandSessionTarget() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", err } + userPath := filepath.Join(homeDir, ".config", "systemd", "user", "hyprland-session.target") + return ensureHyprlandSessionTarget(userPath, packagedHyprlandSessionTargetDirs) +} - targetPath := filepath.Join(homeDir, ".config", "systemd", "user", "hyprland-session.target") - existing, err := os.ReadFile(targetPath) - if err == nil { - content := string(existing) - if content == hyprlandSessionTargetUnit || !slices.Contains(staleHyprlandSessionTargetUnits, content) { - return targetPath, nil +func ensureHyprlandSessionTarget(userPath string, packagedDirs []string) (string, error) { + existing, readErr := os.ReadFile(userPath) + content := string(existing) + ours := readErr == nil && (content == hyprlandSessionTargetUnit || slices.Contains(staleHyprlandSessionTargetUnits, content)) + if readErr == nil && !ours { + return userPath, nil + } + + if packaged := packagedHyprlandSessionTarget(packagedDirs); packaged != "" { + if ours { + if err := os.Remove(userPath); err != nil { + return "", err + } + } + return packaged, nil + } + + if content == hyprlandSessionTargetUnit { + return userPath, nil + } + if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(userPath, []byte(hyprlandSessionTargetUnit), 0o644); err != nil { + return "", err + } + return userPath, nil +} + +func packagedHyprlandSessionTarget(dirs []string) string { + for _, dir := range dirs { + path := filepath.Join(dir, "hyprland-session.target") + if _, err := os.Stat(path); err == nil { + return path } } - - if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { - return "", err - } - if err := os.WriteFile(targetPath, []byte(hyprlandSessionTargetUnit), 0o644); err != nil { - return "", err - } - return targetPath, nil + return "" } diff --git a/core/internal/config/hyprland_session_target_test.go b/core/internal/config/hyprland_session_target_test.go index e42ddadd9..d86027b3c 100644 --- a/core/internal/config/hyprland_session_target_test.go +++ b/core/internal/config/hyprland_session_target_test.go @@ -6,70 +6,72 @@ import ( "testing" ) -func targetPathFor(t *testing.T, home string) string { +func writeUnit(t *testing.T, path, content string) { t.Helper() - return filepath.Join(home, ".config", "systemd", "user", "hyprland-session.target") -} - -func TestEnsureHyprlandSessionTargetWritesMissing(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - path, err := EnsureHyprlandSessionTarget() - if err != nil { - t.Fatal(err) - } - if path != targetPathFor(t, home) { - t.Fatalf("unexpected path %s", path) - } - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(data) != hyprlandSessionTargetUnit { - t.Fatalf("unexpected content:\n%s", data) - } -} - -func TestEnsureHyprlandSessionTargetUpgradesStale(t *testing.T) { - for _, stale := range staleHyprlandSessionTargetUnits { - home := t.TempDir() - t.Setenv("HOME", home) - path := targetPathFor(t, home) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(stale), 0o644); err != nil { - t.Fatal(err) - } - - if _, err := EnsureHyprlandSessionTarget(); err != nil { - t.Fatal(err) - } - data, _ := os.ReadFile(path) - if string(data) != hyprlandSessionTargetUnit { - t.Fatalf("stale unit not upgraded:\n%s", data) - } - } -} - -func TestEnsureHyprlandSessionTargetKeepsCustom(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - path := targetPathFor(t, home) - custom := "[Unit]\nDescription=mine\n" if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(path, []byte(custom), 0o644); err != nil { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } +} - if _, err := EnsureHyprlandSessionTarget(); err != nil { - t.Fatal(err) +func TestEnsureHyprlandSessionTarget(t *testing.T) { + const custom = "[Unit]\nDescription=mine\n" + ours := append([]string{hyprlandSessionTargetUnit}, staleHyprlandSessionTargetUnits...) + + type tc struct { + name string + user string + packaged bool + wantUser string // "" means the user unit must not exist + wantPkg bool + } + cases := []tc{ + {name: "missing, no packaged unit", wantUser: hyprlandSessionTargetUnit}, + {name: "missing, packaged unit", packaged: true, wantPkg: true}, + {name: "custom, no packaged unit", user: custom, wantUser: custom}, + {name: "custom, packaged unit", user: custom, packaged: true, wantUser: custom}, + } + for i, unit := range ours { + cases = append(cases, + tc{name: "ours " + string(rune('a'+i)) + ", no packaged unit", user: unit, wantUser: hyprlandSessionTargetUnit}, + tc{name: "ours " + string(rune('a'+i)) + ", packaged unit", user: unit, packaged: true, wantPkg: true}, + ) } - data, _ := os.ReadFile(path) - if string(data) != custom { - t.Fatalf("custom unit was overwritten:\n%s", data) + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + root := t.TempDir() + userPath := filepath.Join(root, "home", "hyprland-session.target") + pkgDirs := []string{filepath.Join(root, "usr-lib"), filepath.Join(root, "usr-local-lib")} + pkgPath := filepath.Join(pkgDirs[1], "hyprland-session.target") + if c.user != "" { + writeUnit(t, userPath, c.user) + } + if c.packaged { + writeUnit(t, pkgPath, "[Unit]\nDescription=Hyprland session\n") + } + + got, err := ensureHyprlandSessionTarget(userPath, pkgDirs) + if err != nil { + t.Fatal(err) + } + + data, readErr := os.ReadFile(userPath) + switch { + case c.wantUser == "" && readErr == nil: + t.Fatalf("user unit should be gone, has:\n%s", data) + case c.wantUser != "" && string(data) != c.wantUser: + t.Fatalf("user unit = %q, want %q", data, c.wantUser) + } + wantPath := userPath + if c.wantPkg { + wantPath = pkgPath + } + if got != wantPath { + t.Fatalf("path = %s, want %s", got, wantPath) + } + }) } } diff --git a/core/internal/distros/arch.go b/core/internal/distros/arch.go index 319c45a82..f79e2b906 100644 --- a/core/internal/distros/arch.go +++ b/core/internal/distros/arch.go @@ -105,6 +105,7 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w // Hyprland-specific tools if wm == deps.WindowManagerHyprland { dependencies = append(dependencies, a.detectHyprlandTools()...) + dependencies = append(dependencies, a.detectHyprlandGuiutils()) } // Niri-specific tools @@ -232,6 +233,7 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, packages["hyprland"] = a.getHyprlandMapping(variants["hyprland"]) packages["hyprctl"] = a.getHyprlandMapping(variants["hyprland"]) packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem} + packages["hyprland-guiutils"] = PackageMapping{Name: "hyprland-guiutils", Repository: RepoTypeSystem} case deps.WindowManagerNiri: packages["niri"] = a.getNiriMapping(variants["niri"]) packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem} diff --git a/core/internal/distros/base.go b/core/internal/distros/base.go index 9bffb5bba..8f75df1f3 100644 --- a/core/internal/distros/base.go +++ b/core/internal/distros/base.go @@ -212,6 +212,11 @@ func (b *BaseDistribution) detectHyprlandTools() []deps.Dependency { return dependencies } +// Hyprland 0.52+ calls hyprland-dialog and friends from hyprland-guiutils and complains when they are missing. +func (b *BaseDistribution) detectHyprlandGuiutils() deps.Dependency { + return b.detectPackage("hyprland-guiutils", "Hyprland dialogs and helper apps", b.commandExists("hyprland-dialog")) +} + func (b *BaseDistribution) detectQuickshell() deps.Dependency { if !b.commandExists("qs") { return deps.Dependency{ @@ -281,6 +286,22 @@ func (b *BaseDistribution) detectQuickshell() deps.Dependency { } } +var ( + hyprlandTagRegex = regexp.MustCompile(`Tag: v?(\d+\.\d+\.\d+)`) + hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`) +) + +// ParseHyprlandVersion prefers the Tag: line because main-branch builds print a stale version first. +func ParseHyprlandVersion(out string) string { + if m := hyprlandTagRegex.FindStringSubmatch(out); m != nil { + return m[1] + } + if m := hyprlandVersionRegex.FindStringSubmatch(out); m != nil { + return m[1] + } + return "" +} + func (b *BaseDistribution) detectWindowManager(wm deps.WindowManager) deps.Dependency { switch wm { case deps.WindowManagerHyprland: @@ -296,12 +317,7 @@ func (b *BaseDistribution) detectWindowManager(wm deps.WindowManager) deps.Depen if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") { variant = deps.VariantGit } - if versionRegex := regexp.MustCompile(`v(\d+\.\d+\.\d+)`); versionRegex.MatchString(outStr) { - matches := versionRegex.FindStringSubmatch(outStr) - if len(matches) > 1 { - version = matches[1] - } - } + version = ParseHyprlandVersion(outStr) } } return deps.Dependency{ @@ -598,7 +614,7 @@ func (b *BaseDistribution) WriteHyprlandSessionTarget() error { return fmt.Errorf("failed to write hyprland-session.target: %w", err) } - b.log(fmt.Sprintf("Wrote hyprland-session.target to %s", targetPath)) + b.log(fmt.Sprintf("Using hyprland-session.target at %s", targetPath)) return nil } diff --git a/core/internal/distros/base_test.go b/core/internal/distros/base_test.go index 76bd80217..b7d62f939 100644 --- a/core/internal/distros/base_test.go +++ b/core/internal/distros/base_test.go @@ -107,3 +107,31 @@ func TestBaseDistribution_detectDMS_DirectoryWithoutGit(t *testing.T) { t.Error("Expected Required to be true") } } + +func TestParseHyprlandVersion(t *testing.T) { + tests := []struct{ name, out, want string }{ + { + name: "0.56 main build reports the tag, not the first line", + out: "Hyprland 0.56.0 built from branch main at commit 5a78b5e927345860a27e2893bf894f97ee620c48 clean (unknown).\n" + + "Date: Tue Oct 06 01:20:44 2026\n" + + "Tag: 0.56.2, commits: 7899\n" + + "\nLibraries:\nHyprgraphics: built against 0.5.1, system has unknown\n", + want: "0.56.2", + }, + { + name: "v-prefixed tag", + out: "Hyprland, built from branch at commit 918d8340afd652b011b937d29d5eea0be08467f5 (version: bump to v0.41.2).\nTag: v0.41.2, commits: 4880\n", + want: "0.41.2", + }, + { + name: "no tag line falls back to the first version", + out: "Hyprland v0.41.2 built from branch main\n", + want: "0.41.2", + }, + } + for _, tt := range tests { + if got := ParseHyprlandVersion(tt.out); got != tt.want { + t.Errorf("%s: got %q, want %q", tt.name, got, tt.want) + } + } +} diff --git a/core/internal/distros/fedora.go b/core/internal/distros/fedora.go index 902282ef9..e6f56199d 100644 --- a/core/internal/distros/fedora.go +++ b/core/internal/distros/fedora.go @@ -90,6 +90,7 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context, // Hyprland-specific tools if wm == deps.WindowManagerHyprland { dependencies = append(dependencies, f.detectHyprlandTools()...) + dependencies = append(dependencies, f.detectHyprlandGuiutils()) } // Niri-specific tools @@ -147,6 +148,7 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager packages["hyprland"] = f.getHyprlandMapping(variants["hyprland"]) packages["hyprctl"] = f.getHyprlandMapping(variants["hyprland"]) packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem} + packages["hyprland-guiutils"] = PackageMapping{Name: "hyprland-guiutils", Repository: RepoTypeCOPR, RepoURL: "lionheartp/Hyprland"} case deps.WindowManagerNiri: packages["niri"] = f.getNiriMapping(variants["niri"]) packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem} diff --git a/core/internal/distros/opensuse.go b/core/internal/distros/opensuse.go index 785ebad8f..3c90b4ec3 100644 --- a/core/internal/distros/opensuse.go +++ b/core/internal/distros/opensuse.go @@ -82,6 +82,7 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex // Hyprland-specific tools if wm == deps.WindowManagerHyprland { dependencies = append(dependencies, o.detectHyprlandTools()...) + dependencies = append(dependencies, o.detectHyprlandGuiutils()) } // Niri-specific tools @@ -138,6 +139,7 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypeSystem} packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypeSystem} packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem} + packages["hyprland-guiutils"] = PackageMapping{Name: "hyprland-guiutils", Repository: RepoTypeSystem} case deps.WindowManagerNiri: // Niri stable has native package support on openSUSE niriVariant := variants["niri"] diff --git a/core/internal/distros/ubuntu.go b/core/internal/distros/ubuntu.go index f9f8fe947..fa9a1cf68 100644 --- a/core/internal/distros/ubuntu.go +++ b/core/internal/distros/ubuntu.go @@ -71,6 +71,7 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context, // Hyprland-specific tools if wm == deps.WindowManagerHyprland { dependencies = append(dependencies, u.detectHyprlandTools()...) + dependencies = append(dependencies, u.detectHyprlandGuiutils()) } // Niri-specific tools @@ -134,6 +135,7 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"} packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"} packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem} + packages["hyprland-guiutils"] = PackageMapping{Name: "hyprland-guiutils", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"} case deps.WindowManagerNiri: niriVariant := variants["niri"] packages["niri"] = u.getNiriMapping(niriVariant) diff --git a/core/internal/distros/void.go b/core/internal/distros/void.go index ac2eb8e66..49b347302 100644 --- a/core/internal/distros/void.go +++ b/core/internal/distros/void.go @@ -77,6 +77,7 @@ func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, w if wm == deps.WindowManagerHyprland { dependencies = append(dependencies, v.detectHyprlandTools()...) + dependencies = append(dependencies, v.detectHyprlandGuiutils()) } if wm == deps.WindowManagerNiri || wm == deps.WindowManagerMango { @@ -192,6 +193,7 @@ func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypeXBPS, RepoURL: VoidHyprlandRepo} packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypeXBPS, RepoURL: VoidHyprlandRepo} packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem} + packages["hyprland-guiutils"] = PackageMapping{Name: "hyprland-guiutils", Repository: RepoTypeXBPS, RepoURL: VoidHyprlandRepo} case deps.WindowManagerNiri: packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeSystem} packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem} diff --git a/core/internal/keybinds/providers/hyprland.go b/core/internal/keybinds/providers/hyprland.go index ab25e7e60..2b0dcc831 100644 --- a/core/internal/keybinds/providers/hyprland.go +++ b/core/internal/keybinds/providers/hyprland.go @@ -17,6 +17,8 @@ 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 { @@ -48,6 +50,7 @@ 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) @@ -101,6 +104,8 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string { switch { + case dispatcher == "exec" || strings.HasPrefix(dispatcher, "hl.dsp.exec_cmd("): + return "Execute" case strings.Contains(dispatcher, "workspace"): return "Workspace" case strings.Contains(dispatcher, "monitor"): @@ -114,12 +119,9 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string { dispatcher == "fullscreen" || dispatcher == "togglefloating" || dispatcher == "pin" || - dispatcher == "fakefullscreen" || dispatcher == "splitratio" || dispatcher == "resizeactive": return "Window" - case dispatcher == "exec": - return "Execute" case dispatcher == "exit" || strings.Contains(dispatcher, "dpms"): return "System" default: @@ -157,6 +159,11 @@ 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 (source == "dms" || source == "dms-default") && conflicts != nil { normalizedKey := strings.ToLower(keyStr) @@ -220,14 +227,18 @@ func (h *HyprlandProvider) validateAction(action string) error { return fmt.Errorf("exec dispatcher requires arguments") } } + if isRawLuaActionText(action) { + return nil + } + if _, ok := luaActionStringFromHyprlangAction(action); !ok { + return fmt.Errorf("hyprland has no Lua dispatcher for %q", action) + } return nil } var luaExprActionPattern = regexp.MustCompile(`^(function\s*\(|hl\.)`) -// isRawLuaActionText reports that action is a Lua expression to re-emit -// verbatim rather than freeform dispatcher text to wrap for hyprctl. The -// balance check keeps malformed input from corrupting the generated file. +// The balance check keeps malformed input from corrupting the generated file. func isRawLuaActionText(action string) bool { if !luaExprActionPattern.MatchString(action) { return false @@ -280,7 +291,6 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[ existingBinds = make(map[string]*hyprlandOverrideBind) } - // Extract flags from options var flags string if options != nil { if f, ok := options["flags"].(string); ok { @@ -290,11 +300,18 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[ canonicalKey := canonicalHyprlandOverrideKey(key) normalizedKey := hyprlandOverrideMapKey(canonicalKey) + var device string + if prev := existingBinds[normalizedKey]; prev != nil { + device = prev.Device + } else { + device = h.mainConfigBindDevice(normalizedKey) + } existingBinds[normalizedKey] = &hyprlandOverrideBind{ Key: canonicalKey, Action: action, Description: description, Flags: flags, + Device: device, Options: options, RawLuaAction: isRawLuaActionText(action), } @@ -302,6 +319,20 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[ return h.writeOverrideBinds(existingBinds) } +func (h *HyprlandProvider) mainConfigBindDevice(normalizedKey string) string { + parser := NewHyprlandParser(h.configPath) + if _, err := parser.ParseWithDMS(); err != nil { + return "" + } + if kb := parser.bindMap[normalizedKey]; kb != nil && kb.Device != "" { + return kb.Device + } + if kb := parser.conflictingConfigs[normalizedKey]; kb != nil { + return kb.Device + } + return "" +} + func (h *HyprlandProvider) RemoveBind(key string) error { if err := h.ensureWritableConfig(); err != nil { return err @@ -333,7 +364,8 @@ type hyprlandOverrideBind struct { Key string Action string Description string - Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press + Flags string // see hyprlandBindFlagOptions + Device string // raw hl.bind device table, written when Flags has k Options map[string]any // Unbind: negative override (hl.unbind only, no rebind). Unbind bool @@ -541,31 +573,6 @@ func splitHyprlandAction(action string) (dispatcher, params string) { return strings.ToLower(strings.TrimSpace(action[:idx])), strings.TrimSpace(action[idx+1:]) } -func isKnownHyprlandDispatcher(dispatcher string) bool { - switch dispatcher { - case "exec", "execr", "spawn", - "killactive", "forcekillactive", "closewindow", "killwindow", - "signal", "signalwindow", "togglefloating", "setfloating", "settiled", - "workspace", "renameworkspace", "fullscreen", "fullscreenstate", "fakefullscreen", - "movetoworkspace", "movetoworkspacesilent", "pseudo", "movefocus", - "movewindow", "swapwindow", "centerwindow", "togglegroup", "changegroupactive", - "movegroupwindow", "focusmonitor", "movecursortocorner", "movecursor", - "workspaceopt", "exit", "movecurrentworkspacetomonitor", "focusworkspaceoncurrentmonitor", - "moveworkspacetomonitor", "togglespecialworkspace", "forcerendererreload", - "resizeactive", "moveactive", "cyclenext", "focuswindowbyclass", "focuswindow", - "tagwindow", "toggleswallow", "submap", "pass", "sendshortcut", "sendkeystate", - "layoutmsg", "splitratio", "dpms", "movewindowpixel", "resizewindowpixel", - "swapnext", "swapactiveworkspaces", "pin", "mouse", "bringactivetotop", - "alterzorder", "focusurgentorlast", "focuscurrentorlast", "lockgroups", - "lockactivegroup", "moveintogroup", "moveoutofgroup", "movewindoworgroup", - "moveintoorcreategroup", "setignoregrouplock", "denywindowfromgroup", "event", - "global", "setprop", "forceidle": - return true - default: - return false - } -} - func firstParam(params string) (head, rest string) { params = strings.TrimSpace(params) if params == "" { @@ -653,8 +660,46 @@ func splitCommaParams(params string) (left, right string) { return left, right } -func luaHyprctlDispatchFunction(action string) string { - return fmt.Sprintf(`function() hl.exec_cmd(%s) end`, strconv.Quote("hyprctl dispatch "+strings.TrimSpace(action))) +func dispatcherFullscreen(params string) string { + mode, rest := firstParam(params) + action, _ := firstParam(rest) + // the editor drops an empty mode, leaving "fullscreen set" + if action == "" && (mode == "toggle" || mode == "set" || mode == "unset") { + mode, action = "", mode + } + switch mode { + case "", "0": + mode = "fullscreen" + case "1": + mode = "maximized" + default: + return "" + } + switch action { + case "": + action = "toggle" + case "toggle", "set", "unset": + default: + return "" + } + return luaDispatcherTableCall("hl.dsp.window.fullscreen", luaStringField("mode", mode), luaStringField("action", action)) +} + +// The dwindle layoutmsg wants `exact` last, unlike legacy `splitratio exact 0.5`. +func dispatcherSplitRatio(params string) string { + fields := strings.Fields(params) + exact := len(fields) > 0 && strings.EqualFold(fields[0], "exact") + if exact { + fields = fields[1:] + } + if len(fields) != 1 { + return "" + } + msg := "splitratio " + fields[0] + if exact { + msg += " exact" + } + return fmt.Sprintf(`hl.dsp.layout(%s)`, strconv.Quote(msg)) } func luaToggleActionValue(params string) string { @@ -772,10 +817,14 @@ func dispatcherTagWindow(params string) string { return luaDispatcherTableCall("hl.dsp.window.tag", fields...) } -func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { +// ok is false when no hl.dsp equivalent exists: legacy text is a Lua error at press time. +func luaActionStringFromHyprlangAction(action string) (string, bool) { dispatcher, params := splitHyprlandAction(action) switch dispatcher { case "spawn", "exec": + if params == "" { + return "", false + } return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(params)), true case "execr": return fmt.Sprintf(`hl.dsp.exec_raw(%s)`, strconv.Quote(params)), true @@ -800,14 +849,9 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { case "settiled": return dispatcherToggleTableCall("hl.dsp.window.float", "off"), true case "fullscreen": - mode := strings.TrimSpace(params) - switch mode { - case "", "0": - return `hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })`, true - case "1": - return `hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, true + if expr := dispatcherFullscreen(params); expr != "" { + return expr, true } - return luaHyprctlDispatchFunction(action), true case "fullscreenstate": internal, rest := firstParam(params) client, _ := firstParam(rest) @@ -817,8 +861,6 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { luaNumberOrStringField("client", client), ), true } - case "fakefullscreen": - return luaHyprctlDispatchFunction(action), true case "pin": if params == "" { return `hl.dsp.window.pin()`, true @@ -849,22 +891,18 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { if expr := dispatcherActiveMoveResize("hl.dsp.window.resize", params); expr != "" { return expr, true } - return luaHyprctlDispatchFunction(action), true case "moveactive": if expr := dispatcherActiveMoveResize("hl.dsp.window.move", params); expr != "" { return expr, true } - return luaHyprctlDispatchFunction(action), true case "resizewindowpixel": if expr := dispatcherWindowMoveResize("hl.dsp.window.resize", params); expr != "" { return expr, true } - return luaHyprctlDispatchFunction(action), true case "movewindowpixel": if expr := dispatcherWindowMoveResize("hl.dsp.window.move", params); expr != "" { return expr, true } - return luaHyprctlDispatchFunction(action), true case "workspace": if params == "" { return "", false @@ -907,8 +945,6 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { if workspace != "" && monitor != "" { return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("workspace", workspace), luaStringField("monitor", monitor)), true } - case "workspaceopt": - return luaHyprctlDispatchFunction(action), true case "swapactiveworkspaces": monitor1, rest := firstParam(params) monitor2, _ := firstParam(rest) @@ -927,10 +963,6 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { if params != "" { return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", params)), true } - case "focuswindowbyclass": - if params != "" { - return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", "class:"+params)), true - } case "focuscurrentorlast": return `hl.dsp.focus({ last = true })`, true case "focusurgentorlast": @@ -939,13 +971,14 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { if expr := dispatcherCycleNext(params); expr != "" { return expr, true } - return luaHyprctlDispatchFunction(action), true case "layoutmsg": if params != "" { return fmt.Sprintf(`hl.dsp.layout(%s)`, strconv.Quote(params)), true } case "splitratio": - return luaHyprctlDispatchFunction(action), true + if expr := dispatcherSplitRatio(params); expr != "" { + return expr, true + } case "alterzorder": mode, window := firstParam(params) if mode != "" { @@ -982,17 +1015,20 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { return expr, true } case "dpms": - dpmsAction := strings.TrimSpace(params) + dpmsAction, monitor := firstParam(params) switch dpmsAction { + case "": + return `hl.dsp.dpms({})`, true case "on": dpmsAction = "enable" case "off": dpmsAction = "disable" } - if dpmsAction == "" { - return `hl.dsp.dpms({})`, true + fields := []luaField{luaStringField("action", dpmsAction)} + if monitor != "" { + fields = append(fields, luaStringField("monitor", monitor)) } - return luaDispatcherTableCall("hl.dsp.dpms", luaStringField("action", dpmsAction)), true + return luaDispatcherTableCall("hl.dsp.dpms", fields...), true case "exit": return `hl.dsp.exit()`, true case "submap": @@ -1002,8 +1038,9 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { case "event": return fmt.Sprintf(`hl.dsp.event(%s)`, strconv.Quote(params)), true case "pass": + // hl.dsp.pass requires a window selector. if params == "" { - return `hl.dsp.pass({})`, true + return "", false } return luaDispatcherTableCall("hl.dsp.pass", luaStringField("window", params)), true case "sendshortcut": @@ -1041,7 +1078,6 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { if expr := dispatcherGroupActive(params); expr != "" { return expr, true } - return luaHyprctlDispatchFunction(action), true case "movegroupwindow": return dispatcherMoveGroupWindow(params), true case "moveintogroup": @@ -1067,31 +1103,18 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { return dispatcherToggleTableCall("hl.dsp.group.lock_active", params), true case "denywindowfromgroup": return dispatcherToggleTableCall("hl.dsp.window.deny_from_group", params), true - case "setignoregrouplock": - return luaHyprctlDispatchFunction(action), true case "forcerendererreload": return `hl.dsp.force_renderer_reload()`, true case "forceidle": if params != "" && isBareLuaNumber(params) { return fmt.Sprintf(`hl.dsp.force_idle(%s)`, params), true } - } - if isKnownHyprlandDispatcher(dispatcher) { - return luaHyprctlDispatchFunction(action), true + case "releaseinputcapture": + return `hl.dsp.release_input_capture()`, true } return "", false } -func luaActionStringFromHyprlangAction(action string) string { - action = strings.TrimSpace(action) - if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok { - return expr - } - // Unrecognized dispatchers are freeform text, not Lua; forward them to - // hyprctl quoted so a stray `"` can't produce broken Lua output. - return luaHyprctlDispatchFunction(action) -} - // luaExprToInternalAction converts a parsed Lua bind expression back into // "dispatcher params" text. isRawLua reports that expr matched no known hl.* // shape and must be re-emitted verbatim as Lua on write-back. @@ -1100,22 +1123,26 @@ func luaExprToInternalAction(expr string) (action string, isRawLua bool) { if d == expr && p == "" { return expr, true } - if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") { - return "exec " + p, false - } + action = d if p != "" { - return d + " " + p, false + action = d + " " + p } - return d, false + // An unmappable legacy hyprctl wrapper stays verbatim so a rewrite never drops the bind. + if _, ok := luaActionStringFromHyprlangAction(action); !ok { + return expr, true + } + return action, false } func luaBindOptions(bind *hyprlandOverrideBind) []string { var opts []string - if strings.Contains(bind.Flags, "l") { - opts = append(opts, "locked = true") + for _, fo := range hyprlandBindFlagOptions { + if strings.IndexByte(bind.Flags, fo.flag) >= 0 { + opts = append(opts, fo.option+" = true") + } } - if strings.Contains(bind.Flags, "e") { - opts = append(opts, "repeating = true") + if bind.Device != "" && strings.Contains(bind.Flags, "k") { + opts = append(opts, "device = "+bind.Device) } if bind.Description != "" { opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description))) @@ -1130,11 +1157,15 @@ func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) { sb.WriteByte('\n') return } - var expr string - if bind.RawLuaAction { - expr = bind.Action - } else { - expr = luaActionStringFromHyprlangAction(bind.Action) + expr := bind.Action + if !bind.RawLuaAction { + mapped, ok := luaActionStringFromHyprlangAction(bind.Action) + if !ok { + // Commented out: the bind would error at press time, but the user's action survives. + fmt.Fprintf(sb, "-- unsupported action for %s: %s\n", key, strings.NewReplacer("\r", " ", "\n", " ").Replace(bind.Action)) + return + } + expr = mapped } opts := luaBindOptions(bind) fmt.Fprintf(sb, `hl.unbind(%s)`, key) @@ -1159,7 +1190,6 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) { internalKey := luaKeyComboToInternalKey(kbc) action, isRawLua := luaExprToInternalAction(actionExpr) - flags := luaBindOptFlags(optSuffix) description := luaBindOptDescription(optSuffix) if description == "" { description = luaLineTrailingComment(line) @@ -1168,7 +1198,8 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) { Key: internalKey, Action: action, Description: description, - Flags: flags, + Flags: luaBindOptFlags(optSuffix), + Device: luaBindOptDevice(optSuffix), RawLuaAction: isRawLua, }, true } @@ -1235,19 +1266,17 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e if kb == nil { continue } - keyStr := parser.formatBindKey(kb) action := kb.Dispatcher if kb.Params != "" { action = kb.Dispatcher + " " + kb.Params } - flags := kb.Flags - keyStr = canonicalHyprlandOverrideKey(keyStr) + keyStr := canonicalHyprlandOverrideKey(parser.formatBindKey(kb)) normalizedKey := hyprlandOverrideMapKey(keyStr) binds[normalizedKey] = &hyprlandOverrideBind{ Key: keyStr, Action: action, Description: kb.Comment, - Flags: flags, + Flags: kb.Flags, } delete(pendingUnbinds, normalizedKey) } diff --git a/core/internal/keybinds/providers/hyprland_catalog_test.go b/core/internal/keybinds/providers/hyprland_catalog_test.go new file mode 100644 index 000000000..fa946602b --- /dev/null +++ b/core/internal/keybinds/providers/hyprland_catalog_test.go @@ -0,0 +1,87 @@ +package providers + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// hyprlandCatalogSamples fills required params for catalog ids the editor +// completes through HYPRLAND_ACTION_ARGS. +var hyprlandCatalogSamples = map[string]string{ + "workspace": "1", + "movetoworkspace": "2", + "movetoworkspacesilent": "2", + "focusworkspaceoncurrentmonitor": "1", + "renameworkspace": "1 work", + "focusmonitor": "DP-1", + "movecurrentworkspacetomonitor": "DP-1", + "moveworkspacetomonitor": "1 DP-1", + "swapactiveworkspaces": "DP-1 DP-2", + "fullscreenstate": "2 0", + "resizeactive": "10 -10", + "moveactive": "10 -10", + "resizewindowpixel": "100 100,class:^(app)$", + "movewindowpixel": "100 100,class:^(app)$", + "splitratio": "+0.1", + "focuswindow": "class:^(app)$", + "alterzorder": "top", + "setprop": "class:^(app)$ opaque 1", + "forceidle": "300", + "movecursortocorner": "0", + "movecursor": "100 100", + "changegroupactive": "2", + "movefocus": "l", + "swapwindow": "l", + "pass": "class:^(app)$", + "sendshortcut": "SUPER F4", + "sendkeystate": "SUPER a down class:^(app)$", + "layoutmsg": "togglesplit", +} + +func jsObjectBlock(t *testing.T, src, name string) string { + t.Helper() + start := strings.Index(src, "const "+name+" = {") + if start < 0 { + t.Fatalf("%s not found in KeybindActions.js", name) + } + end := strings.Index(src[start:], "\n};") + if end < 0 { + t.Fatalf("%s is not terminated", name) + } + return src[start : start+end] +} + +func TestHyprlandKeybindCatalogMapsToLua(t *testing.T) { + data, err := os.ReadFile("../../../../quickshell/Common/KeybindActions.js") + if os.IsNotExist(err) { + t.Skip("quickshell tree not available") + } + if err != nil { + t.Fatal(err) + } + src := string(data) + ids := regexp.MustCompile(`id: "([^"]+)"`).FindAllStringSubmatch(jsObjectBlock(t, src, "HYPRLAND_ACTIONS"), -1) + if len(ids) == 0 { + t.Fatal("no Hyprland catalog ids found") + } + argBases := map[string]bool{} + for _, m := range regexp.MustCompile(`(?m)^ "([a-z_]+)": \{`).FindAllStringSubmatch(jsObjectBlock(t, src, "HYPRLAND_ACTION_ARGS"), -1) { + argBases[m[1]] = true + } + for _, m := range ids { + id := m[1] + if _, ok := luaActionStringFromHyprlangAction(id); ok { + continue + } + sample, hasSample := hyprlandCatalogSamples[id] + if !argBases[id] || !hasSample { + t.Errorf("catalog id %q has no Lua dispatcher", id) + continue + } + if _, ok := luaActionStringFromHyprlangAction(id + " " + sample); !ok { + t.Errorf("catalog id %q with %q has no Lua dispatcher", id, sample) + } + } +} diff --git a/core/internal/keybinds/providers/hyprland_parser.go b/core/internal/keybinds/providers/hyprland_parser.go index 6c8273446..d83f29601 100644 --- a/core/internal/keybinds/providers/hyprland_parser.go +++ b/core/internal/keybinds/providers/hyprland_parser.go @@ -28,7 +28,8 @@ type HyprlandKeyBinding struct { Params string `json:"params"` Comment string `json:"comment"` Source string `json:"source"` - Flags string `json:"flags"` // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press + Flags string `json:"flags"` // hl.bind options as letters, see hyprlandBindFlagOptions; k=device, d=description + Device string `json:"-"` // raw Lua device table } type HyprlandSection struct { @@ -122,7 +123,15 @@ func (p *HyprlandParser) ReadContent(directory string) error { return nil } +var hyprlandDirectionWords = map[string]string{ + "l": "left", "left": "left", + "r": "right", "right": "right", + "u": "up", "up": "up", "t": "up", + "d": "down", "down": "down", "b": "down", +} + func hyprlandAutogenerateComment(dispatcher, params string) string { + dir, isDir := hyprlandDirectionWords[params] switch dispatcher { case "resizewindow": return "Resize window" @@ -131,16 +140,52 @@ func hyprlandAutogenerateComment(dispatcher, params string) string { if params == "" { return "Move window" } - dirMap := map[string]string{ - "l": "left", - "r": "right", - "u": "up", - "d": "down", + if monitor, ok := strings.CutPrefix(params, "mon:"); ok { + return "move to " + describeHyprlandMonitor(monitor) } - if dir, ok := dirMap[params]; ok { + if isDir { return "move in " + dir + " direction" } - return "move in null direction" + return "move window " + params + + case "movewindoworgroup": + if isDir { + return "move window or group " + dir + } + return "move window or group " + params + + case "moveintogroup": + if isDir { + return "move into " + dir + " group" + } + return "move into group " + params + + case "moveintoorcreategroup": + if isDir { + return "move into or create " + dir + " group" + } + return "move into or create group " + params + + case "moveoutofgroup": + return "move out of group" + + case "togglegroup": + return "toggle group" + + case "changegroupactive": + switch params { + case "f", "": + return "next window in group" + case "b": + return "previous window in group" + } + return "focus group window " + params + + case "movegroupwindow": + if params == "b" { + return "move window back in group" + } + return "move window forward in group" case "pin": return "pin (show on all workspaces)" @@ -148,28 +193,55 @@ func hyprlandAutogenerateComment(dispatcher, params string) string { case "splitratio": return "Window split ratio " + params + case "layoutmsg": + switch params { + case "togglesplit": + return "Toggle split" + case "swapsplit": + return "Swap split" + } + if ratio, ok := strings.CutPrefix(params, "splitratio "); ok { + return "Window split ratio " + ratio + } + return "Layout: " + params + case "togglefloating": return "Float/unfloat window" case "resizeactive": - return "Resize window by " + params + return describeHyprlandResize(params) + + case "moveactive": + return describeHyprlandMove(params) case "killactive": return "Close window" case "fullscreen": + mode, action := firstParam(params) fsMap := map[string]string{ + "": "fullscreen", "0": "fullscreen", "1": "maximization", "2": "fullscreen on Hyprland's side", } - if fs, ok := fsMap[params]; ok { - return "Toggle " + fs + fs, ok := fsMap[mode] + if !ok { + fs = "fullscreen" } - return "Toggle null" + switch action { + case "set": + return "Enter " + fs + case "unset": + return "Exit " + fs + } + return "Toggle " + fs - case "fakefullscreen": - return "Toggle fake fullscreen" + case "focuswindow": + if class, ok := strings.CutPrefix(params, "class:"); ok { + return "focus " + class + " window" + } + return "focus window " + params case "workspace": switch params { @@ -178,47 +250,42 @@ func hyprlandAutogenerateComment(dispatcher, params string) string { case "-1": return "focus left" } - return "focus workspace " + params + return "focus " + describeHyprlandWorkspace(params) + case "movefocus": - dirMap := map[string]string{ - "l": "left", - "r": "right", - "u": "up", - "d": "down", - } - if dir, ok := dirMap[params]; ok { + if isDir { return "move focus " + dir } - return "move focus null" + return "move focus " + params + + case "focusmonitor": + return "focus " + describeHyprlandMonitor(params) case "swapwindow": - dirMap := map[string]string{ - "l": "left", - "r": "right", - "u": "up", - "d": "down", - } - if dir, ok := dirMap[params]; ok { + if isDir { return "swap in " + dir + " direction" } - return "swap in null direction" + return "swap window " + params case "movetoworkspace": - switch params { + ws, _ := firstParam(params) + switch ws { case "+1": return "move to right workspace (non-silent)" case "-1": return "move to left workspace (non-silent)" } - return "move to workspace " + params + " (non-silent)" + return "move to " + describeHyprlandWorkspace(ws) + " (non-silent)" + case "movetoworkspacesilent": - switch params { + ws, _ := firstParam(params) + switch ws { case "+1": return "move to right workspace" case "-1": - return "move to right workspace" + return "move to left workspace" } - return "move to workspace " + params + return "move to " + describeHyprlandWorkspace(ws) case "togglespecialworkspace": return "toggle special" @@ -231,6 +298,109 @@ func hyprlandAutogenerateComment(dispatcher, params string) string { } } +func describeHyprlandWorkspace(ws string) string { + if name, ok := strings.CutPrefix(ws, "special:"); ok { + return "special workspace " + name + } + if ws == "special" { + return "special workspace" + } + return "workspace " + ws +} + +func describeHyprlandMonitor(monitor string) string { + if dir, ok := hyprlandDirectionWords[monitor]; ok { + return dir + " monitor" + } + switch monitor { + case "+1": + return "next monitor" + case "-1": + return "previous monitor" + } + return "monitor " + monitor +} + +func hyprlandSignedAmount(value string) (sign int, magnitude string, ok bool) { + magnitude = strings.TrimPrefix(strings.TrimPrefix(value, "+"), "-") + number := strings.TrimSuffix(magnitude, "%") + f, err := strconv.ParseFloat(number, 64) + if err != nil { + return 0, "", false + } + switch { + case f == 0: + return 0, magnitude, true + case strings.HasPrefix(value, "-"): + return -1, magnitude, true + default: + return 1, magnitude, true + } +} + +func describeHyprlandResize(params string) string { + x, y, relative, ok := xyParams(params) + if !ok { + return "Resize window by " + params + } + if !relative { + return "Resize window to " + x + "x" + y + } + sx, mx, okX := hyprlandSignedAmount(x) + sy, my, okY := hyprlandSignedAmount(y) + if !okX || !okY || (sx == 0 && sy == 0) { + return "Resize window by " + x + " " + y + } + verb := func(sign int) string { + if sign < 0 { + return "shrink" + } + return "grow" + } + var parts []string + if sx != 0 { + parts = append(parts, verb(sx)+" window width by "+mx) + } + if sy != 0 { + noun := " window height by " + if sx != 0 { + noun = " height by " + } + parts = append(parts, verb(sy)+noun+my) + } + desc := strings.Join(parts, ", ") + return strings.ToUpper(desc[:1]) + desc[1:] +} + +func describeHyprlandMove(params string) string { + x, y, relative, ok := xyParams(params) + if !ok { + return "Move window by " + params + } + if !relative { + return "Move window to " + x + " " + y + } + sx, mx, okX := hyprlandSignedAmount(x) + sy, my, okY := hyprlandSignedAmount(y) + if !okX || !okY || (sx == 0 && sy == 0) { + return "Move window by " + x + " " + y + } + var parts []string + switch sx { + case -1: + parts = append(parts, "left by "+mx) + case 1: + parts = append(parts, "right by "+mx) + } + switch sy { + case -1: + parts = append(parts, "up by "+my) + case 1: + parts = append(parts, "down by "+my) + } + return "Move window " + strings.Join(parts, ", ") +} + func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding { line := p.contentLines[lineNumber] return p.parseBindLine(line) @@ -307,6 +477,7 @@ type HyprlandParseResult struct { 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" MainMod string // value of the mainMod Lua variable when the config defines one } @@ -661,6 +832,7 @@ func (p *HyprlandParser) parseLuaLines(content string, baseDir, absPath, section } kb := luaKeyComboToBinding(kbc, action, p.currentSource, desc) kb.Flags = flags + kb.Device = luaBindOptDevice(optSuffix) if p.addBind(kb) { section.Keybinds = append(section.Keybinds, *kb) } @@ -678,88 +850,131 @@ func (p *HyprlandParser) noteRebindSource(key, source string) { p.rebindSources[key][source] = true } +// mouse and auto_consuming work in Hyprland but are missing from hl.meta.lua; separate (s) has no Lua option. +var hyprlandBindFlagOptions = []struct { + flag byte + option string +}{ + {'l', "locked"}, + {'e', "repeating"}, + {'r', "release"}, + {'n', "non_consuming"}, + {'m', "mouse"}, + {'t', "transparent"}, + {'i', "ignore_mods"}, + {'o', "long_press"}, + {'u', "submap_universal"}, + {'a', "auto_consuming"}, + {'x', "allow_input_capture"}, + {'p', "dont_inhibit"}, + {'c', "click"}, + {'g', "drag"}, +} + func luaBindOptFlags(optSuffix string) string { optSuffix = strings.TrimSpace(optSuffix) if optSuffix == "" { return "" } - var flags string - if strings.Contains(optSuffix, "repeating") { - flags += "e" + var flags []byte + for _, fo := range hyprlandBindFlagOptions { + if strings.Contains(optSuffix, fo.option) && luaTableBoolFieldValue(optSuffix, fo.option) { + flags = append(flags, fo.flag) + } } - if strings.Contains(optSuffix, "locked") { - flags += "l" + if luaBindOptDevice(optSuffix) != "" { + flags = append(flags, 'k') } - if strings.Contains(optSuffix, "description") { - flags += "d" + if luaBindOptDescription(optSuffix) != "" { + flags = append(flags, 'd') } - return flags + return string(flags) } +// Returned raw so per-device binds survive a rewrite. +func luaBindOptDevice(optSuffix string) string { + loc := luaDeviceOptPattern.FindStringIndex(optSuffix) + if loc == nil { + return "" + } + start := loc[1] - 1 + depth := 0 + var quote byte + for i := start; i < len(optSuffix); i++ { + c := optSuffix[i] + switch { + case quote != 0: + switch c { + case '\\': + i++ + case quote: + quote = 0 + } + case c == '"' || c == '\'': + quote = c + case c == '{': + depth++ + case c == '}': + depth-- + if depth == 0 { + return optSuffix[start : i+1] + } + } + } + return "" +} + +var luaDeviceOptPattern = regexp.MustCompile(`\bdevice\s*=\s*\{`) + 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 } - // Extract bind type and flags from the left side of "=" - bindType := strings.TrimSpace(parts[0]) - flags := extractBindFlags(bindType) - hasDescFlag := strings.Contains(flags, "d") - - keys := parts[1] - keyParts := strings.SplitN(keys, "#", 2) - keys = keyParts[0] - - var comment string - if len(keyParts) > 1 { - comment = strings.TrimSpace(keyParts[1]) + var flags string + if bindType := strings.TrimSpace(parts[0]); bindType != CommentBindPattern { + var ok bool + if flags, ok = extractBindFlags(bindType); !ok { + return nil + } } - // For bindd, the format is: bindd = MODS, key, description, dispatcher, params - // For regular binds: bind = MODS, key, dispatcher, params - var minFields, descIndex, dispatcherIndex int - if hasDescFlag { - minFields = 4 // mods, key, description, dispatcher - descIndex = 2 - dispatcherIndex = 3 - } else { - minFields = 3 // mods, key, dispatcher - dispatcherIndex = 2 + 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, ",", minFields+2) // Allow for params - if len(keyFields) < minFields { + keyFields := strings.SplitN(keys, ",", dispatcherIndex+2) + if len(keyFields) <= dispatcherIndex { return nil } mods := strings.TrimSpace(keyFields[0]) key := strings.TrimSpace(keyFields[1]) - - var dispatcher, params string - if hasDescFlag { - // bindd format: description is in the bind itself - if comment == "" { - comment = strings.TrimSpace(keyFields[descIndex]) - } - dispatcher = strings.TrimSpace(keyFields[dispatcherIndex]) - if len(keyFields) > dispatcherIndex+1 { - paramParts := keyFields[dispatcherIndex+1:] - params = strings.TrimSpace(strings.Join(paramParts, ",")) - } - } else { - dispatcher = strings.TrimSpace(keyFields[dispatcherIndex]) - if len(keyFields) > dispatcherIndex+1 { - paramParts := keyFields[dispatcherIndex+1:] - params = strings.TrimSpace(strings.Join(paramParts, ",")) - } + 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 comment != "" && strings.HasPrefix(comment, HideComment) { + if strings.HasPrefix(comment, HideComment) { return nil } @@ -772,8 +987,7 @@ func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding { modstring := mods + string(ModSeparators[0]) idx := 0 for index, char := range modstring { - isModSep := slices.Contains(ModSeparators, char) - if isModSep { + if slices.Contains(ModSeparators, char) { if index-idx > 1 { modList = append(modList, modstring[idx:index]) } @@ -792,14 +1006,32 @@ func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding { } } -// extractBindFlags extracts the flags from a bind type string -// e.g., "binde" -> "e", "bindel" -> "el", "bindd" -> "d" -func extractBindFlags(bindType string) string { - bindType = strings.TrimSpace(bindType) - if !strings.HasPrefix(bindType, "bind") { - return "" +// 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 } - return bindType[4:] // Everything after "bind" + 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) { @@ -815,6 +1047,7 @@ func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) { DMSStatus: parser.buildDMSStatus(), ConflictingConfigs: parser.conflictingConfigs, DefaultDMSKeys: parser.defaultDMSKeys, + ConfigFormat: parser.configFormat, MainMod: parser.mainMod, }, nil } @@ -907,6 +1140,10 @@ func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok boo case '(': parenDepth++ case ')': + if parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && functionDepth == 0 { + // the enclosing call's close paren, e.g. the end of hl.bind(...) + return strings.TrimSpace(line[start:i]), i, true + } if parenDepth > 0 { parenDepth-- } @@ -1027,7 +1264,8 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { return "exec", u } } - return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd")) + // A variable argument (exec_cmd(terminal)) can't be resolved here. + return expr, "" case strings.HasPrefix(expr, "hl.dsp.exec_raw("): return "execr", luaCallStringArgValue(expr, "hl.dsp.exec_raw") case strings.HasPrefix(expr, "hl.dispatch("): @@ -1056,13 +1294,21 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { } return "forcekillactive", "" case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("): - switch luaTableStringField(expr, "mode") { + mode := luaTableStringField(expr, "mode") + switch mode { case "maximized", "maximize": - return "fullscreen", "1" + mode = "1" case "fullscreen": - return "fullscreen", "0" + mode = "0" } - return "fullscreen", luaTableStringField(expr, "mode") + switch action := luaTableStringField(expr, "action"); action { + case "set", "unset": + if mode == "" { + mode = "0" + } + return "fullscreen", mode + " " + action + } + return "fullscreen", mode case strings.HasPrefix(expr, "hl.dsp.window.fullscreen_state("): internal := luaStringValue(luaTableScalarField(expr, "internal")) client := luaStringValue(luaTableScalarField(expr, "client")) @@ -1255,16 +1501,18 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { return "layoutmsg", arg } case strings.HasPrefix(expr, "hl.dsp.dpms("): - if action := luaTableStringField(expr, "action"); action != "" { - switch action { - case "enable": - return "dpms", "on" - case "disable": - return "dpms", "off" - } - return "dpms", action + action := luaTableStringField(expr, "action") + switch action { + case "enable": + action = "on" + case "disable": + action = "off" } - return "dpms", "" + monitor := luaTableStringField(expr, "monitor") + if action == "" && monitor != "" { + action = "toggle" + } + return joinDispatcherParams("dpms", action, monitor) case strings.HasPrefix(expr, "hl.dsp.submap("): return "submap", luaCallStringArgValue(expr, "hl.dsp.submap") case strings.HasPrefix(expr, "hl.dsp.global("): @@ -1284,6 +1532,8 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { return "movecursortocorner", luaStringValue(luaTableScalarField(expr, "corner")) case strings.HasPrefix(expr, "hl.dsp.cursor.move("): return joinDispatcherParams("movecursor", luaStringValue(luaTableScalarField(expr, "x")), luaStringValue(luaTableScalarField(expr, "y"))) + case expr == "hl.dsp.release_input_capture()": + return "releaseinputcapture", "" case strings.Contains(expr, "hl.dsp.force_renderer_reload()"): return "forcerendererreload", "" case strings.HasPrefix(expr, "hl.dsp.force_idle("): @@ -1526,7 +1776,6 @@ func isDMSBindsPrimarySourcePath(p string) bool { return p == "dms/binds.conf" || p == "./dms/binds.conf" } -// hyprlandMainConfigPath returns hyprland.lua if present, else hyprland.conf if present. func hyprlandMainConfigPath(dir string) (string, error) { expandedDir, err := utils.ExpandPath(dir) if err != nil { diff --git a/core/internal/keybinds/providers/hyprland_parser_test.go b/core/internal/keybinds/providers/hyprland_parser_test.go index 51be6968f..fd3a7628f 100644 --- a/core/internal/keybinds/providers/hyprland_parser_test.go +++ b/core/internal/keybinds/providers/hyprland_parser_test.go @@ -24,12 +24,26 @@ func TestHyprlandAutogenerateComment(t *testing.T) { {"pin", "", "pin (show on all workspaces)"}, {"splitratio", "0.5", "Window split ratio 0.5"}, {"togglefloating", "", "Float/unfloat window"}, - {"resizeactive", "10 20", "Resize window by 10 20"}, + {"resizeactive", "10 20", "Grow window width by 10, grow height by 20"}, + {"resizeactive", "-100 0", "Shrink window width by 100"}, + {"resizeactive", "0 -5%", "Shrink window height by 5%"}, + {"resizeactive", "exact 1280 720", "Resize window to 1280x720"}, + {"moveactive", "-50 0", "Move window left by 50"}, + {"movewindow", "mon:l", "move to left monitor"}, + {"movewindow", "mon:+1", "move to next monitor"}, + {"movewindow", "mon:DP-1", "move to monitor DP-1"}, + {"focuswindow", "first", "focus window first"}, + {"focuswindow", "class:kitty", "focus kitty window"}, + {"movetoworkspacesilent", "special:magic", "move to special workspace magic"}, + {"moveintogroup", "l", "move into left group"}, + {"changegroupactive", "b", "previous window in group"}, + {"layoutmsg", "togglesplit", "Toggle split"}, + {"layoutmsg", "splitratio 0.1", "Window split ratio 0.1"}, + {"fullscreen", "1 set", "Enter maximization"}, {"killactive", "", "Close window"}, {"fullscreen", "0", "Toggle fullscreen"}, {"fullscreen", "1", "Toggle maximization"}, {"fullscreen", "2", "Toggle fullscreen on Hyprland's side"}, - {"fakefullscreen", "", "Toggle fake fullscreen"}, {"workspace", "+1", "focus right"}, {"workspace", "-1", "focus left"}, {"workspace", "5", "focus workspace 5"}, @@ -45,7 +59,7 @@ func TestHyprlandAutogenerateComment(t *testing.T) { {"movetoworkspace", "-1", "move to left workspace (non-silent)"}, {"movetoworkspace", "3", "move to workspace 3 (non-silent)"}, {"movetoworkspacesilent", "+1", "move to right workspace"}, - {"movetoworkspacesilent", "-1", "move to right workspace"}, + {"movetoworkspacesilent", "-1", "move to left workspace"}, {"movetoworkspacesilent", "2", "move to workspace 2"}, {"togglespecialworkspace", "", "toggle special"}, {"exec", "firefox", "firefox"}, @@ -101,6 +115,11 @@ func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) { {`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"}, {`hl.dsp.workspace.rename({ workspace = "1", name = "work" })`, "renameworkspace", "1 work"}, {`hl.dsp.no_op()`, "hl.dsp.no_op()", ""}, + {`hl.dsp.exec_cmd(terminal)`, "hl.dsp.exec_cmd(terminal)", ""}, + {`hl.dsp.window.fullscreen({ mode = "maximized", action = "unset" })`, "fullscreen", "1 unset"}, + {`hl.dsp.dpms({ action = "disable", monitor = "DP-1" })`, "dpms", "off DP-1"}, + {`hl.dsp.dpms({ monitor = "DP-1" })`, "dpms", "toggle DP-1"}, + {`hl.dsp.release_input_capture()`, "releaseinputcapture", ""}, } for _, tt := range tests { @@ -160,21 +179,16 @@ hl.bind("SUPER + U", hl.dsp.no_op(), { description = "Custom Lua" })` } } -func TestWriteLuaBindLineQuotesUnrecognizedActionWithoutRawLuaFlag(t *testing.T) { +func TestWriteLuaBindLineCommentsOutUnrecognizedAction(t *testing.T) { var sb strings.Builder writeLuaBindLine(&sb, &hyprlandOverrideBind{ Key: "Super+u", - Action: `customdispatcher "),os.execute("id")--`, + Action: "customdispatcher x\nos.execute(\"id\")", }) - got := sb.String() - if !strings.Contains(got, "hl.exec_cmd(") { - t.Fatalf("expected unrecognized action to go through the hyprctl-dispatch wrapper, got %q", got) - } - // an unpaired bare quote means the action broke out of its string literal - withoutEscapedQuotes := strings.ReplaceAll(got, `\"`, "") - if n := strings.Count(withoutEscapedQuotes, `"`); n%2 != 0 { - t.Fatalf("action broke out of its string literal (%d unpaired quotes): %q", n, got) + got := strings.TrimSuffix(sb.String(), "\n") + if strings.Contains(got, "\n") || !strings.HasPrefix(got, "-- ") { + t.Fatalf("expected a single comment line, got %q", got) } } @@ -208,48 +222,98 @@ func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) { {"bringactivetotop", `hl.dsp.window.bring_to_top()`}, {"toggleswallow", `hl.dsp.window.toggle_swallow()`}, {"forceidle 300", `hl.dsp.force_idle(300)`}, + {"splitratio +0.1", `hl.dsp.layout("splitratio +0.1")`}, + {"splitratio exact 0.5", `hl.dsp.layout("splitratio 0.5 exact")`}, + {"fullscreen 0 set", `hl.dsp.window.fullscreen({ mode = "fullscreen", action = "set" })`}, + {"fullscreen 1 unset", `hl.dsp.window.fullscreen({ mode = "maximized", action = "unset" })`}, + {"fullscreen", `hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })`}, + {"releaseinputcapture", `hl.dsp.release_input_capture()`}, + {"dpms off DP-1", `hl.dsp.dpms({ action = "disable", monitor = "DP-1" })`}, + {"pseudo", `hl.dsp.window.pseudo({ action = "toggle" })`}, + {"moveintoorcreategroup l", `hl.dsp.window.move({ into_or_create_group = "l" })`}, + {"layoutmsg swapsplit", `hl.dsp.layout("swapsplit")`}, } for _, tt := range tests { t.Run(tt.action, func(t *testing.T) { - got := luaActionStringFromHyprlangAction(tt.action) - if got != tt.want { - t.Fatalf("luaActionStringFromHyprlangAction(%q) = %q, want %q", tt.action, got, tt.want) + got, ok := luaActionStringFromHyprlangAction(tt.action) + if !ok || got != tt.want { + t.Fatalf("luaActionStringFromHyprlangAction(%q) = %q, %v; want %q", tt.action, got, ok, tt.want) } - if strings.Contains(got, "hyprctl dispatch") { - t.Fatalf("expected native Lua dispatcher, got legacy dispatch wrapper: %q", got) + // each mapping must parse back to the same action + if again, _ := luaActionStringFromHyprlangAction(strings.Join(nonEmpty(luaExprToDispatcherParams(got)), " ")); again != got { + t.Fatalf("round trip of %q produced %q", got, again) } }) } } -func TestLuaActionStringFallsBackForUnsupportedResizePercentages(t *testing.T) { - got := luaActionStringFromHyprlangAction("resizeactive exact 100% 100%") - want := `function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end` - if got != want { - t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want) +func nonEmpty(values ...string) []string { + out := values[:0] + for _, v := range values { + if v != "" { + out = append(out, v) + } + } + return out +} + +func TestHyprlandRejectsActionsWithoutLuaDispatcher(t *testing.T) { + actions := []string{ + "resizeactive exact 100% 100%", + "fakefullscreen", + "focuswindowbyclass kitty", + "togglesplit", + "swapsplit", + "workspaceopt allfloat", + "setignoregrouplock on", + "fullscreen 2", + "pass", + "customdispatcher arg", + } + p := NewHyprlandProvider(t.TempDir()) + for _, action := range actions { + t.Run(action, func(t *testing.T) { + if got, ok := luaActionStringFromHyprlangAction(action); ok { + t.Fatalf("luaActionStringFromHyprlangAction(%q) = %q, want no mapping", action, got) + } + if err := p.validateAction(action); err == nil { + t.Fatalf("validateAction(%q) = nil, want error", action) + } + }) + } + for _, action := range []string{"hl.dsp.no_op()", "splitratio -0.1"} { + if err := p.validateAction(action); err != nil { + t.Fatalf("validateAction(%q) = %v, want nil", action, err) + } } } -func TestParseLuaBindLineHandlesFunctionDispatcherFallback(t *testing.T) { - line := `hl.bind("SUPER + R", function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end, { description = "Unsupported Resize" })` +func TestParseLuaBindLineKeepsUnmappableDispatchWrapperVerbatim(t *testing.T) { + expr := `function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end` + line := `hl.bind("SUPER + R", ` + expr + `, { description = "Unsupported Resize" })` got, ok := parseLuaBindOverrideLine(line) if !ok { t.Fatalf("expected line to parse") } - if got.Action != "resizeactive exact 100% 100%" { - t.Fatalf("Action = %q, want resizeactive exact 100%% 100%%", got.Action) + if got.Action != expr || !got.RawLuaAction { + t.Fatalf("Action = %q (raw %v), want verbatim %q", got.Action, got.RawLuaAction, expr) } if got.Description != "Unsupported Resize" { t.Fatalf("Description = %q, want Unsupported Resize", got.Description) } } -func TestLuaActionStringFromHyprlangActionAlwaysQuotesUnrecognizedText(t *testing.T) { - got := luaActionStringFromHyprlangAction("hl.dsp.no_op()") - want := `function() hl.exec_cmd("hyprctl dispatch hl.dsp.no_op()") end` - if got != want { - t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want) +func TestParseLuaBindLineUpgradesMappableDispatchWrapper(t *testing.T) { + line := `hl.bind("SUPER + O", function() hl.exec_cmd("hyprctl dispatch splitratio +0.1") end)` + got, ok := parseLuaBindOverrideLine(line) + if !ok { + t.Fatalf("expected line to parse") + } + var sb strings.Builder + writeLuaBindLine(&sb, got) + if !strings.Contains(sb.String(), `hl.bind("SUPER + O", hl.dsp.layout("splitratio +0.1"))`) { + t.Fatalf("expected native layout dispatcher, got %q", sb.String()) } } @@ -444,25 +508,6 @@ func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) { } } -func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) { - tmpDir := t.TempDir() - if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("bind = SUPER, T, exec, kitty\n"), 0o644); err != nil { - t.Fatal(err) - } - - provider := NewHyprlandProvider(tmpDir) - err := provider.SetBind("SUPER+N", "workspace 1", "Workspace 1", nil) - if err == nil { - t.Fatal("expected SetBind to reject conf-only Hyprland config") - } - if !strings.Contains(err.Error(), "read-only") { - t.Fatalf("expected read-only error, got %v", err) - } - if _, err := os.Stat(filepath.Join(tmpDir, "dms", "binds-user.lua")); !os.IsNotExist(err) { - t.Fatalf("expected no Lua override to be created for conf-only config, stat err=%v", err) - } -} - func TestIsRawLuaActionText(t *testing.T) { cases := []struct { action string @@ -722,6 +767,98 @@ hl.bind("SUPER + Z", hl.dsp.exec_cmd("custom"))`, } } +func TestHyprlandBindFlagsRoundTrip(t *testing.T) { + tests := []struct { + flag string + opt string + }{ + {"l", "locked = true"}, + {"e", "repeating = true"}, + {"r", "release = true"}, + {"n", "non_consuming = true"}, + {"m", "mouse = true"}, + {"t", "transparent = true"}, + {"i", "ignore_mods = true"}, + {"o", "long_press = true"}, + {"u", "submap_universal = true"}, + {"a", "auto_consuming = true"}, + {"x", "allow_input_capture = true"}, + {"p", "dont_inhibit = true"}, + {"c", "click = true"}, + {"g", "drag = true"}, + {"k", `device = { inclusive = false, list = { "my-kb", "other" } }`}, + } + for _, tt := range tests { + t.Run(tt.flag, func(t *testing.T) { + line := `hl.bind("SUPER + X", hl.dsp.window.close(), { ` + tt.opt + ` })` + kb, ok := parseLuaBindOverrideLine(line) + if !ok { + t.Fatalf("parse failed for %q", line) + } + if kb.Flags != tt.flag { + t.Fatalf("Flags = %q, want %q", kb.Flags, tt.flag) + } + var sb strings.Builder + writeLuaBindLine(&sb, kb) + if !strings.Contains(sb.String(), line) { + t.Fatalf("rewrite = %q, want it to contain %q", sb.String(), line) + } + }) + } +} + +func TestHyprlandSetBindKeepsDeviceTable(t *testing.T) { + tmpDir := t.TempDir() + dmsDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(dmsDir, 0o755); err != nil { + t.Fatal(err) + } + device := `device = { inclusive = true, list = { "my-kb" } }` + if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(`hl.bind("SUPER + X", hl.dsp.window.close(), { `+device+` })`), 0o644); err != nil { + t.Fatal(err) + } + if err := NewHyprlandProvider(tmpDir).SetBind("SUPER+X", "togglefloating", "", map[string]any{"flags": "k"}); err != nil { + t.Fatal(err) + } + if got := readFile(t, filepath.Join(dmsDir, "binds-user.lua")); !strings.Contains(got, device) { + t.Fatalf("expected device table to survive the edit, got:\n%s", got) + } +} + +func TestHyprlandSetBindKeepsMainConfigDeviceTable(t *testing.T) { + tmpDir := t.TempDir() + device := `device = { list = { "my-kb" } }` + main := `hl.bind("SUPER + X", hl.dsp.window.close(), { ` + device + ` })` + "\n" + if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(main), 0o644); err != nil { + t.Fatal(err) + } + if err := NewHyprlandProvider(tmpDir).SetBind("SUPER+X", "togglefloating", "", map[string]any{"flags": "k"}); err != nil { + t.Fatal(err) + } + if got := readFile(t, filepath.Join(tmpDir, "dms", "binds-user.lua")); !strings.Contains(got, device) { + t.Fatalf("expected the main config device table on the override, got:\n%s", got) + } +} + +func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) { + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("bind = SUPER, T, exec, kitty\n"), 0o644); err != nil { + t.Fatal(err) + } + + provider := NewHyprlandProvider(tmpDir) + err := provider.SetBind("SUPER+N", "workspace 1", "Workspace 1", nil) + if err == nil { + t.Fatal("expected SetBind to reject conf-only Hyprland config") + } + if !strings.Contains(err.Error(), "read-only") { + t.Fatalf("expected read-only error, got %v", err) + } + if _, err := os.Stat(filepath.Join(tmpDir, "dms", "binds-user.lua")); !os.IsNotExist(err) { + t.Fatalf("expected no Lua override to be created for conf-only config, stat err=%v", err) + } +} + func TestHyprlandGetKeybindAtLine(t *testing.T) { tests := []struct { name string @@ -1073,18 +1210,25 @@ func TestExtractBindFlags(t *testing.T) { {"bindem", "em"}, {" bind ", ""}, {" binde ", "e"}, - {"notbind", ""}, - {"", ""}, + {"bindkd", "kd"}, + {"bindux", "ux"}, + {"bindpcg", "pcg"}, } for _, tt := range tests { t.Run(tt.bindType, func(t *testing.T) { - result := extractBindFlags(tt.bindType) - if result != tt.expected { - t.Errorf("extractBindFlags(%q) = %q, want %q", tt.bindType, result, tt.expected) + 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) { @@ -1152,6 +1296,30 @@ func TestHyprlandBindFlags(t *testing.T) { expectedDisp: "exec", expectedDesc: "wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+", }, + { + 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 { diff --git a/core/internal/keybinds/providers/hyprland_test.go b/core/internal/keybinds/providers/hyprland_test.go index 87af2c5aa..4c98d6e9c 100644 --- a/core/internal/keybinds/providers/hyprland_test.go +++ b/core/internal/keybinds/providers/hyprland_test.go @@ -37,19 +37,12 @@ func TestNewHyprlandProvider(t *testing.T) { func TestHyprlandProviderGetCheatSheet(t *testing.T) { tmpDir := t.TempDir() - configFile := filepath.Join(tmpDir, "hyprland.conf") + configFile := filepath.Join(tmpDir, "hyprland.lua") - 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 -bind = SUPER, 1, workspace, 1 + content := `hl.bind("SUPER + Q", hl.dsp.window.close()) +hl.bind("SUPER + left", hl.dsp.focus({ direction = "l" })) +hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"), { description = "Terminal" }) +hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" })) ` if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { @@ -99,7 +92,7 @@ func TestHyprlandProviderGetCheatSheetError(t *testing.T) { func TestFormatKey(t *testing.T) { tmpDir := t.TempDir() - configFile := filepath.Join(tmpDir, "hyprland.conf") + configFile := filepath.Join(tmpDir, "hyprland.lua") tests := []struct { name string @@ -109,19 +102,19 @@ func TestFormatKey(t *testing.T) { }{ { name: "single mod", - content: "bind = SUPER, Q, killactive", + content: `hl.bind("SUPER + Q", hl.dsp.window.close())`, expected: "SUPER+Q", category: "Window", }, { name: "multiple mods", - content: "bind = SUPER+SHIFT, F, fullscreen, 0", + content: `hl.bind("SUPER + SHIFT + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))`, expected: "SUPER+SHIFT+F", category: "Window", }, { name: "no mods", - content: "bind = , Print, exec, screenshot", + content: `hl.bind("Print", hl.dsp.exec_cmd("screenshot"))`, expected: "Print", category: "Execute", }, @@ -153,7 +146,7 @@ func TestFormatKey(t *testing.T) { func TestDescriptionFallback(t *testing.T) { tmpDir := t.TempDir() - configFile := filepath.Join(tmpDir, "hyprland.conf") + configFile := filepath.Join(tmpDir, "hyprland.lua") tests := []struct { name string @@ -162,23 +155,23 @@ func TestDescriptionFallback(t *testing.T) { }{ { name: "autogenerated description for known dispatcher", - content: "bind = SUPER, Q, killactive", + content: `hl.bind("SUPER + Q", hl.dsp.window.close())`, wantDesc: "Close window", }, { name: "custom comment overrides autogeneration", - content: "bind = SUPER, T, exec, kitty # Open terminal", + content: `hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"), { description = "Open terminal" })`, wantDesc: "Open terminal", }, { - name: "fallback for unknown dispatcher without params", - content: "bind = SUPER, W, unknowndispatcher", - wantDesc: "unknowndispatcher", + name: "trailing comment overrides autogeneration", + content: `hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty")) -- Open terminal`, + wantDesc: "Open terminal", }, { - name: "fallback for unknown dispatcher with params", - content: "bind = SUPER, X, customdispatcher, arg1", - wantDesc: "customdispatcher arg1", + name: "fallback for unknown Lua expression", + content: `hl.bind("SUPER + W", hl.dsp.no_op())`, + wantDesc: "hl.dsp.no_op()", }, } @@ -232,3 +225,90 @@ hl.bind(mainMod .. " + T", hl.dsp.exec_cmd("kitty")) t.Errorf("Mod = %+v, want %+v", sheet.Mod, want) } } + +func TestHyprlandConvertKeybindLuaAction(t *testing.T) { + tests := []struct { + dispatcher, params string + want string + }{ + {"movefocus", "l", `hl.dsp.focus({ direction = "l" })`}, + {"killactive", "", `hl.dsp.window.close()`}, + {"workspace", "1", `hl.dsp.focus({ workspace = "1" })`}, + {"exec", "foo", `hl.dsp.exec_cmd("foo")`}, + {"workspaceopt", "allfloat", ""}, + {"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} + if got := p.convertKeybind(kb, "", nil, nil).LuaAction; got != tt.want { + t.Fatalf("LuaAction = %q, want %q", got, tt.want) + } + }) + } +} + +func TestHyprlandLuaBindLabels(t *testing.T) { + tests := []struct { + expr string + want string + }{ + {`hl.dsp.window.move({ monitor = "l" })`, "move to left monitor"}, + {`hl.dsp.focus({ window = "first" })`, "focus window first"}, + {`hl.dsp.window.resize({ x = -100, y = 0, relative = true })`, "Shrink window width by 100"}, + {`hl.dsp.window.move({ workspace = "special:magic", follow = false })`, "move to special workspace magic"}, + {`hl.dsp.window.move({ into_group = "r" })`, "move into right group"}, + } + for _, tt := range tests { + t.Run(tt.expr, func(t *testing.T) { + if got := luaKeyComboToBinding("SUPER + X", tt.expr, "", "").Comment; got != tt.want { + t.Fatalf("Comment = %q, want %q", got, tt.want) + } + }) + } +} + +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()`}, + } + 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) + } + }) + } +} diff --git a/core/internal/keybinds/types.go b/core/internal/keybinds/types.go index b296709b1..51f28065c 100644 --- a/core/internal/keybinds/types.go +++ b/core/internal/keybinds/types.go @@ -10,11 +10,12 @@ type Keybind struct { Key string `json:"key"` Description string `json:"desc"` Action string `json:"action,omitempty"` + LuaAction string `json:"luaAction,omitempty"` // Hyprland: hl.dsp expression for `hyprctl dispatch` Subcategory string `json:"subcat,omitempty"` Source string `json:"source,omitempty"` HideOnOverlay bool `json:"hideOnOverlay,omitempty"` CooldownMs int `json:"cooldownMs,omitempty"` - Flags string `json:"flags,omitempty"` // Hyprland bind flags: e=repeat, l=locked, r=release, o=long-press + Flags string `json:"flags,omitempty"` // Hyprland hl.bind options as letters (e=repeating, l=locked, r=release, o=long_press, ...) AllowWhenLocked bool `json:"allowWhenLocked,omitempty"` AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled diff --git a/core/internal/screenshot/region_geometry_test.go b/core/internal/screenshot/region_geometry_test.go index 27aee1705..616fe1f34 100644 --- a/core/internal/screenshot/region_geometry_test.go +++ b/core/internal/screenshot/region_geometry_test.go @@ -164,6 +164,10 @@ func TestSelectedLogicalGeometryPreSelection(t *testing.T) { } func TestWaylandOutputBoundsScaled(t *testing.T) { + // DetectCompositor caches its result, so inside a live Hyprland session the test would exec hyprctl + prev := detectedCompositor + detectedCompositor = CompositorUnknown + t.Cleanup(func() { detectedCompositor = prev }) out := &WaylandOutput{ name: "DP-2", x: 1920, diff --git a/core/internal/screenshot/screenshot.go b/core/internal/screenshot/screenshot.go index 987a64c87..ce6e523dc 100644 --- a/core/internal/screenshot/screenshot.go +++ b/core/internal/screenshot/screenshot.go @@ -53,17 +53,16 @@ func (o *WaylandOutput) bounds() Region { x, y, w, h := o.x, o.y, o.width, o.height if DetectCompositor() == CompositorHyprland { if hx, hy, hw, hh, ok := GetHyprlandMonitorGeometry(o.name); ok { - x, y, w, h = hx, hy, hw, hh - } - } else { - if o.transform == 1 || o.transform == 3 || o.transform == 5 || o.transform == 7 { - w, h = h, w - } - if scale := o.effectiveScale(); scale > 0 { - w = int32(math.Round(float64(w) / scale)) - h = int32(math.Round(float64(h) / scale)) + return Region{X: hx, Y: hy, Width: hw, Height: hh, Output: o.name} } } + if o.transform == 1 || o.transform == 3 || o.transform == 5 || o.transform == 7 { + w, h = h, w + } + if scale := o.effectiveScale(); scale > 0 { + w = int32(math.Round(float64(w) / scale)) + h = int32(math.Round(float64(h) / scale)) + } return Region{ X: x, Y: y, diff --git a/core/internal/screenshot/scroll.go b/core/internal/screenshot/scroll.go index 2b70fc830..a376fa68f 100644 --- a/core/internal/screenshot/scroll.go +++ b/core/internal/screenshot/scroll.go @@ -74,6 +74,7 @@ type scrollSession struct { sigCh chan os.Signal keysBound bool + hyprLua bool st *stitcher } @@ -140,20 +141,7 @@ func (r *RegionSelector) enterScrollPhase(os *OutputSurface, x, y, w, h int) { interval = r.screenshoter.config.IntervalMs } - capX, capY, capW, capH := x, y, w, h - switch DetectCompositor() { - case CompositorHyprland, CompositorMango: - // both take device pixels, deviating from spec (observed) - default: - // spec: logical coordinates, scaled by the compositor - // https://wayland.app/protocols/wlr-screencopy-unstable-v1#zwlr_screencopy_manager_v1:request:capture_output_region - if scale := os.output.fractionalScale; scale > 1 { - capX = int(float64(x)/scale + 0.5) - capY = int(float64(y)/scale + 0.5) - capW = int(float64(w)/scale + 0.5) - capH = int(float64(h)/scale + 0.5) - } - } + capX, capY, capW, capH := scrollCaptureRect(DetectCompositor(), os.output.fractionalScale, x, y, w, h) r.scroll = &scrollSession{ output: os.output, @@ -188,6 +176,17 @@ func (r *RegionSelector) enterScrollPhase(os *OutputSurface, x, y, w, h int) { } } +func scrollCaptureRect(comp Compositor, scale float64, x, y, w, h int) (int, int, int, int) { + // mango takes device pixels, deviating from spec (observed); Hyprland scales the box itself, so it stays on the spec path + if comp == CompositorMango || scale <= 1 { + return x, y, w, h + } + // spec: logical coordinates, scaled by the compositor + // https://wayland.app/protocols/wlr-screencopy-unstable-v1#zwlr_screencopy_manager_v1:request:capture_output_region + return int(float64(x)/scale + 0.5), int(float64(y)/scale + 0.5), + int(float64(w)/scale + 0.5), int(float64(h)/scale + 0.5) +} + // sized for the worst-case counter so the input region is set once func (r *RegionSelector) layoutScrollBar(os *OutputSurface) { s := r.scroll @@ -358,14 +357,28 @@ 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) - hyprlandFocusWindowAt(cx, cy) + s.hyprLua = hyprlandLuaConfigActive() + hyprlandFocusWindowAt(s.hyprLua, cx, cy) s.sigCh = make(chan os.Signal, 2) signal.Notify(s.sigCh, unix.SIGUSR1, unix.SIGUSR2) - s.keysBound = hyprlandBindScrollKeys(os.Getpid()) + s.keysBound = exec.Command("hyprctl", hyprlandBindScrollKeysArgs(s.hyprLua, os.Getpid())...).Run() == nil } -func hyprlandFocusWindowAt(x, y int) { +// `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) { out, err := exec.Command("hyprctl", "-j", "clients").Output() if err != nil { return @@ -397,16 +410,28 @@ func hyprlandFocusWindowAt(x, y int) { if best < 0 { return } - _ = exec.Command("hyprctl", "dispatch", "focuswindow", "address:"+clients[best].Address).Run() + _ = exec.Command("hyprctl", hyprlandFocusArgs(lua, clients[best].Address)...).Run() } -func hyprlandBindScrollKeys(pid int) bool { - batch := fmt.Sprintf("keyword bind ,Return,exec,kill -USR1 %d ; keyword bind ,Escape,exec,kill -USR2 %d", pid, pid) - return exec.Command("hyprctl", "--batch", batch).Run() == nil +func hyprlandFocusArgs(lua bool, address string) []string { + if !lua { + return []string{"dispatch", "focuswindow", "address:" + address} + } + return []string{"dispatch", fmt.Sprintf("hl.dsp.focus({ window = %q })", "address:"+address)} } -func hyprlandUnbindScrollKeys() { - _ = exec.Command("hyprctl", "--batch", "keyword unbind ,Return ; keyword unbind ,Escape").Run() +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)} + } + 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"} + } + return []string{"eval", `hl.unbind("Return"); hl.unbind("Escape")`} } func (r *RegionSelector) scrollBarHit(x, y float64) string { @@ -676,7 +701,7 @@ func (r *RegionSelector) cleanupScroll() { return } if s.keysBound { - hyprlandUnbindScrollKeys() + _ = exec.Command("hyprctl", hyprlandUnbindScrollKeysArgs(s.hyprLua)...).Run() } if s.sigCh != nil { signal.Stop(s.sigCh) diff --git a/core/internal/screenshot/scroll_hyprland_test.go b/core/internal/screenshot/scroll_hyprland_test.go new file mode 100644 index 000000000..208efdbda --- /dev/null +++ b/core/internal/screenshot/scroll_hyprland_test.go @@ -0,0 +1,71 @@ +package screenshot + +import ( + "slices" + "testing" +) + +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"}, + }, + } + 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) + } + } +} + +// Hyprland scales the region by the monitor scale itself; device pixels here +// captured a box offset and enlarged by the scale on fractional outputs +func TestScrollCaptureRect(t *testing.T) { + cases := []struct { + comp Compositor + scale float64 + want [4]int + }{ + {CompositorHyprland, 1.25, [4]int{1230, 160, 642, 480}}, + {CompositorNiri, 1.25, [4]int{1230, 160, 642, 480}}, + {CompositorMango, 1.25, [4]int{1538, 200, 802, 600}}, + {CompositorHyprland, 1, [4]int{1538, 200, 802, 600}}, + } + for _, c := range cases { + x, y, w, h := scrollCaptureRect(c.comp, c.scale, 1538, 200, 802, 600) + if got := [4]int{x, y, w, h}; got != c.want { + t.Errorf("compositor %d scale %v: got %v want %v", c.comp, c.scale, got, c.want) + } + } +} diff --git a/core/internal/windowrules/providers/hyprland_parser.go b/core/internal/windowrules/providers/hyprland_parser.go index 0f64fde0b..b90983eb7 100644 --- a/core/internal/windowrules/providers/hyprland_parser.go +++ b/core/internal/windowrules/providers/hyprland_parser.go @@ -2,35 +2,23 @@ package providers import ( "fmt" - "github.com/AvengeMedia/DankMaterialShell/core/internal/configfrag" "os" "path/filepath" "regexp" "strconv" "strings" + "github.com/AvengeMedia/DankMaterialShell/core/internal/configfrag" "github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules" ) type HyprlandWindowRule struct { - MatchClass string - MatchTitle string - MatchXWayland *bool - MatchFloating *bool - MatchFullscreen *bool - MatchPinned *bool - MatchInitialised *bool - Rule string - Value string - Source string - RawLine string - - // CombinedActions is populated from single hl.window_rule({ … }) Lua calls where - // multiple actions apply together. When non-nil it takes precedence over Rule/Value - // in ConvertHyprlandRulesToWindowRules. - CombinedActions *windowrules.Actions `json:"-"` + Match windowrules.MatchCriteria + Actions windowrules.Actions + Source string + RawLine string } type HyprlandRulesParser struct { @@ -68,7 +56,6 @@ func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) { dmsLua := filepath.Join(expandedDir, "dms", "windowrules.lua") dmsConf := filepath.Join(expandedDir, "dms", "windowrules.conf") - if _, err := os.Stat(dmsLua); err == nil { p.dmsRulesExists = true p.dmsPrimaryPath = dmsLua @@ -84,7 +71,6 @@ func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) { if strings.EqualFold(filepath.Ext(mainConfig), ".lua") { p.configFormat = "lua" - p.readOnly = false p.probeRequireWindowrulesLine(mainConfig) if ap, err := filepath.Abs(mainConfig); err == nil { p.primaryHyprLua = ap @@ -116,19 +102,11 @@ func (p *HyprlandRulesParser) parseDMSRulesDirectly(dmsRulesPath string) { abs = dmsRulesPath } - prevSource := p.currentSource - p.currentSource = abs - if strings.EqualFold(filepath.Ext(abs), ".lua") { p.parseLuaWindowRules(string(data), filepath.Dir(abs), abs, false) } else { - lines := strings.SplitSeq(string(data), "\n") - for line := range lines { - p.parseLine(line) - } + p.parseHyprlangContent(string(data), filepath.Dir(abs), abs, false) } - - p.currentSource = prevSource p.dmsProcessed = true } @@ -152,24 +130,27 @@ func (p *HyprlandRulesParser) parseFile(filePath string) error { p.parseLuaWindowRules(string(data), filepath.Dir(absPath), absPath, true) return nil } + p.parseHyprlangContent(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.SplitSeq(string(data), "\n") - for line := range lines { - trimmed := strings.TrimSpace(line) - - if strings.HasPrefix(trimmed, "source") { - p.handleSource(trimmed, filepath.Dir(absPath)) - continue + 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) } - - p.parseLine(line) } - - p.currentSource = prevSource - return nil } func (p *HyprlandRulesParser) handleSource(line string, baseDir string) { @@ -183,34 +164,57 @@ func (p *HyprlandRulesParser) handleSource(line string, baseDir string) { func (p *HyprlandRulesParser) parseLine(line string) { trimmed := strings.TrimSpace(line) - - if strings.HasPrefix(trimmed, "windowrule") { - rule := p.parseWindowRuleLine(trimmed) - if rule != nil { - rule.Source = p.currentSource - p.rules = append(p.rules, *rule) - } + 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]) - isV2 := strings.HasPrefix(line, "windowrulev2") - rule := &HyprlandWindowRule{ - RawLine: line, - } - - if isV2 { + switch { + case strings.HasPrefix(line, "windowrulev2"): p.parseWindowRuleV2(content, rule) - } else { + case strings.Contains(content, "match:"): + parseWindowRuleMatchSyntax(content, rule) + default: p.parseWindowRuleV1(content, rule) } @@ -223,8 +227,8 @@ func (p *HyprlandRulesParser) parseWindowRuleV1(content string, rule *HyprlandWi return } - rule.Rule = strings.TrimSpace(parts[0]) - rule.MatchClass = strings.TrimSpace(parts[1]) + rule.Match.AppID = strings.TrimSpace(parts[1]) + applyHyprlandRuleAction(&rule.Actions, strings.TrimSpace(parts[0]), "") } func (p *HyprlandRulesParser) parseWindowRuleV2(content string, rule *HyprlandWindowRule) { @@ -233,48 +237,96 @@ func (p *HyprlandRulesParser) parseWindowRuleV2(content string, rule *HyprlandWi return } - ruleAndValue := strings.TrimSpace(parts[0]) - matchPart := strings.TrimSpace(parts[1]) + ruleName, value, _ := strings.Cut(strings.TrimSpace(parts[0]), " ") + applyHyprlandRuleAction(&rule.Actions, ruleName, strings.TrimSpace(value)) - if idx := strings.Index(ruleAndValue, " "); idx > 0 { - rule.Rule = ruleAndValue[:idx] - rule.Value = strings.TrimSpace(ruleAndValue[idx+1:]) - } else { - rule.Rule = ruleAndValue - } + 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" - matchPairs := strings.SplitSeq(matchPart, ",") - for pair := range matchPairs { - pair = strings.TrimSpace(pair) - if colonIdx := strings.Index(pair, ":"); colonIdx > 0 { - key := strings.TrimSpace(pair[:colonIdx]) - value := strings.TrimSpace(pair[colonIdx+1:]) - - switch key { - case "class": - rule.MatchClass = value - case "title": - rule.MatchTitle = value - case "xwayland": - b := value == "1" || value == "true" - rule.MatchXWayland = &b - case "floating": - b := value == "1" || value == "true" - rule.MatchFloating = &b - case "fullscreen": - b := value == "1" || value == "true" - rule.MatchFullscreen = &b - case "pinned": - b := value == "1" || value == "true" - rule.MatchPinned = &b - case "initialised", "initialized": - b := value == "1" || value == "true" - rule.MatchInitialised = &b - } + 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() } @@ -360,26 +412,13 @@ func applyHyprlandRuleAction(actions *windowrules.Actions, rule, value string) { func ConvertHyprlandRulesToWindowRules(hyprRules []HyprlandWindowRule) []windowrules.WindowRule { result := make([]windowrules.WindowRule, 0, len(hyprRules)) for i, hr := range hyprRules { - wr := windowrules.WindowRule{ - ID: strconv.Itoa(i), - Enabled: true, - Source: hr.Source, - MatchCriteria: windowrules.MatchCriteria{ - AppID: hr.MatchClass, - Title: hr.MatchTitle, - XWayland: hr.MatchXWayland, - IsFloating: hr.MatchFloating, - Fullscreen: hr.MatchFullscreen, - Pinned: hr.MatchPinned, - Initialised: hr.MatchInitialised, - }, - } - if hr.CombinedActions != nil { - wr.Actions = *hr.CombinedActions - } else { - applyHyprlandRuleAction(&wr.Actions, hr.Rule, hr.Value) - } - result = append(result, wr) + result = append(result, windowrules.WindowRule{ + ID: strconv.Itoa(i), + Enabled: true, + Source: hr.Source, + MatchCriteria: hr.Match, + Actions: hr.Actions, + }) } return result } @@ -453,6 +492,51 @@ func (p *HyprlandWritableProvider) isLegacyConfigReadOnly() bool { 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 + +type luaField[S, V any] struct { + key string + field func(*S) *V +} + +var hyprBoolMatches = []luaField[windowrules.MatchCriteria, *bool]{ + {"focus", func(m *windowrules.MatchCriteria) **bool { return &m.IsFocused }}, + {"group", func(m *windowrules.MatchCriteria) **bool { return &m.Grouped }}, + {"modal", func(m *windowrules.MatchCriteria) **bool { return &m.Modal }}, +} + +var hyprStringMatches = []luaField[windowrules.MatchCriteria, string]{ + {"initial_class", func(m *windowrules.MatchCriteria) *string { return &m.InitialClass }}, + {"initial_title", func(m *windowrules.MatchCriteria) *string { return &m.InitialTitle }}, + {"tag", func(m *windowrules.MatchCriteria) *string { return &m.Tag }}, + {"workspace", func(m *windowrules.MatchCriteria) *string { return &m.Workspace }}, + {"content", func(m *windowrules.MatchCriteria) *string { return &m.Content }}, + {"xdg_tag", func(m *windowrules.MatchCriteria) *string { return &m.XdgTag }}, +} + +var hyprIntMatches = []luaField[windowrules.MatchCriteria, *int]{ + {"fullscreen_state_internal", func(m *windowrules.MatchCriteria) **int { return &m.FullscreenStateInternal }}, + {"fullscreen_state_client", func(m *windowrules.MatchCriteria) **int { return &m.FullscreenStateClient }}, +} + +var hyprBoolEffects = []luaField[windowrules.Actions, *bool]{ + {"no_initial_focus", func(a *windowrules.Actions) **bool { return &a.NoInitialFocus }}, + {"focus_on_activate", func(a *windowrules.Actions) **bool { return &a.FocusOnActivate }}, + {"stay_focused", func(a *windowrules.Actions) **bool { return &a.StayFocused }}, + {"confine_pointer", func(a *windowrules.Actions) **bool { return &a.ConfinePointer }}, + {"no_xdg_drags", func(a *windowrules.Actions) **bool { return &a.NoXdgDrags }}, + {"no_auto_hdr", func(a *windowrules.Actions) **bool { return &a.NoAutoHDR }}, + {"no_glow", func(a *windowrules.Actions) **bool { return &a.NoGlow }}, + {"no_wobble", func(a *windowrules.Actions) **bool { return &a.NoWobble }}, +} + +var hyprStringEffects = []luaField[windowrules.Actions, string]{ + {"border_color", func(a *windowrules.Actions) *string { return &a.BorderColor }}, + {"tonemap", func(a *windowrules.Actions) *string { return &a.Tonemap }}, + {"suppress_event", func(a *windowrules.Actions) *string { return &a.SuppressEvent }}, +} + func hyprLuaBoolStr(b bool) string { if b { return "true" @@ -486,6 +570,21 @@ func luaAppendMatch(mc windowrules.MatchCriteria, dst *[]string) { if mc.Pinned != nil { *dst = append(*dst, fmt.Sprintf(`pin = %s`, hyprLuaBoolStr(*mc.Pinned))) } + for _, f := range hyprBoolMatches { + if v := *f.field(&mc); v != nil { + *dst = append(*dst, fmt.Sprintf(`%s = %s`, f.key, hyprLuaBoolStr(*v))) + } + } + for _, f := range hyprStringMatches { + if v := *f.field(&mc); v != "" { + *dst = append(*dst, fmt.Sprintf(`%s = %s`, f.key, strconv.Quote(v))) + } + } + for _, f := range hyprIntMatches { + if v := *f.field(&mc); v != nil { + *dst = append(*dst, fmt.Sprintf(`%s = %d`, f.key, *v)) + } + } } func luaAppendActions(a windowrules.Actions, dst *[]string) { @@ -547,11 +646,24 @@ 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, 20))) + *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))) } + for _, f := range hyprBoolEffects { + if v := *f.field(&a); v != nil { + *dst = append(*dst, fmt.Sprintf(`%s = %s`, f.key, hyprLuaBoolStr(*v))) + } + } + if a.ScrollingWidth != nil { + *dst = append(*dst, fmt.Sprintf(`scrolling_width = %s`, strconv.FormatFloat(*a.ScrollingWidth, 'g', -1, 64))) + } + for _, f := range hyprStringEffects { + if v := *f.field(&a); v != "" { + *dst = append(*dst, fmt.Sprintf(`%s = %s`, f.key, strconv.Quote(v))) + } + } } func formatLuaManagedHyprRule(rule windowrules.WindowRule) []string { @@ -575,39 +687,31 @@ func formatLuaManagedHyprRule(rule windowrules.WindowRule) []string { func (p *HyprlandWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) { luaPath := p.GetOverridePath() - expanded, err := utils.ExpandPath(p.configDir) + 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 } - confPath := filepath.Join(expanded, "dms", "windowrules.conf") - - var data []byte - var loadedFrom string - - if data, err = os.ReadFile(luaPath); err == nil { - loadedFrom = luaPath - } else if !os.IsNotExist(err) { - return nil, err - } else if data, err = os.ReadFile(confPath); err == nil { - loadedFrom = confPath - } else if os.IsNotExist(err) { - return []windowrules.WindowRule{}, nil - } else { - return nil, err - } - - if strings.EqualFold(filepath.Ext(loadedFrom), ".lua") { - return p.loadDMSRulesFromLua(data, luaPath) - } - return p.loadDMSRulesFromConf(data, loadedFrom) + return p.loadDMSRulesFromConf(data, confPath) } func (p *HyprlandWritableProvider) loadDMSRulesFromConf(data []byte, rulesPath string) ([]windowrules.WindowRule, error) { var rules []windowrules.WindowRule var currentID, currentName string - lines := strings.SplitSeq(string(data), "\n") + parser := NewHyprlandRulesParser(p.configDir) - for line := range lines { + for line := range strings.SplitSeq(string(data), "\n") { trimmed := strings.TrimSpace(line) if matches := dmsRuleCommentRegex.FindStringSubmatch(trimmed); matches != nil { @@ -616,41 +720,32 @@ func (p *HyprlandWritableProvider) loadDMSRulesFromConf(data []byte, rulesPath s continue } - if strings.HasPrefix(trimmed, "windowrulev2") { - parser := NewHyprlandRulesParser(p.configDir) - hrule := parser.parseWindowRuleLine(trimmed) - if hrule == nil { - continue - } - - wr := windowrules.WindowRule{ - ID: currentID, - Name: currentName, - Enabled: true, - Source: rulesPath, - MatchCriteria: windowrules.MatchCriteria{ - AppID: hrule.MatchClass, - Title: hrule.MatchTitle, - XWayland: hrule.MatchXWayland, - IsFloating: hrule.MatchFloating, - Fullscreen: hrule.MatchFullscreen, - Pinned: hrule.MatchPinned, - Initialised: hrule.MatchInitialised, - }, - } - applyHyprlandRuleAction(&wr.Actions, hrule.Rule, hrule.Value) - - if wr.ID == "" { - wr.ID = hrule.MatchClass - if wr.ID == "" { - wr.ID = hrule.MatchTitle - } - } - - rules = append(rules, wr) - currentID = "" - currentName = "" + 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 @@ -695,7 +790,7 @@ func (p *HyprlandWritableProvider) loadDMSRulesFromLua(data []byte, rulesPath st Name: nameSnap, Enabled: true, Source: rulesPath, - MatchCriteria: luaMatchFieldsToCriteria(mf), + MatchCriteria: mf, Actions: *acts, } if wr.ID == "" { @@ -786,13 +881,6 @@ func (p *HyprlandRulesParser) probeRequireWindowrulesLine(mainLua string) { } } -// luaMatchFields collects fields from Lua match={...} subtrees before copying into HyprlandWindowRule. -type luaMatchFields struct { - class string - title string - xwayland, floating, fullscreen, pinned, initialised *bool -} - func (p *HyprlandRulesParser) parseLuaWindowRules(content, baseDir, absPath string, allowRequires bool) { prev := p.currentSource p.currentSource = absPath @@ -862,14 +950,12 @@ func (p *HyprlandRulesParser) parseLuaWindowRules(content, baseDir, absPath stri if len(raw) > 240 { raw = raw[:240] + "…" } - hr := HyprlandWindowRule{ - Source: curAbs, - RawLine: raw, - CombinedActions: acts, - } - fillRuleFromLuaMatch(&hr, mf) - - p.rules = append(p.rules, hr) + p.rules = append(p.rules, HyprlandWindowRule{ + Match: mf, + Actions: *acts, + Source: curAbs, + RawLine: raw, + }) if p.requireLineInMain > 0 && mainAbs != "" && curAbs == mainAbs && startLine > p.requireLineInMain { p.rulesAfterDMS++ @@ -888,28 +974,6 @@ func (p *HyprlandRulesParser) parseLuaWindowRules(content, baseDir, absPath stri } } -func fillRuleFromLuaMatch(hr *HyprlandWindowRule, m luaMatchFields) { - hr.MatchClass = m.class - hr.MatchTitle = m.title - hr.MatchXWayland = m.xwayland - hr.MatchFloating = m.floating - hr.MatchFullscreen = m.fullscreen - hr.MatchPinned = m.pinned - hr.MatchInitialised = m.initialised -} - -func luaMatchFieldsToCriteria(m luaMatchFields) windowrules.MatchCriteria { - return windowrules.MatchCriteria{ - AppID: m.class, - Title: m.title, - XWayland: m.xwayland, - IsFloating: m.floating, - Fullscreen: m.fullscreen, - Pinned: m.pinned, - Initialised: m.initialised, - } -} - // extractHlWindowRuleTableArg parses a fragment beginning with (optional prefix then) hl.window_rule( ... ). // consumed is counted from frag[0] (caller adds idx offset when iterating). func extractHlWindowRuleTableArg(frag string) (inner string, consumed int, ok bool) { @@ -1103,46 +1167,106 @@ func luaBoolLike(s string) (val bool, ok bool) { } } -func parseMatchLua(val string, m *luaMatchFields) { +func parseMatchLua(val string, m *windowrules.MatchCriteria) { body := trimOuterBraces(val) - segs := splitTopLevelCommaLua(body) - for _, seg := range segs { - k, v, ok := splitLuaKeyVal(seg) - if !ok { - continue - } - switch strings.TrimSpace(strings.ToLower(k)) { - case "class": - m.class = luaStringValue(v) - case "title": - m.title = luaStringValue(v) - case "xwayland": - if b, okb := luaBoolLike(v); okb { - m.xwayland = new(b) - } - case "float", "floating": - if b, okb := luaBoolLike(v); okb { - m.floating = new(b) - } - case "fullscreen": - if b, okb := luaBoolLike(v); okb { - m.fullscreen = new(b) - } - case "pin", "pinned": - if b, okb := luaBoolLike(v); okb { - m.pinned = new(b) - } - case "initialised", "initialized": - if b, okb := luaBoolLike(v); okb { - m.initialised = new(b) - } + for _, seg := range splitTopLevelCommaLua(body) { + if k, v, ok := splitLuaKeyVal(seg); ok { + applyLuaMatchKey(m, strings.TrimSpace(strings.ToLower(k)), v) } } } +func applyLuaMatchKey(m *windowrules.MatchCriteria, key, v string) { + if applyLuaMatchTableKey(m, key, v) { + return + } + switch key { + case "class": + m.AppID = luaStringValue(v) + case "title": + m.Title = luaStringValue(v) + case "xwayland": + if b, okb := luaBoolLike(v); okb { + m.XWayland = new(b) + } + case "float", "floating": + if b, okb := luaBoolLike(v); okb { + m.IsFloating = new(b) + } + case "fullscreen": + if b, okb := luaBoolLike(v); okb { + m.Fullscreen = new(b) + } + case "pin", "pinned": + if b, okb := luaBoolLike(v); okb { + m.Pinned = new(b) + } + case "initialised", "initialized": + if b, okb := luaBoolLike(v); okb { + m.Initialised = new(b) + } + } +} + +func applyLuaMatchTableKey(m *windowrules.MatchCriteria, key, raw string) bool { + for _, f := range hyprBoolMatches { + if f.key != key { + continue + } + if b, ok := luaBoolLike(raw); ok { + *f.field(m) = new(b) + } + return true + } + for _, f := range hyprStringMatches { + if f.key == key { + *f.field(m) = luaStringValue(raw) + return true + } + } + for _, f := range hyprIntMatches { + if f.key != key { + continue + } + if n, err := strconv.Atoi(luaStringValue(raw)); err == nil { + *f.field(m) = new(n) + } + return true + } + return false +} + +func applyLuaActionTableKey(a *windowrules.Actions, key, raw string) (handled bool, known bool) { + for _, f := range hyprBoolEffects { + if f.key != key { + continue + } + b, ok := luaBoolLike(raw) + if ok { + *f.field(a) = new(b) + } + return ok, true + } + for _, f := range hyprStringEffects { + if f.key != key { + continue + } + v := strings.TrimSpace(raw) + if !strings.HasPrefix(v, `"`) && !strings.HasPrefix(v, `'`) { + return false, true + } + *f.field(a) = luaStringValue(v) + return true, true + } + return false, false +} + func applyLuaActionKey(a *windowrules.Actions, key, raw string) bool { k := strings.TrimSpace(strings.ToLower(key)) raw = strings.TrimSpace(raw) + if handled, known := applyLuaActionTableKey(a, k, raw); known { + return handled + } switch k { case "float": if b, ok := luaBoolLike(raw); ok && b { @@ -1168,7 +1292,7 @@ func applyLuaActionKey(a *windowrules.Actions, key, raw string) bool { a.OpenMaximized = &t return true } - case "nofocus", "no_focus", "no_initial_focus": + case "nofocus", "no_focus": if b, ok := luaBoolLike(raw); ok && b { t := true a.NoFocus = &t @@ -1281,6 +1405,11 @@ func applyLuaActionKey(a *windowrules.Actions, key, raw string) bool { case "workspace": a.Workspace = strings.TrimSpace(luaStringValue(raw)) return true + case "scrolling_width": + if f, err := strconv.ParseFloat(luaStringValue(raw), 64); err == nil { + a.ScrollingWidth = &f + return true + } case "idleinhibit", "idle_inhibit": a.Idleinhibit = strings.TrimSpace(luaStringValue(raw)) return true @@ -1290,34 +1419,30 @@ func applyLuaActionKey(a *windowrules.Actions, key, raw string) bool { return false } -func parseHlWindowRuleLuaTable(inner string) (*windowrules.Actions, luaMatchFields, bool) { +func parseHlWindowRuleLuaTable(inner string) (*windowrules.Actions, windowrules.MatchCriteria, bool) { body := trimOuterBraces(strings.TrimSpace(inner)) if body == "" { - return nil, luaMatchFields{}, false + return nil, windowrules.MatchCriteria{}, false } - segs := splitTopLevelCommaLua(body) - var match luaMatchFields + var match windowrules.MatchCriteria var a windowrules.Actions - matchParsed := false haveActions := false - for _, seg := range segs { + for _, seg := range splitTopLevelCommaLua(body) { k, v, ok := splitLuaKeyVal(seg) if !ok { continue } - switch strings.TrimSpace(strings.ToLower(k)) { - case "match": + if strings.TrimSpace(strings.ToLower(k)) == "match" { parseMatchLua(v, &match) - matchParsed = true - default: - if applyLuaActionKey(&a, k, v) { - haveActions = true - } + continue + } + if applyLuaActionKey(&a, k, v) { + haveActions = true } } if !haveActions { - return nil, luaMatchFields{}, false + return nil, windowrules.MatchCriteria{}, false } - return &a, match, matchParsed || haveActions + return &a, match, true } diff --git a/core/internal/windowrules/providers/hyprland_parser_test.go b/core/internal/windowrules/providers/hyprland_parser_test.go index 29d014dea..48db1b0bf 100644 --- a/core/internal/windowrules/providers/hyprland_parser_test.go +++ b/core/internal/windowrules/providers/hyprland_parser_test.go @@ -3,6 +3,7 @@ package providers import ( "os" "path/filepath" + "reflect" "strings" "testing" @@ -16,46 +17,28 @@ func TestParseWindowRuleV1(t *testing.T) { name string line string wantClass string - wantRule string - wantNil bool + wantFloat bool + wantTile bool }{ - { - name: "basic float rule", - line: "windowrule = float, ^(firefox)$", - wantClass: "^(firefox)$", - wantRule: "float", - }, - { - name: "tile rule", - line: "windowrule = tile, steam", - wantClass: "steam", - wantRule: "tile", - }, - { - name: "no match returns empty class", - line: "windowrule = float", - wantClass: "", - wantRule: "", - }, + {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 tt.wantNil { - if result != nil { - t.Errorf("expected nil, got %+v", result) - } - return - } if result == nil { t.Fatal("expected non-nil result") } - if result.MatchClass != tt.wantClass { - t.Errorf("MatchClass = %q, want %q", result.MatchClass, tt.wantClass) + if result.Match.AppID != tt.wantClass { + t.Errorf("AppID = %q, want %q", result.Match.AppID, tt.wantClass) } - if result.Rule != tt.wantRule { - t.Errorf("Rule = %q, want %q", result.Rule, tt.wantRule) + 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) } }) } @@ -65,34 +48,17 @@ func TestParseWindowRuleV2(t *testing.T) { parser := NewHyprlandRulesParser("") tests := []struct { - name string - line string - wantClass string - wantTitle string - wantRule string - wantValue string + name string + line string + wantClass string + wantTitle string + wantOpacity float64 + wantMax bool }{ - { - name: "float with class", - line: "windowrulev2 = float, class:^(firefox)$", - wantClass: "^(firefox)$", - wantRule: "float", - }, - { - name: "opacity with value", - line: "windowrulev2 = opacity 0.8, class:^(code)$", - wantClass: "^(code)$", - wantRule: "opacity", - wantValue: "0.8", - }, - { - name: "size with value and title", - line: "windowrulev2 = size 800 600, class:^(steam)$, title:Settings", - wantClass: "^(steam)$", - wantTitle: "Settings", - wantRule: "size", - wantValue: "800 600", - }, + {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 { @@ -101,48 +67,128 @@ func TestParseWindowRuleV2(t *testing.T) { if result == nil { t.Fatal("expected non-nil result") } - if result.MatchClass != tt.wantClass { - t.Errorf("MatchClass = %q, want %q", result.MatchClass, tt.wantClass) + if result.Match.AppID != tt.wantClass { + t.Errorf("AppID = %q, want %q", result.Match.AppID, tt.wantClass) } - if result.MatchTitle != tt.wantTitle { - t.Errorf("MatchTitle = %q, want %q", result.MatchTitle, tt.wantTitle) + if result.Match.Title != tt.wantTitle { + t.Errorf("Title = %q, want %q", result.Match.Title, tt.wantTitle) } - if result.Rule != tt.wantRule { - t.Errorf("Rule = %q, want %q", result.Rule, tt.wantRule) + 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.Value != tt.wantValue { - t.Errorf("Value = %q, want %q", result.Value, tt.wantValue) + if (result.Actions.OpenMaximized != nil) != tt.wantMax { + t.Errorf("OpenMaximized = %v, want %v", result.Actions.OpenMaximized, tt.wantMax) } }) } } -func TestConvertHyprlandRulesToWindowRules(t *testing.T) { - hyprRules := []HyprlandWindowRule{ - {MatchClass: "^(firefox)$", Rule: "float"}, - {MatchClass: "^(code)$", Rule: "opacity", Value: "0.9"}, - {MatchClass: "^(steam)$", Rule: "maximize"}, +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) } - result := ConvertHyprlandRulesToWindowRules(hyprRules) - - if len(result) != 3 { - t.Errorf("expected 3 rules, got %d", len(result)) + 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)) } - if result[0].MatchCriteria.AppID != "^(firefox)$" { - t.Errorf("rule 0 AppID = %q, want ^(firefox)$", result[0].MatchCriteria.AppID) + 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)}, + }, } - if result[0].Actions.OpenFloating == nil || !*result[0].Actions.OpenFloating { - t.Error("rule 0 should have OpenFloating = true") + 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) { + 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) + } + if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`hl.window_rule({ match = { class = "^lua$" }, float = true })`+"\n"), 0644); err != nil { + t.Fatal(err) } - if result[1].Actions.Opacity == nil || *result[1].Actions.Opacity != 0.9 { - t.Errorf("rule 1 Opacity = %v, want 0.9", result[1].Actions.Opacity) + res, err := ParseHyprlandWindowRules(tmpDir) + if err != nil { + t.Fatalf("ParseHyprlandWindowRules: %v", err) + } + 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) } - if result[2].Actions.OpenMaximized == nil || !*result[2].Actions.OpenMaximized { - t.Error("rule 2 should have OpenMaximized = true") + 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) } } @@ -318,30 +364,6 @@ require("dms.windowrules") } } -func TestParseHyprlandLuaNoInitialFocusAlias(t *testing.T) { - tmpDir := t.TempDir() - if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(` -hl.window_rule({ - match = { class = "^steam$" }, - no_initial_focus = true, -}) -`), 0644); err != nil { - t.Fatal(err) - } - - res, err := ParseHyprlandWindowRules(tmpDir) - if err != nil { - t.Fatalf("ParseHyprlandWindowRules: %v", err) - } - if len(res.Rules) != 1 { - t.Fatalf("expected 1 rule, got %d", len(res.Rules)) - } - wr := ConvertHyprlandRulesToWindowRules(res.Rules)[0] - if wr.Actions.NoFocus == nil || !*wr.Actions.NoFocus { - t.Fatalf("expected no_initial_focus to populate NoFocus action: %#v", wr.Actions) - } -} - func TestFormatLuaManagedHyprRuleUsesLuaFieldNames(t *testing.T) { enabled := true rule := windowrules.WindowRule{ @@ -646,23 +668,23 @@ func TestParseMatchLuaSpellings(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var m luaMatchFields + var m windowrules.MatchCriteria parseMatchLua(tt.in, &m) - if (m.floating == nil) != (tt.wantFloating == nil) || (m.floating != nil && *m.floating != *tt.wantFloating) { - t.Errorf("floating = %v, want %v", m.floating, tt.wantFloating) + if (m.IsFloating == nil) != (tt.wantFloating == nil) || (m.IsFloating != nil && *m.IsFloating != *tt.wantFloating) { + t.Errorf("floating = %v, want %v", m.IsFloating, tt.wantFloating) } - if (m.pinned == nil) != (tt.wantPinned == nil) || (m.pinned != nil && *m.pinned != *tt.wantPinned) { - t.Errorf("pinned = %v, want %v", m.pinned, tt.wantPinned) + if (m.Pinned == nil) != (tt.wantPinned == nil) || (m.Pinned != nil && *m.Pinned != *tt.wantPinned) { + t.Errorf("pinned = %v, want %v", m.Pinned, tt.wantPinned) } }) } } func TestParseMatchLuaStillReadsInitialised(t *testing.T) { - var m luaMatchFields + var m windowrules.MatchCriteria parseMatchLua(`{ initialised = true }`, &m) - if m.initialised == nil || !*m.initialised { - t.Fatalf("initialised not parsed: %v", m.initialised) + if m.Initialised == nil || !*m.Initialised { + t.Fatalf("initialised not parsed: %v", m.Initialised) } } @@ -843,3 +865,94 @@ func TestNiriEndToEndOpaqueLast(t *testing.T) { t.Errorf("opaque rule not last in:\n%s", content) } } + +func TestHyprlandLuaRoundTripCatalogKeys(t *testing.T) { + tests := []struct { + name string + lua string + match windowrules.MatchCriteria + act windowrules.Actions + }{ + {"initial_class", `initial_class = "^steam$"`, windowrules.MatchCriteria{InitialClass: "^steam$"}, windowrules.Actions{}}, + {"initial_title", `initial_title = "^Login$"`, windowrules.MatchCriteria{InitialTitle: "^Login$"}, windowrules.Actions{}}, + {"tag", `tag = "negative:games"`, windowrules.MatchCriteria{Tag: "negative:games"}, windowrules.Actions{}}, + {"workspace", `workspace = "w[tv1]"`, windowrules.MatchCriteria{Workspace: "w[tv1]"}, windowrules.Actions{}}, + {"content", `content = "video"`, windowrules.MatchCriteria{Content: "video"}, windowrules.Actions{}}, + {"xdg_tag", `xdg_tag = "^portal$"`, windowrules.MatchCriteria{XdgTag: "^portal$"}, windowrules.Actions{}}, + {"focus", `focus = false`, windowrules.MatchCriteria{IsFocused: new(false)}, windowrules.Actions{}}, + {"group", `group = true`, windowrules.MatchCriteria{Grouped: new(true)}, windowrules.Actions{}}, + {"modal", `modal = true`, windowrules.MatchCriteria{Modal: new(true)}, windowrules.Actions{}}, + {"fullscreen_state_internal", `fullscreen_state_internal = 2`, windowrules.MatchCriteria{FullscreenStateInternal: new(2)}, windowrules.Actions{}}, + {"fullscreen_state_client", `fullscreen_state_client = 0`, windowrules.MatchCriteria{FullscreenStateClient: new(0)}, windowrules.Actions{}}, + {"no_initial_focus", `no_initial_focus = true`, windowrules.MatchCriteria{}, windowrules.Actions{NoInitialFocus: new(true)}}, + {"focus_on_activate", `focus_on_activate = false`, windowrules.MatchCriteria{}, windowrules.Actions{FocusOnActivate: new(false)}}, + {"stay_focused", `stay_focused = true`, windowrules.MatchCriteria{}, windowrules.Actions{StayFocused: new(true)}}, + {"confine_pointer", `confine_pointer = true`, windowrules.MatchCriteria{}, windowrules.Actions{ConfinePointer: new(true)}}, + {"no_xdg_drags", `no_xdg_drags = true`, windowrules.MatchCriteria{}, windowrules.Actions{NoXdgDrags: new(true)}}, + {"no_auto_hdr", `no_auto_hdr = true`, windowrules.MatchCriteria{}, windowrules.Actions{NoAutoHDR: new(true)}}, + {"no_glow", `no_glow = true`, windowrules.MatchCriteria{}, windowrules.Actions{NoGlow: new(true)}}, + {"no_wobble", `no_wobble = true`, windowrules.MatchCriteria{}, windowrules.Actions{NoWobble: new(true)}}, + {"scrolling_width", `scrolling_width = 0.5`, windowrules.MatchCriteria{}, windowrules.Actions{ScrollingWidth: new(0.5)}}, + {"tonemap", `tonemap = "clamp"`, windowrules.MatchCriteria{}, windowrules.Actions{Tonemap: "clamp"}}, + {"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)}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := NewHyprlandWritableProvider(t.TempDir()) + rule := newTestWindowRule("rt", "Round Trip", "^app$") + tt.match.AppID = "^app$" + rule.MatchCriteria = tt.match + rule.Actions = tt.act + if tt.act == (windowrules.Actions{}) { + rule.Actions.OpenFloating = new(true) + } + if err := provider.SetRule(rule); err != nil { + t.Fatalf("SetRule: %v", err) + } + written, err := os.ReadFile(provider.GetOverridePath()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(written), tt.lua) { + t.Fatalf("missing %q in:\n%s", tt.lua, written) + } + rules, err := provider.LoadDMSRules() + if err != nil || len(rules) != 1 { + t.Fatalf("LoadDMSRules: %v, %d rules", err, len(rules)) + } + if !reflect.DeepEqual(rules[0].MatchCriteria, rule.MatchCriteria) { + t.Errorf("match = %+v, want %+v", rules[0].MatchCriteria, rule.MatchCriteria) + } + if !reflect.DeepEqual(rules[0].Actions, rule.Actions) { + t.Errorf("actions = %+v, want %+v", rules[0].Actions, rule.Actions) + } + }) + } +} + +func TestHyprlandNoInitialFocusStaysDistinct(t *testing.T) { + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(` +hl.window_rule({ + match = { class = "^steam$" }, + no_initial_focus = true, +}) +`), 0644); err != nil { + t.Fatal(err) + } + res, err := ParseHyprlandWindowRules(tmpDir) + if err != nil || len(res.Rules) != 1 { + t.Fatalf("ParseHyprlandWindowRules: %v, %d rules", err, len(res.Rules)) + } + wr := ConvertHyprlandRulesToWindowRules(res.Rules)[0] + if wr.Actions.NoFocus != nil { + t.Errorf("no_initial_focus leaked into NoFocus") + } + joined := strings.Join(formatLuaManagedHyprRule(wr), "\n") + if !strings.Contains(joined, "no_initial_focus = true") || strings.Contains(joined, "no_focus") { + t.Errorf("no_initial_focus not re-emitted unchanged:\n%s", joined) + } +} diff --git a/core/internal/windowrules/types.go b/core/internal/windowrules/types.go index bab77cf48..32f7fa6cd 100644 --- a/core/internal/windowrules/types.go +++ b/core/internal/windowrules/types.go @@ -16,6 +16,17 @@ type MatchCriteria struct { Fullscreen *bool `json:"fullscreen,omitempty"` Pinned *bool `json:"pinned,omitempty"` Initialised *bool `json:"initialised,omitempty"` + + InitialClass string `json:"initialClass,omitempty"` + InitialTitle string `json:"initialTitle,omitempty"` + Tag string `json:"tag,omitempty"` + Workspace string `json:"workspace,omitempty"` + Content string `json:"content,omitempty"` + XdgTag string `json:"xdgTag,omitempty"` + Grouped *bool `json:"group,omitempty"` + Modal *bool `json:"modal,omitempty"` + FullscreenStateInternal *int `json:"fullscreenStateInternal,omitempty"` + FullscreenStateClient *int `json:"fullscreenStateClient,omitempty"` } type Actions struct { @@ -71,6 +82,18 @@ type Actions struct { Opaque *bool `json:"opaque,omitempty"` ForcergbX *bool `json:"forcergbx,omitempty"` Idleinhibit string `json:"idleinhibit,omitempty"` + + NoInitialFocus *bool `json:"noInitialFocus,omitempty"` + FocusOnActivate *bool `json:"focusOnActivate,omitempty"` + StayFocused *bool `json:"stayFocused,omitempty"` + ConfinePointer *bool `json:"confinePointer,omitempty"` + NoXdgDrags *bool `json:"noXdgDrags,omitempty"` + NoAutoHDR *bool `json:"noAutoHdr,omitempty"` + NoGlow *bool `json:"noGlow,omitempty"` + NoWobble *bool `json:"noWobble,omitempty"` + ScrollingWidth *float64 `json:"scrollingWidth,omitempty"` + Tonemap string `json:"tonemap,omitempty"` + SuppressEvent string `json:"suppressEvent,omitempty"` } type WindowRule struct { diff --git a/distro/void/README.md b/distro/void/README.md index 2d9e4426a..e8ee27363 100644 --- a/distro/void/README.md +++ b/distro/void/README.md @@ -101,7 +101,13 @@ compositor's autostart, e.g. niri: spawn-at-startup "dms" "run" ``` -or Hyprland: `exec-once = dms run`. +or Hyprland (`~/.config/hypr/hyprland.lua`): + +```lua +hl.on("hyprland.start", function() + hl.exec_cmd("dms run") +end) +``` From a TTY on Void without a greeter, start your compositor through a D-Bus session: diff --git a/quickshell/Common/ConfigIncludeResolve.js b/quickshell/Common/ConfigIncludeResolve.js index 220ec5d91..c3c1fc88b 100644 --- a/quickshell/Common/ConfigIncludeResolve.js +++ b/quickshell/Common/ConfigIncludeResolve.js @@ -104,7 +104,8 @@ const includeTable = { mango: [confInclude("layout")] }, input: { - niri: [kdlInclude("input")] + niri: [kdlInclude("input")], + hyprland: [luaInclude("input")] }, windowrules: { niri: [kdlInclude("windowrules")], diff --git a/quickshell/Common/KeybindActions.js b/quickshell/Common/KeybindActions.js index 46f9a468b..889c62080 100644 --- a/quickshell/Common/KeybindActions.js +++ b/quickshell/Common/KeybindActions.js @@ -1247,6 +1247,14 @@ const HYPRLAND_ACTIONS = { id: "fullscreen", label: "Toggle Fullscreen" }, + { + id: "fullscreen 1", + label: "Toggle Maximize" + }, + { + id: "pseudo", + label: "Toggle Pseudotile" + }, { id: "fullscreenstate", label: "Set Fullscreen State" @@ -1541,6 +1549,22 @@ const HYPRLAND_ACTIONS = { id: "moveintogroup d", label: "Move into Group Down" }, + { + id: "moveintoorcreategroup l", + label: "Move into or Create Group Left" + }, + { + id: "moveintoorcreategroup r", + label: "Move into or Create Group Right" + }, + { + id: "moveintoorcreategroup u", + label: "Move into or Create Group Up" + }, + { + id: "moveintoorcreategroup d", + label: "Move into or Create Group Down" + }, { id: "moveoutofgroup", label: "Move out of Group" @@ -1604,24 +1628,176 @@ const HYPRLAND_ACTIONS = { { id: "denywindowfromgroup toggle", label: "Toggle Deny from Group" - }, - { - id: "setignoregrouplock on", - label: "Ignore Group Lock" - }, - { - id: "setignoregrouplock off", - label: "Respect Group Lock" - }, - { - id: "setignoregrouplock toggle", - label: "Toggle Ignore Group Lock" } ], "Layout": [ { id: "splitratio", label: "Adjust Split Ratio" + }, + { + id: "layoutmsg", + label: "Layout Message" + }, + { + id: "layoutmsg togglesplit", + label: "Toggle Split (dwindle)" + }, + { + id: "layoutmsg swapsplit", + label: "Swap Split (dwindle)" + }, + { + id: "layoutmsg rotatesplit", + label: "Rotate Split (dwindle)" + }, + { + id: "layoutmsg preselect l", + label: "Preselect Left (dwindle)" + }, + { + id: "layoutmsg preselect r", + label: "Preselect Right (dwindle)" + }, + { + id: "layoutmsg preselect u", + label: "Preselect Up (dwindle)" + }, + { + id: "layoutmsg preselect d", + label: "Preselect Down (dwindle)" + }, + { + id: "layoutmsg movetoroot", + label: "Move to Root (dwindle)" + }, + { + id: "layoutmsg swapwithmaster", + label: "Swap with Master (master)" + }, + { + id: "layoutmsg focusmaster", + label: "Focus Master (master)" + }, + { + id: "layoutmsg cyclenext", + label: "Cycle Next (master)" + }, + { + id: "layoutmsg cycleprev", + label: "Cycle Previous (master)" + }, + { + id: "layoutmsg swapnext", + label: "Swap Next (master)" + }, + { + id: "layoutmsg swapprev", + label: "Swap Previous (master)" + }, + { + id: "layoutmsg rollnext", + label: "Roll Next (master)" + }, + { + id: "layoutmsg rollprev", + label: "Roll Previous (master)" + }, + { + id: "layoutmsg addmaster", + label: "Add Master (master)" + }, + { + id: "layoutmsg removemaster", + label: "Remove Master (master)" + }, + { + id: "layoutmsg orientationnext", + label: "Next Orientation (master)" + }, + { + id: "layoutmsg orientationprev", + label: "Previous Orientation (master)" + }, + { + id: "layoutmsg orientationcycle", + label: "Cycle Orientation (master)" + }, + { + id: "layoutmsg orientationleft", + label: "Orientation Left (master)" + }, + { + id: "layoutmsg orientationright", + label: "Orientation Right (master)" + }, + { + id: "layoutmsg orientationtop", + label: "Orientation Top (master)" + }, + { + id: "layoutmsg orientationbottom", + label: "Orientation Bottom (master)" + }, + { + id: "layoutmsg orientationcenter", + label: "Orientation Center (master)" + }, + { + id: "layoutmsg center", + label: "Center Column (scrolling)" + }, + { + id: "layoutmsg fit_into_view", + label: "Fit Column into View (scrolling)" + }, + { + id: "layoutmsg consume", + label: "Consume into Column (scrolling)" + }, + { + id: "layoutmsg expel", + label: "Expel from Column (scrolling)" + }, + { + id: "layoutmsg consume_or_expel prev", + label: "Consume or Expel Previous (scrolling)" + }, + { + id: "layoutmsg consume_or_expel next", + label: "Consume or Expel Next (scrolling)" + }, + { + id: "layoutmsg promote", + label: "Promote to Column (scrolling)" + }, + { + id: "layoutmsg inhibit_scroll", + label: "Toggle Scroll Inhibit (scrolling)" + }, + { + id: "layoutmsg colresize +conf", + label: "Next Column Width (scrolling)" + }, + { + id: "layoutmsg colresize -conf", + label: "Previous Column Width (scrolling)" + }, + { + id: "layoutmsg swapcol l", + label: "Swap Column Left (scrolling)" + }, + { + id: "layoutmsg swapcol r", + label: "Swap Column Right (scrolling)" + }, + { + id: "layoutmsg move +col", + label: "Scroll Next Column (scrolling)" + }, + { + id: "layoutmsg move -col", + label: "Scroll Previous Column (scrolling)" } ], "System": [ @@ -1649,6 +1825,10 @@ const HYPRLAND_ACTIONS = { id: "forceidle", label: "Force Idle" }, + { + id: "releaseinputcapture", + label: "Release Input Capture" + }, { id: "submap", label: "Enter Submap" @@ -2159,7 +2339,13 @@ const HYPRLAND_ACTION_ARGS = { name: "mode", type: "text", label: "Mode", - placeholder: "0=full, 1=max, 2=fake" + placeholder: "0=fullscreen, 1=maximize" + }, + { + name: "action", + type: "text", + label: "Action", + placeholder: "toggle, set, unset" } ] }, @@ -2527,6 +2713,26 @@ const HYPRLAND_ACTION_ARGS = { } ] }, + "moveintoorcreategroup": { + args: [ + { + name: "direction", + type: "text", + label: "Direction", + placeholder: "l, r, u, d" + } + ] + }, + "layoutmsg": { + args: [ + { + name: "message", + type: "text", + label: "Message", + placeholder: "togglesplit, swapwithmaster, colresize +0.1" + } + ] + }, "movewindoworgroup": { args: [ { @@ -3097,6 +3303,7 @@ function parseCompositorActionArgs(compositor, action) { case "swapactiveworkspaces": case "renameworkspace": case "fullscreenstate": + case "fullscreen": case "movecursor": if (argParts.length >= 2) { args[argConfig.args[0].name] = argParts[0]; diff --git a/quickshell/Common/OutputModel.js b/quickshell/Common/OutputModel.js index aab114a7f..36a7620eb 100644 --- a/quickshell/Common/OutputModel.js +++ b/quickshell/Common/OutputModel.js @@ -165,6 +165,7 @@ function parseNiriOutputs(content) { function hyprLuaSettings(line, disabled, vrrMode) { return { + "vrr": vrrMode, "disabled": disabled || undefined, "bitdepth": hyprLuaField(line, "bitdepth"), "colorManagement": hyprLuaField(line, "cm"), @@ -178,8 +179,7 @@ function hyprLuaSettings(line, disabled, vrrMode) { "sdrMaxLuminance": hyprLuaField(line, "sdr_max_luminance"), "minLuminance": hyprLuaField(line, "min_luminance"), "maxLuminance": hyprLuaField(line, "max_luminance"), - "maxAvgLuminance": hyprLuaField(line, "max_avg_luminance"), - "vrrFullscreenOnly": vrrMode === 2 ? true : undefined + "maxAvgLuminance": hyprLuaField(line, "max_avg_luminance") }; } @@ -187,14 +187,16 @@ function parseHyprlandLuaMonitorLine(line) { if (!line.match(/^\s*hl\.monitor\s*\(/)) return null; const name = hyprLuaField(line, "output"); - if (name === undefined) + // output = "" is the catch-all fallback, not a monitor; the writer re-emits it verbatim. + if (name === undefined || String(name).trim() === "") return null; const disabled = hyprLuaField(line, "disabled") === true; const mode = hyprLuaField(line, "mode") || "preferred"; const position = hyprLuaField(line, "position") || "0x0"; const scaleValue = hyprLuaField(line, "scale"); const transform = Number(hyprLuaField(line, "transform") ?? 0); - const vrrMode = Number(hyprLuaField(line, "vrr") ?? 0); + const vrrField = hyprLuaField(line, "vrr"); + const vrrMode = vrrField === undefined || isNaN(Number(vrrField)) ? undefined : Number(vrrField); const posMatch = String(position).match(/^(-?\d+)x(-?\d+)$/); const modeMatch = String(mode).match(/^(\d+)x(\d+)@([\d.]+)/); return { @@ -210,10 +212,100 @@ function parseHyprlandLuaMonitorLine(line) { "vrr_enabled": vrrMode >= 1, "vrr_supported": vrrMode > 0, "hyprlandSettings": hyprLuaSettings(line, disabled, vrrMode), - "mirror": hyprLuaField(line, "mirror") || "" + "mirror": hyprLuaField(line, "mirror") || "", + "autoPosition": posMatch ? undefined : String(position), + "autoScale": scaleValue === "auto" ? true : undefined }; } +// Keeps Hyprland's "auto" words while the written value still equals what Hyprland resolved them to. +function hyprlandLuaGeometry(output, saved, live) { + const x = output?.logical?.x ?? 0; + const y = output?.logical?.y ?? 0; + const scale = Number(output?.logical?.scale ?? 1.0); + const resolved = live || saved?.logical || {}; + const keepPosition = !!saved?.autoPosition && x === resolved.x && y === resolved.y; + const keepScale = saved?.autoScale === true && Math.abs(scale - Number(resolved.scale)) <= WLR_SCALE_STEP; + return { + "position": keepPosition ? saved.autoPosition : x + "x" + y, + "scale": keepScale ? "auto" : hyprlandScaleValue(scale) + }; +} + +// Hyprland snaps to the nearest n/120 itself; writing that keeps a live float32 scale and the UI preset byte-identical. +function hyprlandScaleValue(scale) { + const value = Number(scale); + if (!isFinite(value) || value <= 0) + return 1; + const numerator = Math.round(value * SCALE_DENOMINATOR); + if (Math.abs(numerator / SCALE_DENOMINATOR - value) <= WLR_SCALE_STEP) + return scaleFromNumerator(numerator); + return parseFloat(value.toFixed(6)); +} + +// Unset per-monitor vrr inherits misc:vrr (Hyprland 0.56); -1 is the explicit "inherit" choice. +// A bare vrr = 0 in the file is what DMS used to write for every untouched monitor, so it is not a choice. +function hyprlandVrrMode(settings, saved) { + const chosen = settings?.vrr; + if (Number.isInteger(chosen)) + return chosen < 0 ? undefined : chosen; + if (settings?.vrrFullscreenOnly) + return 2; + const fromFile = saved?.hyprlandSettings?.vrr; + return Number.isInteger(fromFile) && fromFile > 0 ? fromFile : undefined; +} + +// hardwareDetails exists from Hyprland 0.56; null means unknown and gates nothing. +function hyprlandHardware(ipcObject) { + const details = ipcObject?.hardwareDetails; + if (!details || typeof details !== "object") + return null; + return { + "hdr": details.hdr === true, + "chroma": details.chroma === true, + "bt2020": details.bt2020 === true, + "vrrCapable": details.vrrCapable === true + }; +} + +function forcedFlag(value, fallback) { + if (value === true || value === 1) + return true; + if (value === false || value === -1) + return false; + return fallback; +} + +// Mirrors CMonitor::supportsWideColor/supportsHDR: a forced supports_* flag beats the EDID. +function hyprlandCaps(hardware, settings) { + if (!hardware) + return null; + const wideColor = forcedFlag(settings?.supportsWideColor, hardware.bt2020); + return { + "vrr": hardware.vrrCapable, + "wideColor": wideColor, + "hdr": wideColor && forcedFlag(settings?.supportsHdr, hardware.hdr), + "edidColor": hardware.chroma + }; +} + +// Hyprland falls back to sRGB for edid without EDID primaries and for hdr/hdredid without HDR support. +function hyprlandCmAllowed(cm, caps) { + if (!caps) + return true; + switch (cm) { + case "wide": + return caps.wideColor; + case "edid": + return caps.edidColor; + case "hdr": + case "hdredid": + return caps.hdr; + default: + return true; + } +} + function parseHyprlandDisableLine(line) { const disableMatch = line.match(/^\s*monitor\s*=\s*([^,]+),\s*disable\s*$/); if (!disableMatch) @@ -244,17 +336,17 @@ function hyprlandConfExtras(rest) { 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]) : 0; + const vrrMode = vrrMatch ? parseInt(vrrMatch[1]) : undefined; return { "transform": transformMatch ? parseInt(transformMatch[1]) : 0, - "vrrMode": vrrMode, + "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, - "vrrFullscreenOnly": vrrMode === 2 ? true : undefined + "sdrSaturation": sdrSaturationMatch ? parseFloat(sdrSaturationMatch[1]) : undefined } }; } @@ -441,6 +533,8 @@ function outputsFromWlr(wlrOutputs, liveMonitors) { const live = liveMonitors[output.name]; if (!live) continue; + if (live.hardware) + map[output.name].hardware = live.hardware; map[output.name].logical.x = live.x; map[output.name].logical.y = live.y; map[output.name].logical.scale = live.scale || 1.0; @@ -535,6 +629,8 @@ function outputsDataFromConfigEntry(configEntry, outputs, displayNameMode, compo }; if (cfg.hyprland?.mirror) entry.mirror = cfg.hyprland.mirror; + if (liveOutput?.hardware) + entry.hardware = liveOutput.hardware; result[outputId] = entry; } return result; diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 74d5ca389..74dd0e42e 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -208,6 +208,27 @@ Singleton { property int hyprlandLayoutBorderSize: Spec.SPEC.hyprlandLayoutBorderSize.def property bool hyprlandResizeOnBorder: Spec.SPEC.hyprlandResizeOnBorder.def property int hyprlandWindowOpacity: Spec.SPEC.hyprlandWindowOpacity.def + property int hyprlandBorderGrabArea: Spec.SPEC.hyprlandBorderGrabArea.def + property string hyprlandBlurVariant: Spec.SPEC.hyprlandBlurVariant.def + property int hyprlandBlurAcrylicClarity: Spec.SPEC.hyprlandBlurAcrylicClarity.def + property int hyprlandBlurAuroraIntensity: Spec.SPEC.hyprlandBlurAuroraIntensity.def + property int hyprlandBlurAuroraSpeed: Spec.SPEC.hyprlandBlurAuroraSpeed.def + property int hyprlandBlurHazeIntensity: Spec.SPEC.hyprlandBlurHazeIntensity.def + property int hyprlandBlurHazeIridescence: Spec.SPEC.hyprlandBlurHazeIridescence.def + property int hyprlandBlurRippleStrength: Spec.SPEC.hyprlandBlurRippleStrength.def + property int hyprlandBlurWaterStrength: Spec.SPEC.hyprlandBlurWaterStrength.def + property bool hyprlandGlowEnabled: Spec.SPEC.hyprlandGlowEnabled.def + property int hyprlandGlowRange: Spec.SPEC.hyprlandGlowRange.def + property int hyprlandGlowRenderPower: Spec.SPEC.hyprlandGlowRenderPower.def + property bool hyprlandWobbleEnabled: Spec.SPEC.hyprlandWobbleEnabled.def + property int hyprlandWobbleIntensity: Spec.SPEC.hyprlandWobbleIntensity.def + property int hyprlandWobbleStiffness: Spec.SPEC.hyprlandWobbleStiffness.def + property bool hyprlandMotionBlurEnabled: Spec.SPEC.hyprlandMotionBlurEnabled.def + property int hyprlandMotionBlurSamples: Spec.SPEC.hyprlandMotionBlurSamples.def + property bool hyprlandGroupbarBlur: Spec.SPEC.hyprlandGroupbarBlur.def + property int hyprlandGroupbarTextPadding: Spec.SPEC.hyprlandGroupbarTextPadding.def + property bool hyprlandGroupbarMiddleClickClose: Spec.SPEC.hyprlandGroupbarMiddleClickClose.def + property bool hyprlandGroupbarDisableWhenOnly: Spec.SPEC.hyprlandGroupbarDisableWhenOnly.def property string hyprlandTilingLayout: Spec.SPEC.hyprlandTilingLayout.def property bool hyprlandDwindlePreserveSplit: Spec.SPEC.hyprlandDwindlePreserveSplit.def property bool hyprlandDwindleSmartSplit: Spec.SPEC.hyprlandDwindleSmartSplit.def diff --git a/quickshell/Common/SettingsTabs.qml b/quickshell/Common/SettingsTabs.qml index 388f06ba0..7fa0a4409 100644 --- a/quickshell/Common/SettingsTabs.qml +++ b/quickshell/Common/SettingsTabs.qml @@ -338,7 +338,7 @@ Singleton { "text": I18n.tr("Keyboard", "settings page name"), "icon": "keyboard", "tabIndex": 45, - "niriOnly": true, + "inputCapable": true, "hint": I18n.tr("Layouts, repeat rate, num lock") }, { @@ -767,7 +767,7 @@ Singleton { return false; if (entry.layoutCapable && !CompositorService.supportsLayoutConfig) return false; - if (entry.niriOnly && !CompositorService.supportsInputConfig) + if (entry.inputCapable && !CompositorService.supportsInputConfig) return false; if (entry.pointerCapable && !CompositorService.supportsPointerConfig) return false; diff --git a/quickshell/Common/WindowModel.js b/quickshell/Common/WindowModel.js index a2f62033f..994f00a2e 100644 --- a/quickshell/Common/WindowModel.js +++ b/quickshell/Common/WindowModel.js @@ -134,13 +134,18 @@ 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 +function hyprlandWorkspaceKey(ws) { + return ws.id > 0 ? ws.id : ws.address || ws.name; +} + function hyprlandWindowOnActiveWorkspace(hyprToplevels, focusedWorkspace, toplevel) { if (!focusedWorkspace) return false; const hyprToplevel = hyprToplevels.find(t => t?.wayland === toplevel); if (!hyprToplevel || !hyprToplevel.workspace) return false; - return hyprToplevel.workspace.id === focusedWorkspace.id; + return hyprlandWorkspaceKey(hyprToplevel.workspace) === hyprlandWorkspaceKey(focusedWorkspace); } function hyprlandSpecialWorkspaceName(hyprToplevels, toplevel) { diff --git a/quickshell/Common/WindowRuleMerge.js b/quickshell/Common/WindowRuleMerge.js new file mode 100644 index 000000000..16cc4821e --- /dev/null +++ b/quickshell/Common/WindowRuleMerge.js @@ -0,0 +1,10 @@ +.pragma library + +// The Go providers round-trip more fields than the editor shows; a save starts +// from the stored object so those survive, and clears only the keys it owns. +function carry(stored, ownedKeys) { + const out = Object.assign({}, stored || {}); + for (const key of ownedKeys) + delete out[key]; + return out; +} diff --git a/quickshell/Common/WorkspaceModel.js b/quickshell/Common/WorkspaceModel.js index 04393b6c1..cb6c59300 100644 --- a/quickshell/Common/WorkspaceModel.js +++ b/quickshell/Common/WorkspaceModel.js @@ -108,6 +108,22 @@ 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 +function hyprlandKey(ws) { + if (ws?.id > 0) + return ws.id; + return ws?.address || ws?.name || ""; +} + +// "name:" resolves against the address, which a rename leaves alone; pre-main Hyprland had no address (Quickshell then echoes the id) +function hyprlandSelector(ws) { + if (ws?.id > 0) + return ws.id; + const address = String(ws?.address ?? ""); + const target = address && !/^-?\d+$/.test(address) ? address : String(ws?.name ?? ""); + return hyprlandSpecialName(target) ? target : "name:" + target; +} + function hyprlandOrder(a, b) { const keyA = a.id < 0 ? Number.MAX_SAFE_INTEGER : a.id; const keyB = b.id < 0 ? Number.MAX_SAFE_INTEGER : b.id; @@ -120,6 +136,7 @@ function hyprlandRecord(ws) { return { id: ws.id, idx: ws.id > 0 ? ws.id : null, + address: ws.address ?? "", name: ws.name ?? "", output: ws.monitor?.name ?? "", active: ws.active === true, @@ -129,9 +146,8 @@ function hyprlandRecord(ws) { } function hyprlandCurrentId(raw, screenName, followFocus) { - if (!screenName || followFocus) - return raw.focusedWorkspace?.id || 1; - return raw.monitors.find(m => m.name === screenName)?.activeWorkspace?.id || 1; + const ws = !screenName || followFocus ? raw.focusedWorkspace : raw.monitors.find(m => m.name === screenName)?.activeWorkspace; + return hyprlandKey(ws) || 1; } function hyprlandMonitorWorkspaces(raw, workspaces, screenName) { @@ -152,9 +168,8 @@ function hyprlandListedWorkspaces(raw, screenName, followFocus, occupiedOnly) { const workspaces = !screenName || followFocus ? regular.slice().sort(hyprlandOrder) : hyprlandMonitorWorkspaces(raw, regular, screenName); if (!occupiedOnly) return workspaces.map(hyprlandRecord); - const currentId = hyprlandCurrentId(raw, screenName, followFocus); - const toplevels = raw.toplevels; - return workspaces.filter(ws => ws.id === currentId || toplevels.some(tl => tl.workspace?.id === ws.id)).map(hyprlandRecord); + const currentKey = hyprlandCurrentId(raw, screenName, followFocus); + return workspaces.filter(ws => hyprlandKey(ws) === currentKey || hyprlandWorkspaceOccupied(raw.toplevels, ws)).map(hyprlandRecord); } function hyprlandRuleIds(workspaceString) { @@ -234,7 +249,7 @@ function hyprlandSpecialDisplayName(name) { // Hyprland >= 0.56 reports every special workspace with a null id, so specials only match by name function hyprlandWorkspaceMatches(ws, record) { if (record.special !== true) - return ws?.id === record.id; + return !!ws && hyprlandKey(ws) === hyprlandKey(record); const name = ws?.name ?? ""; return hyprlandSpecialName(name) && hyprlandSpecialDisplayName(name) === record.name; } diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 27af5e84d..1c5f49150 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -167,6 +167,90 @@ var LOCAL_SPEC = { def: 100, onChange: "updateCompositorLayout" }, + hyprlandBorderGrabArea: { + def: 15, + onChange: "updateCompositorLayout" + }, + hyprlandBlurVariant: { + def: "kawase", + onChange: "updateCompositorLayout" + }, + hyprlandBlurAcrylicClarity: { + def: 82, + onChange: "updateCompositorLayout" + }, + hyprlandBlurAuroraIntensity: { + def: 35, + onChange: "updateCompositorLayout" + }, + hyprlandBlurAuroraSpeed: { + def: 1, + onChange: "updateCompositorLayout" + }, + hyprlandBlurHazeIntensity: { + def: 35, + onChange: "updateCompositorLayout" + }, + hyprlandBlurHazeIridescence: { + def: 70, + onChange: "updateCompositorLayout" + }, + hyprlandBlurRippleStrength: { + def: 30, + onChange: "updateCompositorLayout" + }, + hyprlandBlurWaterStrength: { + def: 32, + onChange: "updateCompositorLayout" + }, + hyprlandGlowEnabled: { + def: false, + onChange: "updateCompositorLayout" + }, + hyprlandGlowRange: { + def: 10, + onChange: "updateCompositorLayout" + }, + hyprlandGlowRenderPower: { + def: 3, + onChange: "updateCompositorLayout" + }, + hyprlandWobbleEnabled: { + def: false, + onChange: "updateCompositorLayout" + }, + hyprlandWobbleIntensity: { + def: 20, + onChange: "updateCompositorLayout" + }, + hyprlandWobbleStiffness: { + def: 200, + onChange: "updateCompositorLayout" + }, + hyprlandMotionBlurEnabled: { + def: false, + onChange: "updateCompositorLayout" + }, + hyprlandMotionBlurSamples: { + def: 7, + onChange: "updateCompositorLayout" + }, + hyprlandGroupbarBlur: { + def: false, + onChange: "updateCompositorLayout" + }, + hyprlandGroupbarTextPadding: { + def: 0, + onChange: "updateCompositorLayout" + }, + hyprlandGroupbarMiddleClickClose: { + def: true, + onChange: "updateCompositorLayout" + }, + hyprlandGroupbarDisableWhenOnly: { + def: false, + onChange: "updateCompositorLayout" + }, hyprlandTilingLayout: { def: "", onChange: "updateCompositorLayout" diff --git a/quickshell/Modals/Common/DModal.qml b/quickshell/Modals/Common/DModal.qml index 77797c513..4afe81ad6 100644 --- a/quickshell/Modals/Common/DModal.qml +++ b/quickshell/Modals/Common/DModal.qml @@ -63,7 +63,7 @@ Item { // Hyprland OnDemand grab delivers keyboard focus to the modal content surface. DFocusGrab { windows: (root.contentWindow ? [root.contentWindow] : []).concat(root.transientSurfaceTracker?.focusWindows ?? []) - wanted: KeyboardFocus.wantsGrab(root.shouldHaveFocus, root.customKeyboardFocus) + wanted: KeyboardFocus.wantsGrab(root.shouldBeVisible && root.shouldHaveFocus, root.customKeyboardFocus) } readonly property var contentWindow: impl.item ? impl.item.contentWindow : null readonly property var effectiveScreen: impl.item ? impl.item.effectiveScreen : null diff --git a/quickshell/Modals/KeybindsContent.qml b/quickshell/Modals/KeybindsContent.qml index 5181b5d80..6227dda16 100644 --- a/quickshell/Modals/KeybindsContent.qml +++ b/quickshell/Modals/KeybindsContent.qml @@ -46,7 +46,7 @@ FocusScope { DListItem { id: keybindRow required property var modelData - readonly property bool canExecute: !keybindRow.modelData.isRange && KeybindsService.canExecuteAction(keybindRow.modelData.action) + readonly property bool canExecute: !keybindRow.modelData.isRange && KeybindsService.canExecuteAction(keybindRow.modelData.action, keybindRow.modelData.luaAction) Layout.fillWidth: true Layout.preferredHeight: implicitHeight @@ -57,7 +57,7 @@ FocusScope { Accessible.name: keybindRow.modelData.label || content.getBindLabel(keybindRow.modelData) Accessible.description: (keybindRow.modelData.allKeys ? keybindRow.modelData.allKeys.join(", ") : (keybindRow.modelData.key || "")) + " • " + (keybindRow.modelData.action || "") onClicked: { - if (keybindRow.canExecute && KeybindsService.executeAction(keybindRow.modelData.action)) + if (keybindRow.canExecute && KeybindsService.executeAction(keybindRow.modelData.action, keybindRow.modelData.luaAction)) content.closeRequested(); } @@ -447,6 +447,7 @@ FocusScope { sigs[tokenSig] = true; subcatMap[subcatName][groupKey] = { action: bindAction, + luaAction: bind.luaAction || "", desc: bind.desc, label: label, key: bind.key, diff --git a/quickshell/Modules/Greetd/assets/dms-greeter b/quickshell/Modules/Greetd/assets/dms-greeter index e871c0895..c87ba14b6 100755 --- a/quickshell/Modules/Greetd/assets/dms-greeter +++ b/quickshell/Modules/Greetd/assets/dms-greeter @@ -38,7 +38,7 @@ Options: Examples: dms-greeter --command niri - dms-greeter --command hyprland -C /etc/greetd/custom-hypr.conf + dms-greeter --command hyprland -C /etc/greetd/custom-hypr.lua dms-greeter --command sway -p /home/user/.config/quickshell/custom-dms dms-greeter --command scroll -p /home/user/.config/quickshell/custom-dms dms-greeter --command niri --cache-dir /tmp/dmsgreeter @@ -381,7 +381,7 @@ NIRI_EOF cat >> "$TEMP_CONFIG" << HYPRLAND_LUA_EOF hl.on("hyprland.start", function() - hl.exec_cmd('sh -c "$QS_CMD; hyprctl dispatch exit"') + hl.exec_cmd("sh -c \"$QS_CMD; hyprctl dispatch 'hl.dsp.exit()'\"") end) HYPRLAND_LUA_EOF COMPOSITOR_CONFIG="$TEMP_CONFIG" @@ -397,11 +397,12 @@ hl.config({ }) hl.on("hyprland.start", function() - hl.exec_cmd('sh -c "$QS_CMD; hyprctl dispatch exit"') + hl.exec_cmd("sh -c \"$QS_CMD; hyprctl dispatch 'hl.dsp.exit()'\"") end) HYPRLAND_LUA_EOF COMPOSITOR_CONFIG="$TEMP_CONFIG" else + # hyprlang stays usable until 0.57 ships; legacy dispatch is the only form a .conf session accepts TEMP_CONFIG=$(mktemp) cat "$COMPOSITOR_CONFIG" > "$TEMP_CONFIG" cat >> "$TEMP_CONFIG" << HYPRLAND_EOF diff --git a/quickshell/Modules/Greetd/assets/dms-hypr.conf b/quickshell/Modules/Greetd/assets/dms-hypr.conf deleted file mode 100644 index 03f6063ff..000000000 --- a/quickshell/Modules/Greetd/assets/dms-hypr.conf +++ /dev/null @@ -1,4 +0,0 @@ -# Deprecated: greetd expects Hyprland 0.55+ Lua; use `/etc/greetd/dms-hypr.lua` instead. -env = DMS_RUN_GREETER,1 - -exec = sh -c "qs -p _DMS_PATH_; hyprctl dispatch exit" diff --git a/quickshell/Modules/Greetd/assets/dms-hypr.lua b/quickshell/Modules/Greetd/assets/dms-hypr.lua index f875d6370..1d0fe1029 100644 --- a/quickshell/Modules/Greetd/assets/dms-hypr.lua +++ b/quickshell/Modules/Greetd/assets/dms-hypr.lua @@ -4,5 +4,5 @@ hl.env("DMS_RUN_GREETER", "1") hl.on("hyprland.start", function() - hl.exec_cmd('sh -c "qs -p _DMS_PATH_; hyprctl dispatch exit"') + hl.exec_cmd("sh -c \"qs -p _DMS_PATH_; hyprctl dispatch 'hl.dsp.exit()'\"") end) diff --git a/quickshell/Modules/Settings/CompositorLayoutTab.qml b/quickshell/Modules/Settings/CompositorLayoutTab.qml index 2e3bc2ee2..fb1ceaff4 100644 --- a/quickshell/Modules/Settings/CompositorLayoutTab.qml +++ b/quickshell/Modules/Settings/CompositorLayoutTab.qml @@ -8,6 +8,7 @@ import qs.Modules.Settings.Widgets Item { id: root + readonly property var log: Log.scoped("CompositorLayoutTab") property var parentModal: null LayoutMirroring.enabled: I18n.isRtl @@ -447,6 +448,18 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; onToggled: checked => SettingsData.set("hyprlandResizeOnBorder", checked) } + SettingsSliderRow { + tags: ["hyprland", "resize", "border", "grab", "area", "extend_border_grab_area"] + settingKey: "hyprlandBorderGrabArea" + visible: SettingsData.hyprlandResizeOnBorder + text: I18n.tr("Border grab area", "Hyprland slider, extra space around window borders that still starts a resize") + value: SettingsData.hyprlandBorderGrabArea + minimum: 0 + maximum: 100 + unit: "px" + onSliderValueChanged: newValue => SettingsData.set("hyprlandBorderGrabArea", newValue) + } + SettingsSliderRow { tags: ["hyprland", "opacity", "transparency", "window"] settingKey: "hyprlandWindowOpacity" @@ -479,6 +492,224 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; } } + SettingsCard { + id: hyprEffectsCard + width: parent.width + tags: ["hyprland", "effects", "blur", "glow", "wobble", "motion", "decoration"] + 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")) + + readonly property var blurVariantIds: ["kawase", "frost", "ripple", "drops", "water", "fluid_jar", "prism", "heat_shimmer", "acrylic", "aurora", "haze"] + + SettingsDropdownRow { + tags: ["hyprland", "blur", "variant", "style", "acrylic", "aurora", "frost", "glass"] + settingKey: "hyprlandBlurVariant" + visible: HyprlandService.hyprSupports("decoration:blur:variant") + text: I18n.tr("Blur") + description: I18n.tr("Styles outside of Default use more GPU", "Hyprland blur variant dropdown description") + options: [I18n.tr("Default"), I18n.tr("Frost", "Hyprland blur variant"), I18n.tr("Ripple", "Hyprland blur variant"), I18n.tr("Drops", "Hyprland blur variant"), I18n.tr("Water", "Hyprland blur variant"), I18n.tr("Fluid jar", "Hyprland blur variant"), I18n.tr("Prism", "Hyprland blur variant"), I18n.tr("Heat shimmer", "Hyprland blur variant"), I18n.tr("Acrylic", "Hyprland blur variant"), I18n.tr("Aurora", "Hyprland blur variant"), I18n.tr("Haze", "Hyprland blur variant")] + currentValue: options[Math.max(0, hyprEffectsCard.blurVariantIds.indexOf(SettingsData.hyprlandBlurVariant))] + onValueChanged: value => SettingsData.set("hyprlandBlurVariant", hyprEffectsCard.blurVariantIds[Math.max(0, options.indexOf(value))]) + } + + SettingsSliderRow { + tags: ["hyprland", "blur", "ripple", "strength", "refraction"] + settingKey: "hyprlandBlurRippleStrength" + visible: SettingsData.hyprlandBlurVariant === "ripple" + text: I18n.tr("Refraction", "Hyprland blur slider, how far ripple or water waves bend the backdrop") + value: SettingsData.hyprlandBlurRippleStrength + minimum: 0 + maximum: 32 + unit: "px" + onSliderValueChanged: newValue => SettingsData.set("hyprlandBlurRippleStrength", newValue) + } + + SettingsSliderRow { + tags: ["hyprland", "blur", "water", "strength", "refraction"] + settingKey: "hyprlandBlurWaterStrength" + visible: SettingsData.hyprlandBlurVariant === "water" + text: I18n.tr("Refraction", "Hyprland blur slider, how far ripple or water waves bend the backdrop") + value: SettingsData.hyprlandBlurWaterStrength + minimum: 0 + maximum: 32 + unit: "px" + onSliderValueChanged: newValue => SettingsData.set("hyprlandBlurWaterStrength", newValue) + } + + SettingsSliderRow { + tags: ["hyprland", "blur", "acrylic", "clarity"] + settingKey: "hyprlandBlurAcrylicClarity" + visible: SettingsData.hyprlandBlurVariant === "acrylic" + text: I18n.tr("Clarity", "Hyprland acrylic blur slider, how much sharp backdrop shows through") + value: SettingsData.hyprlandBlurAcrylicClarity + minimum: 0 + maximum: 100 + unit: "%" + onSliderValueChanged: newValue => SettingsData.set("hyprlandBlurAcrylicClarity", newValue) + } + + SettingsSliderRow { + tags: ["hyprland", "blur", "aurora", "intensity"] + settingKey: "hyprlandBlurAuroraIntensity" + visible: SettingsData.hyprlandBlurVariant === "aurora" + text: I18n.tr("Intensity") + value: SettingsData.hyprlandBlurAuroraIntensity + minimum: 0 + maximum: 100 + unit: "%" + onSliderValueChanged: newValue => SettingsData.set("hyprlandBlurAuroraIntensity", newValue) + } + + SettingsSliderRow { + tags: ["hyprland", "blur", "aurora", "speed", "animation"] + settingKey: "hyprlandBlurAuroraSpeed" + visible: SettingsData.hyprlandBlurVariant === "aurora" + text: I18n.tr("Speed") + value: SettingsData.hyprlandBlurAuroraSpeed + minimum: 0 + maximum: 10 + onSliderValueChanged: newValue => SettingsData.set("hyprlandBlurAuroraSpeed", newValue) + } + + SettingsSliderRow { + tags: ["hyprland", "blur", "haze", "intensity", "sheen"] + settingKey: "hyprlandBlurHazeIntensity" + visible: SettingsData.hyprlandBlurVariant === "haze" + text: I18n.tr("Intensity") + value: SettingsData.hyprlandBlurHazeIntensity + minimum: 0 + maximum: 100 + unit: "%" + onSliderValueChanged: newValue => SettingsData.set("hyprlandBlurHazeIntensity", newValue) + } + + SettingsSliderRow { + tags: ["hyprland", "blur", "haze", "iridescence", "color"] + settingKey: "hyprlandBlurHazeIridescence" + visible: SettingsData.hyprlandBlurVariant === "haze" + text: I18n.tr("Iridescence", "Hyprland haze blur slider, strength of the pearlescent color shift") + value: SettingsData.hyprlandBlurHazeIridescence + minimum: 0 + maximum: 100 + unit: "%" + onSliderValueChanged: newValue => SettingsData.set("hyprlandBlurHazeIridescence", newValue) + } + + SettingsToggleSliderRow { + tags: ["hyprland", "glow", "inner", "range"] + settingKey: "hyprlandGlowEnabled" + valueKeys: ["hyprlandGlowRange"] + visible: HyprlandService.hyprSupports("decoration:glow:enabled") + text: I18n.tr("Glow", "Hyprland toggle, inner glow along window edges in the theme color") + checked: SettingsData.hyprlandGlowEnabled + value: SettingsData.hyprlandGlowRange + minimum: 1 + maximum: 50 + unit: "px" + onToggled: checked => SettingsData.set("hyprlandGlowEnabled", checked) + onSliderValueChanged: newValue => SettingsData.set("hyprlandGlowRange", newValue) + } + + SettingsSliderRow { + tags: ["hyprland", "glow", "falloff", "render_power"] + settingKey: "hyprlandGlowRenderPower" + visible: SettingsData.hyprlandGlowEnabled && HyprlandService.hyprSupports("decoration:glow:enabled") + text: I18n.tr("Glow falloff", "Hyprland slider, higher values fade the glow faster") + value: SettingsData.hyprlandGlowRenderPower + minimum: 1 + maximum: 4 + onSliderValueChanged: newValue => SettingsData.set("hyprlandGlowRenderPower", newValue) + } + + SettingsToggleSliderRow { + tags: ["hyprland", "wobble", "wobbly", "windows", "intensity"] + settingKey: "hyprlandWobbleEnabled" + valueKeys: ["hyprlandWobbleIntensity"] + visible: HyprlandService.hyprSupports("decoration:wobble:enabled") + text: I18n.tr("Wobbly windows", "Hyprland toggle, windows flex while moving and resizing") + checked: SettingsData.hyprlandWobbleEnabled + value: SettingsData.hyprlandWobbleIntensity + minimum: 0 + maximum: 200 + unit: "%" + onToggled: checked => SettingsData.set("hyprlandWobbleEnabled", checked) + onSliderValueChanged: newValue => SettingsData.set("hyprlandWobbleIntensity", newValue) + } + + SettingsSliderRow { + tags: ["hyprland", "wobble", "stiffness", "spring"] + settingKey: "hyprlandWobbleStiffness" + visible: SettingsData.hyprlandWobbleEnabled && HyprlandService.hyprSupports("decoration:wobble:enabled") + text: I18n.tr("Wobble stiffness", "Hyprland slider, higher values settle wobbly windows faster") + value: SettingsData.hyprlandWobbleStiffness + minimum: 10 + maximum: 1000 + onSliderValueChanged: newValue => SettingsData.set("hyprlandWobbleStiffness", newValue) + } + + SettingsToggleSliderRow { + tags: ["hyprland", "motion", "blur", "samples", "move", "resize"] + settingKey: "hyprlandMotionBlurEnabled" + valueKeys: ["hyprlandMotionBlurSamples"] + visible: HyprlandService.hyprSupports("decoration:motion_blur:enabled") + text: I18n.tr("Motion blur", "Hyprland toggle, blur windows while they move or resize") + description: I18n.tr("More samples look smoother and cost more GPU", "Hyprland motion blur sample count description") + checked: SettingsData.hyprlandMotionBlurEnabled + value: SettingsData.hyprlandMotionBlurSamples + minimum: 1 + maximum: 64 + onToggled: checked => SettingsData.set("hyprlandMotionBlurEnabled", checked) + onSliderValueChanged: newValue => SettingsData.set("hyprlandMotionBlurSamples", newValue) + } + } + + SettingsCard { + width: parent.width + tags: ["hyprland", "group", "groupbar", "tabs"] + title: I18n.tr("Window groups", "Hyprland settings card title, tabbed window groups") + settingKey: "hyprlandGroupbar" + iconName: "tab" + visible: CompositorService.isHyprland + + SettingsToggleRow { + tags: ["hyprland", "groupbar", "blur"] + settingKey: "hyprlandGroupbarBlur" + text: I18n.tr("Background blur") + checked: SettingsData.hyprlandGroupbarBlur + onToggled: checked => SettingsData.set("hyprlandGroupbarBlur", checked) + } + + SettingsSliderRow { + tags: ["hyprland", "groupbar", "text", "padding", "text_padding"] + settingKey: "hyprlandGroupbarTextPadding" + text: I18n.tr("Title padding", "Hyprland groupbar slider, horizontal padding around tab titles") + value: SettingsData.hyprlandGroupbarTextPadding + minimum: 0 + maximum: 22 + unit: "px" + onSliderValueChanged: newValue => SettingsData.set("hyprlandGroupbarTextPadding", newValue) + } + + SettingsToggleRow { + tags: ["hyprland", "groupbar", "middle", "click", "close"] + settingKey: "hyprlandGroupbarMiddleClickClose" + text: I18n.tr("Middle click closes", "Hyprland groupbar toggle, middle clicking a tab closes that window") + checked: SettingsData.hyprlandGroupbarMiddleClickClose + onToggled: checked => SettingsData.set("hyprlandGroupbarMiddleClickClose", checked) + } + + SettingsToggleRow { + tags: ["hyprland", "groupbar", "single", "hide", "disable_when_only"] + settingKey: "hyprlandGroupbarDisableWhenOnly" + visible: HyprlandService.hyprSupports("group:groupbar:disable_when_only") + text: I18n.tr("Hide with one window", "Hyprland groupbar toggle, no tab bar when the group holds a single window") + checked: SettingsData.hyprlandGroupbarDisableWhenOnly + onToggled: checked => SettingsData.set("hyprlandGroupbarDisableWhenOnly", checked) + } + } + SettingsCard { width: parent.width tags: ["mangowc", "mango", "dwl", "layout", "gaps", "radius", "window", "border"] diff --git a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml index 0f436f2e8..6c788a36d 100644 --- a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml +++ b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml @@ -988,7 +988,6 @@ Singleton { if (CompositorService.isHyprland) { initHyprlandSettingsFromConfig(parsed); - syncHyprlandVrrFromConfig(parsed); syncHyprlandDisabledFromConfig(parsed); } if (CompositorService.isNiri) { @@ -1029,29 +1028,6 @@ Singleton { } } - function syncHyprlandVrrFromConfig(parsedOutputs) { - const current = JSON.parse(JSON.stringify(SessionData.hyprlandOutputSettings)); - let changed = false; - for (const outputName in parsedOutputs) { - const settings = parsedOutputs[outputName]?.hyprlandSettings; - const fromConfig = settings?.vrrFullscreenOnly ?? false; - const stored = current[outputName]?.vrrFullscreenOnly ?? false; - if (fromConfig === stored) - continue; - if (!current[outputName]) - current[outputName] = {}; - if (fromConfig) - current[outputName].vrrFullscreenOnly = true; - else - delete current[outputName].vrrFullscreenOnly; - changed = true; - } - if (changed) { - SessionData.hyprlandOutputSettings = current; - SessionData.saveSettings(); - } - } - function syncNiriVrrFromConfig(parsedOutputs) { for (const outputName in parsedOutputs) { const output = parsedOutputs[outputName]; @@ -1156,7 +1132,8 @@ Singleton { "x": live.x, "y": live.y, "scale": live.scale, - "transformIndex": live.lastIpcObject?.transform ?? 0 + "transformIndex": live.lastIpcObject?.transform ?? 0, + "hardware": OutputModel.hyprlandHardware(live.lastIpcObject) }; } return OutputModel.outputsFromWlr(wlrOutputs, liveMonitors); @@ -1481,6 +1458,30 @@ Singleton { pendingHyprlandChanges = newPending; } + function getHyprlandCaps(output, outputName) { + return OutputModel.hyprlandCaps(outputs[outputName]?.hardware ?? null, { + "supportsWideColor": getHyprlandSetting(output, outputName, "supportsWideColor", undefined), + "supportsHdr": getHyprlandSetting(output, outputName, "supportsHdr", undefined) + }); + } + + function hyprlandCmAllowed(cm, caps) { + return OutputModel.hyprlandCmAllowed(cm, caps); + } + + function getHyprlandVrrMode(output, outputName) { + const settings = { + "vrr": getHyprlandSetting(output, outputName, "vrr", undefined), + "vrrFullscreenOnly": getHyprlandSetting(output, outputName, "vrrFullscreenOnly", false) + }; + return OutputModel.hyprlandVrrMode(settings, savedParsedOutputs[getHyprlandOutputIdentifier(output, outputName)]); + } + + function setHyprlandVrrMode(output, outputName, mode) { + setHyprlandSetting(output, outputName, "vrr", mode); + setHyprlandSetting(output, outputName, "vrrFullscreenOnly", null); + } + function initOriginalHyprlandSettings() { if (originalHyprlandSettings) return; @@ -1634,8 +1635,10 @@ Singleton { changeDescriptions.push(outputId + ": " + I18n.tr("Force HDR") + " → " + (changes.supportsHdr ? I18n.tr("Yes") : I18n.tr("No"))); if (changes.supportsWideColor !== undefined) changeDescriptions.push(outputId + ": " + I18n.tr("Force Wide Color") + " → " + (changes.supportsWideColor ? I18n.tr("Yes") : I18n.tr("No"))); - if (changes.vrrFullscreenOnly !== undefined) - changeDescriptions.push(outputId + ": " + I18n.tr("VRR Fullscreen Only") + " → " + (changes.vrrFullscreenOnly ? I18n.tr("Enabled") : I18n.tr("Disabled"))); + if (changes.vrr !== undefined) + changeDescriptions.push(outputId + ": " + "VRR" + " → " + hyprlandVrrLabel(changes.vrr)); + if (changes.sdrEotf !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("SDR transfer function") + " → " + (changes.sdrEotf || I18n.tr("Default"))); } if (CompositorService.isNiri) { @@ -1738,6 +1741,20 @@ Singleton { } } + function hyprlandVrrLabel(mode) { + switch (mode) { + case 0: + return I18n.tr("Off"); + case 1: + return I18n.tr("On", "adjective, enabled state"); + case 2: + case 3: + return I18n.tr("Fullscreen only"); + default: + return I18n.tr("Default (Global)"); + } + } + function buildMergedHyprlandSettings() { const merged = JSON.parse(JSON.stringify(SessionData.hyprlandOutputSettings)); for (const outputId in pendingHyprlandChanges) { diff --git a/quickshell/Modules/Settings/DisplayConfig/HyprlandOutputSettings.qml b/quickshell/Modules/Settings/DisplayConfig/HyprlandOutputSettings.qml index 49c1e96b7..736c9708e 100644 --- a/quickshell/Modules/Settings/DisplayConfig/HyprlandOutputSettings.qml +++ b/quickshell/Modules/Settings/DisplayConfig/HyprlandOutputSettings.qml @@ -17,6 +17,11 @@ SettingsCard { return DisplayConfigState.getHyprlandSetting(root.outputData, root.outputName, "colorManagement", "auto"); } readonly property bool isHdrMode: colorManagement === "hdr" || colorManagement === "hdredid" + readonly property var caps: { + void (DisplayConfigState.pendingHyprlandChanges); + return DisplayConfigState.getHyprlandCaps(root.outputData, root.outputName); + } + readonly property bool hdrCapable: caps?.hdr ?? true readonly property bool isDisabled: { void (DisplayConfigState.pendingHyprlandChanges); return DisplayConfigState.getHyprlandSetting(root.outputData, root.outputName, "disabled", false); @@ -69,15 +74,15 @@ SettingsCard { } SettingsDropdownRow { + readonly property var cmValues: ["auto", "wide", "dcip3", "dp3", "adobe", "edid", "hdr", "hdredid"] + readonly property var supportedCmValues: cmValues.filter(cm => cm === root.colorManagement || DisplayConfigState.hyprlandCmAllowed(cm, root.caps)) + visible: root.is10Bit text: I18n.tr("Color gamut") enabled: !root.isDisabled - currentValue: { - void (DisplayConfigState.pendingHyprlandChanges); - const val = DisplayConfigState.getHyprlandSetting(root.outputData, root.outputName, "colorManagement", "auto"); - return cmLabelMap[val] || I18n.tr("Auto (Wide)"); - } - options: [I18n.tr("Auto (Wide)"), I18n.tr("Wide (BT2020)"), "DCI-P3", "Apple P3", "Adobe RGB", "EDID", "HDR", I18n.tr("HDR (EDID)")] + description: supportedCmValues.length < cmValues.length ? I18n.tr("Modes this display does not report support for are hidden") : "" + currentValue: cmLabelMap[root.colorManagement] || I18n.tr("Auto (Wide)") + options: supportedCmValues.map(cm => cmLabelMap[cm]) property var cmValueMap: ({ [I18n.tr("Auto (Wide)")]: "auto", @@ -107,19 +112,41 @@ SettingsCard { } } + // Hyprland ignores sdr_eotf in HDR modes. + SettingsDropdownRow { + readonly property string eotf: { + void (DisplayConfigState.pendingHyprlandChanges); + return String(DisplayConfigState.getHyprlandSetting(root.outputData, root.outputName, "sdrEotf", "")); + } + readonly property var eotfValues: ["", "auto", "srgb", "gamma22"] + readonly property var eotfLabels: [I18n.tr("Default"), I18n.tr("Auto"), "sRGB", "Gamma 2.2"] + + visible: !root.isHdrMode + text: I18n.tr("SDR transfer function") + enabled: !root.isDisabled + options: eotfValues.includes(eotf) ? eotfLabels : eotfLabels.concat([eotf]) + currentValue: eotfValues.includes(eotf) ? eotfLabels[eotfValues.indexOf(eotf)] : eotf + onValueChanged: value => { + const index = eotfLabels.indexOf(value); + if (index < 0) + return; + DisplayConfigState.setHyprlandSetting(root.outputData, root.outputName, "sdrEotf", eotfValues[index] || null); + } + } + SettingsRow { visible: root.is10Bit && root.isHdrMode iconName: "warning" iconColor: Theme.warning title: I18n.tr("Experimental feature") titleColor: Theme.warning - subtitle: I18n.tr("HDR mode is experimental. Verify your monitor supports HDR before enabling.") + subtitle: root.hdrCapable ? I18n.tr("HDR mode is experimental. Verify your monitor supports HDR before enabling.") : I18n.tr("This display does not report HDR support, so Hyprland falls back to sRGB.") } SettingsRow { visible: root.is10Bit && root.isHdrMode title: I18n.tr("HDR tone mapping") - enabled: !root.isDisabled + enabled: !root.isDisabled && root.hdrCapable body: Row { width: parent.width diff --git a/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml b/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml index 844e77db0..6e546401c 100644 --- a/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml +++ b/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml @@ -216,21 +216,26 @@ Column { } SettingsDropdownRow { - visible: root.isActive && root.vrrSupported && CompositorService.isHyprland + readonly property var hyprlandCaps: { + void (DisplayConfigState.pendingHyprlandChanges); + return DisplayConfigState.getHyprlandCaps(root.outputData, root.outputName); + } + readonly property bool capable: hyprlandCaps?.vrr ?? true + // Index - 1 is the Lua vrr value; -1 leaves it unset so misc:vrr applies. + readonly property var modeLabels: [I18n.tr("Default (Global)"), I18n.tr("Off"), I18n.tr("On", "adjective, enabled state"), I18n.tr("Fullscreen only")] + + visible: root.isActive && CompositorService.isHyprland && (hyprlandCaps !== null || root.vrrSupported) + enabled: capable text: I18n.tr("Variable refresh rate") - options: [I18n.tr("Off"), I18n.tr("On", "adjective, enabled state"), I18n.tr("Fullscreen only")] + description: capable ? "" : I18n.tr("This display does not report VRR support") + options: modeLabels currentValue: { void (DisplayConfigState.pendingHyprlandChanges); - if (DisplayConfigState.getHyprlandSetting(root.outputData, root.outputName, "vrrFullscreenOnly", false)) - return I18n.tr("Fullscreen only"); - const pendingVrr = DisplayConfigState.getPendingValue(root.outputName, "vrr"); - const vrrEnabled = pendingVrr !== undefined ? pendingVrr : (DisplayConfigState.outputs[root.outputName]?.vrr_enabled ?? false); - return vrrEnabled ? I18n.tr("On") : I18n.tr("Off"); - } - onValueChanged: value => { - DisplayConfigState.setPendingChange(root.outputName, "vrr", value !== I18n.tr("Off")); - DisplayConfigState.setHyprlandSetting(root.outputData, root.outputName, "vrrFullscreenOnly", value === I18n.tr("Fullscreen only") || null); + void (DisplayConfigState.savedParsedOutputs); + const mode = DisplayConfigState.getHyprlandVrrMode(root.outputData, root.outputName); + return modeLabels[mode === undefined ? 0 : Math.min(mode, 2) + 1]; } + onValueChanged: value => DisplayConfigState.setHyprlandVrrMode(root.outputData, root.outputName, modeLabels.indexOf(value) - 1) } SettingsDropdownRow { diff --git a/quickshell/Modules/Settings/KeybindEditorDialog.qml b/quickshell/Modules/Settings/KeybindEditorDialog.qml index 9edc6e393..be608334a 100644 --- a/quickshell/Modules/Settings/KeybindEditorDialog.qml +++ b/quickshell/Modules/Settings/KeybindEditorDialog.qml @@ -589,6 +589,56 @@ DDialog { "label": I18n.tr("Long press"), "value": "o", "tooltip": I18n.tr("Triggers after holding the key", "keybind option tooltip, hyprland long press flag") + }, + { + "label": I18n.tr("Non-consuming", "adjective, hyprland bind flag toggle, key is not swallowed"), + "value": "n", + "tooltip": I18n.tr("The key also reaches the focused window", "keybind option tooltip, hyprland non-consuming flag") + }, + { + "label": I18n.tr("Mouse", "hyprland bind flag toggle, bind for a mouse button"), + "value": "m", + "tooltip": I18n.tr("Binds a mouse button, such as mouse:272", "keybind option tooltip, hyprland mouse flag") + }, + { + "label": I18n.tr("Transparent", "adjective, hyprland bind flag toggle"), + "value": "t", + "tooltip": I18n.tr("Other binds cannot shadow it", "keybind option tooltip, hyprland transparent flag") + }, + { + "label": I18n.tr("Ignore mods", "hyprland bind flag toggle, ignore modifier keys"), + "value": "i", + "tooltip": I18n.tr("Fires with any modifiers held", "keybind option tooltip, hyprland ignore mods flag") + }, + { + "label": I18n.tr("All submaps", "hyprland bind flag toggle, submap universal"), + "value": "u", + "tooltip": I18n.tr("Works in every submap", "keybind option tooltip, hyprland submap universal flag") + }, + { + "label": I18n.tr("Auto-consuming", "adjective, hyprland bind flag toggle"), + "value": "a", + "tooltip": I18n.tr("Consumes the key only when a dispatcher runs", "keybind option tooltip, hyprland auto-consuming flag") + }, + { + "label": I18n.tr("Input capture", "hyprland bind flag toggle, allow input capture"), + "value": "x", + "tooltip": I18n.tr("Fires even while an input capture session is active", "keybind option tooltip, hyprland allow input capture flag") + }, + { + "label": I18n.tr("Not inhibitable", "adjective, hyprland bind flag toggle, dont_inhibit"), + "value": "p", + "tooltip": I18n.tr("Fires even when an app inhibits shortcuts", "keybind option tooltip, hyprland dont inhibit flag") + }, + { + "label": I18n.tr("Click", "hyprland bind flag toggle, mouse click bind"), + "value": "c", + "tooltip": I18n.tr("Triggers on release if the pointer stayed put", "keybind option tooltip, hyprland click flag") + }, + { + "label": I18n.tr("Drag", "hyprland bind flag toggle, mouse drag bind"), + "value": "g", + "tooltip": I18n.tr("Triggers on release after the pointer moved", "keybind option tooltip, hyprland drag flag") } ]; } diff --git a/quickshell/Modules/Settings/KeyboardTab.qml b/quickshell/Modules/Settings/KeyboardTab.qml index c95dc0d1e..03ad563b1 100644 --- a/quickshell/Modules/Settings/KeyboardTab.qml +++ b/quickshell/Modules/Settings/KeyboardTab.qml @@ -132,6 +132,7 @@ Item { iconName: "settings" SettingsButtonGroupRow { + visible: CompositorService.isNiri tags: ["keyboard", "track", "layout"] settingKey: "keyboardTrackLayout" text: I18n.tr("Remember layout") diff --git a/quickshell/Modules/Settings/MouseTouchpadTab.qml b/quickshell/Modules/Settings/MouseTouchpadTab.qml index 3ec067143..08af91caa 100644 --- a/quickshell/Modules/Settings/MouseTouchpadTab.qml +++ b/quickshell/Modules/Settings/MouseTouchpadTab.qml @@ -26,7 +26,7 @@ Item { SettingsCard { width: parent.width - visible: CompositorService.isNiri + visible: CompositorService.supportsInputConfig tags: ["mouse", "input", "sensitivity", "acceleration", "pointer"] title: I18n.tr("Mouse", "mouse input settings card title") settingKey: "mouseSettings" @@ -113,6 +113,7 @@ Item { } SettingsToggleRow { + visible: CompositorService.isNiri tags: ["mouse", "middle", "click", "emulation"] settingKey: "mouseMiddleEmulation" text: I18n.tr("Middle click emulation") @@ -140,7 +141,7 @@ Item { SettingsCard { width: parent.width - visible: CompositorService.isNiri + visible: CompositorService.supportsInputConfig tags: ["touchpad", "input", "sensitivity", "tap", "click", "natural"] title: I18n.tr("Touchpad", "touchpad input settings card title") settingKey: "touchpadSettings" @@ -269,6 +270,7 @@ Item { } SettingsToggleRow { + visible: CompositorService.isNiri tags: ["touchpad", "disable", "external", "mouse"] settingKey: "touchpadDisableOnExternalMouse" text: I18n.tr("Disable with external mouse") diff --git a/quickshell/Modules/Settings/WindowRuleEditorDialog.qml b/quickshell/Modules/Settings/WindowRuleEditorDialog.qml index 33d6cc061..c2c5b20c8 100644 --- a/quickshell/Modules/Settings/WindowRuleEditorDialog.qml +++ b/quickshell/Modules/Settings/WindowRuleEditorDialog.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import qs.Common import "../../Common/WindowRuleSize.js" as WindowRuleSize +import "../../Common/WindowRuleMerge.js" as WindowRuleMerge import qs.Services import qs.DCommon.Widgets import qs.Widgets @@ -41,8 +42,65 @@ DDialog { property string floatingRelativeValue: "top-left" property string columnWidthUnit: WindowRuleSize.PIXELS property string windowHeightUnit: WindowRuleSize.PIXELS + property var storedMatch: ({}) + property var storedActions: ({}) + property var hyprEffectFlags: [] + property int focusOnActivateTri: 0 + property bool scrollingWidthOn: false + property int scrollingWidthValue: 50 + property string tonemapValue: "" + property string contentValue: "" + property int fullscreenInternalValue: -1 + property int fullscreenClientValue: -1 readonly property var triLabels: [I18n.tr("Default"), I18n.tr("On"), I18n.tr("Off")] + readonly property var tonemapOptions: root.withCurrent(["", "off", "on", "clamp", "limited"], root.tonemapValue) + readonly property var contentOptions: root.withCurrent(["", "none", "photo", "video", "game"], root.contentValue) + // Index is the Hyprland state + 1; 0 leaves the match unset. + readonly property var fullscreenStateLabels: [I18n.tr("Default"), I18n.tr("None"), I18n.tr("Maximized", "adjective, Hyprland window fullscreen state 1"), I18n.tr("Fullscreen"), I18n.tr("Maximized and fullscreen", "Hyprland window fullscreen state 3")] + readonly property var hyprMatchKeys: ["initialClass", "initialTitle", "tag", "workspace", "content", "xdgTag", "fullscreenStateInternal", "fullscreenStateClient"] + readonly property var hyprFocusOptions: [ + { + "label": I18n.tr("No initial focus"), + "value": "noInitialFocus", + "tooltip": I18n.tr("Opens without taking keyboard focus", "window rule option tooltip") + }, + { + "label": I18n.tr("Stay focused"), + "value": "stayFocused", + "tooltip": I18n.tr("Keeps focus while the window is open", "window rule option tooltip") + } + ] + readonly property var hyprPointerOptions: [ + { + "label": I18n.tr("Confine pointer"), + "value": "confinePointer", + "tooltip": I18n.tr("Keeps the pointer inside the window", "window rule option tooltip") + }, + { + "label": I18n.tr("No XDG drags"), + "value": "noXdgDrags", + "tooltip": I18n.tr("Ignores drag and drop started by the window", "window rule option tooltip") + } + ] + readonly property var hyprRenderOptions: [ + { + "label": I18n.tr("No glow"), + "value": "noGlow", + "needs": "decoration:glow:enabled" + }, + { + "label": I18n.tr("No wobble"), + "value": "noWobble", + "needs": "decoration:wobble:enabled" + }, + { + "label": I18n.tr("No auto HDR"), + "value": "noAutoHdr", + "tooltip": I18n.tr("Never switches the monitor to HDR for this window", "window rule option tooltip") + } + ].filter(option => !option.needs || HyprlandService.hyprSupports(option.needs)) + readonly property var hyprEffectOptions: root.hyprFocusOptions.concat(root.hyprPointerOptions, root.hyprRenderOptions) readonly property var blockOutOptions: ["", "screencast", "screen-capture"] readonly property var columnDisplayOptions: ["", "tabbed"] readonly property var anchorOptions: ["top-left", "top-right", "bottom-left", "bottom-right", "top", "bottom", "left", "right"] @@ -243,6 +301,15 @@ DDialog { return value === "" ? I18n.tr("Normal") : value; } + function defaultLabel(value) { + return value === "" ? I18n.tr("Default") : value; + } + + // A value written by hand that the list does not know must still show and save. + function withCurrent(values, current) { + return values.includes(current) ? values : values.concat([current]); + } + function resetForm() { nameInput.text = ""; appIdInput.text = ""; @@ -272,7 +339,19 @@ DDialog { windowHeightUnit = WindowRuleSize.PIXELS; blurCond.triState = 0; xrayCond.triState = 0; - for (const field of [outputInput, workspaceInput, columnWidthInput, windowHeightInput, floatingXInput, floatingYInput, minWidthInput, maxWidthInput, minHeightInput, maxHeightInput, moveXInput, moveYInput, sizeWInput, sizeHInput, monitorInput, hyprWorkspaceInput, mangoTagsInput, mangoMonitorInput, mangoSizeInput]) + storedMatch = {}; + storedActions = {}; + hyprEffectFlags = []; + focusOnActivateTri = 0; + scrollingWidthOn = false; + scrollingWidthValue = 50; + tonemapValue = ""; + contentValue = ""; + fullscreenInternalValue = -1; + fullscreenClientValue = -1; + hyprMatchCard.expanded = false; + hyprEffectsCard.expanded = false; + for (const field of [outputInput, workspaceInput, columnWidthInput, windowHeightInput, floatingXInput, floatingYInput, minWidthInput, maxWidthInput, minHeightInput, maxHeightInput, moveXInput, moveYInput, sizeWInput, sizeHInput, monitorInput, hyprWorkspaceInput, mangoTagsInput, mangoMonitorInput, mangoSizeInput, initialClassInput, initialTitleInput, matchTagInput, xdgTagInput, matchWorkspaceInput, borderColorInput, suppressEventInput]) field.text = ""; } @@ -280,17 +359,29 @@ DDialog { nameInput.text = rule.name || ""; const matchList = (rule.matches && rule.matches.length > 0) ? rule.matches : [rule.matchCriteria || {}]; const match = matchList[0] || {}; + storedMatch = match; appIdInput.text = match.appId || ""; titleInput.text = match.title || ""; for (let i = 1; i < matchList.length; i++) extraMatchModel.append({ "rowAppId": matchList[i].appId || "", - "rowTitle": matchList[i].title || "" + "rowTitle": matchList[i].title || "", + "rowStored": JSON.stringify(matchList[i]) }); for (const cond of matchConditions) cond.triState = triFromBool(match[cond.key]); + initialClassInput.text = match.initialClass || ""; + initialTitleInput.text = match.initialTitle || ""; + matchTagInput.text = match.tag || ""; + xdgTagInput.text = match.xdgTag || ""; + matchWorkspaceInput.text = match.workspace || ""; + contentValue = match.content || ""; + fullscreenInternalValue = match.fullscreenStateInternal ?? -1; + fullscreenClientValue = match.fullscreenStateClient ?? -1; + hyprMatchCard.expanded = hyprMatchKeys.some(key => match[key] !== undefined && match[key] !== ""); const actions = rule.actions || {}; + storedActions = actions; const flags = []; if (!isNiri && actions.openFloating) flags.push("float"); @@ -355,6 +446,15 @@ DDialog { monitorInput.text = actions.monitor || ""; hyprWorkspaceInput.text = actions.workspace || ""; + hyprEffectFlags = hyprEffectOptions.map(option => option.value).filter(value => actions[value] === true); + focusOnActivateTri = triFromBool(actions.focusOnActivate); + scrollingWidthOn = actions.scrollingWidth !== undefined && actions.scrollingWidth !== null; + scrollingWidthValue = scrollingWidthOn ? Math.round(actions.scrollingWidth * 100) : 50; + tonemapValue = actions.tonemap || ""; + borderColorInput.text = actions.borderColor || ""; + suppressEventInput.text = actions.suppressEvent || ""; + hyprEffectsCard.expanded = hyprEffectFlags.length > 0 || focusOnActivateTri !== 0 || scrollingWidthOn || !!tonemapValue || !!borderColorInput.text || !!suppressEventInput.text; + mangoFlags = mangoOptions.map(option => option.value).filter(value => actions[value]); mangoTagsInput.text = actions.workspace || ""; mangoMonitorInput.text = actions.monitor || ""; @@ -362,14 +462,32 @@ DDialog { } function collectMatches() { - const matchCriteria = {}; + const conds = matchConditions.filter(cond => cond.visible); + const owned = ["appId", "title"].concat(conds.map(cond => cond.key), isHyprland ? hyprMatchKeys : []); + const matchCriteria = WindowRuleMerge.carry(storedMatch, owned); if (appIdInput.text.trim()) matchCriteria.appId = appIdInput.text.trim(); if (titleInput.text.trim()) matchCriteria.title = titleInput.text.trim(); - for (const cond of matchConditions) { - if (cond.visible) - applyCond(matchCriteria, cond.key, cond.triState); + for (const cond of conds) + applyCond(matchCriteria, cond.key, cond.triState); + if (isHyprland) { + const texts = { + "initialClass": initialClassInput.text.trim(), + "initialTitle": initialTitleInput.text.trim(), + "tag": matchTagInput.text.trim(), + "xdgTag": xdgTagInput.text.trim(), + "workspace": matchWorkspaceInput.text.trim(), + "content": contentValue + }; + for (const key in texts) { + if (texts[key]) + matchCriteria[key] = texts[key]; + } + if (fullscreenInternalValue >= 0) + matchCriteria.fullscreenStateInternal = fullscreenInternalValue; + if (fullscreenClientValue >= 0) + matchCriteria.fullscreenStateClient = fullscreenClientValue; } const matches = []; if (Object.keys(matchCriteria).length > 0) @@ -381,7 +499,7 @@ DDialog { }; for (let i = 0; i < extraMatchModel.count; i++) { const row = extraMatchModel.get(i); - const extra = {}; + const extra = WindowRuleMerge.carry(JSON.parse(row.rowStored || "{}"), ["appId", "title"]); if ((row.rowAppId || "").trim()) extra.appId = row.rowAppId.trim(); if ((row.rowTitle || "").trim()) @@ -395,8 +513,19 @@ DDialog { }; } + function ownedActionKeys() { + const keys = ["opacity", "openFloating", "openMaximized", "openFullscreen", "cornerRadius"]; + if (isNiri) + return keys.concat(["openMaximizedToEdges", "openFocused", "openOnOutput", "openOnWorkspace", "minWidth", "maxWidth", "minHeight", "maxHeight", "defaultColumnWidth", "defaultWindowHeight", "variableRefreshRate", "clipToGeometry", "tiledState", "drawBorderWithBackground", "blockOutFrom", "defaultColumnDisplay", "scrollFactor", "backgroundBlur", "backgroundXray", "backgroundNoise", "backgroundSaturation", "defaultFloatingX", "defaultFloatingY", "defaultFloatingRelativeTo"]); + if (isHyprland) + return keys.concat(hyprOptions.map(option => option.value), hyprEffectOptions.map(option => option.value), ["sizeWidth", "sizeHeight", "moveX", "moveY", "monitor", "workspace", "focusOnActivate", "scrollingWidth", "tonemap", "borderColor", "suppressEvent"]); + if (isMango) + return keys.concat(mangoOptions.map(option => option.value), ["workspace", "monitor", "sizeWidth", "sizeHeight"]); + return keys; + } + function collectActions() { - const actions = {}; + const actions = WindowRuleMerge.carry(storedActions, ownedActionKeys()); const has = value => openingFlags.includes(value); if (opacityOn) actions.opacity = opacityValue / 100; @@ -486,6 +615,17 @@ DDialog { actions.monitor = monitorInput.text.trim(); if (hyprWorkspaceInput.text.trim()) actions.workspace = hyprWorkspaceInput.text.trim(); + for (const flag of hyprEffectFlags) + actions[flag] = true; + applyCond(actions, "focusOnActivate", focusOnActivateTri); + if (scrollingWidthOn) + actions.scrollingWidth = scrollingWidthValue / 100; + if (tonemapValue) + actions.tonemap = tonemapValue; + if (borderColorInput.text.trim()) + actions.borderColor = borderColorInput.text.trim(); + if (suppressEventInput.text.trim()) + actions.suppressEvent = suppressEventInput.text.trim(); } if (isMango) { @@ -647,7 +787,7 @@ DDialog { } } - readonly property var matchConditions: [condFloating, condActive, condFocused, condActiveInColumn, condCastTarget, condUrgent, condAtStartup, condXwayland, condFullscreen, condPinned] + readonly property var matchConditions: [condFloating, condActive, condFocused, condActiveInColumn, condCastTarget, condUrgent, condAtStartup, condXwayland, condFullscreen, condPinned, condGroup, condModal] actions: [ DButton { @@ -691,7 +831,8 @@ DDialog { enabled: root.fieldsEnabled onClicked: extraMatchModel.append({ "rowAppId": "", - "rowTitle": "" + "rowTitle": "", + "rowStored": "{}" }) } @@ -795,7 +936,7 @@ DDialog { id: condFocused key: "isFocused" label: I18n.tr("Focused", "adjective, window rule match condition for the focused window") - visible: root.isNiri + visible: root.isNiri || root.isHyprland } MatchCond { id: condActiveInColumn @@ -839,10 +980,101 @@ DDialog { label: I18n.tr("Pinned", "adjective, state of a pinned window, clipboard entry or item") visible: root.isHyprland } + MatchCond { + id: condGroup + key: "group" + label: I18n.tr("Group", "noun, window rule match condition for windows in a Hyprland group") + visible: root.isHyprland + } + MatchCond { + id: condModal + key: "modal" + label: I18n.tr("Modal", "adjective, window rule match condition for modal dialogs") + visible: root.isHyprland + } } } } + SettingsCard { + id: hyprMatchCard + title: I18n.tr("Advanced Matching") + visible: root.isHyprland + collapsible: true + expanded: false + + SettingsRow { + subtitle: I18n.tr("Prefix a tag with negative: to match windows without it.", "window rule editor hint, negative: is literal syntax") + } + + FieldRow { + Field { + id: initialClassInput + leftIconName: "apps" + labelText: I18n.tr("Initial class") + placeholderText: "^steam$" + } + + Field { + id: initialTitleInput + leftIconName: "title" + labelText: I18n.tr("Initial title") + placeholderText: "^Login$" + } + } + + FieldRow { + Field { + id: matchTagInput + leftIconName: "sell" + labelText: I18n.tr("Tag", "noun, Hyprland window tag in window rule match") + placeholderText: "games" + } + + Field { + id: xdgTagInput + leftIconName: "label" + labelText: I18n.tr("XDG tag") + placeholderText: "^portal$" + } + } + + FieldRow { + Field { + id: matchWorkspaceInput + shares: 1 + leftIconName: "view_module" + labelText: I18n.tr("Workspace") + placeholderText: "w[tv1]" + } + } + + SettingsDropdownRow { + text: I18n.tr("Content", "noun, window content type match: photo, video or game") + options: root.contentOptions.map(root.defaultLabel) + currentValue: root.defaultLabel(root.contentValue) + enabled: root.fieldsEnabled + onValueChanged: value => root.contentValue = root.contentOptions.find(option => root.defaultLabel(option) === value) ?? "" + } + + SettingsDropdownRow { + text: I18n.tr("Fullscreen state") + options: root.fullscreenStateLabels + currentValue: root.fullscreenStateLabels[root.fullscreenInternalValue + 1] ?? root.fullscreenStateLabels[0] + enabled: root.fieldsEnabled + onValueChanged: value => root.fullscreenInternalValue = root.fullscreenStateLabels.indexOf(value) - 1 + } + + SettingsDropdownRow { + text: I18n.tr("Client fullscreen state") + description: I18n.tr("The state the app believes it is in", "window rule match, Hyprland client fullscreen state") + options: root.fullscreenStateLabels + currentValue: root.fullscreenStateLabels[root.fullscreenClientValue + 1] ?? root.fullscreenStateLabels[0] + enabled: root.fieldsEnabled + onValueChanged: value => root.fullscreenClientValue = root.fullscreenStateLabels.indexOf(value) - 1 + } + } + SettingsCard { title: I18n.tr("Window Opening") @@ -1168,6 +1400,105 @@ DDialog { } } + SettingsCard { + id: hyprEffectsCard + title: I18n.tr("Advanced") + visible: root.isHyprland + collapsible: true + expanded: false + + SettingsRow { + title: I18n.tr("Focus", "noun, window rule editor group of keyboard focus options") + + body: DFilterChips { + width: parent.width + multiSelect: true + showCounts: false + enabled: root.fieldsEnabled + model: root.hyprFocusOptions + selectedValues: root.hyprEffectFlags + onSelectionToggled: (index, selected) => root.hyprEffectFlags = root.flagSet(root.hyprEffectFlags, root.hyprFocusOptions[index].value, selected) + } + } + + SettingsButtonGroupRow { + text: I18n.tr("Focus on activate") + description: I18n.tr("Focus the window when it asks to be activated", "window rule option, Hyprland focus_on_activate") + model: root.triLabels + currentIndex: root.focusOnActivateTri + enabled: root.fieldsEnabled + onSelectionChanged: (index, selected) => { + if (selected) + root.focusOnActivateTri = index; + } + } + + SettingsRow { + title: I18n.tr("Pointer") + + body: DFilterChips { + width: parent.width + multiSelect: true + showCounts: false + enabled: root.fieldsEnabled + model: root.hyprPointerOptions + selectedValues: root.hyprEffectFlags + onSelectionToggled: (index, selected) => root.hyprEffectFlags = root.flagSet(root.hyprEffectFlags, root.hyprPointerOptions[index].value, selected) + } + } + + SettingsRow { + title: I18n.tr("Rendering", "window rule editor group of render options") + + body: DFilterChips { + width: parent.width + multiSelect: true + showCounts: false + enabled: root.fieldsEnabled + model: root.hyprRenderOptions + selectedValues: root.hyprEffectFlags + onSelectionToggled: (index, selected) => root.hyprEffectFlags = root.flagSet(root.hyprEffectFlags, root.hyprRenderOptions[index].value, selected) + } + } + + SettingsDropdownRow { + text: I18n.tr("Tone mapping") + options: root.tonemapOptions.map(root.defaultLabel) + currentValue: root.defaultLabel(root.tonemapValue) + enabled: root.fieldsEnabled + onValueChanged: value => root.tonemapValue = root.tonemapOptions.find(option => root.defaultLabel(option) === value) ?? "" + } + + SettingsToggleSliderRow { + text: I18n.tr("Column Width") + description: I18n.tr("Scrolling", "Hyprland tiling layout name") + checked: root.scrollingWidthOn + value: root.scrollingWidthValue + minimum: 10 + maximum: 100 + unit: "%" + enabled: root.fieldsEnabled + onToggled: checked => root.scrollingWidthOn = checked + onSliderValueChanged: newValue => root.scrollingWidthValue = newValue + } + + FieldRow { + Field { + id: borderColorInput + leftIconName: "border_color" + labelText: I18n.tr("Border color") + placeholderText: "rgb(ff0000) rgb(880808)" + } + + Field { + id: suppressEventInput + leftIconName: "block" + labelText: I18n.tr("Suppress events") + placeholderText: "maximize fullscreen" + } + } + } + SettingsCard { title: I18n.tr("Mango Options") visible: root.isMango diff --git a/quickshell/Modules/Settings/WindowRulesTab.qml b/quickshell/Modules/Settings/WindowRulesTab.qml index 4ca9fae58..7acc6719c 100644 --- a/quickshell/Modules/Settings/WindowRulesTab.qml +++ b/quickshell/Modules/Settings/WindowRulesTab.qml @@ -59,7 +59,17 @@ Item { "xwayland": "XWayland", "fullscreen": I18n.tr("Fullscreen"), "pinned": I18n.tr("Pinned"), - "initialised": I18n.tr("Initialised") + "initialised": I18n.tr("Initialised"), + "initialClass": I18n.tr("Initial class"), + "initialTitle": I18n.tr("Initial title"), + "tag": I18n.tr("Tag"), + "xdgTag": I18n.tr("XDG tag"), + "workspace": I18n.tr("Workspace"), + "content": I18n.tr("Content"), + "group": I18n.tr("Group"), + "modal": I18n.tr("Modal"), + "fullscreenStateInternal": I18n.tr("Fullscreen state"), + "fullscreenStateClient": I18n.tr("Client fullscreen state") }) function matchesOf(rule) { @@ -158,7 +168,18 @@ Item { "focusRingOff": I18n.tr("Focus ring off"), "borderOff": I18n.tr("Border off"), "forcergbx": I18n.tr("Force RGBX"), - "idleinhibit": I18n.tr("Idle inhibitor", "feature that keeps the session from going idle") + "idleinhibit": I18n.tr("Idle inhibitor", "feature that keeps the session from going idle"), + "noInitialFocus": I18n.tr("No initial focus"), + "focusOnActivate": I18n.tr("Focus on activate"), + "stayFocused": I18n.tr("Stay focused"), + "confinePointer": I18n.tr("Confine pointer"), + "noXdgDrags": I18n.tr("No XDG drags"), + "noAutoHdr": I18n.tr("No auto HDR"), + "noGlow": I18n.tr("No glow"), + "noWobble": I18n.tr("No wobble"), + "scrollingWidth": I18n.tr("Column Width"), + "tonemap": I18n.tr("Tone mapping"), + "suppressEvent": I18n.tr("Suppress events") }) signal rulesChanged diff --git a/quickshell/Modules/WallpaperBackground.qml b/quickshell/Modules/WallpaperBackground.qml index 89be66c64..dab55a83f 100644 --- a/quickshell/Modules/WallpaperBackground.qml +++ b/quickshell/Modules/WallpaperBackground.qml @@ -6,6 +6,7 @@ import Quickshell.Wayland import qs.Common import qs.Widgets import qs.Services +import "../Common/WorkspaceModel.js" as WorkspaceModel Variants { id: variants @@ -421,8 +422,8 @@ Variants { const monitorWorkspaces = workspaces.filter(ws => ws.monitor?.name === modelData.name).sort((a, b) => a.id - b.id); totalWorkspaces = monitorWorkspaces.length; - const focusedId = Hyprland.focusedWorkspace?.id; - currentWorkspaceIndex = monitorWorkspaces.findIndex(ws => ws.id === focusedId); + const focusedKey = WorkspaceModel.hyprlandKey(Hyprland.focusedWorkspace); + currentWorkspaceIndex = monitorWorkspaces.findIndex(ws => WorkspaceModel.hyprlandKey(ws) === focusedKey); if (currentWorkspaceIndex < 0) currentWorkspaceIndex = 0; diff --git a/quickshell/Services/CompositorService.qml b/quickshell/Services/CompositorService.qml index 0a02a714e..598600d2a 100644 --- a/quickshell/Services/CompositorService.qml +++ b/quickshell/Services/CompositorService.qml @@ -150,8 +150,8 @@ Singleton { readonly property bool reservesDesktopInput: isLabwc || isSway || isScroll || isKwin readonly property bool supportsSmartDock: isNiri || isHyprland || isMango || isAqueous readonly property bool supportsNativeOverview: isNiri || isAqueous - readonly property bool supportsPointerConfig: isNiri || isMango - readonly property bool supportsInputConfig: isNiri + readonly property bool supportsPointerConfig: isNiri || isHyprland || isMango + readonly property bool supportsInputConfig: isNiri || isHyprland readonly property string displayName: { switch (compositor) { @@ -973,8 +973,8 @@ Singleton { const t = _hyprlandToplevelFor(window); if (!t?.address) return; - const target = t.monitor?.activeWorkspace?.id; - HyprlandService.moveToWorkspace(target > 0 ? target : "+0", t.address, true); + const target = t.monitor?.activeWorkspace; + HyprlandService.moveToWorkspace(target?.name ? WorkspaceModel.hyprlandSelector(target) : "+0", t.address, true); } function moveWindowToSpecial(window, name) { @@ -1307,6 +1307,10 @@ Singleton { return toplevels.filter(w => monitorWindows.has(w)); } + function _workspaceKeyOf(ws) { + return ws ? (WorkspaceModel.hyprlandKey(ws) || null) : null; + } + function filterHyprlandCurrentWorkspaceSafe(toplevels, screenName) { if (!toplevels || toplevels.length === 0 || !Hyprland.toplevels) return toplevels; @@ -1316,14 +1320,14 @@ Singleton { if (Hyprland.monitors) { const monitor = Hyprland.monitors.values.find(m => m.name === screenName); if (monitor) - currentWorkspaceId = _get(monitor, ["activeWorkspace", "id"], null); + currentWorkspaceId = _workspaceKeyOf(_get(monitor, ["activeWorkspace"], null)); } if (currentWorkspaceId === null) { const hy = Array.from(Hyprland.toplevels.values); for (const t of hy) { const mon = _get(t, ["monitor", "name"], ""); - const wsId = _get(t, ["workspace", "id"], null); + const wsId = _workspaceKeyOf(_get(t, ["workspace"], null)); const active = !!_get(t, ["activated"], false); if (mon === screenName && wsId !== null) { if (active) { @@ -1338,10 +1342,10 @@ Singleton { if (currentWorkspaceId === null && Hyprland.workspaces) { const wss = Array.from(Hyprland.workspaces.values); - const focusedId = _get(Hyprland, ["focusedWorkspace", "id"], null); + const focusedId = _workspaceKeyOf(Hyprland.focusedWorkspace); for (const ws of wss) { const monName = _get(ws, ["monitor", "name"], ""); - const wsId = _get(ws, ["id"], null); + const wsId = _workspaceKeyOf(ws); if (monName === screenName && wsId !== null) { if (focusedId !== null && wsId === focusedId) { currentWorkspaceId = wsId; @@ -1363,7 +1367,7 @@ Singleton { try { const hy = Array.from(Hyprland.toplevels.values); for (const t of hy) { - const wsId = _get(t, ["workspace", "id"], null); + const wsId = _workspaceKeyOf(_get(t, ["workspace"], null)); if (t && t.wayland && wsId !== null) map.set(t.wayland, wsId); } @@ -1796,6 +1800,8 @@ Singleton { switch (compositor) { case "niri": return record.idx; + case "hyprland": + return WorkspaceModel.hyprlandKey(record); default: return record.id; } @@ -1857,7 +1863,7 @@ Singleton { case "aqueous": return record.active === true; case "hyprland": - return record.special === true ? record.active === true : record.id === currentKey; + return record.special === true ? record.active === true : WorkspaceModel.hyprlandKey(record) === currentKey; default: return _workspaceKey(record) === currentKey; } @@ -1981,7 +1987,7 @@ Singleton { HyprlandService.toggleSpecial(record.name === "special" ? "" : record.name); return; } - HyprlandService.focusWorkspace(record.id > 0 ? record.id : "name:" + (record.name ?? "")); + HyprlandService.focusWorkspace(WorkspaceModel.hyprlandSelector(record)); return; case "mango": MangoService.switchToTag(record.output, record.id); @@ -2168,6 +2174,8 @@ Singleton { function onCompositorInputRefreshNeeded() { if (root.isNiri && typeof NiriService !== "undefined") NiriService.generateNiriInputConfig(); + if (root.isHyprland && typeof HyprlandService !== "undefined") + HyprlandService.generateInputConfig(); } function onCompositorCursorRefreshNeeded() { diff --git a/quickshell/Services/HyprlandService.qml b/quickshell/Services/HyprlandService.qml index 81f9a370e..82ff285bf 100644 --- a/quickshell/Services/HyprlandService.qml +++ b/quickshell/Services/HyprlandService.qml @@ -9,6 +9,7 @@ import qs.Common import "../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve import qs.Services import "../Common/OutputModel.js" as OutputModel +import "../Common/WorkspaceModel.js" as WorkspaceModel Singleton { id: root @@ -19,6 +20,7 @@ Singleton { readonly property string outputsPath: hyprDmsDir + "/outputs.lua" readonly property string layoutPath: hyprDmsDir + "/layout.lua" readonly property string cursorPath: hyprDmsDir + "/cursor.lua" + readonly property string inputPath: hyprDmsDir + "/input.lua" readonly property string windowrulesPath: hyprDmsDir + "/windowrules.lua" readonly property bool luaConfigActive: CompositorService.isHyprland && (Hyprland.usingLua === true || luaConfigDetected) @@ -33,6 +35,16 @@ Singleton { 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 + property var hyprOptionNames: ({}) + property bool hyprOptionsReady: false + property bool hyprOptionsLoading: false + property bool inputGenerationPending: false + property bool inputGenerationRunning: false + // Hyprland only has per-device touchpad speed; probed once per compositor, a hotplugged touchpad waits for the next shell start + property var hyprTouchpadNames: [] + property bool hyprDevicesReady: false + property bool hyprDevicesLoading: false property int _layoutRequestRevision: 0 property int _layoutAppliedRevision: 0 property int _frameTransitionRevision: 0 @@ -49,9 +61,18 @@ Singleton { onTriggered: root.doGenerateLayoutConfig() } + DeferredAction { + id: inputGenerationAction + onTriggered: root.doGenerateInputConfig() + } + onLuaConfigStatusLoadingChanged: { - if (!luaConfigStatusLoading && layoutGenerationPending) + if (luaConfigStatusLoading) + return; + if (layoutGenerationPending) layoutGenerationAction.schedule(); + if (inputGenerationPending) + inputGenerationAction.schedule(); } onLuaConfigActiveChanged: { @@ -93,6 +114,7 @@ Singleton { function ensureDmsLuaConfigs() { Qt.callLater(generateLayoutConfig); + Qt.callLater(generateInputConfig); Qt.callLater(ensureWindowrulesConfig); } @@ -131,6 +153,10 @@ Singleton { luaConfigStatusReady = false; luaConfigStatusLoading = false; luaConfigFormat = ""; + hyprOptionNames = {}; + hyprOptionsReady = false; + hyprTouchpadNames = []; + hyprDevicesReady = false; } } @@ -188,6 +214,53 @@ Singleton { return false; } + function hyprSupports(name) { + return hyprOptionNames[name] === true; + } + + function loadHyprOptionNames() { + if (!CompositorService.isHyprland || hyprOptionsReady || hyprOptionsLoading) + return; + hyprOptionsLoading = true; + Proc.runCommand("hypr-option-names", ["hyprctl", "-j", "descriptions"], (output, exitCode) => { + hyprOptionsLoading = false; + hyprOptionsReady = true; + if (exitCode !== 0) + return; + try { + const names = {}; + for (const option of JSON.parse(output)) + names[option.name] = true; + hyprOptionNames = names; + } catch (error) { + log.warn("hyprctl descriptions parse failed:", error); + } + if (layoutGenerationPending) + layoutGenerationAction.schedule(); + if (inputGenerationPending) + inputGenerationAction.schedule(); + }); + } + + function loadHyprTouchpads() { + if (!CompositorService.isHyprland || hyprDevicesReady || hyprDevicesLoading) + return; + hyprDevicesLoading = true; + Proc.runCommand("hypr-devices", ["hyprctl", "-j", "devices"], (output, exitCode) => { + hyprDevicesLoading = false; + hyprDevicesReady = true; + if (exitCode === 0) { + try { + hyprTouchpadNames = touchpadNames(JSON.parse(output)); + } catch (error) { + log.warn("hyprctl devices parse failed:", error); + } + } + if (inputGenerationPending) + inputGenerationAction.schedule(); + }); + } + function forceFlagValue(value) { if (value === true) return 1; @@ -209,6 +282,13 @@ Singleton { } const settings = hyprlandSettings || SessionData.hyprlandOutputSettings; + Proc.runCommand("hypr-read-outputs", ["cat", outputsPath], (existing, readExitCode) => { + const saved = readExitCode === 0 ? OutputModel.parseHyprlandOutputs(existing) : {}; + writeOutputsConfig(buildOutputsLua(outputsData, settings, saved), callback, skipReload); + }); + } + + function buildOutputsLua(outputsData, settings, saved) { let lines = ["-- Auto-generated by DMS — do not edit manually", ""]; const liveIdentifiers = {}; @@ -224,6 +304,8 @@ Singleton { continue; const identifier = getOutputIdentifier(output, outputName); + if (!identifier.trim()) + continue; if (!(output.make && output.model) && liveIdentifiers[identifier.trim()]) continue; @@ -241,21 +323,17 @@ Singleton { resolution = mode.width + "x" + mode.height + "@" + (mode.refresh_rate / 1000).toFixed(3); } - const x = output.logical?.x ?? 0; - const y = output.logical?.y ?? 0; - const position = x + "x" + y; - const scale = output.logical?.scale ?? 1.0; - - const parts = [`output = ${luaQuoted(identifier)}`, `mode = ${luaQuoted(resolution)}`, `position = ${luaQuoted(position)}`, `scale = ${Number(scale)}`]; + const geometry = OutputModel.hyprlandLuaGeometry(output, saved[identifier], liveMonitor(outputName)); + const scale = geometry.scale === "auto" ? luaQuoted("auto") : geometry.scale; + const parts = [`output = ${luaQuoted(identifier)}`, `mode = ${luaQuoted(resolution)}`, `position = ${luaQuoted(geometry.position)}`, `scale = ${scale}`]; const transform = OutputModel.transformIndex(output.logical?.transform ?? "Normal"); if (transform !== 0) parts.push(`transform = ${transform}`); - if (output.vrr_supported) { - const vrrMode = outputSettings.vrrFullscreenOnly ? 2 : (output.vrr_enabled ? 1 : 0); + const vrrMode = OutputModel.hyprlandVrrMode(outputSettings, saved[identifier]); + if (vrrMode !== undefined) parts.push(`vrr = ${vrrMode}`); - } if (output.mirror && output.mirror.length > 0) parts.push(`mirror = ${luaQuoted(output.mirror)}`); @@ -302,9 +380,12 @@ Singleton { lines.push("hl.monitor({ " + parts.join(", ") + " })"); } + lines.push('hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })'); lines.push(""); - const content = lines.join("\n"); + return lines.join("\n"); + } + function writeOutputsConfig(content, callback, skipReload) { Proc.runCommand("hypr-write-outputs", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && cat > "${outputsPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => { if (exitCode !== 0) { log.warn("Failed to write outputs config:", output); @@ -414,6 +495,11 @@ Singleton { _layoutAppliedRevision = Math.max(_layoutAppliedRevision, requestRevision); return; } + if (!hyprOptionsReady) { + layoutGenerationPending = true; + loadHyprOptionNames(); + return; + } layoutGenerationRunning = true; const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.getPrimaryBarConfig()?.spacing ?? 4)) : 4; @@ -441,20 +527,20 @@ Singleton { if (manageGaps) generalLines.push(`gaps_in = ${gapsIn},`, `gaps_out = ${gapsOut},`); generalLines.push(`border_size = ${borderSize},`, `resize_on_border = ${resizeOnBorder},`); + if (resizeOnBorder && SettingsData.hyprlandBorderGrabArea !== 15) + generalLines.push(`extend_border_grab_area = ${SettingsData.hyprlandBorderGrabArea},`); if (tilingLayout) generalLines.push(`layout = ${luaString(tilingLayout)},`); - const sections = [`\tgeneral = {\n${generalLines.map(l => "\t\t" + l).join("\n")}\n\t},`]; + const luaSection = (name, lines) => `\t${name} = {\n${lines.map(l => "\t\t" + l).join("\n")}\n\t},`; + const sections = [luaSection("general", generalLines)]; const tilingLines = tilingLayoutLines(tilingLayout); if (tilingLines.length) - sections.push(`\t${tilingLayout} = {\n${tilingLines.map(l => "\t\t" + l).join("\n")}\n\t},`); - const windowOpacity = typeof SettingsData !== "undefined" ? SettingsData.hyprlandWindowOpacity : 100; - const decorationLines = [`rounding = ${cornerRadius},`]; - if (windowOpacity < 100) { - const opacity = (windowOpacity / 100).toFixed(2); - decorationLines.push(`active_opacity = ${opacity},`, `inactive_opacity = ${opacity},`); - } - sections.push(`\tdecoration = {\n${decorationLines.map(l => "\t\t" + l).join("\n")}\n\t},`); + sections.push(luaSection(tilingLayout, tilingLines)); + sections.push(luaSection("decoration", decorationLines(SettingsData, cornerRadius))); + const groupLines = luaTable("groupbar", groupbarLines(SettingsData)); + if (groupLines.length) + sections.push(luaSection("group", groupLines)); let content = `-- Auto-generated by DMS — do not edit manually @@ -462,6 +548,15 @@ hl.config({ ${sections.join("\n")} }) `; + // Shell windows stay rigid like the rest of the shell chrome + if (SettingsData.hyprlandWobbleEnabled && hyprSupports("decoration:wobble:enabled")) { + content += ` +hl.window_rule({ + match = { class = "^(com\\\\.danklinux\\\\.dms)$" }, + no_wobble = true, +}) +`; + } if (layoutXrayEnabled) { content += ` @@ -522,6 +617,61 @@ hl.layer_rule({ } } + function luaTable(name, lines) { + return lines.length ? [`${name} = {`, ...lines.map(l => "\t" + l), "},"] : []; + } + + function blurLines(s) { + const variant = s.hyprlandBlurVariant; + if (!variant || variant === "kawase" || !hyprSupports("decoration:blur:variant")) + return []; + // A user config with blur disabled would otherwise hide the chosen variant + const lines = ["enabled = true,", `variant = ${luaString(variant)},`]; + switch (variant) { + case "ripple": + return lines.concat(luaTable("ripple", [`strength = ${s.hyprlandBlurRippleStrength},`])); + case "water": + return lines.concat(luaTable("water", [`strength = ${s.hyprlandBlurWaterStrength},`])); + case "acrylic": + return lines.concat(luaTable("acrylic", [`clarity = ${s.hyprlandBlurAcrylicClarity / 100},`])); + case "aurora": + return lines.concat(luaTable("aurora", [`intensity = ${s.hyprlandBlurAuroraIntensity / 100},`, `speed = ${s.hyprlandBlurAuroraSpeed},`])); + case "haze": + return lines.concat(luaTable("haze", [`intensity = ${s.hyprlandBlurHazeIntensity / 100},`, `iridescence = ${s.hyprlandBlurHazeIridescence / 100},`])); + default: + return lines; + } + } + + function decorationLines(s, rounding) { + const lines = [`rounding = ${rounding},`]; + if (s.hyprlandWindowOpacity < 100) { + const opacity = (s.hyprlandWindowOpacity / 100).toFixed(2); + lines.push(`active_opacity = ${opacity},`, `inactive_opacity = ${opacity},`); + } + lines.push(...luaTable("blur", blurLines(s))); + if (s.hyprlandGlowEnabled && hyprSupports("decoration:glow:enabled")) + lines.push(...luaTable("glow", ["enabled = true,", `range = ${s.hyprlandGlowRange},`, `render_power = ${s.hyprlandGlowRenderPower},`])); + if (s.hyprlandWobbleEnabled && hyprSupports("decoration:wobble:enabled")) + lines.push(...luaTable("wobble", ["enabled = true,", `intensity = ${s.hyprlandWobbleIntensity / 100},`, `stiffness = ${s.hyprlandWobbleStiffness},`])); + if (s.hyprlandMotionBlurEnabled && hyprSupports("decoration:motion_blur:enabled")) + lines.push(...luaTable("motion_blur", ["enabled = true,", `samples = ${s.hyprlandMotionBlurSamples},`])); + return lines; + } + + function groupbarLines(s) { + const lines = []; + if (s.hyprlandGroupbarBlur && hyprSupports("group:groupbar:blur")) + lines.push("blur = true,"); + if (s.hyprlandGroupbarTextPadding > 0 && hyprSupports("group:groupbar:text_padding")) + lines.push(`text_padding = ${s.hyprlandGroupbarTextPadding},`); + if (!s.hyprlandGroupbarMiddleClickClose && hyprSupports("group:groupbar:middle_click_close")) + lines.push("middle_click_close = false,"); + if (s.hyprlandGroupbarDisableWhenOnly && hyprSupports("group:groupbar:disable_when_only")) + lines.push("disable_when_only = true,"); + return lines; + } + function generateCursorConfig() { if (!CompositorService.isHyprland) return; @@ -592,26 +742,181 @@ hl.layer_rule({ }); } - function renameWorkspace(newName) { - if (!Hyprland.focusedWorkspace) + function generateInputConfig() { + if (!CompositorService.isHyprland) return; - const wsId = Hyprland.focusedWorkspace.id; - if (!wsId) + inputGenerationPending = true; + inputGenerationAction.schedule(); + } + + function doGenerateInputConfig() { + if (inputGenerationRunning) return; - const fullName = wsId + " " + newName; - if (luaConfigActive) { - Hyprland.dispatch(`hl.dsp.workspace.rename({ workspace = ${luaValue(wsId)}, name = ${luaString(fullName)} })`); - } else { - Hyprland.dispatch(`renameworkspace ${wsId} ${fullName}`); + inputGenerationPending = false; + if (!canWriteLuaConfig("input")) { + if (luaConfigStatusLoading || !luaConfigStatusReady) + inputGenerationPending = true; + return; + } + if (!hyprOptionsReady || !hyprDevicesReady) { + inputGenerationPending = true; + loadHyprOptionNames(); + loadHyprTouchpads(); + return; + } + inputGenerationRunning = true; + + let content = "-- Auto-generated by DMS — do not edit manually\n"; + const lines = inputLines(SettingsData); + if (lines.length) + content += `\nhl.config({\n\tinput = {\n${lines.map(l => "\t\t" + l).join("\n")}\n\t},\n})\n`; + const devices = touchpadDeviceLines(SettingsData, hyprTouchpadNames); + if (devices.length) + content += "\n" + devices.join("\n") + "\n"; + + // Exit 3 = unchanged, so a shell start does not reload Hyprland for nothing + const script = `mkdir -p "${hyprDmsDir}" && cat > "${inputPath}.tmp" << 'EOF'\n${content}EOF\nif cmp -s "${inputPath}.tmp" "${inputPath}"; then rm -f "${inputPath}.tmp"; exit 3; fi\nmv -f "${inputPath}.tmp" "${inputPath}"`; + Proc.runCommand("hypr-write-input", ["sh", "-c", script], (output, exitCode) => { + const finish = () => { + inputGenerationRunning = false; + if (inputGenerationPending) + inputGenerationAction.schedule(); + }; + if (exitCode === 3) { + finish(); + return; + } + if (exitCode !== 0) { + log.warn("Failed to write input config:", output); + finish(); + return; + } + reloadConfig(finish); + }); + } + + function hyprScrollMethod(method) { + switch (method) { + case "no-scroll": + return "no_scroll"; + case "two-finger": + return "2fg"; + case "edge": + return "edge"; + case "on-button-down": + return "on_button_down"; + default: + return ""; } } - function focusWorkspace(workspace) { - if (luaConfigActive) { - Hyprland.dispatch(`hl.dsp.focus({ workspace = ${luaValue(workspace)} })`); + function touchpadNames(devices) { + return (devices?.mice ?? []).map(mouse => mouse.name).filter(name => /touchpad|trackpad|synaptics|elan/i.test(name ?? "")); + } + + // Keys the setup template sets are always written, everything else only when it differs from Hyprland's default + function inputLines(s) { + const lines = []; + const add = (option, line) => { + if (hyprSupports(option)) + lines.push(line); + }; + if (s.mouseAccelSpeed !== 0) + add("input:sensitivity", `sensitivity = ${s.mouseAccelSpeed.toFixed(2)},`); + if (s.mouseAccelProfile !== "default") + add("input:accel_profile", `accel_profile = ${luaString(s.mouseAccelProfile)},`); + if (s.mouseNaturalScroll) + add("input:natural_scroll", "natural_scroll = true,"); + if (s.mouseLeftHanded) + add("input:left_handed", "left_handed = true,"); + if (s.mouseScrollFactor !== 1) + add("input:scroll_factor", `scroll_factor = ${s.mouseScrollFactor.toFixed(2)},`); + const scrollMethod = hyprScrollMethod(s.mouseScrollMethod); + if (scrollMethod) + add("input:scroll_method", `scroll_method = ${luaString(scrollMethod)},`); + // Mouse middle emulation has no global key; Hyprland reads input:touchpad:middle_button_emulation for every pointer + + const touchpad = []; + const addTouchpad = (option, line) => { + if (hyprSupports("input:touchpad:" + option)) + touchpad.push(line); + }; + addTouchpad("natural_scroll", `natural_scroll = ${s.touchpadNaturalScroll},`); + addTouchpad("tap-to-click", `tap_to_click = ${s.touchpadTapToClick},`); + if (!s.touchpadTapAndDrag) + addTouchpad("tap-and-drag", "tap_and_drag = false,"); + if (s.touchpadDragLock) + addTouchpad("drag_lock", "drag_lock = 1,"); + if (!s.touchpadDisableWhileTyping) + addTouchpad("disable_while_typing", "disable_while_typing = false,"); + if (s.touchpadMiddleEmulation) + addTouchpad("middle_button_emulation", "middle_button_emulation = true,"); + if (s.touchpadScrollFactor !== 1) + addTouchpad("scroll_factor", `scroll_factor = ${s.touchpadScrollFactor.toFixed(2)},`); + if (s.touchpadClickMethod !== "default") + addTouchpad("clickfinger_behavior", `clickfinger_behavior = ${s.touchpadClickMethod === "clickfinger"},`); + lines.push(...luaTable("touchpad", touchpad)); + + // 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; + if (!keyboardConfigured) + return lines; + if (s.keyboardKeymapFile) { + add("input:kb_file", `kb_file = ${luaString(s.keyboardKeymapFile)},`); } else { - Hyprland.dispatch(`workspace ${workspace}`); + if (s.keyboardLayouts) + add("input:kb_layout", `kb_layout = ${luaString(s.keyboardLayouts)},`); + if (s.keyboardVariants) + add("input:kb_variant", `kb_variant = ${luaString(s.keyboardVariants)},`); + if (s.keyboardModel) + add("input:kb_model", `kb_model = ${luaString(s.keyboardModel)},`); + if (s.keyboardOptions) + add("input:kb_options", `kb_options = ${luaString(s.keyboardOptions)},`); } + if (s.keyboardRepeatDelay > 0) + add("input:repeat_delay", `repeat_delay = ${s.keyboardRepeatDelay},`); + if (s.keyboardRepeatRate > 0) + add("input:repeat_rate", `repeat_rate = ${s.keyboardRepeatRate},`); + add("input:numlock_by_default", `numlock_by_default = ${!!s.keyboardNumlock},`); + return lines; + } + + // Global sensitivity, accel_profile, scroll_method and left_handed also reach touchpads, so pin the touchpad's own values per device + function touchpadDeviceLines(s, names) { + const fields = []; + if ((s.touchpadAccelSpeed !== 0 || s.mouseAccelSpeed !== 0) && hyprSupports("input:sensitivity")) + fields.push(`sensitivity = ${s.touchpadAccelSpeed.toFixed(2)}`); + if ((s.touchpadAccelProfile !== "default" || s.mouseAccelProfile !== "default") && hyprSupports("input:accel_profile")) + fields.push(`accel_profile = ${luaString(s.touchpadAccelProfile === "default" ? "" : s.touchpadAccelProfile)}`); + const scrollMethod = hyprScrollMethod(s.touchpadScrollMethod); + if ((scrollMethod || hyprScrollMethod(s.mouseScrollMethod)) && hyprSupports("input:scroll_method")) + fields.push(`scroll_method = ${luaString(scrollMethod)}`); + if (s.mouseLeftHanded && hyprSupports("input:left_handed")) + fields.push("left_handed = false"); + if (!fields.length) + return []; + return names.map(name => `hl.device({ name = ${luaString(name)}, ${fields.join(", ")} })`); + } + + function renameWorkspace(newName) { + 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}`); + return; + } + Hyprland.dispatch(`hl.dsp.focus({ workspace = ${luaValue(workspace)} })`); } function luaString(value) { @@ -638,76 +943,72 @@ hl.layer_rule({ const selector = windowSelector(windowAddress); if (!selector) return; - - if (luaConfigActive) { - Hyprland.dispatch(`hl.dsp.focus({ window = ${luaString(selector)} })`); - } else { + if (!luaConfigActive) { Hyprland.dispatch(`focuswindow ${selector}`); + return; } + Hyprland.dispatch(`hl.dsp.focus({ window = ${luaString(selector)} })`); } function closeWindow(windowAddress) { const selector = windowSelector(windowAddress); if (!selector) return; - - if (luaConfigActive) { - Hyprland.dispatch(`hl.dsp.window.close(${luaString(selector)})`); - } else { + if (!luaConfigActive) { Hyprland.dispatch(`closewindow ${selector}`); + return; } + Hyprland.dispatch(`hl.dsp.window.close(${luaString(selector)})`); } function moveToWorkspace(workspace, windowAddress, follow = true) { const selector = windowSelector(windowAddress); if (!selector) return; - - if (luaConfigActive) { - Hyprland.dispatch(`hl.dsp.window.move({ workspace = ${luaValue(workspace)}, window = ${luaString(selector)}, follow = ${follow ? "true" : "false"} })`); - } else { - const dispatcher = follow ? "movetoworkspace" : "movetoworkspacesilent"; - Hyprland.dispatch(`${dispatcher} ${workspace},${selector}`); + 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 focusMonitor(monitor) { - if (luaConfigActive) { - Hyprland.dispatch(`hl.dsp.focus({ monitor = ${luaString(monitor)} })`); - } else { + if (!luaConfigActive) { Hyprland.dispatch(`focusmonitor ${monitor}`); + return; } + Hyprland.dispatch(`hl.dsp.focus({ monitor = ${luaString(monitor)} })`); } function toggleSpecial(specialName) { - if (luaConfigActive) { - Hyprland.dispatch(`hl.dsp.workspace.toggle_special(${luaString(specialName)})`); - } else { + if (!luaConfigActive) { Hyprland.dispatch("togglespecialworkspace " + specialName); + return; } + Hyprland.dispatch(`hl.dsp.workspace.toggle_special(${luaString(specialName)})`); } function exit() { - if (luaConfigActive) { - Hyprland.dispatch("hl.dsp.exit()"); - } else { + if (!luaConfigActive) { Hyprland.dispatch("exit"); + return; } + Hyprland.dispatch("hl.dsp.exit()"); } function dpmsOff() { - if (luaConfigActive) { - Hyprland.dispatch(`hl.dsp.dpms({ action = "disable" })`); - } else { + if (!luaConfigActive) { Hyprland.dispatch("dpms off"); + return; } + Hyprland.dispatch(`hl.dsp.dpms({ action = "disable" })`); } function dpmsOn() { - if (luaConfigActive) { - Hyprland.dispatch(`hl.dsp.dpms({ action = "enable" })`); - } else { + if (!luaConfigActive) { Hyprland.dispatch("dpms on"); + return; } + Hyprland.dispatch(`hl.dsp.dpms({ action = "enable" })`); } } diff --git a/quickshell/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml index 3c5beec29..132cfb7ee 100644 --- a/quickshell/Services/KeybindsService.qml +++ b/quickshell/Services/KeybindsService.qml @@ -368,21 +368,24 @@ Singleton { cheatsheetProcess.running = true; } - function canExecuteAction(action) { + function canExecuteAction(action, luaAction) { if (!action) return false; if (action.startsWith("spawn ") || action.startsWith("spawn_shell ") || action.startsWith("spawn-sh ") || action.startsWith("exec ")) return true; const provider = currentProvider || cheatsheetProvider; - if (provider === "niri") { - const base = action.trim().split(/\s+/)[0]; - if (base === "next-window" || base === "previous-window") - return false; - } + const base = action.trim().split(/\s+/)[0]; + if (provider === "niri" && (base === "next-window" || base === "previous-window")) + return false; + // 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) + return false; return provider === "niri" || provider === "hyprland" || provider === "mangowc"; } - function executeAction(action) { + function executeAction(action, luaAction) { if (!action) return false; log.info("Executing keybind action:", action); @@ -431,7 +434,14 @@ Singleton { return true; } if (provider === "hyprland") { - Quickshell.execDetached(["sh", "-c", "hyprctl dispatch " + action]); + 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) + return false; + Quickshell.execDetached(["hyprctl", "dispatch", luaAction]); return true; } if (provider === "mangowc") { diff --git a/quickshell/Services/SettingsSearchService.qml b/quickshell/Services/SettingsSearchService.qml index 8e3b7df41..26b7fd2d3 100644 --- a/quickshell/Services/SettingsSearchService.qml +++ b/quickshell/Services/SettingsSearchService.qml @@ -60,6 +60,7 @@ Singleton { readonly property var conditionMap: ({ "isNiri": () => CompositorService.isNiri, "pointerCapable": () => CompositorService.supportsPointerConfig, + "inputCapable": () => CompositorService.supportsInputConfig, "isHyprland": () => CompositorService.isHyprland, "isMango": () => CompositorService.isMango, "isAqueous": () => CompositorService.isAqueous, diff --git a/quickshell/Widgets/DFocusGrab.qml b/quickshell/Widgets/DFocusGrab.qml index df8a3181a..908dc3f93 100644 --- a/quickshell/Widgets/DFocusGrab.qml +++ b/quickshell/Widgets/DFocusGrab.qml @@ -1,6 +1,8 @@ import QtQuick import Quickshell.Hyprland +import Quickshell.Wayland import qs.Common +import "../Common/WorkspaceModel.js" as WorkspaceModel // Delays grab release so keyboardFocus=None commits before the grab dies, // keeping Hyprland from handing focus back to the closing surface (#2577) @@ -11,7 +13,8 @@ HyprlandFocusGrab { property bool _held: false property bool _compositorCleared: false property var _restoreToplevel: null - property int _restoreWorkspaceId: -1 + property string _restoreWorkspace: "" + readonly property Toplevel liveToplevel: ToplevelManager.activeToplevel property Timer _releaseTimer: Timer { interval: 50 @@ -20,12 +23,18 @@ HyprlandFocusGrab { root.active = false; // Restoring a toplevel from another workspace would drag the user back // to the workspace they just navigated away from (#2963) - const workspaceChanged = (Hyprland.focusedWorkspace?.id ?? -1) !== root._restoreWorkspaceId; + const workspaceChanged = String(WorkspaceModel.hyprlandKey(Hyprland.focusedWorkspace)) !== root._restoreWorkspace; root._restoreToplevel = (root._compositorCleared || workspaceChanged) ? null : KeyboardFocus.restoreToplevel(root._restoreToplevel); } } onWantedChanged: _sync() + // Don't restore a window the user already left while the grab was held + onLiveToplevelChanged: { + if (!_held || !wanted || !liveToplevel || liveToplevel === _restoreToplevel) + return; + _restoreToplevel = null; + } Component.onCompleted: _sync() function _sync() { @@ -38,7 +47,7 @@ HyprlandFocusGrab { _held = true; _compositorCleared = false; _restoreToplevel = KeyboardFocus.captureActiveToplevel(); - _restoreWorkspaceId = Hyprland.focusedWorkspace?.id ?? -1; + _restoreWorkspace = String(WorkspaceModel.hyprlandKey(Hyprland.focusedWorkspace)); active = true; } diff --git a/quickshell/matugen/templates/hypr-colors.lua b/quickshell/matugen/templates/hypr-colors.lua index c1d51a998..6bb7cb14a 100644 --- a/quickshell/matugen/templates/hypr-colors.lua +++ b/quickshell/matugen/templates/hypr-colors.lua @@ -25,3 +25,18 @@ hl.config({ }, }, }) + +-- Effect colors exist only on newer Hyprland; skip them elsewhere instead of raising config errors +local function themed(key, value) + local ok, current = pcall(hl.get_config, key) + if ok and current ~= nil then + hl.config({ [key] = value }) + end +end + +themed("decoration.glow.color", "rgba({{colors.primary.default.hex_stripped}}ee)") +themed("decoration.glow.color_inactive", "rgba({{colors.outline.default.hex_stripped}}ee)") +themed("decoration.blur.acrylic.tint", "rgba({{colors.primary.default.hex_stripped}}14)") +themed("decoration.blur.aurora.color1", "rgba({{colors.primary.default.hex_stripped}}29)") +themed("decoration.blur.aurora.color2", "rgba({{colors.tertiary.default.hex_stripped}}7a)") +themed("decoration.blur.fluid_jar.color", "rgba({{colors.primary.default.hex_stripped}}cc)") diff --git a/quickshell/tests/config-include.test.mjs b/quickshell/tests/config-include.test.mjs index 664aeb4f7..cb5d5f34b 100644 --- a/quickshell/tests/config-include.test.mjs +++ b/quickshell/tests/config-include.test.mjs @@ -11,8 +11,7 @@ vm.runInContext(readFileSync(new URL("../Common/ConfigIncludeResolve.js", import const plain = value => JSON.parse(JSON.stringify(value)); test("includeSpec returns null off the table", () => { - assert.equal(resolve.includeSpec("input", "hyprland"), null, "input exists for niri only"); - assert.equal(resolve.includeSpec("input", "mango"), null, "input exists for niri only"); + assert.equal(resolve.includeSpec("input", "mango"), null, "input exists for niri and hyprland only"); assert.equal(resolve.includeSpec("colors", "niri"), null, "unknown kind"); assert.equal(resolve.includeSpec("outputs", "sway"), null, "unknown compositor"); assert.equal(resolve.includeSpec("outputs", ""), null, "empty compositor"); @@ -35,18 +34,19 @@ test("includePaths joins the compositor directory, with hypr for hyprland", () = assert.deepEqual(plain(mango.fragmentFiles), ["/home/u/.config/mango/dms/cursor.conf"]); const binds = resolve.includePaths("binds", "hyprland", "/home/u/.config"); assert.deepEqual(plain(binds.fragmentFiles), ["/home/u/.config/hypr/dms/binds.lua", "/home/u/.config/hypr/dms/binds-user.lua"]); - assert.equal(resolve.includePaths("input", "hyprland", "/home/u/.config"), null, "no spec, no paths"); + assert.equal(resolve.includePaths("input", "mango", "/home/u/.config"), null, "no spec, no paths"); }); test("resolveIncludeArgs spells mango as mangowc and names the fragment", () => { assert.deepEqual(plain(resolve.resolveIncludeArgs("outputs", "niri")), ["niri", "outputs.kdl"]); assert.deepEqual(plain(resolve.resolveIncludeArgs("layout", "hyprland")), ["hyprland", "layout.lua"]); + assert.deepEqual(plain(resolve.resolveIncludeArgs("input", "hyprland")), ["hyprland", "input.lua"]); assert.deepEqual(plain(resolve.resolveIncludeArgs("windowrules", "mango")), ["mangowc", "windowrules.conf"]); assert.equal(resolve.resolveIncludeArgs("input", "mango"), null, "no spec, no args"); }); test("repairScriptFor returns an empty script off the table", () => { - assert.equal(resolve.repairScriptFor("input", "hyprland", "/home/u/.config", "/x"), ""); + assert.equal(resolve.repairScriptFor("input", "mango", "/home/u/.config", "/x"), ""); assert.equal(resolve.repairScriptFor("nope", "niri", "/home/u/.config", "/x"), ""); }); diff --git a/quickshell/tests/hyprland-lua.test.mjs b/quickshell/tests/hyprland-lua.test.mjs new file mode 100644 index 000000000..e492ec070 --- /dev/null +++ b/quickshell/tests/hyprland-lua.test.mjs @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import { loadScript } from "./qml-script.mjs"; + +const OutputModel = loadScript(new URL("../Common/OutputModel.js", import.meta.url)); +const SPEC = loadScript(new URL("../Common/settings/SettingsSpec.js", import.meta.url)).SPEC; +const source = readFileSync(new URL("../Services/HyprlandService.qml", import.meta.url), "utf8"); +const extract = name => { + const found = source.match(new RegExp(`^ function ${name}\\([^)]*\\) \\{\\n[\\s\\S]*?^ \\}`, "m")); + if (!found) + throw new Error(`${name} not found in HyprlandService.qml`); + return found[0]; +}; + +const live = {}; +// Every option is reported as supported unless a test shrinks the set +const scope = vm.createContext({ + OutputModel, + SettingsData: { displayNameMode: "system" }, + 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"]) + vm.runInContext(extract(fn), scope); +const withOptions = (names, run) => { + const previous = scope.hyprOptionNames; + scope.hyprOptionNames = names; + try { + return run(); + } finally { + scope.hyprOptionNames = previous; + } +}; +// Arrays from the vm realm fail deepStrictEqual against local ones +const decorationLines = (s, rounding) => [...scope.decorationLines(s, rounding)]; +const groupbarLines = s => [...scope.groupbarLines(s)]; +const inputLines = s => [...scope.inputLines(s)].map(line => line.trim()); +const deviceLines = (s, names) => [...scope.touchpadDeviceLines(s, names)]; + +const settingsFor = prefix => { + const defaults = Object.fromEntries(Object.keys(SPEC).filter(key => prefix.test(key)).map(key => [key, SPEC[key].def])); + return overrides => Object.assign({}, defaults, overrides); +}; +const layout = settingsFor(/^hyprland/); +const input = settingsFor(/^(mouse|touchpad|keyboard)[A-Z]/); +const table = (lines, name) => { + const start = lines.indexOf(`${name} = {`); + return start < 0 ? null : lines.slice(start + 1, lines.indexOf("},", start)).map(line => line.trim()); +}; + +const fallback = 'hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })'; +const saved = OutputModel.parseHyprlandOutputs([ + fallback, + 'hl.monitor({ output = "eDP-1", mode = "2400x1600@120.000", position = "0x0", scale = 1.25, vrr = 0 })', + 'hl.monitor({ output = "DP-1", mode = "preferred", position = "auto", scale = "auto" })', + 'hl.monitor({ output = "DP-2", mode = "preferred", position = "auto-right", scale = "auto" })' +].join("\n")); +const connected = (name, x, y, scale) => ({ name, logical: { x, y, scale, transform: "Normal" }, modes: [], current_mode: -1 }); +const monitorLine = (content, name) => content.split("\n").find(line => line.includes(`output = "${name}"`)); + +test("outputs: the catch-all line is not a monitor and is rewritten verbatim, once, last", () => { + assert.deepEqual(Object.keys(saved), ["eDP-1", "DP-1", "DP-2"]); + const content = scope.buildOutputsLua({ "": { name: "", logical: { x: 0, y: 0, scale: 1 } }, "eDP-1": connected("eDP-1", 0, 0, 1.25) }, {}, saved); + assert.deepEqual(content.split("\n").filter(line => line.includes('output = ""')), [fallback]); + assert.equal(content.trimEnd().split("\n").at(-1), fallback); +}); + +test("outputs: auto position and scale survive a rewrite until the written value differs from what Hyprland resolved", () => { + live["eDP-1"] = { x: 0, y: 0, scale: 1.25 }; + live["DP-1"] = { x: 1920, y: 0, scale: 1.5 }; + const cases = [ + [connected("DP-1", 1920, 0, 1.5 + 1 / 512), 'position = "auto", scale = "auto"'], + [connected("DP-1", 2000, 0, 1.5), 'position = "2000x0", scale = "auto"'], + [connected("DP-1", 1920, 0, 2), 'position = "auto", scale = 2'] + ]; + for (const [output, want] of cases) { + const line = monitorLine(scope.buildOutputsLua({ "eDP-1": connected("eDP-1", 0, 0, 1.25), "DP-1": output }, {}, saved), "DP-1"); + assert.ok(line.includes(want), line); + } + const disconnected = monitorLine(scope.buildOutputsLua({ "eDP-1": connected("eDP-1", 0, 0, 1.25), "DP-2": saved["DP-2"] }, {}, saved), "DP-2"); + assert.ok(disconnected.includes('position = "auto-right", scale = "auto"'), disconnected); +}); + +test("outputs: vrr is written only for a chosen mode, so untouched monitors inherit misc:vrr", () => { + const vrrFile = OutputModel.parseHyprlandOutputs([ + 'hl.monitor({ output = "eDP-1", mode = "2400x1600@120.000", position = "0x0", scale = 1.25, vrr = 0 })', + 'hl.monitor({ output = "DP-1", mode = "preferred", position = "2400x0", scale = 1, vrr = 2 })' + ].join("\n")); + const supported = name => Object.assign(connected(name, 0, 0, 1), { vrr_supported: true, vrr_enabled: false }); + const vrrOf = (name, settings) => monitorLine(scope.buildOutputsLua({ [name]: supported(name) }, settings, vrrFile), name).match(/vrr = (-?\d+)/)?.[1]; + const cases = [ + ["eDP-1", {}, undefined], + ["DP-1", {}, "2"], + ["DP-1", { "DP-1": { vrr: -1 } }, undefined], + ["eDP-1", { "eDP-1": { vrr: 0 } }, "0"], + ["eDP-1", { "eDP-1": { vrrFullscreenOnly: true } }, "2"] + ]; + assert.deepEqual(cases.map(([name, settings]) => vrrOf(name, settings)), cases.map(c => c[2])); +}); + +test("outputs: written scale snaps to Hyprland's n/120 grid so live float32 scales round-trip unchanged", () => { + live["eDP-1"] = { x: 0, y: 0, scale: 1.25 }; + live["DP-1"] = { x: 1920, y: 0, scale: 1.3333333730697632 }; + const scaleOf = (name, scale) => monitorLine(scope.buildOutputsLua({ [name]: connected(name, name === "DP-1" ? 1920 : 0, 0, scale) }, {}, saved), name).match(/scale = ([^,}\s]+)/)[1]; + assert.deepEqual([scaleOf("eDP-1", 1.3333333730697632), scaleOf("eDP-1", 1.33), scaleOf("DP-1", 1.3333333730697632)], ["1.333333", "1.333333", '"auto"']); +}); + +test("layout: a blur variant forces blur on and writes only its own tuning table", () => { + assert.equal(table(decorationLines(layout({ hyprlandBlurVariant: "kawase" }), 0), "blur"), null); + const frost = table(decorationLines(layout({ hyprlandBlurVariant: "frost" }), 0), "blur"); + assert.deepEqual(frost, ["enabled = true,", 'variant = "frost",']); + const aurora = table(decorationLines(layout({ hyprlandBlurVariant: "aurora" }), 0), "blur"); + assert.deepEqual(aurora.slice(0, 2), ["enabled = true,", 'variant = "aurora",']); + assert.deepEqual(table(aurora, "aurora"), ["intensity = 0.35,", "speed = 1,"]); + assert.equal(table(aurora, "haze"), null); +}); + +test("layout: effects the running Hyprland does not know are not written even when enabled", () => { + const enabled = layout({ + hyprlandBlurVariant: "aurora", + hyprlandGlowEnabled: true, + hyprlandWobbleEnabled: true, + hyprlandMotionBlurEnabled: true, + hyprlandGroupbarBlur: true + }); + withOptions({ "decoration:glow:enabled": true }, () => { + assert.deepEqual(decorationLines(enabled, 12).filter(line => line.endsWith("= {")), ["glow = {"]); + assert.deepEqual(groupbarLines(enabled), []); + }); +}); + +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,")); +}); + +test("input: keyboard keys stay out until one is configured, then numlock is explicit and kb_file replaces the xkb names", () => { + const untouched = inputLines(input({ keyboardTrackLayout: "window" })); + assert.equal(untouched.some(line => /^(kb_|repeat_|numlock)/.test(line)), false); + const keyboard = inputLines(input({ keyboardLayouts: "us,de", keyboardOptions: "grp:alt_shift_toggle", keyboardRepeatRate: 40 })); + assert.deepEqual(keyboard.slice(keyboard.lastIndexOf("},") + 1), [ + 'kb_layout = "us,de",', + 'kb_options = "grp:alt_shift_toggle",', + "repeat_rate = 40,", + "numlock_by_default = false," + ]); + const file = inputLines(input({ keyboardKeymapFile: "/k.xkb", keyboardLayouts: "us" })); + assert.ok(file.includes('kb_file = "/k.xkb",')); + assert.equal(file.some(line => line.startsWith("kb_layout")), false); +}); + +test("input: click method maps to clickfinger_behavior and default leaves it out", () => { + const clickfinger = method => table(inputLines(input({ touchpadClickMethod: method })), "touchpad").find(line => line.startsWith("clickfinger_behavior")); + assert.equal(clickfinger("clickfinger"), "clickfinger_behavior = true,"); + assert.equal(clickfinger("button-areas"), "clickfinger_behavior = false,"); + assert.equal(clickfinger("default"), undefined); +}); + +test("input: an option the running Hyprland does not know is never written", () => { + withOptions({ "input:sensitivity": false, "input:touchpad:natural_scroll": true, "input:touchpad:tap-to-click": false }, () => { + assert.deepEqual(inputLines(input({ mouseAccelSpeed: 0.5, touchpadDragLock: true })), ["touchpad = {", "natural_scroll = true,", "},"]); + assert.deepEqual(deviceLines(input({ touchpadAccelSpeed: 0.3 }), ["elan-touchpad"]), []); + }); +}); + +test("input: touchpad speed goes per device and pins its own values against the global mouse ones", () => { + assert.deepEqual(deviceLines(input({ touchpadAccelSpeed: 0.3 }), ["elan-touchpad"]), ['hl.device({ name = "elan-touchpad", sensitivity = 0.30 })']); + assert.deepEqual(deviceLines(input({ mouseAccelSpeed: -0.5, mouseAccelProfile: "flat", mouseLeftHanded: true }), ["a-touchpad"]), [ + 'hl.device({ name = "a-touchpad", sensitivity = 0.00, accel_profile = "", left_handed = false })' + ]); + assert.deepEqual([...scope.touchpadNames({ mice: [{ name: "logitech-mx-master-3" }, { name: "elan0670:00-04f3:3150-touchpad" }, { name: "apple-inc.-magic-trackpad" }] })], ["elan0670:00-04f3:3150-touchpad", "apple-inc.-magic-trackpad"]); +}); diff --git a/quickshell/tests/keybinds-execute.test.mjs b/quickshell/tests/keybinds-execute.test.mjs new file mode 100644 index 000000000..915322be5 --- /dev/null +++ b/quickshell/tests/keybinds-execute.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const source = readFileSync(new URL("../Services/KeybindsService.qml", import.meta.url), "utf8"); + +function extract(name) { + const found = source.match(new RegExp(`^ function ${name}\\([^)]*\\) \\{\\n[\\s\\S]*?^ \\}`, "m")); + if (!found) + throw new Error(`${name} not found in KeybindsService.qml`); + return found[0]; +} + +const calls = []; +const scope = vm.createContext({ + currentProvider: "hyprland", + cheatsheetProvider: "", + log: { info() {} }, + HyprlandService: { luaConfigActive: true }, + Quickshell: { execDetached: argv => calls.push(Array.from(argv)) }, + MangoService: { dispatch() {} } +}); +vm.runInContext(extract("executeAction") + "\n" + extract("canExecuteAction"), scope); + +test("hyprland runs the Lua dispatch as one argv entry and refuses legacy text without it", () => { + scope.HyprlandService.luaConfigActive = true; + const lua = 'hl.dsp.focus({ direction = "l" })'; + calls.length = 0; + assert.equal(scope.executeAction("movefocus l", lua), true); + assert.deepEqual(calls, [["hyprctl", "dispatch", lua]]); + for (const args of [["movefocus l", ""], ["movefocus l"]]) { + calls.length = 0; + assert.equal(scope.executeAction(...args), false, args.join(" | ")); + assert.deepEqual(calls, []); + } +}); + +test("hyprland on a conf config dispatches the legacy text", () => { + scope.HyprlandService.luaConfigActive = false; + for (const args of [["movefocus l", ""], ["movefocus l"]]) { + calls.length = 0; + assert.equal(scope.executeAction(...args), true, args.join(" | ")); + assert.deepEqual(calls, [["sh", "-c", "hyprctl dispatch movefocus l"]]); + } +}); + +test("hyprland rows without a Lua action run only on a conf config", () => { + const cases = [ + [true, "movefocus l", 'hl.dsp.focus({ direction = "l" })', true], + [true, "movefocus l", "", false], + [false, "movefocus l", "", true], + [false, "movewindow", "", false], + [false, "exit", "", false] + ]; + for (const [lua, action, luaAction, expected] of cases) { + scope.HyprlandService.luaConfigActive = lua; + assert.equal(scope.canExecuteAction(action, luaAction), expected, `${lua} ${action}`); + } +}); diff --git a/quickshell/tests/output-model.test.mjs b/quickshell/tests/output-model.test.mjs index b4536ae01..9a6b74f8d 100644 --- a/quickshell/tests/output-model.test.mjs +++ b/quickshell/tests/output-model.test.mjs @@ -62,7 +62,7 @@ test("hyprland conf parser: extras, disable line, desc name, trailing space, mir const parsed = plain(model.parseHyprlandOutputs(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, { bitdepth: 10, colorManagement: "hdr", sdrBrightness: 1.2, sdrSaturation: 0.9, vrrFullscreenOnly: true }); + 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.equal(parsed["desc:Dell Inc. DELL U2720Q ABC123"].logical.transform, "Flipped90"); @@ -73,10 +73,10 @@ test("hyprland conf parser: extras, disable line, desc name, trailing space, mir test("hyprland lua parser: quoted and long-bracket strings, hdr fields, mode-less lines, apostrophe names", () => { const parsed = plain(model.parseHyprlandOutputs(text("hyprland-monitors.lua"))); assert.deepEqual(Object.keys(parsed), ["DP-1", "HDMI-A-1", "desc:Dell Inc. DELL U2720Q ABC123", "eDP-1", "DP-2", "DP-3", "it's"]); - assert.deepEqual(parsed["DP-1"].hyprlandSettings, { bitdepth: 10, colorManagement: "hdr", sdrBrightness: 1.2, sdrSaturation: 0.9, supportsWideColor: true, supportsHdr: false, sdrEotf: "gamma22", icc: "/home/u/profile with spaces.icc", sdrMinLuminance: 0.005, sdrMaxLuminance: 200, minLuminance: 0.001, maxLuminance: 1000, maxAvgLuminance: 400, vrrFullscreenOnly: true }); + assert.deepEqual(parsed["DP-1"].hyprlandSettings, { bitdepth: 10, colorManagement: "hdr", sdrBrightness: 1.2, sdrSaturation: 0.9, supportsWideColor: true, supportsHdr: false, sdrEotf: "gamma22", icc: "/home/u/profile with spaces.icc", sdrMinLuminance: 0.005, sdrMaxLuminance: 200, minLuminance: 0.001, maxLuminance: 1000, maxAvgLuminance: 400, vrr: 2 }); assert.deepEqual([parsed["HDMI-A-1"].hyprlandSettings, parsed["HDMI-A-1"].current_mode], [{ disabled: true }, -1]); assert.deepEqual([parsed["eDP-1"].current_mode, parsed["eDP-1"].mirror], [-1, "DP-1"]); - assert.deepEqual([parsed["DP-3"].vrr_enabled, parsed["DP-3"].vrr_supported, parsed["DP-3"].hyprlandSettings], [true, true, { vrrFullscreenOnly: true }]); + assert.deepEqual([parsed["DP-3"].vrr_enabled, parsed["DP-3"].vrr_supported, parsed["DP-3"].hyprlandSettings], [true, true, { vrr: 2 }]); assert.equal(parsed["it's"].modes[0].width, 1920); assert.equal(model.parseHyprlandLuaMonitorLine("monitor=DP-1,disable"), null); assert.equal(model.parseHyprlandLuaMonitorLine("hl.monitor({ scale = 1 })"), null); @@ -176,9 +176,9 @@ test("neutral config carries the backend settings for the compositor and hoists }); test("filterDisconnectedOnly keeps saved outputs no live head matches, desc: keys resolve only on hyprland, names are trimmed", () => { - const hyprParsed = plain(model.parseHyprlandOutputs(text("hyprland-monitors.conf"))); - assert.deepEqual(Object.keys(model.filterDisconnectedOnly(hyprParsed, live, "model", "hyprland")), ["DP-2"]); - assert.deepEqual(Object.keys(model.filterDisconnectedOnly(hyprParsed, live, "system", "niri")), ["desc:Dell Inc. DELL U2720Q ABC123", "DP-2"]); + const hyprParsed = plain(model.parseHyprlandOutputs(text("hyprland-monitors.lua"))); + assert.deepEqual(Object.keys(model.filterDisconnectedOnly(hyprParsed, live, "model", "hyprland")), ["DP-2", "DP-3", "it's"]); + assert.deepEqual(Object.keys(model.filterDisconnectedOnly(hyprParsed, live, "system", "niri")), ["desc:Dell Inc. DELL U2720Q ABC123", "DP-2", "DP-3", "it's"]); assert.deepEqual(model.filterDisconnectedOnly(plain(model.parseNiriOutputs(text("niri-outputs.kdl"))), live, "model", "niri"), {}); assert.deepEqual(Object.keys(model.filterDisconnectedOnly({ " DP-1 ": { name: "DP-1" }, "DP-7 ": { name: "DP-7" } }, live, "system", "niri")), ["DP-7 "]); }); @@ -326,3 +326,20 @@ test("mango rule writer carries hdr/icc/disable from existing rules, keeps fract ]); assert.ok(model.mangoMonitorRuleLines(outputs, "", true).every(line => line.startsWith("monitor_rule="))); }); + +test("hyprland hardwareDetails gate vrr and color modes like Hyprland does, and gate nothing when absent", () => { + const edp = model.hyprlandHardware({ hardwareDetails: { backend: "drm", hdr: false, chroma: true, bt2020: false, vrrCapable: true } }); + const hdrPanel = model.hyprlandHardware({ hardwareDetails: { hdr: true, chroma: false, bt2020: true, vrrCapable: false } }); + assert.equal(model.hyprlandHardware({ name: "eDP-1" }), null); + const allowed = caps => ["auto", "wide", "edid", "hdr", "hdredid", "dcip3"].filter(cm => model.hyprlandCmAllowed(cm, caps)); + const matrix = [ + [model.hyprlandCaps(null, {}), ["auto", "wide", "edid", "hdr", "hdredid", "dcip3"], undefined], + [model.hyprlandCaps(edp, {}), ["auto", "edid", "dcip3"], true], + [model.hyprlandCaps(edp, { supportsWideColor: 1, supportsHdr: true }), ["auto", "wide", "edid", "hdr", "hdredid", "dcip3"], true], + [model.hyprlandCaps(hdrPanel, {}), ["auto", "wide", "hdr", "hdredid", "dcip3"], false], + [model.hyprlandCaps(hdrPanel, { supportsWideColor: false }), ["auto", "dcip3"], false] + ]; + assert.deepEqual(matrix.map(([caps]) => [allowed(caps), caps?.vrr]), matrix.map(([, cms, vrr]) => [cms, vrr])); + const wlr = model.outputsFromWlr([{ name: "eDP-1", modes: [] }], { "eDP-1": { x: 0, y: 0, scale: 1.25, hardware: edp } }); + assert.deepEqual(plain(wlr["eDP-1"].hardware), plain(edp)); +}); diff --git a/quickshell/tests/window-model.test.mjs b/quickshell/tests/window-model.test.mjs index 5baf8721a..8b2e44392 100644 --- a/quickshell/tests/window-model.test.mjs +++ b/quickshell/tests/window-model.test.mjs @@ -174,6 +174,14 @@ test("hyprland: workspace membership against the focused workspace, pid from the assert.equal(specialWorkspaceName(hypr, null), ""); }); +test("hyprland main: named workspaces all report id -1, so active-workspace membership compares addresses", () => { + const music = { id: -1, address: "music", name: "music" }; + const chat = { id: -1, address: "chat", name: "chat" }; + const win = { id: "w", appId: "w", title: "w" }; + const state = { toplevels: [{ wayland: win, workspace: music, lastIpcObject: { pid: 7 } }] }; + assert.deepEqual([chat, music].map(focused => focusedAppHyprland(state, win, focused).onActiveWorkspace), [false, true]); +}); + test("mango: compositor visibility covers global windows and overview without counting hidden clients", () => { const windows = [ { id: 1, monitor: "DP-1", tags: [2], is_visible: true }, diff --git a/quickshell/tests/window-rule-merge.test.mjs b/quickshell/tests/window-rule-merge.test.mjs new file mode 100644 index 000000000..2a72c99e9 --- /dev/null +++ b/quickshell/tests/window-rule-merge.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import test from "node:test"; + +const context = vm.createContext({}); +vm.runInContext(readFileSync(new URL("../Common/WindowRuleMerge.js", import.meta.url), "utf8").replace(/^\.pragma.*$/m, ""), context); +const plain = value => JSON.parse(JSON.stringify(value)); + +test("an edit keeps fields the editor does not own and clears the ones it does", () => { + const stored = { + "idleinhibit": "focus", + "forcergbx": true, + "focusRingColor": "#ff0000", + "noGlow": true, + "opacity": 0.5 + }; + assert.deepEqual(plain(context.carry(stored, ["noGlow", "opacity", "monitor"])), { + "idleinhibit": "focus", + "forcergbx": true, + "focusRingColor": "#ff0000" + }); + assert.equal(stored.noGlow, true); + assert.deepEqual(plain(context.carry(null, ["opacity"])), {}); +}); diff --git a/quickshell/tests/workspace-model.test.mjs b/quickshell/tests/workspace-model.test.mjs index fb4a00ee9..7c5c49f53 100644 --- a/quickshell/tests/workspace-model.test.mjs +++ b/quickshell/tests/workspace-model.test.mjs @@ -149,7 +149,7 @@ test("hyprland padding fills numbered ids up to the minimum, skipping ids owned assert.deepEqual(pick(model.hyprlandWorkspacesForScreen(hyprland("empty"), "DP-1", false, false, 3), "id"), [1, 2, 3]); assert.deepEqual(pick(model.hyprlandWorkspacesForScreen(raw, "HDMI-A-1", false, false, 0), "id"), [2, 4, -1337]); const filled = model.hyprlandWorkspacesForScreen(raw, "HDMI-A-1", false, false, 5).find(ws => ws.id === 5); - assert.deepEqual(plain(filled), { id: 5, idx: 5, name: "5", output: "HDMI-A-1", active: false, placeholder: false, urgent: false }); + assert.deepEqual(plain(filled), { id: 5, idx: 5, address: "", name: "5", output: "HDMI-A-1", active: false, placeholder: false, urgent: false }); raw.workspaces.push({ id: 5, name: "5", monitor: null, lastIpcObject: {} }); assert.deepEqual(pick(model.hyprlandWorkspacesForScreen(raw, "HDMI-A-1", false, false, 5), "id"), [2, 4, 5, -1337]); }); @@ -248,6 +248,23 @@ test("hyprland active workspace, windows and occupancy", () => { assert.equal(model.hyprlandWorkspaceOccupied(raw.toplevels, { id: 2 }), false); }); +test("hyprland main: named workspaces all report id -1, so identity and targets come from the address", () => { + const monitor = { name: "DP-1" }; + const ws = (id, address, name) => ({ id, address, name, monitor, lastIpcObject: {} }); + const workspaces = [ws(1, "1", "1"), ws(-1, "music", "Tunes"), ws(-1, "chat", "chat"), ws(-1, "notes", "notes")]; + const [one, music, chat] = workspaces; + monitor.activeWorkspace = chat; + const win = { address: "0xa" }; + const toplevels = [{ workspace: one, wayland: null }, { workspace: music, wayland: win }]; + const raw = { workspaces, monitors: [monitor], focusedWorkspace: chat, toplevels, visibleSpecials: {} }; + assert.equal(model.hyprlandCurrentId(raw, "DP-1", false), "chat"); + assert.deepEqual(pick(model.hyprlandWorkspacesForScreen(raw, "DP-1", false, true), "name"), ["1", "chat", "Tunes"]); + const chatRecord = model.hyprlandWorkspacesForScreen(raw, "DP-1", false, false).find(record => record.name === "chat"); + assert.equal(model.hyprlandWorkspaceOccupied(toplevels, chatRecord), false); + assert.deepEqual(model.hyprlandWindowsOnWorkspace([win], chatRecord, toplevels), []); + assert.deepEqual([one, music, { id: -1337, address: "-1337", name: "chat" }, { id: -1, address: "special:scratch", name: "special:scratch" }].map(model.hyprlandSelector), [1, "name:music", "name:chat", "special:scratch"]); +}); + test("mango tags come from the output state, visible or all", () => { const dp1 = mango("available", "DP-1"); const visible = model.mangoWorkspacesForScreen(dp1, "DP-1", false); diff --git a/quickshell/translations/extract_settings_index.py b/quickshell/translations/extract_settings_index.py index 77819dd08..68c4e6fd9 100644 --- a/quickshell/translations/extract_settings_index.py +++ b/quickshell/translations/extract_settings_index.py @@ -165,7 +165,7 @@ SIDEBAR_GATE_CONDITIONS = [ ("dmsOnly", "networkAvailable"), ("hyprlandNiriOnly", "isHyprlandOrNiri"), ("clipboardOnly", "dmsConnected"), - ("niriOnly", "isNiri"), + ("inputCapable", "inputCapable"), ("pointerCapable", "pointerCapable"), ("windowRulesCapable", "windowRulesCapable"), ("layoutCapable", "layoutCapable"), @@ -458,7 +458,7 @@ def find_settings_components(content, filename, wrappers, tab_meta, hub_meta): if desc_raw: description = extract_i18n_string(desc_raw) - visible_raw = extract_property(block, "visible") + visible_raw = extract_property(own_scope(block), "visible") page_meta = hub_meta.get(file_page) if file_page else None condition_key = page_meta[2] if page_meta else tab_meta.get(tab_index, TAB_META_DEFAULT)[2] if visible_raw: @@ -477,7 +477,7 @@ def find_settings_components(content, filename, wrappers, tab_meta, hub_meta): elif "CompositorService.supportsPointerConfig" in visible_raw: condition_key = "pointerCapable" elif "CompositorService.supportsInputConfig" in visible_raw: - condition_key = "isNiri" + condition_key = "inputCapable" elif "CompositorService.isAqueous" in visible_raw: if "CompositorService.isHyprland" in visible_raw: condition_key = "smartDockCapable" diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 223475a72..897139f04 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -35,8 +35,7 @@ "wallpapers" ], "page": "personalization", - "description": "DMS stops drawing wallpapers on every display", - "conditionKey": "isNiri" + "description": "DMS stops drawing wallpapers on every display" }, { "section": "notificationDndAllowCritical", @@ -12382,6 +12381,77 @@ "conditionKey": "layoutCapable", "runtimeType": "pageLabel" }, + { + "section": "hyprlandGroupbarBlur", + "label": "Background blur", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "background", + "blur", + "groupbar", + "hyprland", + "integrations", + "system" + ], + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandBlurVariant", + "label": "Blur", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "acrylic", + "aurora", + "blur", + "default", + "frost", + "glass", + "hyprland", + "integrations", + "more", + "outside", + "style", + "styles", + "system", + "variant" + ], + "description": "Styles outside of Default use more GPU", + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandBorderGrabArea", + "label": "Border grab area", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "area", + "border", + "extend_border_grab_area", + "grab", + "hyprland", + "integrations", + "resize", + "system" + ], + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandBlurAcrylicClarity", + "label": "Clarity", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "acrylic", + "blur", + "clarity", + "hyprland", + "integrations", + "system" + ], + "conditionKey": "layoutCapable" + }, { "section": "closeNiriOverviewOnWindowFocus", "label": "Close on window focus", @@ -12613,6 +12683,54 @@ "description": "Auto follows bar spacing, Off keeps your %1 config", "conditionKey": "layoutCapable" }, + { + "section": "hyprlandGlowEnabled", + "label": "Glow", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "glow", + "hyprland", + "inner", + "integrations", + "range", + "system" + ], + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandGlowRenderPower", + "label": "Glow falloff", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "falloff", + "glow", + "hyprland", + "integrations", + "render_power", + "system" + ], + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandGroupbarDisableWhenOnly", + "label": "Hide with one window", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "disable_when_only", + "groupbar", + "hide", + "hyprland", + "integrations", + "one", + "single", + "system", + "window" + ], + "conditionKey": "layoutCapable" + }, { "section": "hyprlandLayoutGapsOverride", "label": "Inner gaps", @@ -12647,6 +12765,53 @@ ], "conditionKey": "layoutCapable" }, + { + "section": "hyprlandBlurAuroraIntensity", + "label": "Intensity", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "aurora", + "blur", + "hyprland", + "integrations", + "intensity", + "system" + ], + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandBlurHazeIntensity", + "label": "Intensity", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "blur", + "haze", + "hyprland", + "integrations", + "intensity", + "sheen", + "system" + ], + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandBlurHazeIridescence", + "label": "Iridescence", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "blur", + "color", + "haze", + "hyprland", + "integrations", + "iridescence", + "system" + ], + "conditionKey": "layoutCapable" + }, { "section": "hyprlandLayout", "label": "Layout overrides", @@ -12778,6 +12943,45 @@ ], "conditionKey": "layoutCapable" }, + { + "section": "hyprlandGroupbarMiddleClickClose", + "label": "Middle click closes", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "click", + "close", + "closes", + "groupbar", + "hyprland", + "integrations", + "middle", + "system" + ], + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandMotionBlurEnabled", + "label": "Motion blur", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "blur", + "cost", + "hyprland", + "integrations", + "look", + "more", + "motion", + "move", + "resize", + "samples", + "smoother", + "system" + ], + "description": "More samples look smoother and cost more GPU", + "conditionKey": "layoutCapable" + }, { "section": "hyprlandMasterNewStatus", "label": "New windows", @@ -12962,6 +13166,38 @@ "description": "Split direction stays fixed when the container resizes", "conditionKey": "layoutCapable" }, + { + "section": "hyprlandBlurRippleStrength", + "label": "Refraction", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "blur", + "hyprland", + "integrations", + "refraction", + "ripple", + "strength", + "system" + ], + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandBlurWaterStrength", + "label": "Refraction", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "blur", + "hyprland", + "integrations", + "refraction", + "strength", + "system", + "water" + ], + "conditionKey": "layoutCapable" + }, { "section": "hyprlandResizeOnBorder", "label": "Resize on border", @@ -13016,6 +13252,22 @@ "description": "Split direction follows the cursor position in the window", "conditionKey": "layoutCapable" }, + { + "section": "hyprlandBlurAuroraSpeed", + "label": "Speed", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "animation", + "aurora", + "blur", + "hyprland", + "integrations", + "speed", + "system" + ], + "conditionKey": "layoutCapable" + }, { "section": "hyprlandTilingLayout", "label": "Tiling layout", @@ -13042,6 +13294,48 @@ "description": "Split direction stays fixed when the container resizes", "conditionKey": "isHyprland" }, + { + "section": "hyprlandGroupbarTextPadding", + "label": "Title padding", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "groupbar", + "hyprland", + "integrations", + "padding", + "system", + "text", + "text_padding", + "title" + ], + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandEffects", + "label": "Visual effects", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "blur", + "decoration", + "default", + "effects", + "glow", + "hyprland", + "integrations", + "more", + "motion", + "outside", + "styles", + "system", + "visual", + "wobble" + ], + "icon": "auto_awesome", + "description": "Styles outside of Default use more GPU", + "conditionKey": "isHyprland" + }, { "section": "niriLayoutGapsOverride", "label": "Window gaps", @@ -13057,6 +13351,24 @@ ], "conditionKey": "layoutCapable" }, + { + "section": "hyprlandGroupbar", + "label": "Window groups", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "group", + "groupbar", + "groups", + "hyprland", + "integrations", + "system", + "tabs", + "window" + ], + "icon": "tab", + "conditionKey": "isHyprland" + }, { "section": "hyprlandWindowOpacity", "label": "Window opacity", @@ -13072,6 +13384,37 @@ ], "conditionKey": "layoutCapable" }, + { + "section": "hyprlandWobbleStiffness", + "label": "Wobble stiffness", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "hyprland", + "integrations", + "spring", + "stiffness", + "system", + "wobble" + ], + "conditionKey": "layoutCapable" + }, + { + "section": "hyprlandWobbleEnabled", + "label": "Wobbly windows", + "tabIndex": 37, + "category": "System & integrations", + "keywords": [ + "hyprland", + "integrations", + "intensity", + "system", + "windows", + "wobble", + "wobbly" + ], + "conditionKey": "layoutCapable" + }, { "section": "hyprlandLayoutXrayEnabled", "label": "Xray blur", @@ -13970,7 +14313,7 @@ "touchpad" ], "parentLabel": "Input", - "conditionKey": "pointerCapable" + "conditionKey": "isNiri" }, { "section": "touchpadDragLock", @@ -14018,7 +14361,7 @@ "touchpad" ], "parentLabel": "Input", - "conditionKey": "pointerCapable" + "conditionKey": "isNiri" }, { "section": "touchpadMiddleEmulation", @@ -14051,7 +14394,7 @@ ], "parentLabel": "Input", "icon": "mouse", - "conditionKey": "isNiri" + "conditionKey": "inputCapable" }, { "section": "_tab_44", @@ -14282,7 +14625,7 @@ ], "parentLabel": "Input", "icon": "trackpad_input_2", - "conditionKey": "isNiri" + "conditionKey": "inputCapable" }, { "section": "keyboardBehaviorSettings", @@ -14300,7 +14643,7 @@ ], "parentLabel": "Input", "icon": "settings", - "conditionKey": "isNiri" + "conditionKey": "inputCapable" }, { "section": "_tab_45", @@ -14318,7 +14661,7 @@ "icon": "keyboard", "parentLabel": "Input", "description": "Layouts, repeat rate, num lock", - "conditionKey": "isNiri" + "conditionKey": "inputCapable" }, { "section": "keyboardLayoutsSettings", @@ -14344,7 +14687,7 @@ "parentLabel": "Input", "icon": "keyboard", "description": "Comma-separated list of layout names. Leave empty to use the system keyboard settings.", - "conditionKey": "isNiri" + "conditionKey": "inputCapable" }, { "section": "keyboardNumlock", @@ -14360,7 +14703,7 @@ "startup" ], "parentLabel": "Input", - "conditionKey": "isNiri" + "conditionKey": "inputCapable" }, { "section": "keyboardTrackLayout", @@ -14390,7 +14733,7 @@ "speed" ], "parentLabel": "Input", - "conditionKey": "isNiri" + "conditionKey": "inputCapable" }, { "section": "keyboardRepeatRate", @@ -14405,7 +14748,7 @@ "speed" ], "parentLabel": "Input", - "conditionKey": "isNiri" + "conditionKey": "inputCapable" }, { "section": "keyboardOptions", @@ -14421,7 +14764,7 @@ "xkb" ], "parentLabel": "Input", - "conditionKey": "isNiri" + "conditionKey": "inputCapable" }, { "section": "networkCellular",