1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-16 16:15:23 -04:00

feat(Hyprland): Introduce Lua support for Hyprland configurations

- Note: We do not convert your existing conf configs to lua. This update only reflects DMS defaults state
- Updated README.md to reflect changes
- Updated Keyboard shortcut support
This commit is contained in:
purian23
2026-05-18 13:06:58 -04:00
parent 8dd891f93a
commit 0b55bf5dac
48 changed files with 3756 additions and 1057 deletions
+2
View File
@@ -6,6 +6,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server" "github.com/AvengeMedia/DankMaterialShell/core/internal/server"
@@ -37,6 +38,7 @@ var runCmd = &cobra.Command{
} }
} }
log.ApplyEnvOverrides() log.ApplyEnvOverrides()
config.CleanupStrayHyprlandConfFile(log.Infof)
if daemon { if daemon {
runShellDaemon(session) runShellDaemon(session)
} else { } else {
+41 -8
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -27,7 +28,21 @@ var resolveIncludeCmd = &cobra.Command{
case 0: case 0:
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
case 1: case 1:
return []string{"cursor.kdl", "cursor.conf", "outputs.kdl", "outputs.conf", "binds.kdl", "binds.conf"}, cobra.ShellCompDirectiveNoFileComp return []string{
"binds.lua",
"binds-user.lua",
"colors.lua",
"layout.lua",
"outputs.lua",
"cursor.lua",
"windowrules.lua",
"cursor.kdl",
"outputs.kdl",
"binds.kdl",
"cursor.conf",
"outputs.conf",
"binds.conf",
}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -82,17 +97,35 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
result.Exists = true result.Exists = true
} }
mainConfig := filepath.Join(configDir, "hyprland.conf") targetAbs, err := filepath.Abs(targetPath)
if _, err := os.Stat(mainConfig); os.IsNotExist(err) { if err != nil {
return result, nil return result, err
}
targetRel := filepath.ToSlash(filepath.Join("dms", filename))
mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil {
processedLua := make(map[string]bool)
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
result.Included = true
return result, nil
}
}
mainConf := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConf); err == nil {
processed := make(map[string]bool)
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
result.Included = true
return result, nil
}
} }
processed := make(map[string]bool)
result.Included = hyprlandFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil return result, nil
} }
func hyprlandFindInclude(filePath, target string, processed map[string]bool) bool { func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath) absPath, err := filepath.Abs(filePath)
if err != nil { if err != nil {
return false return false
@@ -141,7 +174,7 @@ func hyprlandFindInclude(filePath, target string, processed map[string]bool) boo
continue continue
} }
if hyprlandFindInclude(expanded, target, processed) { if hyprlandFindIncludeHyprlang(expanded, target, processed) {
return true return true
} }
} }
+27 -2
View File
@@ -51,12 +51,20 @@ var keybindsSetCmd = &cobra.Command{
var keybindsRemoveCmd = &cobra.Command{ var keybindsRemoveCmd = &cobra.Command{
Use: "remove <provider> <key>", Use: "remove <provider> <key>",
Short: "Remove a keybind override", Short: "Remove a keybind",
Long: "Remove a keybind override from the specified provider", Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.",
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
Run: runKeybindsRemove, Run: runKeybindsRemove,
} }
var keybindsResetCmd = &cobra.Command{
Use: "reset <provider> <key>",
Short: "Reset a keybind override to its DMS default",
Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.",
Args: cobra.ExactArgs(2),
Run: runKeybindsReset,
}
func init() { func init() {
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON") keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider") keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
@@ -72,6 +80,7 @@ func init() {
keybindsCmd.AddCommand(keybindsShowCmd) keybindsCmd.AddCommand(keybindsShowCmd)
keybindsCmd.AddCommand(keybindsSetCmd) keybindsCmd.AddCommand(keybindsSetCmd)
keybindsCmd.AddCommand(keybindsRemoveCmd) keybindsCmd.AddCommand(keybindsRemoveCmd)
keybindsCmd.AddCommand(keybindsResetCmd)
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) { keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
return providers.NewJSONFileProvider(filePath) return providers.NewJSONFileProvider(filePath)
@@ -263,3 +272,19 @@ func runKeybindsRemove(_ *cobra.Command, args []string) {
}, "", " ") }, "", " ")
fmt.Fprintln(os.Stdout, string(output)) fmt.Fprintln(os.Stdout, string(output))
} }
func runKeybindsReset(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.ResetBind(key); err != nil {
log.Fatalf("Error resetting keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"reset": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
+23 -17
View File
@@ -109,25 +109,25 @@ type dmsConfigSpec struct {
var dmsConfigSpecs = map[string]dmsConfigSpec{ var dmsConfigSpecs = map[string]dmsConfigSpec{
"binds": { "binds": {
niriFile: "binds.kdl", niriFile: "binds.kdl",
hyprFile: "binds.conf", hyprFile: "binds.lua",
niriContent: func(t string) string { niriContent: func(t string) string {
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t) return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
}, },
hyprContent: func(t string) string { hyprContent: func(t string) string {
return strings.ReplaceAll(config.HyprBindsConfig, "{{TERMINAL_COMMAND}}", t) return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t)
}, },
}, },
"layout": { "layout": {
niriFile: "layout.kdl", niriFile: "layout.kdl",
hyprFile: "layout.conf", hyprFile: "layout.lua",
niriContent: func(_ string) string { return config.NiriLayoutConfig }, niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.HyprLayoutConfig }, hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
}, },
"colors": { "colors": {
niriFile: "colors.kdl", niriFile: "colors.kdl",
hyprFile: "colors.conf", hyprFile: "colors.lua",
niriContent: func(_ string) string { return config.NiriColorsConfig }, niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.HyprColorsConfig }, hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
}, },
"alttab": { "alttab": {
niriFile: "alttab.kdl", niriFile: "alttab.kdl",
@@ -135,21 +135,21 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
}, },
"outputs": { "outputs": {
niriFile: "outputs.kdl", niriFile: "outputs.kdl",
hyprFile: "outputs.conf", hyprFile: "outputs.lua",
niriContent: func(_ string) string { return "" }, niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" }, hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
}, },
"cursor": { "cursor": {
niriFile: "cursor.kdl", niriFile: "cursor.kdl",
hyprFile: "cursor.conf", hyprFile: "cursor.lua",
niriContent: func(_ string) string { return "" }, niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" }, hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
}, },
"windowrules": { "windowrules": {
niriFile: "windowrules.kdl", niriFile: "windowrules.kdl",
hyprFile: "windowrules.conf", hyprFile: "windowrules.lua",
niriContent: func(_ string) string { return "" }, niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" }, hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig },
}, },
} }
@@ -438,16 +438,22 @@ func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.
willBackup := false willBackup := false
if wmSelected { if wmSelected {
var configPath string var configPaths []string
switch wm { switch wm {
case deps.WindowManagerNiri: case deps.WindowManagerNiri:
configPath = filepath.Join(homeDir, ".config", "niri", "config.kdl") configPaths = []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
case deps.WindowManagerHyprland: case deps.WindowManagerHyprland:
configPath = filepath.Join(homeDir, ".config", "hypr", "hyprland.conf") configPaths = []string{
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
}
} }
if _, err := os.Stat(configPath); err == nil { for _, configPath := range configPaths {
willBackup = true if _, err := os.Stat(configPath); err == nil {
willBackup = true
break
}
} }
} }
+8 -10
View File
@@ -26,7 +26,7 @@ var windowrulesListCmd = &cobra.Command{
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -40,8 +40,7 @@ var windowrulesAddCmd = &cobra.Command{
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
// ! disabled hyprland return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -55,7 +54,7 @@ var windowrulesUpdateCmd = &cobra.Command{
Args: cobra.ExactArgs(3), Args: cobra.ExactArgs(3),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -69,7 +68,7 @@ var windowrulesRemoveCmd = &cobra.Command{
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -83,7 +82,7 @@ var windowrulesReorderCmd = &cobra.Command{
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -118,9 +117,9 @@ func getCompositor(args []string) string {
if os.Getenv("NIRI_SOCKET") != "" { if os.Getenv("NIRI_SOCKET") != "" {
return "niri" return "niri"
} }
// if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" { if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
// return "hyprland" return "hyprland"
// } }
return "" return ""
} }
@@ -183,7 +182,6 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.DMSStatus = parseResult.DMSStatus result.DMSStatus = parseResult.DMSStatus
case "hyprland": case "hyprland":
log.Fatalf("Hyprland support is currently disabled.") // ! disabled hyprland
configDir, err := utils.ExpandPath("$HOME/.config/hypr") configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil { if err != nil {
log.Fatalf("Failed to expand hyprland config path: %v", err) log.Fatalf("Failed to expand hyprland config path: %v", err)
+179 -110
View File
@@ -12,6 +12,8 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
) )
const hyprlandBackupDirName = ".dms-backups"
type ConfigDeployer struct { type ConfigDeployer struct {
logChan chan<- string logChan chan<- string
} }
@@ -63,12 +65,23 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
var results []DeploymentResult var results []DeploymentResult
// Primary config file paths used to detect fresh installs. // Primary config file paths used to detect fresh installs.
configPrimaryPaths := map[string]string{ configPrimaryPaths := map[string][]string{
"Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"), "Niri": {
"Hyprland": filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"), filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
"Ghostty": filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"), },
"Kitty": filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"), "Hyprland": {
"Alacritty": filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"), filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
},
"Ghostty": {
filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
},
"Kitty": {
filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
},
"Alacritty": {
filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
},
} }
shouldReplaceConfig := func(configType string) bool { shouldReplaceConfig := func(configType string) bool {
@@ -81,8 +94,15 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
} }
// Config is explicitly set to "don't replace" — but still deploy // Config is explicitly set to "don't replace" — but still deploy
// if the config file doesn't exist yet (fresh install scenario). // if the config file doesn't exist yet (fresh install scenario).
if primaryPath, ok := configPrimaryPaths[configType]; ok { if primaryPaths, ok := configPrimaryPaths[configType]; ok {
if _, err := os.Stat(primaryPath); os.IsNotExist(err) { exists := false
for _, primaryPath := range primaryPaths {
if _, err := os.Stat(primaryPath); err == nil {
exists = true
break
}
}
if !exists {
return true return true
} }
} }
@@ -495,7 +515,7 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms
func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) { func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
result := DeploymentResult{ result := DeploymentResult{
ConfigType: "Hyprland", ConfigType: "Hyprland",
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"), Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
} }
configDir := filepath.Dir(result.Path) configDir := filepath.Dir(result.Path)
@@ -510,20 +530,20 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error return result, result.Error
} }
timestamp := time.Now().Format("2006-01-02_15-04-05")
backupDir := filepath.Join(configDir, hyprlandBackupDirName, timestamp)
var existingConfig string var existingConfig string
if _, err := os.Stat(result.Path); err == nil { existingData, existingPath, err := readExistingHyprlandConfig(configDir)
cd.log("Found existing Hyprland configuration") if err != nil {
result.Error = err
return result, result.Error
}
if existingData != "" {
existingConfig = existingData
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath))
existingData, err := os.ReadFile(result.Path) result.BackupPath = filepath.Join(backupDir, filepath.Base(existingPath))
if err != nil { if err := backupHyprlandConfigFile(existingPath, result.BackupPath, []byte(existingData), strings.EqualFold(filepath.Ext(existingPath), ".conf")); err != nil {
result.Error = fmt.Errorf("failed to read existing config: %w", err)
return result, result.Error
}
existingConfig = string(existingData)
timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err) result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error return result, result.Error
} }
@@ -542,10 +562,10 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
terminalCommand = "ghostty" terminalCommand = "ghostty"
} }
newConfig := strings.ReplaceAll(HyprlandConfig, "{{TERMINAL_COMMAND}}", terminalCommand) newConfig := strings.ReplaceAll(HyprlandLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if !useSystemd { if !useSystemd {
newConfig = cd.transformHyprlandConfigForNonSystemd(newConfig, terminalCommand) newConfig = transformHyprlandLuaForNonSystemd(newConfig, terminalCommand)
} }
if existingConfig != "" { if existingConfig != "" {
@@ -563,6 +583,18 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error return result, result.Error
} }
movedLegacy, 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 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 { if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error return result, result.Error
@@ -573,29 +605,118 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, nil return result, nil
} }
func backupHyprlandConfigFile(src, dst string, data []byte, removeSource bool) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return err
}
if removeSource {
if err := os.Remove(src); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, error) {
legacyPaths := []string{filepath.Join(configDir, "hyprland.conf")}
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
for _, src := range legacyPaths {
info, err := os.Lstat(src)
if os.IsNotExist(err) {
continue
}
if err != nil {
return moved, err
}
if info.IsDir() {
continue
}
rel, err := filepath.Rel(configDir, src)
if err != nil {
rel = filepath.Base(src)
}
dst := filepath.Join(backupDir, rel)
if err := moveHyprlandConfigFile(src, dst); err != nil {
return moved, err
}
moved++
}
return moved, nil
}
func moveHyprlandConfigFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.Rename(src, dst)
}
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 { func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
configs := []struct { configs := []struct {
name string name string
content string content string
overwrite bool
}{ }{
{"colors.conf", HyprColorsConfig}, {name: "colors.lua", content: DMSColorsLuaConfig},
{"layout.conf", HyprLayoutConfig}, {name: "layout.lua", content: DMSLayoutLuaConfig},
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)}, {name: "binds.lua", content: strings.ReplaceAll(DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand), overwrite: true},
{"outputs.conf", ""}, {name: "binds-user.lua", content: DMSBindsUserLuaConfig},
{"cursor.conf", ""}, {name: "outputs.lua", content: DMSOutputsLuaConfig},
{"windowrules.conf", ""}, {name: "cursor.lua", content: DMSCursorLuaConfig},
{name: "windowrules.lua", content: DMSWindowRulesLuaConfig},
} }
for _, cfg := range configs { for _, cfg := range configs {
path := filepath.Join(dmsDir, cfg.name) path := filepath.Join(dmsDir, cfg.name)
// Skip if file already exists and is not empty to preserve user modifications existed := false
if info, err := os.Stat(path); err == nil && info.Size() > 0 { if info, err := os.Stat(path); err == nil && info.Size() > 0 {
existed = true
}
if existed && !cfg.overwrite {
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name)) cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue continue
} }
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil { if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err) return fmt.Errorf("failed to write %s: %w", cfg.name, err)
} }
if existed {
cd.log(fmt.Sprintf("Updated %s", cfg.name))
continue
}
cd.log(fmt.Sprintf("Deployed %s", cfg.name)) cd.log(fmt.Sprintf("Deployed %s", cfg.name))
} }
@@ -603,94 +724,42 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman
} }
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) { func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`) _ = newConfig
existingMonitors := monitorRegex.FindAllString(existingConfig, -1) lines := extractHyprlangMonitorLines(existingConfig)
if len(lines) == 0 {
if len(existingMonitors) == 0 {
return newConfig, nil return newConfig, nil
} }
outputsPath := filepath.Join(dmsDir, "outputs.conf") outputsPath := filepath.Join(dmsDir, "outputs.lua")
if _, err := os.Stat(outputsPath); err != nil { if info, err := os.Stat(outputsPath); err == nil && info.Size() > 0 {
var outputsContent strings.Builder cd.log("Skipping monitor migration: dms/outputs.lua already exists")
for _, monitor := range existingMonitors { return newConfig, nil
outputsContent.WriteString(monitor)
outputsContent.WriteString("\n")
}
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil {
cd.log(fmt.Sprintf("Warning: Failed to migrate monitors to %s: %v", outputsPath, err))
} else {
cd.log("Migrated monitor sections to dms/outputs.conf")
}
} }
exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`) var b strings.Builder
mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "") b.WriteString("-- Migrated from existing hyprlang monitor lines\n\n")
ok := 0
monitorHeaderRegex := regexp.MustCompile(`(?m)^# MONITOR CONFIG\n# ==================$`)
headerMatch := monitorHeaderRegex.FindStringIndex(mergedConfig)
if headerMatch == nil {
return "", fmt.Errorf("could not find MONITOR CONFIG section")
}
insertPos := headerMatch[1] + 1
var builder strings.Builder
builder.WriteString(mergedConfig[:insertPos])
builder.WriteString("# Monitors from existing configuration\n")
for _, monitor := range existingMonitors {
builder.WriteString(monitor)
builder.WriteString("\n")
}
builder.WriteString(mergedConfig[insertPos:])
return builder.String(), nil
}
func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalCommand string) string {
lines := strings.Split(config, "\n")
var result []string
startupSectionFound := false
for _, line := range lines { for _, line := range lines {
trimmed := strings.TrimSpace(line) lua, err := hyprlangMonitorLineToLua(line)
if strings.HasPrefix(trimmed, "exec-once = dbus-update-activation-environment") { if err != nil {
cd.log(fmt.Sprintf("Warning: could not migrate monitor line %q: %v", line, err))
continue continue
} }
if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") { b.WriteString(lua)
startupSectionFound = true b.WriteByte('\n')
result = append(result, "exec-once = dms run") ok++
result = append(result, "env = QT_QPA_PLATFORM,wayland;xcb")
result = append(result, "env = ELECTRON_OZONE_PLATFORM_HINT,auto")
result = append(result, "env = QT_QPA_PLATFORMTHEME,gtk3")
result = append(result, "env = QT_QPA_PLATFORMTHEME_QT6,gtk3")
result = append(result, fmt.Sprintf("env = TERMINAL,%s", terminalCommand))
continue
}
result = append(result, line)
} }
if ok == 0 {
if !startupSectionFound { return newConfig, nil
for i, line := range result {
if strings.Contains(line, "STARTUP APPS") {
insertLines := []string{
"exec-once = dms run",
"env = QT_QPA_PLATFORM,wayland;xcb",
"env = ELECTRON_OZONE_PLATFORM_HINT,auto",
"env = QT_QPA_PLATFORMTHEME,gtk3",
"env = QT_QPA_PLATFORMTHEME_QT6,gtk3",
fmt.Sprintf("env = TERMINAL,%s", terminalCommand),
}
result = append(result[:i+2], append(insertLines, result[i+2:]...)...)
break
}
}
} }
b.WriteByte('\n')
return strings.Join(result, "\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 { func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
+168 -132
View File
@@ -259,130 +259,56 @@ func getGhosttyPath() string {
func TestMergeHyprlandMonitorSections(t *testing.T) { func TestMergeHyprlandMonitorSections(t *testing.T) {
cd := &ConfigDeployer{} cd := &ConfigDeployer{}
tests := []struct { t.Run("no monitors in existing", func(t *testing.T) {
name string tmp := t.TempDir()
newConfig string out, err := cd.mergeHyprlandMonitorSections(`hl.config({})`, `input { kb_layout = us }`, tmp)
existingConfig string require.NoError(t, err)
wantError bool assert.Equal(t, `hl.config({})`, out)
wantContains []string _, e := os.Stat(filepath.Join(tmp, "outputs.lua"))
wantNotContains []string assert.True(t, os.IsNotExist(e))
}{ })
{
name: "no existing monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ================== t.Run("writes outputs lua from hyprlang monitors", func(t *testing.T) {
# ENVIRONMENT VARS tmp := t.TempDir()
# ================== existing := `monitor = DP-1, 1920x1080@144, 0x0, 1
env = XDG_CURRENT_DESKTOP,niri`,
existingConfig: `# Some other config
input {
kb_layout = us
}`,
wantError: false,
wantContains: []string{"MONITOR CONFIG", "ENVIRONMENT VARS"},
},
{
name: "merge single monitor",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================`,
existingConfig: `# My config
monitor = DP-1, 1920x1080@144, 0x0, 1
input {
kb_layout = us
}`,
wantError: false,
wantContains: []string{
"MONITOR CONFIG",
"monitor = DP-1, 1920x1080@144, 0x0, 1",
"Monitors from existing configuration",
},
wantNotContains: []string{
"monitor = eDP-2", // Example monitor should be removed
},
},
{
name: "merge multiple monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================`,
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1 # monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1
monitor = eDP-1, 2560x1440@165, auto, 1.25`, monitor = eDP-1, 2560x1440@165, auto, 1.25`
wantError: false, out, err := cd.mergeHyprlandMonitorSections(`return`, existing, tmp)
wantContains: []string{ require.NoError(t, err)
"monitor = DP-1", assert.Equal(t, `return`, out)
"# monitor = HDMI-A-1", // Commented monitor preserved b, err := os.ReadFile(filepath.Join(tmp, "outputs.lua"))
"monitor = eDP-1", require.NoError(t, err)
"Monitors from existing configuration", s := string(b)
}, assert.Contains(t, s, "hl.monitor")
wantNotContains: []string{ assert.Contains(t, s, "DP-1")
"monitor = eDP-2", // Example monitor should be removed assert.Contains(t, s, "HDMI-A-1")
}, assert.Contains(t, s, "eDP-1")
}, assert.Contains(t, s, "preferred") // fallback rule at end
{ })
name: "preserve commented monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================`, t.Run("skips when outputs lua already exists", func(t *testing.T) {
existingConfig: `# monitor = DP-1, 1920x1080@144, 0x0, 1 tmp := t.TempDir()
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1`, path := filepath.Join(tmp, "outputs.lua")
wantError: false, require.NoError(t, os.WriteFile(path, []byte("-- keep\n"), 0o644))
wantContains: []string{ _, err := cd.mergeHyprlandMonitorSections(`x`, `monitor = DP-1, 1920x1080@144, 0x0, 1`, tmp)
"# monitor = DP-1", require.NoError(t, err)
"# monitor = HDMI-A-1", b, err := os.ReadFile(path)
"Monitors from existing configuration", require.NoError(t, err)
}, assert.Equal(t, "-- keep\n", string(b))
}, })
{ }
name: "no monitor config section",
newConfig: `# Some config without monitor section
input {
kb_layout = us
}`,
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1`,
wantError: true,
},
}
for _, tt := range tests { func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) {
t.Run(tt.name, func(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`)
tmpDir := t.TempDir() require.NoError(t, err)
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig, tmpDir)
if tt.wantError { assert.Contains(t, got, `output = "DP-1"`)
assert.Error(t, err) assert.Contains(t, got, `transform = 1`)
return assert.Contains(t, got, `vrr = 2`)
} assert.Contains(t, got, `bitdepth = 10`)
assert.Contains(t, got, `cm = "hdr"`)
require.NoError(t, err) assert.Contains(t, got, `sdrbrightness = 1.2`)
assert.Contains(t, got, `sdrsaturation = 0.98`)
for _, want := range tt.wantContains {
assert.Contains(t, result, want, "merged config should contain: %s", want)
}
for _, notWant := range tt.wantNotContains {
assert.NotContains(t, result, notWant, "merged config should NOT contain: %s", notWant)
}
})
}
} }
func TestHyprlandConfigDeployment(t *testing.T) { func TestHyprlandConfigDeployment(t *testing.T) {
@@ -398,6 +324,10 @@ func TestHyprlandConfigDeployment(t *testing.T) {
cd := NewConfigDeployer(logChan) cd := NewConfigDeployer(logChan)
t.Run("deploy hyprland config to empty directory", func(t *testing.T) { t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-empty")
require.NoError(t, err)
defer os.RemoveAll(td)
os.Setenv("HOME", td)
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true) result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
require.NoError(t, err) require.NoError(t, err)
@@ -408,12 +338,16 @@ func TestHyprlandConfigDeployment(t *testing.T) {
content, err := os.ReadFile(result.Path) content, err := os.ReadFile(result.Path)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, string(content), "# MONITOR CONFIG") assert.Contains(t, string(content), `require("dms.binds")`)
assert.Contains(t, string(content), "source = ./dms/binds.conf") assert.Contains(t, string(content), "DMS_STARTUP_BEGIN")
assert.Contains(t, string(content), "exec-once = ") assert.Contains(t, string(content), "hl.config(")
}) })
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) { t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-merge")
require.NoError(t, err)
defer os.RemoveAll(td)
os.Setenv("HOME", td)
existingContent := `# My existing Hyprland config existingContent := `# My existing Hyprland config
monitor = DP-1, 1920x1080@144, 0x0, 1 monitor = DP-1, 1920x1080@144, 0x0, 1
monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5 monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5
@@ -422,11 +356,17 @@ general {
gaps_in = 10 gaps_in = 10
} }
` `
hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf") hyprPath := filepath.Join(td, ".config", "hypr", "hyprland.conf")
err := os.MkdirAll(filepath.Dir(hyprPath), 0o755) err = os.MkdirAll(filepath.Dir(hyprPath), 0o755)
require.NoError(t, err) require.NoError(t, err)
err = os.WriteFile(hyprPath, []byte(existingContent), 0o644) err = os.WriteFile(hyprPath, []byte(existingContent), 0o644)
require.NoError(t, err) 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, "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))
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true) result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
require.NoError(t, err) require.NoError(t, err)
@@ -440,13 +380,76 @@ general {
backupContent, err := os.ReadFile(result.BackupPath) backupContent, err := os.ReadFile(result.BackupPath)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent)) 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", "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, "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) newContent, err := os.ReadFile(result.Path)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144") assert.Contains(t, string(newContent), `require("dms.binds")`)
assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60")
assert.Contains(t, string(newContent), "source = ./dms/binds.conf") outputsPath := filepath.Join(td, ".config", "hypr", "dms", "outputs.lua")
assert.NotContains(t, string(newContent), "monitor = eDP-2") outBytes, err := os.ReadFile(outputsPath)
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("", "dankinstall-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(deps.TerminalKitty, 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)
})
t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-refresh-binds")
require.NoError(t, err)
defer os.RemoveAll(td)
os.Setenv("HOME", td)
dmsDir := filepath.Join(td, ".config", "hypr", "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte("-- stale managed binds\n"), 0o644))
userBinds := "-- custom user binds\n"
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(userBinds), 0o644))
_, err = cd.deployHyprlandConfig(deps.TerminalKitty, true)
require.NoError(t, err)
managed, err := os.ReadFile(filepath.Join(dmsDir, "binds.lua"))
require.NoError(t, err)
assert.Contains(t, string(managed), `hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))`)
assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })`)
user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
require.NoError(t, err)
assert.Equal(t, userBinds, string(user))
}) })
} }
@@ -459,10 +462,10 @@ func TestNiriConfigStructure(t *testing.T) {
} }
func TestHyprlandConfigStructure(t *testing.T) { func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG") assert.Contains(t, HyprlandLuaConfig, `require("dms.binds")`)
assert.Contains(t, HyprlandConfig, "# STARTUP APPS") assert.Contains(t, HyprlandLuaConfig, "DMS_STARTUP_BEGIN")
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG") assert.Contains(t, HyprlandLuaConfig, "hl.config(")
assert.Contains(t, HyprlandConfig, "source = ./dms/binds.conf") assert.Contains(t, HyprlandLuaConfig, "input =")
} }
func TestGhosttyConfigStructure(t *testing.T) { func TestGhosttyConfigStructure(t *testing.T) {
@@ -789,4 +792,37 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
} }
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true") 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("", "dankinstall-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")
}
}
})
} }
@@ -0,0 +1 @@
-- Optional per-user keybind overrides (managed by DMS). Loaded after default binds.
@@ -1,165 +0,0 @@
# === Application Launchers ===
bind = SUPER, T, exec, {{TERMINAL_COMMAND}}
bind = SUPER, space, exec, dms ipc call spotlight toggle
bind = SUPER, V, exec, dms ipc call clipboard toggle
bind = SUPER, M, exec, dms ipc call processlist focusOrToggle
bind = SUPER, comma, exec, dms ipc call settings focusOrToggle
bind = SUPER, N, exec, dms ipc call notifications toggle
bind = SUPER SHIFT, N, exec, dms ipc call notepad toggle
bind = SUPER, Y, exec, dms ipc call dankdash wallpaper
bind = SUPER, TAB, exec, dms ipc call hypr toggleOverview
bind = SUPER, X, exec, dms ipc call powermenu toggle
# === Cheat sheet
bind = SUPER SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
# === Security ===
bind = SUPER ALT, L, exec, dms ipc call lock lock
bind = SUPER SHIFT, E, exit
bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle
# === Audio Controls ===
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
bindl = , XF86AudioMute, exec, dms ipc call audio mute
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
bindl = , XF86AudioNext, exec, dms ipc call mpris next
bindel = CTRL, XF86AudioRaiseVolume, exec, dms ipc call mpris increment 3
bindel = CTRL, XF86AudioLowerVolume, exec, dms ipc call mpris decrement 3
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
# === Window Management ===
bind = SUPER, Q, killactive
bind = SUPER, F, fullscreen, 1
bind = SUPER SHIFT, F, fullscreen, 0
bind = SUPER SHIFT, T, togglefloating
bind = SUPER, W, togglegroup
bind = SUPER SHIFT, W, exec, dms ipc call window-rules toggle
# === Focus Navigation ===
bind = SUPER, left, movefocus, l
bind = SUPER, down, movefocus, d
bind = SUPER, up, movefocus, u
bind = SUPER, right, movefocus, r
bind = SUPER, H, movefocus, l
bind = SUPER, J, movefocus, d
bind = SUPER, K, movefocus, u
bind = SUPER, L, movefocus, r
# === Window Movement ===
bind = SUPER SHIFT, left, movewindow, l
bind = SUPER SHIFT, down, movewindow, d
bind = SUPER SHIFT, up, movewindow, u
bind = SUPER SHIFT, right, movewindow, r
bind = SUPER SHIFT, H, movewindow, l
bind = SUPER SHIFT, J, movewindow, d
bind = SUPER SHIFT, K, movewindow, u
bind = SUPER SHIFT, L, movewindow, r
# === Column Navigation ===
bind = SUPER, Home, focuswindow, first
bind = SUPER, End, focuswindow, last
# === Monitor Navigation ===
bind = SUPER CTRL, left, focusmonitor, l
bind = SUPER CTRL, right, focusmonitor, r
bind = SUPER CTRL, H, focusmonitor, l
bind = SUPER CTRL, J, focusmonitor, d
bind = SUPER CTRL, K, focusmonitor, u
bind = SUPER CTRL, L, focusmonitor, r
# === Move to Monitor ===
bind = SUPER SHIFT CTRL, left, movewindow, mon:l
bind = SUPER SHIFT CTRL, down, movewindow, mon:d
bind = SUPER SHIFT CTRL, up, movewindow, mon:u
bind = SUPER SHIFT CTRL, right, movewindow, mon:r
bind = SUPER SHIFT CTRL, H, movewindow, mon:l
bind = SUPER SHIFT CTRL, J, movewindow, mon:d
bind = SUPER SHIFT CTRL, K, movewindow, mon:u
bind = SUPER SHIFT CTRL, L, movewindow, mon:r
# === Workspace Navigation ===
bind = SUPER, Page_Down, workspace, e+1
bind = SUPER, Page_Up, workspace, e-1
bind = SUPER, U, workspace, e+1
bind = SUPER, I, workspace, e-1
bind = SUPER CTRL, down, movetoworkspace, e+1
bind = SUPER CTRL, up, movetoworkspace, e-1
bind = SUPER CTRL, U, movetoworkspace, e+1
bind = SUPER CTRL, I, movetoworkspace, e-1
# === Workspace Management ===
bind = CTRL SHIFT, R, exec, dms ipc call workspace-rename open
# === Move Workspaces ===
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1
bind = SUPER SHIFT, U, movetoworkspace, e+1
bind = SUPER SHIFT, I, movetoworkspace, e-1
# === Mouse Wheel Navigation ===
bind = SUPER, mouse_down, workspace, e+1
bind = SUPER, mouse_up, workspace, e-1
bind = SUPER CTRL, mouse_down, movetoworkspace, e+1
bind = SUPER CTRL, mouse_up, movetoworkspace, e-1
# === Numbered Workspaces ===
bind = SUPER, 1, workspace, 1
bind = SUPER, 2, workspace, 2
bind = SUPER, 3, workspace, 3
bind = SUPER, 4, workspace, 4
bind = SUPER, 5, workspace, 5
bind = SUPER, 6, workspace, 6
bind = SUPER, 7, workspace, 7
bind = SUPER, 8, workspace, 8
bind = SUPER, 9, workspace, 9
# === Move to Numbered Workspaces ===
bind = SUPER SHIFT, 1, movetoworkspace, 1
bind = SUPER SHIFT, 2, movetoworkspace, 2
bind = SUPER SHIFT, 3, movetoworkspace, 3
bind = SUPER SHIFT, 4, movetoworkspace, 4
bind = SUPER SHIFT, 5, movetoworkspace, 5
bind = SUPER SHIFT, 6, movetoworkspace, 6
bind = SUPER SHIFT, 7, movetoworkspace, 7
bind = SUPER SHIFT, 8, movetoworkspace, 8
bind = SUPER SHIFT, 9, movetoworkspace, 9
# === Column Management ===
bind = SUPER, bracketleft, layoutmsg, preselect l
bind = SUPER, bracketright, layoutmsg, preselect r
# === Sizing & Layout ===
bind = SUPER, R, layoutmsg, togglesplit
bind = SUPER CTRL, F, resizeactive, exact 100% 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = SUPER, mouse:272, Move window, movewindow
bindmd = SUPER, mouse:273, Resize window, resizewindow
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindd = SUPER, code:20, Expand window left, resizeactive, -100 0
bindd = SUPER, code:21, Shrink window left, resizeactive, 100 0
# === Manual Sizing ===
binde = SUPER, minus, resizeactive, -10% 0
binde = SUPER, equal, resizeactive, 10% 0
binde = SUPER SHIFT, minus, resizeactive, 0 -10%
binde = SUPER SHIFT, equal, resizeactive, 0 10%
# === Screenshots ===
bind = , Print, exec, dms screenshot
bind = CTRL, Print, exec, dms screenshot full
bind = ALT, Print, exec, dms screenshot window
# === Display Profiles ===
bind = SUPER, P, exec, dms ipc outputs cycleProfile
# === System Controls ===
bind = SUPER SHIFT, P, dpms, toggle
@@ -0,0 +1,166 @@
-- DMS default keybinds (Hyprland 0.55+ Lua)
-- === Application Launchers ===
hl.bind("SUPER + T", hl.dsp.exec_cmd("{{TERMINAL_COMMAND}}"))
hl.bind("SUPER + space", hl.dsp.exec_cmd("dms ipc call spotlight toggle"))
hl.bind("SUPER + V", hl.dsp.exec_cmd("dms ipc call clipboard toggle"))
hl.bind("SUPER + M", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
hl.bind("SUPER + comma", hl.dsp.exec_cmd("dms ipc call settings focusOrToggle"))
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notifications toggle"))
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper"))
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
-- === Cheat sheet
hl.bind("SUPER + SHIFT + Slash", hl.dsp.exec_cmd("dms ipc call keybinds toggle hyprland"))
-- === Security ===
hl.bind("SUPER + ALT + L", hl.dsp.exec_cmd("dms ipc call lock lock"))
hl.bind("SUPER + SHIFT + E", hl.dsp.exit())
hl.bind("CTRL + ALT + Delete", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
-- === Audio Controls ===
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("dms ipc call audio increment 3"), { locked = true, repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("dms ipc call audio decrement 3"), { locked = true, repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("dms ipc call audio mute"), { locked = true })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("dms ipc call audio micmute"), { locked = true })
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("dms ipc call mpris playPause"), { locked = true })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("dms ipc call mpris playPause"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("dms ipc call mpris previous"), { locked = true })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("dms ipc call mpris next"), { locked = true })
hl.bind("CTRL + XF86AudioRaiseVolume", hl.dsp.exec_cmd("dms ipc call mpris increment 3"), { locked = true, repeating = true })
hl.bind("CTRL + XF86AudioLowerVolume", hl.dsp.exec_cmd("dms ipc call mpris decrement 3"), { locked = true, repeating = true })
-- === Brightness Controls ===
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]]), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd([[dms ipc call brightness decrement 5 ""]]), { locked = true, repeating = true })
-- === Window Management ===
hl.bind("SUPER + Q", hl.dsp.window.kill())
hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))
hl.bind("SUPER + SHIFT + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))
hl.bind("SUPER + SHIFT + T", hl.dsp.window.float({ action = "toggle" }))
hl.bind("SUPER + W", hl.dsp.group.toggle())
hl.bind("SUPER + SHIFT + W", hl.dsp.exec_cmd("dms ipc call window-rules toggle"))
-- === Focus Navigation ===
hl.bind("SUPER + left", hl.dsp.focus({ direction = "l" }))
hl.bind("SUPER + down", hl.dsp.focus({ direction = "d" }))
hl.bind("SUPER + up", hl.dsp.focus({ direction = "u" }))
hl.bind("SUPER + right", hl.dsp.focus({ direction = "r" }))
hl.bind("SUPER + H", hl.dsp.focus({ direction = "l" }))
hl.bind("SUPER + J", hl.dsp.focus({ direction = "d" }))
hl.bind("SUPER + K", hl.dsp.focus({ direction = "u" }))
hl.bind("SUPER + L", hl.dsp.focus({ direction = "r" }))
-- === Window Movement ===
hl.bind("SUPER + SHIFT + left", hl.dsp.window.move({ direction = "l" }))
hl.bind("SUPER + SHIFT + down", hl.dsp.window.move({ direction = "d" }))
hl.bind("SUPER + SHIFT + up", hl.dsp.window.move({ direction = "u" }))
hl.bind("SUPER + SHIFT + right", hl.dsp.window.move({ direction = "r" }))
hl.bind("SUPER + SHIFT + H", hl.dsp.window.move({ direction = "l" }))
hl.bind("SUPER + SHIFT + J", hl.dsp.window.move({ direction = "d" }))
hl.bind("SUPER + SHIFT + K", hl.dsp.window.move({ direction = "u" }))
hl.bind("SUPER + SHIFT + L", hl.dsp.window.move({ direction = "r" }))
-- === Column Navigation ===
hl.bind("SUPER + Home", hl.dsp.focus({ window = "first" }))
hl.bind("SUPER + End", hl.dsp.focus({ window = "last" }))
-- === Monitor Navigation ===
hl.bind("SUPER + CTRL + left", hl.dsp.focus({ monitor = "l" }))
hl.bind("SUPER + CTRL + right", hl.dsp.focus({ monitor = "r" }))
hl.bind("SUPER + CTRL + H", hl.dsp.focus({ monitor = "l" }))
hl.bind("SUPER + CTRL + J", hl.dsp.focus({ monitor = "d" }))
hl.bind("SUPER + CTRL + K", hl.dsp.focus({ monitor = "u" }))
hl.bind("SUPER + CTRL + L", hl.dsp.focus({ monitor = "r" }))
-- === Move to Monitor ===
hl.bind("SUPER + SHIFT + CTRL + left", hl.dsp.window.move({ monitor = "l" }))
hl.bind("SUPER + SHIFT + CTRL + down", hl.dsp.window.move({ monitor = "d" }))
hl.bind("SUPER + SHIFT + CTRL + up", hl.dsp.window.move({ monitor = "u" }))
hl.bind("SUPER + SHIFT + CTRL + right", hl.dsp.window.move({ monitor = "r" }))
hl.bind("SUPER + SHIFT + CTRL + H", hl.dsp.window.move({ monitor = "l" }))
hl.bind("SUPER + SHIFT + CTRL + J", hl.dsp.window.move({ monitor = "d" }))
hl.bind("SUPER + SHIFT + CTRL + K", hl.dsp.window.move({ monitor = "u" }))
hl.bind("SUPER + SHIFT + CTRL + L", hl.dsp.window.move({ monitor = "r" }))
-- === Workspace Navigation ===
hl.bind("SUPER + Page_Down", hl.dsp.focus({ workspace = "e+1" }))
hl.bind("SUPER + Page_Up", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + U", hl.dsp.focus({ workspace = "e+1" }))
hl.bind("SUPER + I", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + up", hl.dsp.window.move({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + U", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + I", hl.dsp.window.move({ workspace = "e-1" }))
-- === Workspace Management ===
hl.bind("CTRL + SHIFT + R", hl.dsp.exec_cmd("dms ipc call workspace-rename open"))
-- === Move Workspaces ===
hl.bind("SUPER + SHIFT + Page_Down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + SHIFT + Page_Up", hl.dsp.window.move({ workspace = "e-1" }))
hl.bind("SUPER + SHIFT + U", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + SHIFT + I", hl.dsp.window.move({ workspace = "e-1" }))
-- === Mouse Wheel Navigation ===
hl.bind("SUPER + mouse_down", hl.dsp.focus({ workspace = "e+1" }))
hl.bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + mouse_down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + mouse_up", hl.dsp.window.move({ workspace = "e-1" }))
-- === Numbered Workspaces ===
hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))
hl.bind("SUPER + 2", hl.dsp.focus({ workspace = "2" }))
hl.bind("SUPER + 3", hl.dsp.focus({ workspace = "3" }))
hl.bind("SUPER + 4", hl.dsp.focus({ workspace = "4" }))
hl.bind("SUPER + 5", hl.dsp.focus({ workspace = "5" }))
hl.bind("SUPER + 6", hl.dsp.focus({ workspace = "6" }))
hl.bind("SUPER + 7", hl.dsp.focus({ workspace = "7" }))
hl.bind("SUPER + 8", hl.dsp.focus({ workspace = "8" }))
hl.bind("SUPER + 9", hl.dsp.focus({ workspace = "9" }))
-- === Move to Numbered Workspaces ===
hl.bind("SUPER + SHIFT + 1", hl.dsp.window.move({ workspace = "1" }))
hl.bind("SUPER + SHIFT + 2", hl.dsp.window.move({ workspace = "2" }))
hl.bind("SUPER + SHIFT + 3", hl.dsp.window.move({ workspace = "3" }))
hl.bind("SUPER + SHIFT + 4", hl.dsp.window.move({ workspace = "4" }))
hl.bind("SUPER + SHIFT + 5", hl.dsp.window.move({ workspace = "5" }))
hl.bind("SUPER + SHIFT + 6", hl.dsp.window.move({ workspace = "6" }))
hl.bind("SUPER + SHIFT + 7", hl.dsp.window.move({ workspace = "7" }))
hl.bind("SUPER + SHIFT + 8", hl.dsp.window.move({ workspace = "8" }))
hl.bind("SUPER + SHIFT + 9", hl.dsp.window.move({ workspace = "9" }))
-- === Column Management ===
hl.bind("SUPER + bracketleft", hl.dsp.layout("preselect l"))
hl.bind("SUPER + bracketright", hl.dsp.layout("preselect r"))
-- === Sizing & Layout ===
hl.bind("SUPER + R", hl.dsp.layout("togglesplit"))
hl.bind("SUPER + CTRL + F", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive exact 100% 100%]]))
-- === Move/resize windows with mainMod + LMB/RMB and dragging ===
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" })
hl.bind("SUPER + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window" })
hl.bind("SUPER + code:20", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { description = "Expand window left" })
hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" })
-- === Manual Sizing ===
hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })
hl.bind("SUPER + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 10% 0]]), { repeating = true })
hl.bind("SUPER + SHIFT + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 -10%]]), { repeating = true })
hl.bind("SUPER + SHIFT + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 10%]]), { repeating = true })
-- === Screenshots ===
hl.bind("Print", hl.dsp.exec_cmd("dms screenshot"))
hl.bind("CTRL + Print", hl.dsp.exec_cmd("dms screenshot full"))
hl.bind("ALT + Print", hl.dsp.exec_cmd("dms screenshot window"))
-- === Display Profiles ===
hl.bind("SUPER + P", hl.dsp.exec_cmd("dms ipc outputs cycleProfile"))
-- === System Controls ===
hl.bind("SUPER + SHIFT + P", hl.dsp.dpms({ action = "toggle" }))
@@ -1,25 +0,0 @@
# ! Auto-generated file. Do not edit directly.
# Remove source = ./dms/colors.conf from your config to override.
$primary = rgb(d0bcff)
$outline = rgb(948f99)
$error = rgb(f2b8b5)
general {
col.active_border = $primary
col.inactive_border = $outline
}
group {
col.border_active = $primary
col.border_inactive = $outline
col.border_locked_active = $error
col.border_locked_inactive = $outline
groupbar {
col.active = $primary
col.inactive = $outline
col.locked_active = $error
col.locked_inactive = $outline
}
}
@@ -0,0 +1,27 @@
-- ! Auto-generated file. Do not edit directly.
-- Regenerate via DMS theme tools or remove require("dms.colors") from hyprland.lua to override.
hl.config({
general = {
col = {
active_border = "rgb(d0bcff)",
inactive_border = "rgb(948f99)",
},
},
group = {
col = {
border_active = "rgb(d0bcff)",
border_inactive = "rgb(948f99)",
border_locked_active = "rgb(f2b8b5)",
border_locked_inactive = "rgb(948f99)",
},
groupbar = {
col = {
active = "rgb(d0bcff)",
inactive = "rgb(948f99)",
locked_active = "rgb(f2b8b5)",
locked_inactive = "rgb(948f99)",
},
},
},
})
@@ -0,0 +1 @@
-- Cursor theme overrides. Deploy writes ~/.config/hypr/dms/cursor.lua
@@ -1,11 +0,0 @@
# Auto-generated by DMS - do not edit manually
general {
gaps_in = 4
gaps_out = 4
border_size = 2
}
decoration {
rounding = 12
}
@@ -0,0 +1,12 @@
-- Auto-generated by DMS — do not edit manually
hl.config({
general = {
gaps_in = 4,
gaps_out = 4,
border_size = 2,
},
decoration = {
rounding = 12,
},
})
@@ -0,0 +1,3 @@
-- Per-output monitor rules — embedded sibling of the legacy outputs.conf fragment. Deploy writes ~/.config/hypr/dms/outputs.lua
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
@@ -0,0 +1 @@
-- Window rules. Deploy writes ~/.config/hypr/dms/windowrules.lua
-117
View File
@@ -1,117 +0,0 @@
# Hyprland Configuration
# https://wiki.hypr.land/Configuring/
# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
monitor = , preferred,auto,auto
# ==================
# STARTUP APPS
# ==================
exec-once = dbus-update-activation-environment --systemd --all
exec-once = systemctl --user start hyprland-session.target
# ==================
# INPUT CONFIG
# ==================
input {
kb_layout = us
numlock_by_default = true
}
# ==================
# GENERAL LAYOUT
# ==================
general {
gaps_in = 5
gaps_out = 5
border_size = 2
layout = dwindle
}
# ==================
# DECORATION
# ==================
decoration {
rounding = 12
active_opacity = 1.0
inactive_opacity = 1.0
shadow {
enabled = true
range = 30
render_power = 5
offset = 0 5
color = rgba(00000070)
}
}
# ==================
# ANIMATIONS
# ==================
animations {
enabled = true
animation = windowsIn, 1, 3, default
animation = windowsOut, 1, 3, default
animation = workspaces, 1, 5, default
animation = windowsMove, 1, 4, default
animation = fade, 1, 3, default
animation = border, 1, 3, default
}
# ==================
# LAYOUTS
# ==================
dwindle {
preserve_split = true
}
master {
mfact = 0.5
}
# ==================
# MISC
# ==================
misc {
disable_hyprland_logo = true
disable_splash_rendering = true
}
# ==================
# WINDOW RULES
# ==================
windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$
windowrule = rounding 12, match:class ^(org\.gnome\.)
windowrule = tile on, match:class ^(gnome-control-center)$
windowrule = tile on, match:class ^(pavucontrol)$
windowrule = tile on, match:class ^(nm-connection-editor)$
windowrule = float on, match:class ^(org\.gnome\.Calculator)$
windowrule = float on, match:class ^(gnome-calculator)$
windowrule = float on, match:class ^(galculator)$
windowrule = float on, match:class ^(blueman-manager)$
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
windowrule = float on, match:class ^(xdg-desktop-portal)$
windowrule = no_initial_focus on, match:class ^(steam)$, match:title ^(notificationtoasts)
windowrule = pin on, match:class ^(steam)$, match:title ^(notificationtoasts)
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
windowrule = float on, match:class ^(zoom)$
layerrule = no_anim on, match:namespace ^(quickshell)$
layerrule = no_anim on, match:namespace ^dms:.*
source = ./dms/colors.conf
source = ./dms/outputs.conf
source = ./dms/layout.conf
source = ./dms/cursor.conf
source = ./dms/binds.conf
@@ -0,0 +1,84 @@
-- Hyprland configuration (Lua) — https://wiki.hypr.land/Configuring/Start/
hl.config({ autogenerated = false })
-- DMS_STARTUP_BEGIN
hl.on("hyprland.start", function()
hl.exec_cmd("dbus-update-activation-environment --systemd --all")
hl.exec_cmd("systemctl --user start hyprland-session.target")
end)
-- DMS_STARTUP_END
hl.config({
input = {
kb_layout = "us",
numlock_by_default = true,
},
general = {
gaps_in = 5,
gaps_out = 5,
border_size = 2,
layout = "dwindle",
},
decoration = {
rounding = 12,
active_opacity = 1.0,
inactive_opacity = 1.0,
shadow = {
enabled = true,
range = 30,
render_power = 5,
offset = "0 5",
color = "rgba(00000070)",
},
},
misc = {
disable_hyprland_logo = true,
disable_splash_rendering = true,
},
dwindle = {
preserve_split = true,
},
master = {
mfact = 0.5,
},
})
hl.animation({ leaf = "windowsIn", enabled = true, speed = 3, bezier = "default" })
hl.animation({ leaf = "windowsOut", enabled = true, speed = 3, bezier = "default" })
hl.animation({ leaf = "workspaces", enabled = true, speed = 5, bezier = "default" })
hl.animation({ leaf = "windowsMove", enabled = true, speed = 4, bezier = "default" })
hl.animation({ leaf = "fade", enabled = true, speed = 3, bezier = "default" })
hl.animation({ leaf = "border", enabled = true, speed = 3, bezier = "default" })
hl.window_rule({ match = { class = "^(org\\.wezfurlong\\.wezterm)$" }, tile = true })
hl.window_rule({ match = { class = "^(org\\.gnome\\.)" }, rounding = 12 })
hl.window_rule({ match = { class = "^(gnome-control-center)$" }, tile = true })
hl.window_rule({ match = { class = "^(pavucontrol)$" }, tile = true })
hl.window_rule({ match = { class = "^(nm-connection-editor)$" }, tile = true })
hl.window_rule({ match = { class = "^(org\\.gnome\\.Calculator)$" }, float = true })
hl.window_rule({ match = { class = "^(gnome-calculator)$" }, float = true })
hl.window_rule({ match = { class = "^(galculator)$" }, float = true })
hl.window_rule({ match = { class = "^(blueman-manager)$" }, float = true })
hl.window_rule({ match = { class = "^(org\\.gnome\\.Nautilus)$" }, float = true })
hl.window_rule({ match = { class = "^(xdg-desktop-portal)$" }, float = true })
hl.window_rule({
match = { class = "^(steam)$", title = "^(notificationtoasts)" },
no_initial_focus = true,
pin = true,
})
hl.window_rule({
match = { class = "^(firefox)$", title = "^(Picture-in-Picture)$" },
float = true,
})
hl.window_rule({ match = { class = "^(zoom)$" }, float = true })
hl.layer_rule({ match = { namespace = "^(quickshell)$" }, no_anim = true })
hl.layer_rule({ match = { namespace = "^dms:.*" }, no_anim = true })
require("dms.colors")
require("dms.outputs")
require("dms.layout")
require("dms.cursor")
require("dms.binds")
require("dms.binds-user")
require("dms.windowrules")
+20 -8
View File
@@ -2,14 +2,26 @@ package config
import _ "embed" import _ "embed"
//go:embed embedded/hyprland.conf //go:embed embedded/hyprland.lua
var HyprlandConfig string var HyprlandLuaConfig string
//go:embed embedded/hypr-colors.conf //go:embed embedded/hypr-colors.lua
var HyprColorsConfig string var DMSColorsLuaConfig string
//go:embed embedded/hypr-layout.conf //go:embed embedded/hypr-layout.lua
var HyprLayoutConfig string var DMSLayoutLuaConfig string
//go:embed embedded/hypr-binds.conf //go:embed embedded/hypr-binds.lua
var HyprBindsConfig string var DMSBindsLuaConfig string
//go:embed embedded/hypr-outputs.lua
var DMSOutputsLuaConfig string
//go:embed embedded/hypr-cursor.lua
var DMSCursorLuaConfig string
//go:embed embedded/hypr-windowrules.lua
var DMSWindowRulesLuaConfig string
//go:embed embedded/hypr-binds-user.lua
var DMSBindsUserLuaConfig string
+169
View File
@@ -0,0 +1,169 @@
package config
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
const (
hyprlandStartupBegin = "-- DMS_STARTUP_BEGIN"
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)
if start == -1 || end == -1 || end <= start {
return config
}
endClose := end + len(hyprlandStartupEnd)
replacement := hyprlandStartupBegin + "\n" +
`hl.env("QT_QPA_PLATFORM", "wayland;xcb")` + "\n" +
`hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto")` + "\n" +
`hl.env("QT_QPA_PLATFORMTHEME", "gtk3")` + "\n" +
`hl.env("QT_QPA_PLATFORMTHEME_QT6", "gtk3")` + "\n" +
fmt.Sprintf(`hl.env("TERMINAL", %s)`, strconv.Quote(terminalCommand)) + "\n\n" +
`hl.on("hyprland.start", function()` + "\n" +
` hl.exec_cmd("dms run")` + "\n" +
`end)` + "\n" +
hyprlandStartupEnd
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 a stray ~/.config/hypr/hyprland.conf
// into .dms-backups/<timestamp>/ when running under Hyprland. Hyprland 0.55
// auto-generates hyprland.conf when launched without -c, so this is invoked
// from dms run startup to keep the active config tree single-file.
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")
confPath := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(confPath); err != nil {
return
}
ts := time.Now().Format("2006-01-02_15-04-05")
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, "hyprland.conf")
if err := moveHyprlandConfigFile(confPath, dst); err != nil {
if logFn != nil {
logFn("Could not move stray hyprland.conf: %v", err)
}
return
}
if logFn != nil {
logFn("Moved stray hyprland.conf to %s", dst)
}
}
+214 -175
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
@@ -48,7 +49,7 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
h.parsed = true h.parsed = true
categorizedBinds := make(map[string][]keybinds.Keybind) categorizedBinds := make(map[string][]keybinds.Keybind)
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs) h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs, result.DefaultDMSKeys)
sheet := &keybinds.CheatSheet{ sheet := &keybinds.CheatSheet{
Title: "Hyprland Keybinds", Title: "Hyprland Keybinds",
@@ -88,7 +89,7 @@ func (h *HyprlandProvider) HasDMSBindsIncluded() bool {
return h.dmsBindsIncluded return h.dmsBindsIncluded
} }
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding) { func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding, defaultKeys map[string]bool) {
currentSubcat := subcategory currentSubcat := subcategory
if section.Name != "" { if section.Name != "" {
currentSubcat = section.Name currentSubcat = section.Name
@@ -96,12 +97,12 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
for _, kb := range section.Keybinds { for _, kb := range section.Keybinds {
category := h.categorizeByDispatcher(kb.Dispatcher) category := h.categorizeByDispatcher(kb.Dispatcher)
bind := h.convertKeybind(&kb, currentSubcat, conflicts) bind := h.convertKeybind(&kb, currentSubcat, conflicts, defaultKeys)
categorizedBinds[category] = append(categorizedBinds[category], bind) categorizedBinds[category] = append(categorizedBinds[category], bind)
} }
for _, child := range section.Children { for _, child := range section.Children {
h.convertSection(&child, currentSubcat, categorizedBinds, conflicts) h.convertSection(&child, currentSubcat, categorizedBinds, conflicts, defaultKeys)
} }
} }
@@ -133,7 +134,7 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
} }
} }
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding) keybinds.Keybind { func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding, defaultKeys map[string]bool) keybinds.Keybind {
keyStr := h.formatKey(kb) keyStr := h.formatKey(kb)
rawAction := h.formatRawAction(kb.Dispatcher, kb.Params) rawAction := h.formatRawAction(kb.Dispatcher, kb.Params)
desc := kb.Comment desc := kb.Comment
@@ -143,8 +144,15 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
} }
source := "config" source := "config"
if strings.Contains(kb.Source, "dms/binds.conf") { if isDMSBindsUserOverridePath(kb.Source) {
source = "dms" source = "dms"
} else if isDMSBindsPrimarySourcePath(kb.Source) {
source = "dms-default"
}
hasDefault := false
if source == "dms" && defaultKeys != nil {
hasDefault = defaultKeys[strings.ToLower(keyStr)]
} }
bind := keybinds.Keybind{ bind := keybinds.Keybind{
@@ -154,9 +162,10 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
Subcategory: subcategory, Subcategory: subcategory,
Source: source, Source: source,
Flags: kb.Flags, Flags: kb.Flags,
HasDefault: hasDefault,
} }
if source == "dms" && conflicts != nil { if (source == "dms" || source == "dms-default") && conflicts != nil {
normalizedKey := strings.ToLower(keyStr) normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok { if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{ bind.Conflict = &keybinds.Keybind{
@@ -188,9 +197,9 @@ func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
func (h *HyprlandProvider) GetOverridePath() string { func (h *HyprlandProvider) GetOverridePath() string {
expanded, err := utils.ExpandPath(h.configPath) expanded, err := utils.ExpandPath(h.configPath)
if err != nil { if err != nil {
return filepath.Join(h.configPath, "dms", "binds.conf") return filepath.Join(h.configPath, "dms", "binds-user.lua")
} }
return filepath.Join(expanded, "dms", "binds.conf") return filepath.Join(expanded, "dms", "binds-user.lua")
} }
func (h *HyprlandProvider) validateAction(action string) error { func (h *HyprlandProvider) validateAction(action string) error {
@@ -250,7 +259,16 @@ func (h *HyprlandProvider) RemoveBind(key string) error {
if err != nil { if err != nil {
return nil return nil
} }
normalizedKey := strings.ToLower(key)
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: key, Unbind: true}
return h.writeOverrideBinds(existingBinds)
}
func (h *HyprlandProvider) ResetBind(key string) error {
existingBinds, err := h.loadOverrideBinds()
if err != nil {
return nil
}
normalizedKey := strings.ToLower(key) normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey) delete(existingBinds, normalizedKey)
return h.writeOverrideBinds(existingBinds) return h.writeOverrideBinds(existingBinds)
@@ -262,116 +280,12 @@ type hyprlandOverrideBind struct {
Description string Description string
Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
Options map[string]any Options map[string]any
// Unbind: negative override (hl.unbind only, no rebind).
Unbind bool
} }
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) { func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
overridePath := h.GetOverridePath() return readLuaOrHyprlangOverride(h.GetOverridePath())
binds := make(map[string]*hyprlandOverrideBind)
data, err := os.ReadFile(overridePath)
if os.IsNotExist(err) {
return binds, nil
}
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !strings.HasPrefix(line, "bind") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
// Extract flags from bind type
bindType := strings.TrimSpace(parts[0])
flags := extractBindFlags(bindType)
hasDescFlag := strings.Contains(flags, "d")
content := strings.TrimSpace(parts[1])
commentParts := strings.SplitN(content, "#", 2)
bindContent := strings.TrimSpace(commentParts[0])
var comment string
if len(commentParts) > 1 {
comment = strings.TrimSpace(commentParts[1])
}
// For bindd, format is: mods, key, description, dispatcher, params
var minFields, descIndex, dispatcherIndex int
if hasDescFlag {
minFields = 4
descIndex = 2
dispatcherIndex = 3
} else {
minFields = 3
dispatcherIndex = 2
}
fields := strings.SplitN(bindContent, ",", minFields+2)
if len(fields) < minFields {
continue
}
mods := strings.TrimSpace(fields[0])
keyName := strings.TrimSpace(fields[1])
var dispatcher, params string
if hasDescFlag {
if comment == "" {
comment = strings.TrimSpace(fields[descIndex])
}
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
if len(fields) > dispatcherIndex+1 {
paramParts := fields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
} else {
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
if len(fields) > dispatcherIndex+1 {
paramParts := fields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
}
keyStr := h.buildKeyString(mods, keyName)
normalizedKey := strings.ToLower(keyStr)
action := dispatcher
if params != "" {
action = dispatcher + " " + params
}
binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr,
Action: action,
Description: comment,
Flags: flags,
}
}
return binds, nil
}
func (h *HyprlandProvider) buildKeyString(mods, key string) string {
if mods == "" {
return key
}
modList := strings.FieldsFunc(mods, func(r rune) bool {
return r == '+' || r == ' '
})
parts := append(modList, key)
return strings.Join(parts, "+")
} }
func (h *HyprlandProvider) getBindSortPriority(action string) int { func (h *HyprlandProvider) getBindSortPriority(action string) int {
@@ -420,78 +334,203 @@ func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverri
}) })
var sb strings.Builder var sb strings.Builder
sb.WriteString("-- DMS user keybind overrides (edit via Control Center or dms; do not remove this header)\n\n")
for _, bind := range bindList { for _, bind := range bindList {
h.writeBindLine(&sb, bind) writeLuaBindLine(&sb, bind)
} }
return sb.String() return sb.String()
} }
func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) { func formatLuaBindKey(internalKey string) string {
mods, key := h.parseKeyString(bind.Key) internalKey = strings.TrimSpace(internalKey)
dispatcher, params := h.parseAction(bind.Action) parts := strings.Split(internalKey, "+")
for i := range parts {
// Write bind type with flags (e.g., "bind", "binde", "bindel") parts[i] = normalizeLuaBindKeyPart(strings.TrimSpace(parts[i]))
sb.WriteString("bind")
if bind.Flags != "" {
sb.WriteString(bind.Flags)
} }
sb.WriteString(" = ") return strings.Join(parts, " + ")
sb.WriteString(mods)
sb.WriteString(", ")
sb.WriteString(key)
sb.WriteString(", ")
// For bindd (description flag), include description before dispatcher
if strings.Contains(bind.Flags, "d") && bind.Description != "" {
sb.WriteString(bind.Description)
sb.WriteString(", ")
}
sb.WriteString(dispatcher)
if params != "" {
sb.WriteString(", ")
sb.WriteString(params)
}
// Only add comment if not using bindd (which has inline description)
if bind.Description != "" && !strings.Contains(bind.Flags, "d") {
sb.WriteString(" # ")
sb.WriteString(bind.Description)
}
sb.WriteString("\n")
} }
func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) { func normalizeLuaBindKeyPart(part string) string {
parts := strings.Split(keyStr, "+") switch strings.ToLower(part) {
switch len(parts) { case "super", "mod4", "mainmod":
case 0: return "SUPER"
return "", keyStr case "ctrl", "control":
case 1: return "CTRL"
return "", parts[0] case "shift":
return "SHIFT"
case "alt", "mod1":
return "ALT"
}
if len(part) == 1 {
return strings.ToUpper(part)
}
return part
}
func luaActionStringFromHyprlangAction(action string) string {
action = strings.TrimSpace(action)
if strings.HasPrefix(action, "spawn ") {
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimSpace(strings.TrimPrefix(action, "spawn "))))
}
if strings.HasPrefix(action, "exec ") {
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimPrefix(action, "exec ")))
}
switch action {
case "killactive":
return `hl.dsp.window.kill()`
case "togglefloating":
return `hl.dsp.window.float({ action = "toggle" })`
case "exit":
return `hl.dsp.exit()`
default: default:
return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1] return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action))
} }
} }
func (h *HyprlandProvider) parseAction(action string) (dispatcher, params string) { func luaExprToInternalAction(expr string) string {
parts := strings.SplitN(action, " ", 2) d, p := luaExprToDispatcherParams(expr)
switch len(parts) { if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
case 0: return "exec " + p
return action, ""
case 1:
dispatcher = parts[0]
default:
dispatcher = parts[0]
params = parts[1]
} }
if p != "" {
// Convert internal spawn format to Hyprland's exec return d + " " + p
if dispatcher == "spawn" {
dispatcher = "exec"
} }
return d
return dispatcher, params }
func luaBindOptions(bind *hyprlandOverrideBind) []string {
var opts []string
if strings.Contains(bind.Flags, "l") {
opts = append(opts, "locked = true")
}
if strings.Contains(bind.Flags, "e") {
opts = append(opts, "repeating = true")
}
if bind.Description != "" && strings.Contains(bind.Flags, "d") {
opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description)))
}
return opts
}
func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
key := formatLuaBindKey(bind.Key)
if bind.Unbind {
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
sb.WriteByte('\n')
return
}
expr := luaActionStringFromHyprlangAction(bind.Action)
opts := luaBindOptions(bind)
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
sb.WriteByte('\n')
if len(opts) > 0 {
fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
} else {
if bind.Description != "" {
fmt.Fprintf(sb, `hl.bind("%s", %s) -- %s`, key, expr, bind.Description)
} else {
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
}
}
sb.WriteByte('\n')
}
func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "--") {
return nil, false
}
kbc, actionExpr, optSuffix, ok := parseLuaBindInvocation(line)
if !ok {
return nil, false
}
internalKey := luaKeyComboToInternalKey(kbc)
action := luaExprToInternalAction(actionExpr)
flags := luaBindOptFlags(optSuffix)
description := luaBindOptDescription(optSuffix)
return &hyprlandOverrideBind{
Key: internalKey,
Action: action,
Description: description,
Flags: flags,
}, true
}
func parseLuaUnbindLine(line string) (string, bool) {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "hl.unbind") {
return "", false
}
rest := strings.TrimSpace(line[len("hl.unbind"):])
if !strings.HasPrefix(rest, "(") {
return "", false
}
rest = rest[1:]
combo, _, ok := parseLuaStringLiteral(rest, 0)
if !ok {
return "", false
}
return luaKeyComboToInternalKey(combo), true
}
func luaKeyComboToInternalKey(combo string) string {
parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(combo, "+", " "), " ", " "))
return strings.Join(parts, "+")
}
func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, error) {
binds := make(map[string]*hyprlandOverrideBind)
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return binds, nil
}
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
parser := NewHyprlandParser("")
pendingUnbinds := make(map[string]string)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "--") {
continue
}
if key, ok := parseLuaUnbindLine(line); ok {
pendingUnbinds[strings.ToLower(key)] = key
continue
}
if kb, ok := parseLuaBindOverrideLine(line); ok {
normalizedKey := strings.ToLower(kb.Key)
binds[normalizedKey] = kb
delete(pendingUnbinds, normalizedKey)
continue
}
if !strings.HasPrefix(line, "bind") {
continue
}
kb := parser.parseBindLine(line)
if kb == nil {
continue
}
keyStr := parser.formatBindKey(kb)
action := kb.Dispatcher
if kb.Params != "" {
action = kb.Dispatcher + " " + kb.Params
}
flags := kb.Flags
normalizedKey := strings.ToLower(keyStr)
binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr,
Action: action,
Description: kb.Comment,
Flags: flags,
}
delete(pendingUnbinds, normalizedKey)
}
for normKey, origKey := range pendingUnbinds {
binds[normKey] = &hyprlandOverrideBind{Key: origKey, Unbind: true}
}
return binds, nil
} }
@@ -4,8 +4,10 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
@@ -50,6 +52,8 @@ type HyprlandParser struct {
bindOrder []string bindOrder []string
processedFiles map[string]bool processedFiles map[string]bool
dmsProcessed bool dmsProcessed bool
removedKeys map[string]bool // bare hl.unbind targets (negative overrides)
defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf}
} }
func NewHyprlandParser(configDir string) *HyprlandParser { func NewHyprlandParser(configDir string) *HyprlandParser {
@@ -64,6 +68,8 @@ func NewHyprlandParser(configDir string) *HyprlandParser {
bindMap: make(map[string]*HyprlandKeyBinding), bindMap: make(map[string]*HyprlandKeyBinding),
bindOrder: []string{}, bindOrder: []string{},
processedFiles: make(map[string]bool), processedFiles: make(map[string]bool),
removedKeys: make(map[string]bool),
defaultDMSKeys: make(map[string]bool),
} }
} }
@@ -292,6 +298,7 @@ type HyprlandParseResult struct {
DMSBindsIncluded bool DMSBindsIncluded bool
DMSStatus *HyprlandDMSStatus DMSStatus *HyprlandDMSStatus
ConflictingConfigs map[string]*HyprlandKeyBinding ConflictingConfigs map[string]*HyprlandKeyBinding
DefaultDMSKeys map[string]bool // keys with a DMS default in binds.{lua,conf}
} }
type HyprlandDMSStatus struct { type HyprlandDMSStatus struct {
@@ -317,10 +324,10 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
switch { switch {
case !p.dmsBindsExists: case !p.dmsBindsExists:
status.Effective = false status.Effective = false
status.StatusMessage = "dms/binds.conf does not exist" status.StatusMessage = "dms/binds.lua (or legacy binds.conf) does not exist"
case !p.dmsBindsIncluded: case !p.dmsBindsIncluded:
status.Effective = false status.Effective = false
status.StatusMessage = "dms/binds.conf is not sourced in config" status.StatusMessage = "dms binds are not loaded from Hyprland config (require / source)"
case p.bindsAfterDMS > 0: case p.bindsAfterDMS > 0:
status.Effective = true status.Effective = true
status.OverriddenBy = p.bindsAfterDMS status.OverriddenBy = p.bindsAfterDMS
@@ -347,8 +354,11 @@ func (p *HyprlandParser) normalizeKey(key string) string {
func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool { func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool {
key := p.formatBindKey(kb) key := p.formatBindKey(kb)
normalizedKey := p.normalizeKey(key) normalizedKey := p.normalizeKey(key)
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf") isDMSBind := isDMSBindsSourcePath(kb.Source)
if isDMSBindsPrimarySourcePath(kb.Source) {
p.defaultDMSKeys[normalizedKey] = true
}
if isDMSBind { if isDMSBind {
p.dmsBindKeys[normalizedKey] = true p.dmsBindKeys[normalizedKey] = true
} else if p.dmsBindKeys[normalizedKey] { } else if p.dmsBindKeys[normalizedKey] {
@@ -373,12 +383,21 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
return nil, err return nil, err
} }
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf") dmsBindsLua := filepath.Join(expandedDir, "dms", "binds.lua")
if _, err := os.Stat(dmsBindsPath); err == nil { dmsBindsConf := filepath.Join(expandedDir, "dms", "binds.conf")
dmsBindsPath := ""
if _, err := os.Stat(dmsBindsLua); err == nil {
p.dmsBindsExists = true p.dmsBindsExists = true
dmsBindsPath = dmsBindsLua
} else if _, err := os.Stat(dmsBindsConf); err == nil {
p.dmsBindsExists = true
dmsBindsPath = dmsBindsConf
} }
mainConfig := filepath.Join(expandedDir, "hyprland.conf") mainConfig, err := hyprlandMainConfigPath(p.configDir)
if err != nil {
return nil, err
}
section, err := p.parseFileWithSource(mainConfig, "") section, err := p.parseFileWithSource(mainConfig, "")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -387,10 +406,65 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
if p.dmsBindsExists && !p.dmsProcessed { if p.dmsBindsExists && !p.dmsProcessed {
p.parseDMSBindsDirectly(dmsBindsPath, section) p.parseDMSBindsDirectly(dmsBindsPath, section)
} }
p.removeShadowedDMSBinds(section)
p.removeUnboundDMSBinds(section)
return section, nil return section, nil
} }
func (p *HyprlandParser) removeUnboundDMSBinds(section *HyprlandSection) {
if len(p.removedKeys) == 0 {
return
}
filtered := section.Keybinds[:0]
for i := range section.Keybinds {
kb := section.Keybinds[i]
if isDMSBindsSourcePath(kb.Source) && p.removedKeys[p.normalizeKey(p.formatBindKey(&kb))] {
continue
}
filtered = append(filtered, kb)
}
section.Keybinds = filtered
for i := range section.Children {
p.removeUnboundDMSBinds(&section.Children[i])
}
}
func (p *HyprlandParser) removeShadowedDMSBinds(section *HyprlandSection) {
counts := make(map[string]int)
p.countDMSBinds(section, counts)
p.filterShadowedDMSBinds(section, counts)
}
func (p *HyprlandParser) countDMSBinds(section *HyprlandSection, counts map[string]int) {
for i := range section.Keybinds {
kb := &section.Keybinds[i]
if isDMSBindsSourcePath(kb.Source) {
counts[p.normalizeKey(p.formatBindKey(kb))]++
}
}
for i := range section.Children {
p.countDMSBinds(&section.Children[i], counts)
}
}
func (p *HyprlandParser) filterShadowedDMSBinds(section *HyprlandSection, counts map[string]int) {
filtered := section.Keybinds[:0]
for i := range section.Keybinds {
kb := section.Keybinds[i]
key := p.normalizeKey(p.formatBindKey(&kb))
if isDMSBindsSourcePath(kb.Source) && counts[key] > 1 {
counts[key]--
continue
}
filtered = append(filtered, kb)
}
section.Keybinds = filtered
for i := range section.Children {
p.filterShadowedDMSBinds(&section.Children[i], counts)
}
}
func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) { func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) {
absPath, err := filepath.Abs(filePath) absPath, err := filepath.Abs(filePath)
if err != nil { if err != nil {
@@ -407,6 +481,10 @@ func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*Hyp
return nil, err return nil, err
} }
if strings.EqualFold(filepath.Ext(absPath), ".lua") {
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName)
}
prevSource := p.currentSource prevSource := p.currentSource
p.currentSource = absPath p.currentSource = absPath
@@ -446,7 +524,7 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas
} }
sourcePath := strings.TrimSpace(parts[1]) sourcePath := strings.TrimSpace(parts[1])
isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf") isDMSSource := isDMSBindsPrimarySourcePath(sourcePath)
p.includeCount++ p.includeCount++
if isDMSSource { if isDMSSource {
@@ -474,6 +552,17 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas
} }
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) { 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) data, err := os.ReadFile(dmsBindsPath)
if err != nil { if err != nil {
return return
@@ -503,6 +592,124 @@ func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *Hyp
p.dmsProcessed = true p.dmsProcessed = true
} }
func (p *HyprlandParser) parseLuaLinesFromPath(absPath string) (*HyprlandSection, error) {
data, err := os.ReadFile(absPath)
if err != nil {
return nil, err
}
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, "")
}
// parseLuaLines reads a Hyprland Lua config fragment: require() includes and hl.bind keybinds.
func (p *HyprlandParser) parseLuaLines(content string, baseDir, absPath, sectionName string) (*HyprlandSection, error) {
section := &HyprlandSection{Name: sectionName}
prevSource := p.currentSource
p.currentSource = absPath
lines := strings.Split(content, "\n")
boundInFile := make(map[string]bool)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "--") || !strings.Contains(trimmed, "hl.bind") {
continue
}
if kbc, _, _, ok := parseLuaBindInvocation(trimmed); ok {
boundInFile[strings.ToLower(luaKeyComboToInternalKey(kbc))] = true
}
}
rootDir := baseDir
if expanded, err := utils.ExpandPath(p.configDir); err == nil && expanded != "" {
rootDir = expanded
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "--") {
continue
}
if modules := luaconfig.Requires(trimmed); len(modules) > 0 {
for _, mod := range modules {
rel := luaconfig.ModuleToRelPath(mod)
if rel == "" {
continue
}
isDMS := isDMSBindsPrimarySourcePath(rel)
p.includeCount++
if isDMS {
p.dmsBindsIncluded = true
p.dmsIncludePos = p.includeCount
p.dmsProcessed = true
}
fullPath := luaconfig.ModuleToPath(rootDir, mod)
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
continue
}
includedSection, err := p.parseFileWithSource(expanded, "")
if err != nil {
continue
}
section.Children = append(section.Children, *includedSection)
}
continue
}
if strings.HasPrefix(trimmed, "hl.unbind") {
if key, ok := parseLuaUnbindLine(trimmed); ok {
normalized := strings.ToLower(key)
if !boundInFile[normalized] {
p.removedKeys[normalized] = true
}
}
continue
}
if !strings.Contains(trimmed, "hl.bind") {
continue
}
kbc, action, optSuffix, ok := parseLuaBindInvocation(trimmed)
if !ok {
continue
}
flags := luaBindOptFlags(optSuffix)
desc := luaBindOptDescription(optSuffix)
if desc == "" {
desc = luaLineTrailingComment(line)
}
kb := luaKeyComboToBinding(kbc, action, p.currentSource, desc)
kb.Flags = flags
if p.addBind(kb) {
section.Keybinds = append(section.Keybinds, *kb)
}
}
p.currentSource = prevSource
return section, nil
}
func luaBindOptFlags(optSuffix string) string {
optSuffix = strings.TrimSpace(optSuffix)
if optSuffix == "" {
return ""
}
var flags string
if strings.Contains(optSuffix, "repeating") {
flags += "e"
}
if strings.Contains(optSuffix, "locked") {
flags += "l"
}
if strings.Contains(optSuffix, "description") {
flags += "d"
}
return flags
}
func luaBindOptDescription(optSuffix string) string {
return luaTableStringField(optSuffix, "description")
}
func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding { func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
parts := strings.SplitN(line, "=", 2) parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 { if len(parts) < 2 {
@@ -623,5 +830,356 @@ func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
DMSBindsIncluded: parser.dmsBindsIncluded, DMSBindsIncluded: parser.dmsBindsIncluded,
DMSStatus: parser.buildDMSStatus(), DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs, ConflictingConfigs: parser.conflictingConfigs,
DefaultDMSKeys: parser.defaultDMSKeys,
}, nil }, nil
} }
func skipLuaWS(s string, i int) int {
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r') {
i++
}
return i
}
// parseLuaStringLiteral reads a Lua "..." or '...' starting at i (first quote).
func parseLuaStringLiteral(line string, i int) (value string, next int, ok bool) {
if i >= len(line) {
return "", i, false
}
q := line[i]
if q != '"' && q != '\'' {
return "", i, false
}
i++
var sb strings.Builder
for i < len(line) {
c := line[i]
if c == '\\' && i+1 < len(line) {
i++
sb.WriteByte(line[i])
i++
continue
}
if c == q {
return sb.String(), i + 1, true
}
sb.WriteByte(c)
i++
}
return "", i, false
}
// parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping when parentheses
// opened from the first '(' are balanced (handles nested () and {} and double-quoted strings).
func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok bool) {
start = skipLuaWS(line, start)
if start >= len(line) {
return "", start, false
}
// Find first '(' of the call (e.g. hl.dsp.exec_cmd(...)
firstParen := strings.IndexByte(line[start:], '(')
if firstParen < 0 {
return "", start, false
}
i := start + firstParen
depth := 0
inStr := byte(0)
esc := false
exprStart := start
for ; i < len(line); i++ {
c := line[i]
if inStr != 0 {
if esc {
esc = false
continue
}
if c == '\\' && inStr == '"' {
esc = true
continue
}
if c == inStr {
inStr = 0
}
continue
}
switch c {
case '"', '\'':
inStr = c
case '(':
depth++
case ')':
depth--
if depth == 0 {
return strings.TrimSpace(line[exprStart : i+1]), i + 1, true
}
}
}
return "", start, false
}
// parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line.
func parseLuaBindInvocation(line string) (keyCombo, actionExpr, optSuffix string, ok bool) {
idx := strings.Index(line, "hl.bind")
if idx < 0 {
return "", "", "", false
}
i := idx + len("hl.bind")
i = skipLuaWS(line, i)
if i >= len(line) || line[i] != '(' {
return "", "", "", false
}
i++
i = skipLuaWS(line, i)
keyCombo, i, ok = parseLuaStringLiteral(line, i)
if !ok {
return "", "", "", false
}
i = skipLuaWS(line, i)
if i >= len(line) || line[i] != ',' {
return "", "", "", false
}
i++
i = skipLuaWS(line, i)
actionExpr, i, ok = parseLuaFirstArgExpr(line, i)
if !ok {
return "", "", "", false
}
i = skipLuaWS(line, i)
if i < len(line) && line[i] == ',' {
optSuffix = strings.TrimSpace(line[i:])
}
return keyCombo, strings.TrimSpace(actionExpr), optSuffix, true
}
func luaKeyComboToBinding(keyCombo, actionExpr, source, lineComment string) *HyprlandKeyBinding {
keyCombo = strings.TrimSpace(keyCombo)
mods, leaf := luaKeyComboToModsKey(keyCombo)
dispatcher, params := luaExprToDispatcherParams(actionExpr)
comment := lineComment
if comment == "" {
comment = hyprlandAutogenerateComment(dispatcher, params)
}
return &HyprlandKeyBinding{
Mods: mods,
Key: leaf,
Dispatcher: dispatcher,
Params: params,
Comment: comment,
Source: source,
Flags: "",
}
}
func luaKeyComboToModsKey(combo string) (mods []string, leaf string) {
parts := strings.Split(combo, "+")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
switch len(parts) {
case 0:
return nil, ""
case 1:
return nil, parts[0]
default:
return parts[:len(parts)-1], parts[len(parts)-1]
}
}
func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
expr = strings.TrimSpace(expr)
switch {
case strings.HasPrefix(expr, "hl.dsp.exec_cmd("):
arg := extractLuaCallStringArg(expr, "hl.dsp.exec_cmd")
if arg != "" {
if u, err := strconv.Unquote(arg); err == nil {
if strings.HasPrefix(u, "hyprctl dispatch ") {
rest := strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch "))
parts := strings.SplitN(rest, " ", 2)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], parts[1]
}
return "exec", u
}
}
return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd"))
case strings.Contains(expr, "hl.dsp.window.kill()"):
return "killactive", ""
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("):
switch luaTableStringField(expr, "mode") {
case "maximized", "maximize":
return "fullscreen", "1"
case "fullscreen":
return "fullscreen", "0"
}
return "fullscreen", luaTableStringField(expr, "mode")
case strings.HasPrefix(expr, "hl.dsp.window.float("):
return "togglefloating", ""
case strings.Contains(expr, "hl.dsp.group.toggle()"):
return "togglegroup", ""
case strings.HasPrefix(expr, "hl.dsp.focus("):
switch {
case luaTableStringField(expr, "direction") != "":
return "movefocus", luaTableStringField(expr, "direction")
case luaTableStringField(expr, "monitor") != "":
return "focusmonitor", luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "":
return "workspace", luaTableStringField(expr, "workspace")
case luaTableStringField(expr, "window") != "":
return "focuswindow", luaTableStringField(expr, "window")
}
case strings.HasPrefix(expr, "hl.dsp.window.move("):
switch {
case luaTableStringField(expr, "direction") != "":
return "movewindow", luaTableStringField(expr, "direction")
case luaTableStringField(expr, "monitor") != "":
return "movewindow", "mon:" + luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "":
return "movetoworkspace", luaTableStringField(expr, "workspace")
}
case expr == "hl.dsp.window.drag()":
return "movewindow", ""
case expr == "hl.dsp.window.resize()":
return "resizewindow", ""
case strings.HasPrefix(expr, "hl.dsp.window.resize("):
x := luaStringValue(luaTableScalarField(expr, "x"))
y := luaStringValue(luaTableScalarField(expr, "y"))
if x != "" || y != "" {
if x == "" {
x = "0"
}
if y == "" {
y = "0"
}
return "resizeactive", x + " " + y
}
case strings.HasPrefix(expr, "hl.dsp.layout("):
arg := extractLuaCallStringArg(expr, "hl.dsp.layout")
if arg != "" {
if u, err := strconv.Unquote(arg); err == nil {
return "layoutmsg", u
}
}
case strings.HasPrefix(expr, "hl.dsp.dpms("):
if action := luaTableStringField(expr, "action"); action != "" {
return "dpms", action
}
case strings.Contains(expr, "hl.dsp.exit()"):
return "exit", ""
default:
return "exec", "hyprctl dispatch lua:" + expr
}
return "exec", "hyprctl dispatch lua:" + expr
}
func extractLuaCallStringArg(callExpr, funcName string) string {
callExpr = strings.TrimSpace(callExpr)
prefix := funcName + "("
if !strings.HasPrefix(callExpr, prefix) {
return ""
}
inner := callExpr[len(prefix):]
inner = strings.TrimSpace(inner)
if len(inner) == 0 {
return ""
}
switch inner[0] {
case '"', '\'':
s, _, ok := parseLuaStringLiteral(inner, 0)
if ok {
return strconv.Quote(s)
}
case '[':
if strings.HasPrefix(inner, "[[") {
if end := strings.Index(inner[2:], "]]"); end >= 0 {
return strconv.Quote(inner[2 : 2+end])
}
}
}
return ""
}
func luaTableStringField(expr, field string) string {
return luaStringValue(luaTableScalarField(expr, field))
}
func luaTableScalarField(expr, field string) string {
re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`)
m := re.FindStringSubmatch(expr)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}
func luaStringValue(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "[[") && strings.HasSuffix(raw, "]]") {
return raw[2 : len(raw)-2]
}
if len(raw) >= 2 {
q := raw[0]
if (q == '"' || q == '\'') && raw[len(raw)-1] == q {
if q == '"' {
if u, err := strconv.Unquote(raw); err == nil {
return u
}
}
return strings.ReplaceAll(raw[1:len(raw)-1], `\'`, `'`)
}
}
return raw
}
func luaLineTrailingComment(line string) string {
if idx := strings.Index(line, "--"); idx >= 0 {
return strings.TrimSpace(line[idx+2:])
}
return ""
}
func isDMSBindsSourcePath(p string) bool {
p = filepath.ToSlash(strings.TrimSpace(p))
if isDMSBindsPrimarySourcePath(p) {
return true
}
return isDMSBindsUserOverridePath(p)
}
func isDMSBindsUserOverridePath(p string) bool {
p = filepath.ToSlash(strings.TrimSpace(p))
return p == "dms/binds-user.lua" || p == "./dms/binds-user.lua" ||
strings.HasSuffix(p, "/dms/binds-user.lua")
}
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"
}
// hyprlandMainConfigPath returns hyprland.lua if present, else hyprland.conf if present.
func hyprlandMainConfigPath(dir string) (string, error) {
expandedDir, err := utils.ExpandPath(dir)
if err != nil {
return "", err
}
luaPath := filepath.Join(expandedDir, "hyprland.lua")
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
return luaPath, nil
}
confPath := filepath.Join(expandedDir, "hyprland.conf")
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
return confPath, nil
}
return "", os.ErrNotExist
}
@@ -3,7 +3,10 @@ package providers
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
) )
func TestHyprlandAutogenerateComment(t *testing.T) { func TestHyprlandAutogenerateComment(t *testing.T) {
@@ -60,6 +63,341 @@ func TestHyprlandAutogenerateComment(t *testing.T) {
} }
} }
func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) {
tests := []struct {
expr string
wantDispatcher string
wantParams string
}{
{`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`},
{`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"},
{`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"},
{`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"},
{`hl.dsp.window.resize({ x = "-10%", y = 0, relative = true })`, "resizeactive", "-10% 0"},
{`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"},
{`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"},
}
for _, tt := range tests {
t.Run(tt.expr, func(t *testing.T) {
gotDispatcher, gotParams := luaExprToDispatcherParams(tt.expr)
if gotDispatcher != tt.wantDispatcher || gotParams != tt.wantParams {
t.Fatalf("luaExprToDispatcherParams() = %q, %q; want %q, %q", gotDispatcher, gotParams, tt.wantDispatcher, tt.wantParams)
}
})
}
}
func TestWriteLuaBindLineOptionsInsideCall(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+k",
Action: "exec kitty",
Description: "Open terminal",
Flags: "led",
})
want := `hl.unbind("SUPER + K")
hl.bind("SUPER + K", hl.dsp.exec_cmd("kitty"), { locked = true, repeating = true, description = "Open terminal" })`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestWriteLuaBindLineMapsSpawnActionForHyprland(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+n",
Action: "spawn dms ipc call notepad toggle",
Description: "Notepad: Toggle",
})
want := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
require("dms.binds")
require("dms.binds-user")
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(`hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(`hl.bind("SUPER + T", hl.dsp.exec_cmd("foot"), { description = "User terminal" })`), 0o644); err != nil {
t.Fatal(err)
}
result, err := ParseHyprlandKeysWithDMS(tmpDir)
if err != nil {
t.Fatal(err)
}
var found []HyprlandKeyBinding
var walk func(HyprlandSection)
walk = func(section HyprlandSection) {
for _, kb := range section.Keybinds {
if strings.EqualFold(strings.Join(append(kb.Mods, kb.Key), "+"), "SUPER+T") {
found = append(found, kb)
}
}
for _, child := range section.Children {
walk(child)
}
}
walk(*result.Section)
if len(found) != 1 {
t.Fatalf("expected one effective SUPER+T bind, got %d: %#v", len(found), found)
}
if found[0].Params != "foot" || found[0].Comment != "User terminal" {
t.Fatalf("expected user override bind, got %#v", found[0])
}
}
func TestWriteLuaBindLineEmitsUnbindOnlyForNegativeOverride(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{Key: "Super+i", Unbind: true})
want := `hl.unbind("SUPER + I")`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestReadLuaOverrideRecognizesLoneUnbindAsNegativeOverride(t *testing.T) {
tmpDir := t.TempDir()
overridePath := filepath.Join(tmpDir, "binds-user.lua")
contents := `-- DMS user keybind overrides
hl.unbind("SUPER + I")
hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
`
if err := os.WriteFile(overridePath, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
binds, err := readLuaOrHyprlangOverride(overridePath)
if err != nil {
t.Fatal(err)
}
got, ok := binds["super+i"]
if !ok {
t.Fatalf("expected SUPER+I entry in override map, got: %#v", binds)
}
if !got.Unbind {
t.Fatalf("expected SUPER+I to be marked Unbind, got: %#v", got)
}
if rebind, ok := binds["super+n"]; !ok || rebind.Unbind {
t.Fatalf("expected SUPER+N to be a normal rebind override, got: %#v", rebind)
}
}
func TestParserDropsDMSDefaultsSuppressedByBindsUserUnbind(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
require("dms.binds")
require("dms.binds-user")
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(
`hl.bind("SUPER + I", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(`hl.unbind("SUPER + I")`), 0o644); err != nil {
t.Fatal(err)
}
result, err := ParseHyprlandKeysWithDMS(tmpDir)
if err != nil {
t.Fatal(err)
}
var keys []string
var walk func(HyprlandSection)
walk = func(section HyprlandSection) {
for _, kb := range section.Keybinds {
keys = append(keys, strings.ToUpper(strings.Join(append(kb.Mods, kb.Key), "+")))
}
for _, child := range section.Children {
walk(child)
}
}
walk(*result.Section)
for _, k := range keys {
if k == "SUPER+I" {
t.Fatalf("expected SUPER+I to be suppressed by binds-user.lua unbind, got: %v", keys)
}
}
foundT := false
for _, k := range keys {
if k == "SUPER+T" {
foundT = true
}
}
if !foundT {
t.Fatalf("expected SUPER+T to remain (only SUPER+I was unbound), got: %v", keys)
}
}
func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.RemoveBind("SUPER+I"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `hl.unbind("SUPER + I")`) {
t.Fatalf("expected negative override hl.unbind line, got:\n%s", string(data))
}
if strings.Contains(string(data), `hl.bind("SUPER + I"`) {
t.Fatalf("expected NO hl.bind for SUPER+I, got:\n%s", string(data))
}
}
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
override := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
`
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.RemoveBind("SUPER+N"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `hl.unbind("SUPER + N")`) {
t.Fatalf("expected negative override hl.unbind line, got:\n%s", string(data))
}
if strings.Contains(string(data), `hl.bind("SUPER + N"`) {
t.Fatalf("expected NO hl.bind for SUPER+N after remove, got:\n%s", string(data))
}
}
func TestHyprlandResetBindRevertsExistingOverrideToDefault(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
override := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
`
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.ResetBind("SUPER+N"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), `SUPER + N`) {
t.Fatalf("expected SUPER+N to be fully removed (revert to default), got:\n%s", string(data))
}
}
func TestHyprlandHasDefaultSetForOverrideOfDefaultKey(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
require("dms.binds")
require("dms.binds-user")
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(
`hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(
`hl.unbind("SUPER + T")
hl.bind("SUPER + T", hl.dsp.exec_cmd("foot"))
hl.bind("SUPER + Z", hl.dsp.exec_cmd("custom"))`,
), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
sheet, err := provider.GetCheatSheet()
if err != nil {
t.Fatal(err)
}
var foundT, foundZ *keybinds.Keybind
for _, group := range sheet.Binds {
for i := range group {
kb := group[i]
keyUpper := strings.ToUpper(kb.Key)
if keyUpper == "SUPER+T" {
foundT = &group[i]
}
if keyUpper == "SUPER+Z" {
foundZ = &group[i]
}
}
}
if foundT == nil {
t.Fatalf("expected SUPER+T override in cheatsheet")
}
if !foundT.HasDefault {
t.Fatalf("expected SUPER+T HasDefault=true (default exists in binds.lua), got %+v", foundT)
}
if foundZ == nil {
t.Fatalf("expected SUPER+Z (user-only) in cheatsheet")
}
if foundZ.HasDefault {
t.Fatalf("expected SUPER+Z HasDefault=false (no default), got %+v", foundZ)
}
}
func TestHyprlandGetKeybindAtLine(t *testing.T) { func TestHyprlandGetKeybindAtLine(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+6 -2
View File
@@ -141,7 +141,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
source := "config" source := "config"
if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") { if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") {
source = "dms" source = "dms-default"
} }
bind := keybinds.Keybind{ bind := keybinds.Keybind{
@@ -151,7 +151,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
Source: source, Source: source,
} }
if source == "dms" && conflicts != nil { if source == "dms-default" && conflicts != nil {
normalizedKey := strings.ToLower(keyStr) normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok { if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{ bind.Conflict = &keybinds.Keybind{
@@ -249,6 +249,10 @@ func (m *MangoWCProvider) RemoveBind(key string) error {
return m.writeOverrideBinds(existingBinds) return m.writeOverrideBinds(existingBinds)
} }
func (m *MangoWCProvider) ResetBind(key string) error {
return m.RemoveBind(key)
}
type mangowcOverrideBind struct { type mangowcOverrideBind struct {
Key string Key string
Action string Action string
+6 -2
View File
@@ -149,7 +149,7 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
source := "config" source := "config"
if strings.Contains(kb.Source, "dms/binds.kdl") { if strings.Contains(kb.Source, "dms/binds.kdl") {
source = "dms" source = "dms-default"
} }
bind := keybinds.Keybind{ bind := keybinds.Keybind{
@@ -165,7 +165,7 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
Repeat: kb.Repeat, Repeat: kb.Repeat,
} }
if source == "dms" && conflicts != nil { if source == "dms-default" && conflicts != nil {
if conflictKb, ok := conflicts[keyStr]; ok { if conflictKb, ok := conflicts[keyStr]; ok {
bind.Conflict = &keybinds.Keybind{ bind.Conflict = &keybinds.Keybind{
Key: keyStr, Key: keyStr,
@@ -269,6 +269,10 @@ func (n *NiriProvider) RemoveBind(key string) error {
return n.writeOverrideBinds(existingBinds) return n.writeOverrideBinds(existingBinds)
} }
func (n *NiriProvider) ResetBind(key string) error {
return n.RemoveBind(key)
}
type overrideBind struct { type overrideBind struct {
Key string Key string
Action string Action string
+6
View File
@@ -13,6 +13,7 @@ type Keybind struct {
AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled
Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled
Conflict *Keybind `json:"conflict,omitempty"` Conflict *Keybind `json:"conflict,omitempty"`
HasDefault bool `json:"hasDefault,omitempty"` // override has a DMS default to revert to
} }
type DMSBindsStatus struct { type DMSBindsStatus struct {
@@ -42,6 +43,11 @@ type Provider interface {
type WritableProvider interface { type WritableProvider interface {
Provider Provider
SetBind(key, action, description string, options map[string]any) error SetBind(key, action, description string, options map[string]any) error
// RemoveBind removes the bind. Hyprland writes a negative override to
// dms/binds-user.lua; single-file providers delete the line.
RemoveBind(key string) error RemoveBind(key string) error
// ResetBind reverts a user override to its DMS default. On single-file
// providers this aliases to RemoveBind.
ResetBind(key string) error
GetOverridePath() string GetOverridePath() string
} }
+129
View File
@@ -0,0 +1,129 @@
package luaconfig
import (
"os"
"path/filepath"
"regexp"
"strings"
)
var luaRequireRE = regexp.MustCompile(`(?i)\brequire\s*\(\s*["']([^"']+)["']\s*\)`)
func ModuleToRelPath(module string) string {
module = strings.TrimSpace(module)
if module == "" {
return ""
}
module = strings.NewReplacer(".", string(filepath.Separator), "/", string(filepath.Separator)).Replace(module)
return filepath.Clean(module + ".lua")
}
func ModuleToPath(baseDir, module string) string {
rel := ModuleToRelPath(module)
if rel == "" {
return ""
}
return filepath.Clean(filepath.Join(baseDir, rel))
}
func Requires(line string) []string {
line = stripLineComment(line)
if strings.TrimSpace(line) == "" {
return nil
}
matches := luaRequireRE.FindAllStringSubmatch(line, -1)
if len(matches) == 0 {
return nil
}
modules := make([]string, 0, len(matches))
for _, match := range matches {
if len(match) > 1 && strings.TrimSpace(match[1]) != "" {
modules = append(modules, strings.TrimSpace(match[1]))
}
}
return modules
}
func Require(line string) (string, bool) {
modules := Requires(line)
if len(modules) != 1 {
return "", false
}
return modules[0], true
}
func RequiresTarget(filePath, targetAbs string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
return requiresTarget(absPath, filepath.Dir(absPath), targetAbs, processed)
}
func requiresTarget(filePath, rootDir, targetAbs string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
targetAbsClean := filepath.Clean(targetAbs)
if processed[absPath] {
return false
}
processed[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return false
}
for _, raw := range strings.Split(string(data), "\n") {
for _, module := range Requires(raw) {
candidate := ModuleToPath(rootDir, module)
if candidate == "" {
continue
}
if filepath.Clean(candidate) == targetAbsClean {
return true
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
if requiresTarget(candidate, rootDir, targetAbs, processed) {
return true
}
}
}
}
return false
}
func stripLineComment(line string) string {
inStr := byte(0)
esc := false
for i := 0; i+1 < len(line); i++ {
c := line[i]
if inStr != 0 {
if esc {
esc = false
continue
}
if c == '\\' && inStr == '"' {
esc = true
continue
}
if c == inStr {
inStr = 0
}
continue
}
switch c {
case '"', '\'':
inStr = c
case '-':
if line[i+1] == '-' {
return line[:i]
}
}
}
return line
}
+56
View File
@@ -0,0 +1,56 @@
package luaconfig
import (
"os"
"path/filepath"
"testing"
)
func TestModuleToRelPath(t *testing.T) {
tests := map[string]string{
"dms.binds": filepath.Join("dms", "binds.lua"),
"dms/binds-user": filepath.Join("dms", "binds-user.lua"),
"awesome/anim": filepath.Join("awesome", "anim.lua"),
"awesome.colors": filepath.Join("awesome", "colors.lua"),
" awesome.binds ": filepath.Join("awesome", "binds.lua"),
}
for input, want := range tests {
if got := ModuleToRelPath(input); got != want {
t.Fatalf("ModuleToRelPath(%q) = %q, want %q", input, got, want)
}
}
}
func TestRequiresSkipsComments(t *testing.T) {
if modules := Requires(`-- require("dms.binds")`); len(modules) != 0 {
t.Fatalf("expected commented require to be ignored, got %#v", modules)
}
modules := Requires(`print("-- not a comment") require("dms.binds") -- require("ignored")`)
if len(modules) != 1 || modules[0] != "dms.binds" {
t.Fatalf("unexpected modules: %#v", modules)
}
}
func TestRequiresTargetRecurses(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(dmsDir, "windowrules.lua")
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`require("dms.extra")`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "extra.lua"), []byte(`require("dms.windowrules")`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, []byte(`-- rules`), 0o644); err != nil {
t.Fatal(err)
}
if !RequiresTarget(filepath.Join(tmpDir, "hyprland.lua"), target, make(map[string]bool)) {
t.Fatal("expected recursive require lookup to find target")
}
}
+7 -2
View File
@@ -300,9 +300,14 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
Exists: niriExists, Exists: niriExists,
}) })
} else { } else {
hyprlandPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf") hyprlandLuaPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua")
hyprlandConfPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf")
hyprlandPath := hyprlandLuaPath
hyprlandExists := false hyprlandExists := false
if _, err := os.Stat(hyprlandPath); err == nil { if _, err := os.Stat(hyprlandLuaPath); err == nil {
hyprlandExists = true
} else if _, err := os.Stat(hyprlandConfPath); err == nil {
hyprlandPath = hyprlandConfPath
hyprlandExists = true hyprlandExists = true
} }
configs = append(configs, ExistingConfigInfo{ configs = append(configs, ExistingConfigInfo{
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,10 @@ package providers
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
) )
func TestParseWindowRuleV1(t *testing.T) { func TestParseWindowRuleV1(t *testing.T) {
@@ -151,7 +154,7 @@ func TestHyprlandWritableProvider(t *testing.T) {
t.Errorf("Name() = %q, want hyprland", provider.Name()) t.Errorf("Name() = %q, want hyprland", provider.Name())
} }
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.conf") expectedPath := filepath.Join(tmpDir, "dms", "windowrules.lua")
if provider.GetOverridePath() != expectedPath { if provider.GetOverridePath() != expectedPath {
t.Errorf("GetOverridePath() = %q, want %q", provider.GetOverridePath(), expectedPath) t.Errorf("GetOverridePath() = %q, want %q", provider.GetOverridePath(), expectedPath)
} }
@@ -270,6 +273,104 @@ windowrulev2 = tile, class:^(extraapp)$
} }
} }
func TestParseHyprlandLuaRequiresFragment(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0755); err != nil {
t.Fatal(err)
}
mainLua := filepath.Join(tmpDir, "hyprland.lua")
fragLua := filepath.Join(dmsDir, "windowrules.lua")
if err := os.WriteFile(fragLua, []byte(`
hl.window_rule({ match = { class = "^test$" }, float = true })
`), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(mainLua, []byte(`
require("dms.windowrules")
`), 0644); err != nil {
t.Fatal(err)
}
res, err := ParseHyprlandWindowRules(tmpDir)
if err != nil {
t.Fatalf("ParseHyprlandWindowRules: %v", err)
}
if len(res.Rules) != 1 {
t.Fatalf("expected 1 rule, got %d", len(res.Rules))
}
if !res.DMSRulesIncluded {
t.Fatal("expected dms.windowrules fragment to be marked included")
}
wr := ConvertHyprlandRulesToWindowRules(res.Rules)[0]
if wr.MatchCriteria.AppID != "^test$" || wr.Actions.OpenFloating == nil || !*wr.Actions.OpenFloating {
t.Fatalf("unexpected merged rule: %#v", wr)
}
}
func TestParseHyprlandLuaNoInitialFocusAlias(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
hl.window_rule({
match = { class = "^steam$" },
no_initial_focus = true,
})
`), 0644); err != nil {
t.Fatal(err)
}
res, err := ParseHyprlandWindowRules(tmpDir)
if err != nil {
t.Fatalf("ParseHyprlandWindowRules: %v", err)
}
if len(res.Rules) != 1 {
t.Fatalf("expected 1 rule, got %d", len(res.Rules))
}
wr := ConvertHyprlandRulesToWindowRules(res.Rules)[0]
if wr.Actions.NoFocus == nil || !*wr.Actions.NoFocus {
t.Fatalf("expected no_initial_focus to populate NoFocus action: %#v", wr.Actions)
}
}
func TestFormatLuaManagedHyprRuleUsesLuaFieldNames(t *testing.T) {
enabled := true
rule := windowrules.WindowRule{
ID: "test-rule",
Enabled: true,
MatchCriteria: windowrules.MatchCriteria{
AppID: "^app$",
},
Actions: windowrules.Actions{
NoFocus: &enabled,
NoShadow: &enabled,
NoDim: &enabled,
NoBlur: &enabled,
NoAnim: &enabled,
ForcergbX: &enabled,
Idleinhibit: "focus",
},
}
lines := formatLuaManagedHyprRule(rule)
joined := strings.Join(lines, "\n")
for _, want := range []string{
"no_focus = true",
"no_shadow = true",
"no_dim = true",
"no_blur = true",
"no_anim = true",
"force_rgbx = true",
`idle_inhibit = "focus"`,
} {
if !strings.Contains(joined, want) {
t.Fatalf("formatted rule missing %q: %s", want, joined)
}
}
}
func TestBoolToInt(t *testing.T) { func TestBoolToInt(t *testing.T) {
if boolToInt(true) != 1 { if boolToInt(true) != 1 {
t.Error("boolToInt(true) should be 1") t.Error("boolToInt(true) should be 1")
+193
View File
@@ -0,0 +1,193 @@
# Hyprland Lua Migration
Hyprland 0.55 moved configuration toward Lua. DMS now follows that path for new
Hyprland setup and migration.
This guide covers what changes, where files live, and how to check that your
session is using the new config.
## Quick Summary
DMS now deploys Hyprland as:
```text
~/.config/hypr/hyprland.lua
~/.config/hypr/dms/*.lua
```
The old hyprlang files are moved out of the active config tree:
```text
~/.config/hypr/hyprland.conf
~/.config/hypr/dms/*.conf
```
Backups are stored here:
```text
~/.config/hypr/.dms-backups/<timestamp>/
```
## What `dms setup` Does
When Hyprland is selected, `dms setup` writes a Lua main config and DMS Lua
fragments.
| File | Purpose |
| --- | --- |
| `hyprland.lua` | Main Hyprland config. |
| `dms/colors.lua` | Theme colors. |
| `dms/outputs.lua` | Monitors and display settings. |
| `dms/layout.lua` | Layout, gaps, borders, and decoration. |
| `dms/cursor.lua` | Cursor settings. |
| `dms/binds.lua` | DMS-managed default shortcuts. |
| `dms/binds-user.lua` | User shortcut overrides. |
| `dms/windowrules.lua` | Window rules. |
`dms/binds.lua` is managed by DMS and may be refreshed by setup. Put custom
keyboard shortcuts in `dms/binds-user.lua`, or use the Keyboard Shortcuts page in
DMS Settings.
Most other existing non-empty Lua fragments are preserved.
## Legacy Config Migration
During migration, DMS moves legacy active files into the backup folder so
Hyprland does not see both config formats at once.
DMS also migrates legacy `monitor = ...` lines from `hyprland.conf` into
`dms/outputs.lua` when `outputs.lua` is empty or missing. If you already have a
custom `outputs.lua`, DMS leaves it alone.
## DMS Settings Support
DMS Settings now targets Lua files for Hyprland:
| Settings page | Lua file |
| --- | --- |
| Keyboard Shortcuts | `dms/binds-user.lua` |
| Displays | `dms/outputs.lua` |
| Theme Colors | `dms/colors.lua` |
| Cursor | `dms/cursor.lua` |
| Window Rules | `dms/windowrules.lua` |
The main config should include the DMS fragments:
```lua
require("dms.colors")
require("dms.outputs")
require("dms.layout")
require("dms.cursor")
require("dms.binds")
require("dms.binds-user")
require("dms.windowrules")
```
### Keyboard Shortcuts: Delete and Reset
The Keyboard Shortcuts page exposes two actions on any DMS-managed bind:
- **Delete** — removes the shortcut entirely. For default DMS shortcuts (from
`dms/binds.lua`), this saves an `hl.unbind("KEY")` line into
`dms/binds-user.lua` so the removal sticks across `dms setup` runs.
- **Reset to default** — only visible when you are editing a user override of
a DMS default. It drops your override so the original DMS default re-applies.
Binds from your own `hyprland.lua` (outside the `dms/` folder) are read-only
in Settings — DMS does not write into files it does not manage.
## Starting Hyprland
For the Lua config to be active, Hyprland must start with:
```sh
Hyprland -c ~/.config/hypr/hyprland.lua
```
If Hyprland warns that it is using an autogenerated config, or the warning
mentions `hyprland.conf`, the session is not using the DMS Lua config yet.
## Verify Everything
After updating DMS, run:
```sh
dms setup
hyprctl reload
hyprctl configerrors
```
If the current session was not started from `hyprland.lua`, restart Hyprland with
the Lua config and check again.
Useful file checks:
```sh
test -f ~/.config/hypr/hyprland.lua
test ! -f ~/.config/hypr/hyprland.conf
ls ~/.config/hypr/dms
```
The live `dms` folder should contain Lua files like `binds.lua`,
`binds-user.lua`, `outputs.lua`, and `windowrules.lua`.
Note: Hyprland 0.55 still auto-generates `hyprland.conf` if you launch it
without `-c ~/.config/hypr/hyprland.lua`. DMS sweeps any stray
`hyprland.conf` into `.dms-backups/<timestamp>/` on the next `dms run`
startup, so the second check above is the right long-term state. If you see
`hyprland.conf` persist between `dms run` invocations, the session was not
started from `hyprland.lua` — restart Hyprland with the `-c` flag (or update
your session/desktop entry to include it).
## Troubleshooting
If shortcuts do not work, confirm `hyprland.lua` includes both:
```lua
require("dms.binds")
require("dms.binds-user")
```
If `hyprctl configerrors` reports errors in `dms/binds.lua`, rerun `dms setup`
with the latest DMS binary so the DMS-managed shortcut file is refreshed.
If a migrated monitor setup looks wrong, compare:
```text
~/.config/hypr/dms/outputs.lua
~/.config/hypr/.dms-backups/<timestamp>/
```
Your previous config should be available in the timestamped backup folder.
## Reference Map
```text
~/.config/hypr/
|-- hyprland.lua # Main DMS Hyprland config
|-- .dms-backups/ # Timestamped backups from setup/migration
`-- dms/
|-- colors.lua # Theme colors
|-- outputs.lua # Monitor/output config
|-- layout.lua # Layout, gaps, borders, decoration
|-- cursor.lua # Cursor settings
|-- binds.lua # DMS-managed default shortcuts
|-- binds-user.lua # User shortcut overrides
`-- windowrules.lua # DMS-managed window rules
```
Legacy files such as `hyprland.conf` and `dms/*.conf` should live in
`.dms-backups/<timestamp>/` after migration, not in the active config tree.
## Maintainer Note
Embedded source files live in `core/internal/config/embedded/` and use names like
`hypr-binds.lua`. Installed user files use shorter names like `dms/binds.lua`.
After changing Hyprland config deployment or parsing, run:
```sh
cd core
go test ./internal/config ./internal/keybinds/providers ./internal/windowrules/providers
go test ./...
```
+38
View File
@@ -0,0 +1,38 @@
function shQuote(value) {
return "'" + String(value ?? "").replace(/'/g, "'\\''") + "'";
}
function dirname(path) {
const idx = String(path ?? "").lastIndexOf("/");
return idx > 0 ? path.substring(0, idx) : ".";
}
function buildRepairScript(options) {
const configFile = options.configFile;
const backupFile = options.backupFile;
const fragments = options.fragmentFiles || (options.fragmentFile ? [options.fragmentFile] : []);
const includes = options.includes || [{
grepPattern: options.grepPattern,
includeLine: options.includeLine
}];
const commands = [];
if (backupFile)
commands.push(`cp ${shQuote(configFile)} ${shQuote(backupFile)} 2>/dev/null || true`);
const dirs = {};
for (const fragment of fragments)
dirs[dirname(fragment)] = true;
for (const dir in dirs)
commands.push(`mkdir -p ${shQuote(dir)}`);
if (fragments.length > 0)
commands.push("touch " + fragments.map(shQuote).join(" "));
for (const include of includes) {
if (!include.grepPattern || !include.includeLine)
continue;
commands.push(`if ! grep -v '^[[:space:]]*\\(//\\|#\\|--\\)' ${shQuote(configFile)} 2>/dev/null | grep -q ${shQuote(include.grepPattern)}; then echo '' >> ${shQuote(configFile)} && printf '%s\\n' ${shQuote(include.includeLine)} >> ${shQuote(configFile)}; fi`);
}
return commands.join("; ");
}
+1 -1
View File
@@ -178,7 +178,7 @@ sudo systemctl enable greetd
#### Legacy installation (deprecated) #### Legacy installation (deprecated)
If you prefer the old method with separate shell scripts and config files: If you prefer the old method with separate shell scripts and config files:
1. Copy `assets/dms-niri.kdl` or `assets/dms-hypr.conf` to `/etc/greetd` 1. Copy `assets/dms-niri.kdl` or `assets/dms-hypr.lua` (legacy: `assets/dms-hypr.conf`) to `/etc/greetd`
2. Copy `assets/greet-niri.sh` or `assets/greet-hyprland.sh` to `/usr/local/bin/start-dms-greetd.sh` 2. Copy `assets/greet-niri.sh` or `assets/greet-hyprland.sh` to `/usr/local/bin/start-dms-greetd.sh`
3. Edit the config file and replace `_DMS_PATH_` with your DMS installation path 3. Edit the config file and replace `_DMS_PATH_` with your DMS installation path
4. Configure greetd to use `/usr/local/bin/start-dms-greetd.sh` 4. Configure greetd to use `/usr/local/bin/start-dms-greetd.sh`
@@ -1,3 +1,4 @@
# Deprecated: greetd expects Hyprland 0.55+ Lua; use `/etc/greetd/dms-hypr.lua` instead.
env = DMS_RUN_GREETER,1 env = DMS_RUN_GREETER,1
exec = sh -c "qs -p _DMS_PATH_; hyprctl dispatch exit" exec = sh -c "qs -p _DMS_PATH_; hyprctl dispatch exit"
@@ -0,0 +1,8 @@
-- Minimal Hyprland (Lua) session for greetd — replace _DMS_PATH_ with your DMS checkout.
-- Copy to `/etc/greetd/dms-hypr.lua` alongside `greet-hyprland.sh`.
hl.env("DMS_RUN_GREETER", "1")
hl.on("hyprland.start", function()
hl.exec_cmd('sh -c "qs -p _DMS_PATH_; hyprctl dispatch exit"')
end)
@@ -5,7 +5,7 @@ export QT_QPA_PLATFORM=wayland
export QT_WAYLAND_DISABLE_WINDOWDECORATION=1 export QT_WAYLAND_DISABLE_WINDOWDECORATION=1
export EGL_PLATFORM=gbm export EGL_PLATFORM=gbm
if command -v start-hyprland >/dev/null 2>&1; then if command -v start-hyprland >/dev/null 2>&1; then
exec start-hyprland -- -c /etc/greetd/dms-hypr.conf exec start-hyprland -- -c /etc/greetd/dms-hypr.lua
else else
exec Hyprland -c /etc/greetd/dms-hypr.conf exec Hyprland -c /etc/greetd/dms-hypr.lua
fi fi
@@ -7,6 +7,7 @@ import Quickshell
import Quickshell.Io import Quickshell.Io
import qs.Common import qs.Common
import qs.Services import qs.Services
import "../../../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve
Singleton { Singleton {
id: root id: root
@@ -1074,10 +1075,86 @@ Singleton {
return result; return result;
} }
function hyprLuaField(line, field) {
const re = new RegExp("\\b" + field + "\\s*=\\s*(\\\"(?:\\\\\\\\.|[^\\\"])*\\\"|'(?:\\\\\\\\.|[^'])*'|\\[\\[.*?\\]\\]|[^,}\\s]+)");
const match = line.match(re);
if (!match)
return undefined;
const raw = match[1].trim();
if (raw.startsWith("[[") && raw.endsWith("]]"))
return raw.slice(2, -2);
if (raw.startsWith("\"")) {
try {
return JSON.parse(raw);
} catch (e) {
return raw.slice(1, -1);
}
}
if (raw.startsWith("'") && raw.endsWith("'"))
return raw.slice(1, -1).replace(/\\'/g, "'");
if (raw === "true")
return true;
if (raw === "false")
return false;
const num = Number(raw);
return isNaN(num) ? raw : num;
}
function parseHyprlandLuaMonitorLine(line) {
if (!line.match(/^\s*hl\.monitor\s*\(/))
return null;
const name = hyprLuaField(line, "output");
if (name === undefined)
return null;
const disabled = hyprLuaField(line, "disabled") === true;
const mode = hyprLuaField(line, "mode") || "preferred";
const position = hyprLuaField(line, "position") || "0x0";
const scaleValue = hyprLuaField(line, "scale");
const transform = Number(hyprLuaField(line, "transform") ?? 0);
const vrrMode = Number(hyprLuaField(line, "vrr") ?? 0);
const posMatch = String(position).match(/^(-?\d+)x(-?\d+)$/);
const modeMatch = String(mode).match(/^(\d+)x(\d+)@([\d.]+)/);
const settings = {
"disabled": disabled || undefined,
"bitdepth": hyprLuaField(line, "bitdepth"),
"colorManagement": hyprLuaField(line, "cm"),
"sdrBrightness": hyprLuaField(line, "sdrbrightness"),
"sdrSaturation": hyprLuaField(line, "sdrsaturation"),
"supportsWideColor": hyprLuaField(line, "supports_wide_color"),
"supportsHdr": hyprLuaField(line, "supports_hdr"),
"vrrFullscreenOnly": vrrMode === 2 ? true : undefined
};
return {
"name": String(name),
"logical": {
"x": posMatch ? parseInt(posMatch[1]) : 0,
"y": posMatch ? parseInt(posMatch[2]) : 0,
"scale": typeof scaleValue === "number" ? scaleValue : 1.0,
"transform": hyprlandToTransform(transform)
},
"modes": modeMatch ? [{
"width": parseInt(modeMatch[1]),
"height": parseInt(modeMatch[2]),
"refresh_rate": Math.round(parseFloat(modeMatch[3]) * 1000)
}] : [],
"current_mode": modeMatch ? 0 : -1,
"vrr_enabled": vrrMode >= 1,
"vrr_supported": vrrMode > 0,
"hyprlandSettings": settings,
"mirror": hyprLuaField(line, "mirror") || ""
};
}
function parseHyprlandOutputs(content) { function parseHyprlandOutputs(content) {
const result = {}; const result = {};
const lines = content.split("\n"); const lines = content.split("\n");
for (const line of lines) { for (const line of lines) {
const luaMonitor = parseHyprlandLuaMonitorLine(line);
if (luaMonitor) {
result[luaMonitor.name] = luaMonitor;
continue;
}
const disableMatch = line.match(/^\s*monitor\s*=\s*([^,]+),\s*disable\s*$/); const disableMatch = line.match(/^\s*monitor\s*=\s*([^,]+),\s*disable\s*$/);
if (disableMatch) { if (disableMatch) {
const name = disableMatch[1].trim(); const name = disableMatch[1].trim();
@@ -1269,10 +1346,10 @@ Singleton {
}; };
case "hyprland": case "hyprland":
return { return {
"configFile": configDir + "/hypr/hyprland.conf", "configFile": configDir + "/hypr/hyprland.lua",
"outputsFile": configDir + "/hypr/dms/outputs.conf", "outputsFile": configDir + "/hypr/dms/outputs.lua",
"grepPattern": 'source.*dms/outputs.conf', "grepPattern": "dms.outputs",
"includeLine": "source = ./dms/outputs.conf" "includeLine": "require(\"dms.outputs\")"
}; };
case "dwl": case "dwl":
return { return {
@@ -1296,7 +1373,7 @@ Singleton {
return; return;
} }
const filename = (compositor === "niri") ? "outputs.kdl" : "outputs.conf"; const filename = (compositor === "niri") ? "outputs.kdl" : ((compositor === "hyprland") ? "outputs.lua" : "outputs.conf");
const compositorArg = (compositor === "dwl") ? "mangowc" : compositor; const compositorArg = (compositor === "dwl") ? "mangowc" : compositor;
checkingInclude = true; checkingInclude = true;
@@ -1326,11 +1403,17 @@ Singleton {
return; return;
fixingInclude = true; fixingInclude = true;
const outputsDir = paths.outputsFile.substring(0, paths.outputsFile.lastIndexOf("/"));
const unixTime = Math.floor(Date.now() / 1000); const unixTime = Math.floor(Date.now() / 1000);
const backupFile = paths.configFile + ".backup" + unixTime; const backupFile = paths.configFile + ".backup" + unixTime;
const script = ConfigIncludeResolve.buildRepairScript({
configFile: paths.configFile,
backupFile: backupFile,
fragmentFile: paths.outputsFile,
grepPattern: paths.grepPattern,
includeLine: paths.includeLine
});
Proc.runCommand("fix-outputs-include", ["sh", "-c", `cp "${paths.configFile}" "${backupFile}" 2>/dev/null; ` + `mkdir -p "${outputsDir}" && ` + `touch "${paths.outputsFile}" && ` + `if ! grep -v '^[[:space:]]*\\(//\\|#\\)' "${paths.configFile}" 2>/dev/null | grep -q '${paths.grepPattern}'; then ` + `echo '' >> "${paths.configFile}" && ` + `echo '${paths.includeLine}' >> "${paths.configFile}"; fi`], (output, exitCode) => { Proc.runCommand("fix-outputs-include", ["sh", "-c", script], (output, exitCode) => {
fixingInclude = false; fixingInclude = false;
if (exitCode !== 0) if (exitCode !== 0)
return; return;
+38 -4
View File
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell import Quickshell
import qs.Common import qs.Common
import qs.Modals.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
@@ -96,6 +97,32 @@ Item {
expandedKey = bindData.action; expandedKey = bindData.action;
} }
function confirmRemoveBind(key, remainingKey) {
removeBindConfirm.showWithOptions({
title: I18n.tr("Remove Shortcut?"),
message: KeybindsService.currentProvider === "hyprland" ? I18n.tr("Remove the shortcut %1? An unbind entry will be saved to dms/binds-user.lua so it stays removed across DMS updates.").arg(key) : I18n.tr("Remove the shortcut %1?").arg(key),
confirmText: I18n.tr("Remove"),
confirmColor: Theme.primary,
onConfirm: () => {
KeybindsService.removeBind(key);
keybindsTab._editingKey = remainingKey;
}
});
}
function confirmResetBind(key, remainingKey) {
removeBindConfirm.showWithOptions({
title: I18n.tr("Reset to Default?"),
message: I18n.tr("Drop your override for %1 so the DMS default action re-applies?").arg(key),
confirmText: I18n.tr("Reset"),
confirmColor: Theme.primary,
onConfirm: () => {
KeybindsService.resetBind(key);
keybindsTab._editingKey = remainingKey;
}
});
}
function _onSaveSuccess() { function _onSaveSuccess() {
if (showingNewBind) { if (showingNewBind) {
showingNewBind = false; showingNewBind = false;
@@ -129,6 +156,10 @@ Item {
onTriggered: keybindsTab._updateFiltered() onTriggered: keybindsTab._updateFiltered()
} }
ConfirmModal {
id: removeBindConfirm
}
Connections { Connections {
target: KeybindsService target: KeybindsService
function onBindsLoaded() { function onBindsLoaded() {
@@ -238,7 +269,7 @@ Item {
} }
StyledText { StyledText {
readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : "dms/binds.conf" readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf"
text: I18n.tr("Click any shortcut to edit. Changes save to %1").arg(bindsFile) text: I18n.tr("Click any shortcut to edit. Changes save to %1").arg(bindsFile)
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
@@ -336,7 +367,7 @@ Item {
} }
StyledText { StyledText {
readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : "dms/binds.conf" readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf"
text: { text: {
if (warningBox.showSetup) if (warningBox.showSetup)
return I18n.tr("Click 'Setup' to create %1 and add include to config.").arg(bindsFile); return I18n.tr("Click 'Setup' to create %1 and add include to config.").arg(bindsFile);
@@ -623,8 +654,11 @@ Item {
} }
onRemoveBind: key => { onRemoveBind: key => {
const remainingKey = bindItem.keys.find(k => k.key !== key)?.key ?? ""; const remainingKey = bindItem.keys.find(k => k.key !== key)?.key ?? "";
KeybindsService.removeBind(key); keybindsTab.confirmRemoveBind(key, remainingKey);
keybindsTab._editingKey = remainingKey; }
onResetBind: key => {
const remainingKey = bindItem.keys.find(k => k.key !== key)?.key ?? "";
keybindsTab.confirmResetBind(key, remainingKey);
} }
onIsExpandedChanged: { onIsExpandedChanged: {
if (!isExpanded || !keybindsTab._editingKey) if (!isExpanded || !keybindsTab._editingKey)
+14 -7
View File
@@ -7,6 +7,7 @@ import qs.Modals.FileBrowser
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
import qs.Modules.Settings.Widgets import qs.Modules.Settings.Widgets
import "../../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve
Item { Item {
id: themeColorsTab id: themeColorsTab
@@ -39,10 +40,10 @@ Item {
}; };
case "hyprland": case "hyprland":
return { return {
"configFile": configDir + "/hypr/hyprland.conf", "configFile": configDir + "/hypr/hyprland.lua",
"cursorFile": configDir + "/hypr/dms/cursor.conf", "cursorFile": configDir + "/hypr/dms/cursor.lua",
"grepPattern": 'source.*dms/cursor.conf', "grepPattern": "dms.cursor",
"includeLine": "source = ./dms/cursor.conf" "includeLine": "require(\"dms.cursor\")"
}; };
case "dwl": case "dwl":
return { return {
@@ -66,7 +67,7 @@ Item {
return; return;
} }
const filename = (compositor === "niri") ? "cursor.kdl" : "cursor.conf"; const filename = (compositor === "niri") ? "cursor.kdl" : ((compositor === "hyprland") ? "cursor.lua" : "cursor.conf");
const compositorArg = (compositor === "dwl") ? "mangowc" : compositor; const compositorArg = (compositor === "dwl") ? "mangowc" : compositor;
checkingCursorInclude = true; checkingCursorInclude = true;
@@ -95,10 +96,16 @@ Item {
if (!paths) if (!paths)
return; return;
fixingCursorInclude = true; fixingCursorInclude = true;
const cursorDir = paths.cursorFile.substring(0, paths.cursorFile.lastIndexOf("/"));
const unixTime = Math.floor(Date.now() / 1000); const unixTime = Math.floor(Date.now() / 1000);
const backupFile = paths.configFile + ".backup" + unixTime; const backupFile = paths.configFile + ".backup" + unixTime;
Proc.runCommand("fix-cursor-include", ["sh", "-c", `cp "${paths.configFile}" "${backupFile}" 2>/dev/null; ` + `mkdir -p "${cursorDir}" && ` + `touch "${paths.cursorFile}" && ` + `if ! grep -v '^[[:space:]]*\\(//\\|#\\)' "${paths.configFile}" 2>/dev/null | grep -q '${paths.grepPattern}'; then ` + `echo '' >> "${paths.configFile}" && ` + `echo '${paths.includeLine}' >> "${paths.configFile}"; fi`], (output, exitCode) => { const script = ConfigIncludeResolve.buildRepairScript({
configFile: paths.configFile,
backupFile: backupFile,
fragmentFile: paths.cursorFile,
grepPattern: paths.grepPattern,
includeLine: paths.includeLine
});
Proc.runCommand("fix-cursor-include", ["sh", "-c", script], (output, exitCode) => {
fixingCursorInclude = false; fixingCursorInclude = false;
if (exitCode !== 0) if (exitCode !== 0)
return; return;
+16 -9
View File
@@ -8,6 +8,7 @@ import Quickshell.Wayland
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
import "../../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve
Item { Item {
id: root id: root
@@ -54,10 +55,10 @@ Item {
}; };
case "hyprland": case "hyprland":
return { return {
"configFile": configDir + "/hypr/hyprland.conf", "configFile": configDir + "/hypr/hyprland.lua",
"rulesFile": configDir + "/hypr/dms/windowrules.conf", "rulesFile": configDir + "/hypr/dms/windowrules.lua",
"grepPattern": 'source.*dms/windowrules.conf', "grepPattern": "dms.windowrules",
"includeLine": "source = ./dms/windowrules.conf" "includeLine": "require(\"dms.windowrules\")"
}; };
default: default:
return null; return null;
@@ -135,7 +136,7 @@ Item {
return; return;
} }
const filename = (compositor === "niri") ? "windowrules.kdl" : "windowrules.conf"; const filename = (compositor === "niri") ? "windowrules.kdl" : "windowrules.lua";
checkingInclude = true; checkingInclude = true;
Proc.runCommand("check-windowrules-include", ["dms", "config", "resolve-include", compositor, filename], (output, exitCode) => { Proc.runCommand("check-windowrules-include", ["dms", "config", "resolve-include", compositor, filename], (output, exitCode) => {
checkingInclude = false; checkingInclude = false;
@@ -162,10 +163,16 @@ Item {
if (!paths) if (!paths)
return; return;
fixingInclude = true; fixingInclude = true;
const rulesDir = paths.rulesFile.substring(0, paths.rulesFile.lastIndexOf("/"));
const unixTime = Math.floor(Date.now() / 1000); const unixTime = Math.floor(Date.now() / 1000);
const backupFile = paths.configFile + ".backup" + unixTime; const backupFile = paths.configFile + ".backup" + unixTime;
Proc.runCommand("fix-windowrules-include", ["sh", "-c", `cp "${paths.configFile}" "${backupFile}" 2>/dev/null; ` + `mkdir -p "${rulesDir}" && ` + `touch "${paths.rulesFile}" && ` + `if ! grep -v '^[[:space:]]*\\(//\\|#\\)' "${paths.configFile}" 2>/dev/null | grep -q '${paths.grepPattern}'; then ` + `echo '' >> "${paths.configFile}" && ` + `echo '${paths.includeLine}' >> "${paths.configFile}"; fi`], (output, exitCode) => { const script = ConfigIncludeResolve.buildRepairScript({
configFile: paths.configFile,
backupFile: backupFile,
fragmentFile: paths.rulesFile,
grepPattern: paths.grepPattern,
includeLine: paths.includeLine
});
Proc.runCommand("fix-windowrules-include", ["sh", "-c", script], (output, exitCode) => {
fixingInclude = false; fixingInclude = false;
if (exitCode !== 0) if (exitCode !== 0)
return; return;
@@ -252,7 +259,7 @@ Item {
} }
StyledText { StyledText {
text: I18n.tr("Define rules for window behavior. Saves to %1").arg(CompositorService.isNiri ? "dms/windowrules.kdl" : "dms/windowrules.conf") text: I18n.tr("Define rules for window behavior. Saves to %1").arg(CompositorService.isNiri ? "dms/windowrules.kdl" : "dms/windowrules.lua")
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
@@ -351,7 +358,7 @@ Item {
} }
StyledText { StyledText {
readonly property string rulesFile: CompositorService.isNiri ? "dms/windowrules.kdl" : "dms/windowrules.conf" readonly property string rulesFile: CompositorService.isNiri ? "dms/windowrules.kdl" : "dms/windowrules.lua"
text: warningBox.showSetup ? I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg(rulesFile) : I18n.tr("%1 exists but is not included. Window rules won't apply.").arg(rulesFile) text: warningBox.showSetup ? I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg(rulesFile) : I18n.tr("%1 exists but is not included. Window rules won't apply.").arg(rulesFile)
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
+57 -69
View File
@@ -14,10 +14,10 @@ Singleton {
readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation))
readonly property string hyprDmsDir: configDir + "/hypr/dms" readonly property string hyprDmsDir: configDir + "/hypr/dms"
readonly property string outputsPath: hyprDmsDir + "/outputs.conf" readonly property string outputsPath: hyprDmsDir + "/outputs.lua"
readonly property string layoutPath: hyprDmsDir + "/layout.conf" readonly property string layoutPath: hyprDmsDir + "/layout.lua"
readonly property string cursorPath: hyprDmsDir + "/cursor.conf" readonly property string cursorPath: hyprDmsDir + "/cursor.lua"
readonly property string windowrulesPath: hyprDmsDir + "/windowrules.conf" readonly property string windowrulesPath: hyprDmsDir + "/windowrules.lua"
property int _lastGapValue: -1 property int _lastGapValue: -1
@@ -31,7 +31,7 @@ Singleton {
function ensureWindowrulesConfig() { function ensureWindowrulesConfig() {
Proc.runCommand("hypr-ensure-windowrules", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && [ ! -f "${windowrulesPath}" ] && touch "${windowrulesPath}" || true`], (output, exitCode) => { Proc.runCommand("hypr-ensure-windowrules", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && [ ! -f "${windowrulesPath}" ] && touch "${windowrulesPath}" || true`], (output, exitCode) => {
if (exitCode !== 0) if (exitCode !== 0)
log.warn("Failed to ensure windowrules.conf:", output); log.warn("Failed to ensure windowrules.lua:", output);
}); });
} }
@@ -62,6 +62,18 @@ Singleton {
return outputName; return outputName;
} }
function luaQuoted(str) {
return JSON.stringify(String(str ?? ""));
}
function forceFlagValue(value) {
if (value === true)
return 1;
if (value === false)
return -1;
return Number(value);
}
function generateOutputsConfig(outputsData, hyprlandSettings, callback) { function generateOutputsConfig(outputsData, hyprlandSettings, callback) {
if (!outputsData || Object.keys(outputsData).length === 0) { if (!outputsData || Object.keys(outputsData).length === 0) {
if (callback) if (callback)
@@ -70,8 +82,7 @@ Singleton {
} }
const settings = hyprlandSettings || SettingsData.hyprlandOutputSettings; const settings = hyprlandSettings || SettingsData.hyprlandOutputSettings;
let lines = ["# Auto-generated by DMS - do not edit manually", ""]; let lines = ["-- Auto-generated by DMS do not edit manually", ""];
let monitorv2Blocks = [];
for (const outputName in outputsData) { for (const outputName in outputsData) {
const output = outputsData[outputName]; const output = outputsData[outputName];
@@ -82,7 +93,7 @@ Singleton {
const outputSettings = settings[identifier] || {}; const outputSettings = settings[identifier] || {};
if (outputSettings.disabled) { if (outputSettings.disabled) {
lines.push("monitor = " + identifier + ", disable"); lines.push(`hl.monitor({ output = ${luaQuoted(identifier)}, disabled = true })`);
continue; continue;
} }
@@ -98,68 +109,42 @@ Singleton {
const position = x + "x" + y; const position = x + "x" + y;
const scale = output.logical?.scale ?? 1.0; const scale = output.logical?.scale ?? 1.0;
let monitorLine = "monitor = " + identifier + ", " + resolution + ", " + position + ", " + scale; const parts = [`output = ${luaQuoted(identifier)}`, `mode = ${luaQuoted(resolution)}`, `position = ${luaQuoted(position)}`, `scale = ${Number(scale)}`];
const transform = transformToHyprland(output.logical?.transform ?? "Normal"); const transform = transformToHyprland(output.logical?.transform ?? "Normal");
if (transform !== 0) if (transform !== 0)
monitorLine += ", transform, " + transform; parts.push(`transform = ${transform}`);
if (output.vrr_supported) { if (output.vrr_supported) {
const vrrMode = outputSettings.vrrFullscreenOnly ? 2 : (output.vrr_enabled ? 1 : 0); const vrrMode = outputSettings.vrrFullscreenOnly ? 2 : (output.vrr_enabled ? 1 : 0);
monitorLine += ", vrr, " + vrrMode; parts.push(`vrr = ${vrrMode}`);
} }
if (output.mirror && output.mirror.length > 0) if (output.mirror && output.mirror.length > 0)
monitorLine += ", mirror, " + output.mirror; parts.push(`mirror = ${luaQuoted(output.mirror)}`);
if (outputSettings.bitdepth && outputSettings.bitdepth !== 8) if (outputSettings.bitdepth && outputSettings.bitdepth !== 8)
monitorLine += ", bitdepth, " + outputSettings.bitdepth; parts.push(`bitdepth = ${Number(outputSettings.bitdepth)}`);
if (outputSettings.colorManagement && outputSettings.colorManagement !== "auto") if (outputSettings.colorManagement && outputSettings.colorManagement !== "auto")
monitorLine += ", cm, " + outputSettings.colorManagement; parts.push(`cm = ${luaQuoted(outputSettings.colorManagement)}`);
if (outputSettings.sdrBrightness !== undefined && outputSettings.sdrBrightness !== 1.0) if (outputSettings.sdrBrightness !== undefined && outputSettings.sdrBrightness !== 1.0)
monitorLine += ", sdrbrightness, " + outputSettings.sdrBrightness; parts.push(`sdrbrightness = ${Number(outputSettings.sdrBrightness)}`);
if (outputSettings.sdrSaturation !== undefined && outputSettings.sdrSaturation !== 1.0) if (outputSettings.sdrSaturation !== undefined && outputSettings.sdrSaturation !== 1.0)
monitorLine += ", sdrsaturation, " + outputSettings.sdrSaturation; parts.push(`sdrsaturation = ${Number(outputSettings.sdrSaturation)}`);
lines.push(monitorLine); if (outputSettings.supportsWideColor !== undefined)
parts.push(`supports_wide_color = ${forceFlagValue(outputSettings.supportsWideColor)}`);
const needsMonitorv2 = outputSettings.supportsHdr || outputSettings.supportsWideColor || outputSettings.sdrMinLuminance !== undefined || outputSettings.sdrMaxLuminance !== undefined || outputSettings.minLuminance !== undefined || outputSettings.maxLuminance !== undefined || outputSettings.maxAvgLuminance !== undefined; if (outputSettings.supportsHdr !== undefined)
parts.push(`supports_hdr = ${forceFlagValue(outputSettings.supportsHdr)}`);
if (needsMonitorv2) { lines.push("hl.monitor({ " + parts.join(", ") + " })");
let block = "monitorv2 {\n";
block += " output = " + identifier + "\n";
if (outputSettings.supportsWideColor)
block += " supports_wide_color = true\n";
if (outputSettings.supportsHdr)
block += " supports_hdr = true\n";
if (outputSettings.sdrMinLuminance !== undefined)
block += " sdr_min_luminance = " + outputSettings.sdrMinLuminance + "\n";
if (outputSettings.sdrMaxLuminance !== undefined)
block += " sdr_max_luminance = " + outputSettings.sdrMaxLuminance + "\n";
if (outputSettings.minLuminance !== undefined)
block += " min_luminance = " + outputSettings.minLuminance + "\n";
if (outputSettings.maxLuminance !== undefined)
block += " max_luminance = " + outputSettings.maxLuminance + "\n";
if (outputSettings.maxAvgLuminance !== undefined)
block += " max_avg_luminance = " + outputSettings.maxAvgLuminance + "\n";
block += "}";
monitorv2Blocks.push(block);
}
}
if (monitorv2Blocks.length > 0) {
lines.push("");
for (const block of monitorv2Blocks)
lines.push(block);
} }
lines.push(""); lines.push("");
const content = lines.join("\n"); const content = lines.join("\n");
Proc.runCommand("hypr-write-outputs", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && cat > "${outputsPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => { Proc.runCommand("hypr-write-outputs", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && cat > "${outputsPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => {
@@ -196,17 +181,18 @@ Singleton {
const gaps = (typeof SettingsData !== "undefined" && SettingsData.hyprlandLayoutGapsOverride >= 0) ? SettingsData.hyprlandLayoutGapsOverride : defaultGaps; const gaps = (typeof SettingsData !== "undefined" && SettingsData.hyprlandLayoutGapsOverride >= 0) ? SettingsData.hyprlandLayoutGapsOverride : defaultGaps;
const borderSize = (typeof SettingsData !== "undefined" && SettingsData.hyprlandLayoutBorderSize >= 0) ? SettingsData.hyprlandLayoutBorderSize : defaultBorderSize; const borderSize = (typeof SettingsData !== "undefined" && SettingsData.hyprlandLayoutBorderSize >= 0) ? SettingsData.hyprlandLayoutBorderSize : defaultBorderSize;
let content = `# Auto-generated by DMS - do not edit manually let content = `-- Auto-generated by DMS do not edit manually
general { hl.config({
gaps_in = ${gaps} general = {
gaps_out = ${gaps} gaps_in = ${gaps},
border_size = ${borderSize} gaps_out = ${gaps},
} border_size = ${borderSize},
},
decoration { decoration = {
rounding = ${cornerRadius} rounding = ${cornerRadius},
} },
})
`; `;
Proc.runCommand("hypr-write-layout", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && cat > "${layoutPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => { Proc.runCommand("hypr-write-layout", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && cat > "${layoutPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => {
@@ -271,7 +257,7 @@ decoration {
const settings = typeof SettingsData !== "undefined" ? SettingsData.cursorSettings : null; const settings = typeof SettingsData !== "undefined" ? SettingsData.cursorSettings : null;
if (!settings) { if (!settings) {
Proc.runCommand("hypr-write-cursor", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && : > "${cursorPath}"`], (output, exitCode) => { Proc.runCommand("hypr-write-cursor", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && printf '%s\\n' "-- Auto-generated by DMS — do not edit manually" "" > "${cursorPath}"`], (output, exitCode) => {
if (exitCode !== 0) if (exitCode !== 0)
log.warn("Failed to write cursor config:", output); log.warn("Failed to write cursor config:", output);
}); });
@@ -289,32 +275,34 @@ decoration {
const hasCursorSettings = hideOnKeyPress || hideOnTouch || inactiveTimeout > 0; const hasCursorSettings = hideOnKeyPress || hideOnTouch || inactiveTimeout > 0;
if (!hasTheme && !hasNonDefaultSize && !hasCursorSettings) { if (!hasTheme && !hasNonDefaultSize && !hasCursorSettings) {
Proc.runCommand("hypr-write-cursor", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && : > "${cursorPath}"`], (output, exitCode) => { Proc.runCommand("hypr-write-cursor", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && printf '%s\\n' "-- Auto-generated by DMS — do not edit manually" "" > "${cursorPath}"`], (output, exitCode) => {
if (exitCode !== 0) if (exitCode !== 0)
log.warn("Failed to write cursor config:", output); log.warn("Failed to write cursor config:", output);
}); });
return; return;
} }
let lines = ["# Auto-generated by DMS - do not edit manually", ""]; let lines = ["-- Auto-generated by DMS do not edit manually", ""];
if (hasTheme) { if (hasTheme) {
lines.push(`env = HYPRCURSOR_THEME,${themeName}`); lines.push(`hl.env("HYPRCURSOR_THEME", ${luaQuoted(themeName)})`);
lines.push(`env = XCURSOR_THEME,${themeName}`); lines.push(`hl.env("XCURSOR_THEME", ${luaQuoted(themeName)})`);
} }
lines.push(`env = HYPRCURSOR_SIZE,${size}`); lines.push(`hl.env("HYPRCURSOR_SIZE", ${luaQuoted(String(size))})`);
lines.push(`env = XCURSOR_SIZE,${size}`); lines.push(`hl.env("XCURSOR_SIZE", ${luaQuoted(String(size))})`);
if (hasCursorSettings) { if (hasCursorSettings) {
lines.push(""); lines.push("");
lines.push("cursor {"); lines.push("hl.config({");
lines.push("\tcursor = {");
if (hideOnKeyPress) if (hideOnKeyPress)
lines.push(" hide_on_key_press = true"); lines.push("\t\thide_on_key_press = true,");
if (hideOnTouch) if (hideOnTouch)
lines.push(" hide_on_touch = true"); lines.push("\t\thide_on_touch = true,");
if (inactiveTimeout > 0) if (inactiveTimeout > 0)
lines.push(` inactive_timeout = ${inactiveTimeout}`); lines.push(`\t\tinactive_timeout = ${inactiveTimeout},`);
lines.push("}"); lines.push("\t},");
lines.push("})");
} }
lines.push(""); lines.push("");
+58 -8
View File
@@ -7,6 +7,7 @@ import Quickshell
import Quickshell.Io import Quickshell.Io
import qs.Common import qs.Common
import qs.Services import qs.Services
import "../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve
import "../Common/KeybindActions.js" as Actions import "../Common/KeybindActions.js" as Actions
Singleton { Singleton {
@@ -82,6 +83,7 @@ Singleton {
case "niri": case "niri":
return compositorConfigDir + "/dms/binds.kdl"; return compositorConfigDir + "/dms/binds.kdl";
case "hyprland": case "hyprland":
return compositorConfigDir + "/dms/binds.lua";
case "mangowc": case "mangowc":
return compositorConfigDir + "/dms/binds.conf"; return compositorConfigDir + "/dms/binds.conf";
default: default:
@@ -93,7 +95,7 @@ Singleton {
case "niri": case "niri":
return compositorConfigDir + "/config.kdl"; return compositorConfigDir + "/config.kdl";
case "hyprland": case "hyprland":
return compositorConfigDir + "/hyprland.conf"; return compositorConfigDir + "/hyprland.lua";
case "mangowc": case "mangowc":
return compositorConfigDir + "/config.conf"; return compositorConfigDir + "/config.conf";
default: default:
@@ -247,8 +249,8 @@ Singleton {
root.lastError = ""; root.lastError = "";
root.dmsBindsIncluded = true; root.dmsBindsIncluded = true;
root.dmsBindsFixed(); root.dmsBindsFixed();
const bindsFile = root.currentProvider === "niri" ? "dms/binds.kdl" : "dms/binds.conf"; const bindsRel = root.currentProvider === "niri" ? "dms/binds.kdl" : root.currentProvider === "hyprland" ? "dms/binds.lua" : "dms/binds.conf";
ToastService.showInfo(I18n.tr("Binds include added"), I18n.tr("%1 is now included in config").arg(bindsFile), "", "keybinds"); ToastService.showInfo(I18n.tr("Binds include added"), I18n.tr("%1 is now included in config").arg(bindsRel), "", "keybinds");
Qt.callLater(root.forceReload); Qt.callLater(root.forceReload);
} }
} }
@@ -262,13 +264,36 @@ Singleton {
let script; let script;
switch (currentProvider) { switch (currentProvider) {
case "niri": case "niri":
script = `mkdir -p "${compositorConfigDir}/dms" && touch "${compositorConfigDir}/dms/binds.kdl" && cp "${mainConfigPath}" "${backupPath}" && echo 'include "dms/binds.kdl"' >> "${mainConfigPath}"`; script = ConfigIncludeResolve.buildRepairScript({
configFile: mainConfigPath,
backupFile: backupPath,
fragmentFile: compositorConfigDir + "/dms/binds.kdl",
grepPattern: 'include.*"dms/binds.kdl"',
includeLine: 'include "dms/binds.kdl"'
});
break; break;
case "hyprland": case "hyprland":
script = `mkdir -p "${compositorConfigDir}/dms" && touch "${compositorConfigDir}/dms/binds.conf" && cp "${mainConfigPath}" "${backupPath}" && echo 'source = ./dms/binds.conf' >> "${mainConfigPath}"`; script = ConfigIncludeResolve.buildRepairScript({
configFile: mainConfigPath,
backupFile: backupPath,
fragmentFiles: [compositorConfigDir + "/dms/binds.lua", compositorConfigDir + "/dms/binds-user.lua"],
includes: [{
grepPattern: "dms.binds",
includeLine: "require(\"dms.binds\")"
}, {
grepPattern: "dms.binds-user",
includeLine: "require(\"dms.binds-user\")"
}]
});
break; break;
case "mangowc": case "mangowc":
script = `mkdir -p "${compositorConfigDir}/dms" && touch "${compositorConfigDir}/dms/binds.conf" && cp "${mainConfigPath}" "${backupPath}" && echo 'source = ./dms/binds.conf' >> "${mainConfigPath}"`; script = ConfigIncludeResolve.buildRepairScript({
configFile: mainConfigPath,
backupFile: backupPath,
fragmentFile: compositorConfigDir + "/dms/binds.conf",
grepPattern: "source.*dms/binds.conf",
includeLine: "source = ./dms/binds.conf"
});
break; break;
default: default:
fixing = false; fixing = false;
@@ -321,6 +346,7 @@ Singleton {
"statusMessage": status.statusMessage ?? "" "statusMessage": status.statusMessage ?? ""
}; };
} }
_maybeWarnHyprlandLegacyConf();
if (!_rawData?.binds) { if (!_rawData?.binds) {
_allBinds = {}; _allBinds = {};
@@ -365,10 +391,13 @@ Singleton {
for (var i = 0; i < binds.length; i++) { for (var i = 0; i < binds.length; i++) {
const bind = binds[i]; const bind = binds[i];
const action = bind.action || ""; const action = bind.action || "";
const sourceStr = bind.source || "config";
const keyData = { const keyData = {
"key": bind.key || "", "key": bind.key || "",
"source": bind.source || "config", "source": sourceStr,
"isOverride": bind.source === "dms", "isOverride": sourceStr === "dms",
"isDMSManaged": sourceStr === "dms" || sourceStr === "dms-default",
"hasDefault": bind.hasDefault === true,
"cooldownMs": bind.cooldownMs || 0, "cooldownMs": bind.cooldownMs || 0,
"flags": bind.flags || "", "flags": bind.flags || "",
"allowWhenLocked": bind.allowWhenLocked || false, "allowWhenLocked": bind.allowWhenLocked || false,
@@ -456,6 +485,19 @@ Singleton {
_pendingSavedKey = bindData.key; _pendingSavedKey = bindData.key;
} }
property bool _hyprlandLegacyWarnShown: false
function _maybeWarnHyprlandLegacyConf() {
if (_hyprlandLegacyWarnShown)
return;
if (currentProvider !== "hyprland")
return;
if (!dmsStatus.exists || dmsStatus.included)
return;
_hyprlandLegacyWarnShown = true;
ToastService.showWarning(I18n.tr("Hyprland config still uses hyprlang"), I18n.tr("DMS Settings now writes Lua. Edits won't apply until you migrate."), "dms setup", "hyprland-migration");
}
function removeBind(key) { function removeBind(key) {
if (!key) if (!key)
return; return;
@@ -464,6 +506,14 @@ Singleton {
bindRemoved(key); bindRemoved(key);
} }
function resetBind(key) {
if (!key)
return;
removeProcess.command = ["dms", "keybinds", "reset", currentProvider, key];
removeProcess.running = true;
bindRemoved(key);
}
function isDmsAction(action) { function isDmsAction(action) {
return Actions.isDmsAction(action); return Actions.isDmsAction(action);
} }
+18 -4
View File
@@ -66,13 +66,12 @@ Item {
signal toggleExpand signal toggleExpand
signal saveBind(string originalKey, var newData) signal saveBind(string originalKey, var newData)
signal removeBind(string key) signal removeBind(string key)
signal resetBind(string key)
signal cancelEdit signal cancelEdit
implicitHeight: contentColumn.implicitHeight implicitHeight: contentColumn.implicitHeight
height: implicitHeight height: implicitHeight
Component.onDestruction: _destroyShortcutInhibitor()
Component.onCompleted: { Component.onCompleted: {
if (isNew && isExpanded) if (isNew && isExpanded)
resetEdits(); resetEdits();
@@ -831,9 +830,12 @@ Item {
color: root._actionType === modelData.id ? Theme.surfaceContainerHighest : Theme.surfaceContainer color: root._actionType === modelData.id ? Theme.surfaceContainerHighest : Theme.surfaceContainer
border.color: root._actionType === modelData.id ? Theme.outline : (typeArea.containsMouse ? Theme.outlineVariant : "transparent") border.color: root._actionType === modelData.id ? Theme.outline : (typeArea.containsMouse ? Theme.outlineVariant : "transparent")
border.width: 1 border.width: 1
clip: true
RowLayout { RowLayout {
anchors.centerIn: parent anchors.fill: parent
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingXS spacing: Theme.spacingXS
DankIcon { DankIcon {
@@ -843,10 +845,13 @@ Item {
} }
StyledText { StyledText {
Layout.fillWidth: true
text: typeDelegate.modelData.label text: typeDelegate.modelData.label
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: root._actionType === typeDelegate.modelData.id ? Theme.surfaceText : Theme.surfaceVariantText color: root._actionType === typeDelegate.modelData.id ? Theme.surfaceText : Theme.surfaceVariantText
visible: typeDelegate.width > 100 visible: typeDelegate.width > 100
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
} }
} }
@@ -1763,10 +1768,19 @@ Item {
iconName: "delete" iconName: "delete"
iconSize: Theme.iconSize - 4 iconSize: Theme.iconSize - 4
iconColor: Theme.error iconColor: Theme.error
visible: root.editingKeyIndex >= 0 && root.editingKeyIndex < root.keys.length && root.keys[root.editingKeyIndex].isOverride && !root.isNew visible: root.editingKeyIndex >= 0 && root.editingKeyIndex < root.keys.length && (root.keys[root.editingKeyIndex].isDMSManaged || root.keys[root.editingKeyIndex].isOverride) && !root.isNew
onClicked: root.removeBind(root._originalKey) onClicked: root.removeBind(root._originalKey)
} }
DankButton {
text: I18n.tr("Reset to default")
buttonHeight: root._buttonHeight
backgroundColor: Theme.surfaceContainer
textColor: Theme.primary
visible: root.editingKeyIndex >= 0 && root.editingKeyIndex < root.keys.length && root.keys[root.editingKeyIndex].isOverride === true && root.keys[root.editingKeyIndex].hasDefault === true && !root.isNew
onClicked: root.resetBind(root._originalKey)
}
Item { Item {
Layout.fillWidth: true Layout.fillWidth: true
} }
+2 -2
View File
@@ -1,3 +1,3 @@
[templates.dmshyprland] [templates.dmshyprland]
input_path = 'SHELL_DIR/matugen/templates/hypr-colors.conf' input_path = 'SHELL_DIR/matugen/templates/hypr-colors.lua'
output_path = 'CONFIG_DIR/hypr/dms/colors.conf' output_path = 'CONFIG_DIR/hypr/dms/colors.lua'
@@ -1,25 +0,0 @@
# Auto-generated by DMS - do not edit manually
# Remove source = ./dms/colors.conf from your config to override.
$primary = rgb({{colors.primary.default.hex_stripped}})
$outline = rgb({{colors.outline.default.hex_stripped}})
$error = rgb({{colors.error.default.hex_stripped}})
general {
col.active_border = $primary
col.inactive_border = $outline
}
group {
col.border_active = $primary
col.border_inactive = $outline
col.border_locked_active = $error
col.border_locked_inactive = $outline
groupbar {
col.active = $primary
col.inactive = $outline
col.locked_active = $error
col.locked_inactive = $outline
}
}
@@ -0,0 +1,27 @@
-- Auto-generated by DMS Matugen hook — do not edit manually.
-- Remove require("dms.colors") from hyprland.lua to override.
hl.config({
general = {
col = {
active_border = "rgb({{colors.primary.default.hex_stripped}})",
inactive_border = "rgb({{colors.outline.default.hex_stripped}})",
},
},
group = {
col = {
border_active = "rgb({{colors.primary.default.hex_stripped}})",
border_inactive = "rgb({{colors.outline.default.hex_stripped}})",
border_locked_active = "rgb({{colors.error.default.hex_stripped}})",
border_locked_inactive = "rgb({{colors.outline.default.hex_stripped}})",
},
groupbar = {
col = {
active = "rgb({{colors.primary.default.hex_stripped}})",
inactive = "rgb({{colors.outline.default.hex_stripped}})",
locked_active = "rgb({{colors.error.default.hex_stripped}})",
locked_inactive = "rgb({{colors.outline.default.hex_stripped}})",
},
},
},
})