hyprland: drop legacy conf support and close 0.57 gaps

Port 2.0
This commit is contained in:
purian23 committed 2026-10-30 09:22:04 -04:00
1 parent 46a12d9152
commit 4927ef5b9b
62 files changed
+1472 -2475

No files matched your search

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