hyprland: bring up features parity overhaul w/0.56+ and upcoming stable releases

- new blur variants, glow, wobble, motion blur and groupbar support
- new inputs support for mouse, touchpad and keyboard settings
- greeter, cheatsheet and scroll capture dispatch in Lua
- scrolling workspace screenshot focus fix
- fix scrolling screenshots from being cut-off on fractional-scale displays
- workspaces are keyed by address on the beta branch and upcoming stable release where named workspaces have no id
- bind flags, per-device binds, new window-rule keys and effects
- outputs default to preferred/auto/auto and gate HDR/VRR on hardwareDetails
- new dms setup/installer systemd session support on hyprland-session.target
This commit is contained in:
purian23 committed 2026-10-09 05:14:55 -04:00
1 parent f9574ef9ae
commit 2747a75198
75 files changed
+4385 -1046

No files matched your search

+10 -24
View File
@@ -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
}
+30
View File
@@ -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)
}
})
}
}
+63 -24
View File
@@ -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 {
+38
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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)
+10
View File
@@ -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)
+3
View File
@@ -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
+26 -1
View File
@@ -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},
}
+13
View File
@@ -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)
}
}
@@ -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)")
@@ -0,0 +1 @@
-- Mouse, touchpad and keyboard settings. DMS rewrites this file from Settings.
+3 -3
View File
@@ -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")
+3
View File
@@ -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
+45 -15
View File
@@ -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 ""
}
@@ -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)
}
})
}
}
+2
View File
@@ -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}
+23 -7
View File
@@ -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
}
+28
View File
@@ -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)
}
}
}
+2
View File
@@ -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}
+2
View File
@@ -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"]
+2
View File
@@ -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)
+2
View File
@@ -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}
+127 -98
View File
@@ -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)
}
@@ -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)
}
}
}
@@ -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 {
@@ -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 {
+105 -25
View File
@@ -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)
}
})
}
}
+2 -1
View File
@@ -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
@@ -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,
+8 -9
View File
@@ -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,
+49 -24
View File
@@ -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)
@@ -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)
}
}
}
@@ -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
}
@@ -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)
}
}
+23
View File
@@ -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 {
+7 -1
View File
@@ -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:
+2 -1
View File
@@ -104,7 +104,8 @@ const includeTable = {
mango: [confInclude("layout")]
},
input: {
niri: [kdlInclude("input")]
niri: [kdlInclude("input")],
hyprland: [luaInclude("input")]
},
windowrules: {
niri: [kdlInclude("windowrules")],
+220 -13
View File
@@ -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];
+105 -9
View File
@@ -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;
+21
View File
@@ -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
+2 -2
View File
@@ -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;
+6 -1
View File
@@ -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) {
+10
View File
@@ -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;
}
+22 -7
View File
@@ -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;
}
@@ -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"
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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,
+4 -3
View File
@@ -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
@@ -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"
@@ -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)
@@ -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"]
@@ -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) {
@@ -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
@@ -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 {
@@ -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")
}
];
}
@@ -132,6 +132,7 @@ Item {
iconName: "settings"
SettingsButtonGroupRow {
visible: CompositorService.isNiri
tags: ["keyboard", "track", "layout"]
settingKey: "keyboardTrackLayout"
text: I18n.tr("Remember layout")
@@ -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")
@@ -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
+23 -2
View File
@@ -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
+3 -2
View File
@@ -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;
+19 -11
View File
@@ -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() {
+363 -62
View File
@@ -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" })`);
}
}
+18 -8
View File
@@ -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") {
@@ -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,
+12 -3
View File
@@ -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;
}
@@ -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)")
+4 -4
View File
@@ -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"), "");
});
+173
View File
@@ -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 = "[email protected]", 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 = "[email protected]", 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"]);
});
@@ -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}`);
}
});
+23 -6
View File
@@ -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));
});
+8
View File
@@ -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 },
@@ -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"])), {});
});
+18 -1
View File
@@ -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);
@@ -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"
@@ -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",