From 0b55bf5dacbd39c5a49087d03d79e912da30fc40 Mon Sep 17 00:00:00 2001 From: purian23 Date: Mon, 18 May 2026 13:06:58 -0400 Subject: [PATCH] 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 --- core/cmd/dms/commands_common.go | 2 + core/cmd/dms/commands_config.go | 49 +- core/cmd/dms/commands_keybinds.go | 29 +- core/cmd/dms/commands_setup.go | 40 +- core/cmd/dms/commands_windowrules.go | 18 +- core/internal/config/deployer.go | 289 ++++-- core/internal/config/deployer_test.go | 300 +++--- .../config/embedded/hypr-binds-user.lua | 1 + core/internal/config/embedded/hypr-binds.conf | 165 --- core/internal/config/embedded/hypr-binds.lua | 166 +++ .../internal/config/embedded/hypr-colors.conf | 25 - core/internal/config/embedded/hypr-colors.lua | 27 + core/internal/config/embedded/hypr-cursor.lua | 1 + .../internal/config/embedded/hypr-layout.conf | 11 - core/internal/config/embedded/hypr-layout.lua | 12 + .../internal/config/embedded/hypr-outputs.lua | 3 + .../config/embedded/hypr-windowrules.lua | 1 + core/internal/config/embedded/hyprland.conf | 117 --- core/internal/config/embedded/hyprland.lua | 84 ++ core/internal/config/hyprland.go | 28 +- core/internal/config/hyprland_lua.go | 169 ++++ core/internal/keybinds/providers/hyprland.go | 389 +++---- .../keybinds/providers/hyprland_parser.go | 572 ++++++++++- .../providers/hyprland_parser_test.go | 338 +++++++ core/internal/keybinds/providers/mangowc.go | 8 +- core/internal/keybinds/providers/niri.go | 8 +- core/internal/keybinds/types.go | 6 + core/internal/luaconfig/lua.go | 129 +++ core/internal/luaconfig/lua_test.go | 56 + core/internal/tui/views_config.go | 9 +- .../windowrules/providers/hyprland_parser.go | 957 +++++++++++++++--- .../providers/hyprland_parser_test.go | 103 +- docs/Hyprland_Lua_Migration.md | 193 ++++ quickshell/Common/ConfigIncludeResolve.js | 38 + quickshell/Modules/Greetd/README.md | 2 +- .../Modules/Greetd/assets/dms-hypr.conf | 1 + quickshell/Modules/Greetd/assets/dms-hypr.lua | 8 + .../Modules/Greetd/assets/greet-hyprland.sh | 4 +- .../DisplayConfig/DisplayConfigState.qml | 97 +- quickshell/Modules/Settings/KeybindsTab.qml | 42 +- .../Modules/Settings/ThemeColorsTab.qml | 21 +- .../Modules/Settings/WindowRulesTab.qml | 25 +- quickshell/Services/HyprlandService.qml | 126 ++- quickshell/Services/KeybindsService.qml | 66 +- quickshell/Widgets/KeybindItem.qml | 22 +- quickshell/matugen/configs/hyprland.toml | 4 +- quickshell/matugen/templates/hypr-colors.conf | 25 - quickshell/matugen/templates/hypr-colors.lua | 27 + 48 files changed, 3756 insertions(+), 1057 deletions(-) create mode 100644 core/internal/config/embedded/hypr-binds-user.lua delete mode 100644 core/internal/config/embedded/hypr-binds.conf create mode 100644 core/internal/config/embedded/hypr-binds.lua delete mode 100644 core/internal/config/embedded/hypr-colors.conf create mode 100644 core/internal/config/embedded/hypr-colors.lua create mode 100644 core/internal/config/embedded/hypr-cursor.lua delete mode 100644 core/internal/config/embedded/hypr-layout.conf create mode 100644 core/internal/config/embedded/hypr-layout.lua create mode 100644 core/internal/config/embedded/hypr-outputs.lua create mode 100644 core/internal/config/embedded/hypr-windowrules.lua delete mode 100644 core/internal/config/embedded/hyprland.conf create mode 100644 core/internal/config/embedded/hyprland.lua create mode 100644 core/internal/config/hyprland_lua.go create mode 100644 core/internal/luaconfig/lua.go create mode 100644 core/internal/luaconfig/lua_test.go create mode 100644 docs/Hyprland_Lua_Migration.md create mode 100644 quickshell/Common/ConfigIncludeResolve.js create mode 100644 quickshell/Modules/Greetd/assets/dms-hypr.lua delete mode 100644 quickshell/matugen/templates/hypr-colors.conf create mode 100644 quickshell/matugen/templates/hypr-colors.lua diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index b479814e..05c29c11 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" + "github.com/AvengeMedia/DankMaterialShell/core/internal/config" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/server" @@ -37,6 +38,7 @@ var runCmd = &cobra.Command{ } } log.ApplyEnvOverrides() + config.CleanupStrayHyprlandConfFile(log.Infof) if daemon { runShellDaemon(session) } else { diff --git a/core/cmd/dms/commands_config.go b/core/cmd/dms/commands_config.go index d604256c..fc2fe4af 100644 --- a/core/cmd/dms/commands_config.go +++ b/core/cmd/dms/commands_config.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/spf13/cobra" ) @@ -27,7 +28,21 @@ var resolveIncludeCmd = &cobra.Command{ case 0: return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp 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 }, @@ -82,17 +97,35 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) { result.Exists = true } - mainConfig := filepath.Join(configDir, "hyprland.conf") - if _, err := os.Stat(mainConfig); os.IsNotExist(err) { - return result, nil + targetAbs, err := filepath.Abs(targetPath) + if err != 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 } -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) if err != nil { return false @@ -141,7 +174,7 @@ func hyprlandFindInclude(filePath, target string, processed map[string]bool) boo continue } - if hyprlandFindInclude(expanded, target, processed) { + if hyprlandFindIncludeHyprlang(expanded, target, processed) { return true } } diff --git a/core/cmd/dms/commands_keybinds.go b/core/cmd/dms/commands_keybinds.go index cdb15ac0..91501142 100644 --- a/core/cmd/dms/commands_keybinds.go +++ b/core/cmd/dms/commands_keybinds.go @@ -51,12 +51,20 @@ var keybindsSetCmd = &cobra.Command{ var keybindsRemoveCmd = &cobra.Command{ Use: "remove ", - Short: "Remove a keybind override", - Long: "Remove a keybind override from the specified provider", + Short: "Remove a keybind", + 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), Run: runKeybindsRemove, } +var keybindsResetCmd = &cobra.Command{ + Use: "reset ", + 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() { keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON") keybindsShowCmd.Flags().String("path", "", "Override config path for the provider") @@ -72,6 +80,7 @@ func init() { keybindsCmd.AddCommand(keybindsShowCmd) keybindsCmd.AddCommand(keybindsSetCmd) keybindsCmd.AddCommand(keybindsRemoveCmd) + keybindsCmd.AddCommand(keybindsResetCmd) keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) { return providers.NewJSONFileProvider(filePath) @@ -263,3 +272,19 @@ func runKeybindsRemove(_ *cobra.Command, args []string) { }, "", " ") 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)) +} diff --git a/core/cmd/dms/commands_setup.go b/core/cmd/dms/commands_setup.go index 5f390656..80fa367f 100644 --- a/core/cmd/dms/commands_setup.go +++ b/core/cmd/dms/commands_setup.go @@ -109,25 +109,25 @@ type dmsConfigSpec struct { var dmsConfigSpecs = map[string]dmsConfigSpec{ "binds": { niriFile: "binds.kdl", - hyprFile: "binds.conf", + hyprFile: "binds.lua", niriContent: func(t string) string { return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t) }, hyprContent: func(t string) string { - return strings.ReplaceAll(config.HyprBindsConfig, "{{TERMINAL_COMMAND}}", t) + return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t) }, }, "layout": { niriFile: "layout.kdl", - hyprFile: "layout.conf", + hyprFile: "layout.lua", niriContent: func(_ string) string { return config.NiriLayoutConfig }, - hyprContent: func(_ string) string { return config.HyprLayoutConfig }, + hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig }, }, "colors": { niriFile: "colors.kdl", - hyprFile: "colors.conf", + hyprFile: "colors.lua", niriContent: func(_ string) string { return config.NiriColorsConfig }, - hyprContent: func(_ string) string { return config.HyprColorsConfig }, + hyprContent: func(_ string) string { return config.DMSColorsLuaConfig }, }, "alttab": { niriFile: "alttab.kdl", @@ -135,21 +135,21 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{ }, "outputs": { niriFile: "outputs.kdl", - hyprFile: "outputs.conf", + hyprFile: "outputs.lua", niriContent: func(_ string) string { return "" }, - hyprContent: func(_ string) string { return "" }, + hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig }, }, "cursor": { niriFile: "cursor.kdl", - hyprFile: "cursor.conf", + hyprFile: "cursor.lua", niriContent: func(_ string) string { return "" }, - hyprContent: func(_ string) string { return "" }, + hyprContent: func(_ string) string { return config.DMSCursorLuaConfig }, }, "windowrules": { niriFile: "windowrules.kdl", - hyprFile: "windowrules.conf", + hyprFile: "windowrules.lua", 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 if wmSelected { - var configPath string + var configPaths []string switch wm { case deps.WindowManagerNiri: - configPath = filepath.Join(homeDir, ".config", "niri", "config.kdl") + configPaths = []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")} 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 { - willBackup = true + for _, configPath := range configPaths { + if _, err := os.Stat(configPath); err == nil { + willBackup = true + break + } } } diff --git a/core/cmd/dms/commands_windowrules.go b/core/cmd/dms/commands_windowrules.go index 0883811d..c11d75c5 100644 --- a/core/cmd/dms/commands_windowrules.go +++ b/core/cmd/dms/commands_windowrules.go @@ -26,7 +26,7 @@ var windowrulesListCmd = &cobra.Command{ Args: cobra.MaximumNArgs(1), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -40,8 +40,7 @@ var windowrulesAddCmd = &cobra.Command{ Args: cobra.ExactArgs(2), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - // ! disabled hyprland return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp - return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -55,7 +54,7 @@ var windowrulesUpdateCmd = &cobra.Command{ Args: cobra.ExactArgs(3), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -69,7 +68,7 @@ var windowrulesRemoveCmd = &cobra.Command{ Args: cobra.ExactArgs(2), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -83,7 +82,7 @@ var windowrulesReorderCmd = &cobra.Command{ Args: cobra.ExactArgs(2), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -118,9 +117,9 @@ func getCompositor(args []string) string { if os.Getenv("NIRI_SOCKET") != "" { return "niri" } - // if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" { - // return "hyprland" - // } + if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" { + return "hyprland" + } return "" } @@ -183,7 +182,6 @@ func runWindowrulesList(cmd *cobra.Command, args []string) { result.DMSStatus = parseResult.DMSStatus case "hyprland": - log.Fatalf("Hyprland support is currently disabled.") // ! disabled hyprland configDir, err := utils.ExpandPath("$HOME/.config/hypr") if err != nil { log.Fatalf("Failed to expand hyprland config path: %v", err) diff --git a/core/internal/config/deployer.go b/core/internal/config/deployer.go index 7cc513f2..fdbb09b1 100644 --- a/core/internal/config/deployer.go +++ b/core/internal/config/deployer.go @@ -12,6 +12,8 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" ) +const hyprlandBackupDirName = ".dms-backups" + type ConfigDeployer struct { logChan chan<- string } @@ -63,12 +65,23 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d var results []DeploymentResult // Primary config file paths used to detect fresh installs. - configPrimaryPaths := map[string]string{ - "Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"), - "Hyprland": 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"), + configPrimaryPaths := map[string][]string{ + "Niri": { + filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"), + }, + "Hyprland": { + 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 { @@ -81,8 +94,15 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d } // Config is explicitly set to "don't replace" — but still deploy // if the config file doesn't exist yet (fresh install scenario). - if primaryPath, ok := configPrimaryPaths[configType]; ok { - if _, err := os.Stat(primaryPath); os.IsNotExist(err) { + if primaryPaths, ok := configPrimaryPaths[configType]; ok { + exists := false + for _, primaryPath := range primaryPaths { + if _, err := os.Stat(primaryPath); err == nil { + exists = true + break + } + } + if !exists { return true } } @@ -495,7 +515,7 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) { result := DeploymentResult{ 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) @@ -510,20 +530,20 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem return result, result.Error } + timestamp := time.Now().Format("2006-01-02_15-04-05") + backupDir := filepath.Join(configDir, hyprlandBackupDirName, timestamp) var existingConfig string - if _, err := os.Stat(result.Path); err == nil { - cd.log("Found existing Hyprland configuration") + existingData, existingPath, err := readExistingHyprlandConfig(configDir) + 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) - if 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.BackupPath = filepath.Join(backupDir, filepath.Base(existingPath)) + if err := backupHyprlandConfigFile(existingPath, result.BackupPath, []byte(existingData), strings.EqualFold(filepath.Ext(existingPath), ".conf")); err != nil { result.Error = fmt.Errorf("failed to create backup: %w", err) return result, result.Error } @@ -542,10 +562,10 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem terminalCommand = "ghostty" } - newConfig := strings.ReplaceAll(HyprlandConfig, "{{TERMINAL_COMMAND}}", terminalCommand) + newConfig := strings.ReplaceAll(HyprlandLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand) if !useSystemd { - newConfig = cd.transformHyprlandConfigForNonSystemd(newConfig, terminalCommand) + newConfig = transformHyprlandLuaForNonSystemd(newConfig, terminalCommand) } if existingConfig != "" { @@ -563,6 +583,18 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem 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 { result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) return result, result.Error @@ -573,29 +605,118 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem 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 { configs := []struct { - name string - content string + name string + content string + overwrite bool }{ - {"colors.conf", HyprColorsConfig}, - {"layout.conf", HyprLayoutConfig}, - {"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)}, - {"outputs.conf", ""}, - {"cursor.conf", ""}, - {"windowrules.conf", ""}, + {name: "colors.lua", content: DMSColorsLuaConfig}, + {name: "layout.lua", content: DMSLayoutLuaConfig}, + {name: "binds.lua", content: strings.ReplaceAll(DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand), overwrite: true}, + {name: "binds-user.lua", content: DMSBindsUserLuaConfig}, + {name: "outputs.lua", content: DMSOutputsLuaConfig}, + {name: "cursor.lua", content: DMSCursorLuaConfig}, + {name: "windowrules.lua", content: DMSWindowRulesLuaConfig}, } for _, cfg := range configs { 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 { + existed = true + } + if existed && !cfg.overwrite { cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name)) continue } if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil { 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)) } @@ -603,94 +724,42 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman } func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) { - monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`) - existingMonitors := monitorRegex.FindAllString(existingConfig, -1) - - if len(existingMonitors) == 0 { + _ = newConfig + lines := extractHyprlangMonitorLines(existingConfig) + if len(lines) == 0 { return newConfig, nil } - outputsPath := filepath.Join(dmsDir, "outputs.conf") - if _, err := os.Stat(outputsPath); err != nil { - var outputsContent strings.Builder - for _, monitor := range existingMonitors { - 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") - } + outputsPath := filepath.Join(dmsDir, "outputs.lua") + if info, err := os.Stat(outputsPath); err == nil && info.Size() > 0 { + cd.log("Skipping monitor migration: dms/outputs.lua already exists") + return newConfig, nil } - exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`) - mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "") - - 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 - + var b strings.Builder + b.WriteString("-- Migrated from existing hyprlang monitor lines\n\n") + ok := 0 for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "exec-once = dbus-update-activation-environment") { + lua, err := hyprlangMonitorLineToLua(line) + if err != nil { + cd.log(fmt.Sprintf("Warning: could not migrate monitor line %q: %v", line, err)) continue } - if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") { - startupSectionFound = true - result = append(result, "exec-once = dms run") - 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) + b.WriteString(lua) + b.WriteByte('\n') + ok++ } - - if !startupSectionFound { - 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 - } - } + if ok == 0 { + return newConfig, nil } - - return strings.Join(result, "\n") + b.WriteByte('\n') + b.WriteString("-- Default fallback\n") + b.WriteString("hl.monitor({ output = \"\", mode = \"preferred\", position = \"auto\", scale = \"auto\" })\n") + if err := os.WriteFile(outputsPath, []byte(b.String()), 0o644); err != nil { + return newConfig, err + } + cd.log("Migrated monitor sections to dms/outputs.lua") + return newConfig, nil } func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string { diff --git a/core/internal/config/deployer_test.go b/core/internal/config/deployer_test.go index 37266340..d3645b95 100644 --- a/core/internal/config/deployer_test.go +++ b/core/internal/config/deployer_test.go @@ -259,130 +259,56 @@ func getGhosttyPath() string { func TestMergeHyprlandMonitorSections(t *testing.T) { cd := &ConfigDeployer{} - tests := []struct { - name string - newConfig string - existingConfig string - wantError bool - wantContains []string - wantNotContains []string - }{ - { - name: "no existing monitors", - newConfig: `# ================== -# MONITOR CONFIG -# ================== -# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1 + t.Run("no monitors in existing", func(t *testing.T) { + tmp := t.TempDir() + out, err := cd.mergeHyprlandMonitorSections(`hl.config({})`, `input { kb_layout = us }`, tmp) + require.NoError(t, err) + assert.Equal(t, `hl.config({})`, out) + _, e := os.Stat(filepath.Join(tmp, "outputs.lua")) + assert.True(t, os.IsNotExist(e)) + }) -# ================== -# ENVIRONMENT VARS -# ================== -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 + t.Run("writes outputs lua from hyprlang monitors", func(t *testing.T) { + tmp := t.TempDir() + existing := `monitor = DP-1, 1920x1080@144, 0x0, 1 # monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1 -monitor = eDP-1, 2560x1440@165, auto, 1.25`, - wantError: false, - wantContains: []string{ - "monitor = DP-1", - "# monitor = HDMI-A-1", // Commented monitor preserved - "monitor = eDP-1", - "Monitors from existing configuration", - }, - wantNotContains: []string{ - "monitor = eDP-2", // Example monitor should be removed - }, - }, - { - name: "preserve commented monitors", - newConfig: `# ================== -# MONITOR CONFIG -# ================== -# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1 +monitor = eDP-1, 2560x1440@165, auto, 1.25` + out, err := cd.mergeHyprlandMonitorSections(`return`, existing, tmp) + require.NoError(t, err) + assert.Equal(t, `return`, out) + b, err := os.ReadFile(filepath.Join(tmp, "outputs.lua")) + require.NoError(t, err) + s := string(b) + assert.Contains(t, s, "hl.monitor") + assert.Contains(t, s, "DP-1") + assert.Contains(t, s, "HDMI-A-1") + assert.Contains(t, s, "eDP-1") + assert.Contains(t, s, "preferred") // fallback rule at end + }) -# ==================`, - existingConfig: `# monitor = DP-1, 1920x1080@144, 0x0, 1 -# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1`, - wantError: false, - wantContains: []string{ - "# monitor = DP-1", - "# monitor = HDMI-A-1", - "Monitors from existing configuration", - }, - }, - { - 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, - }, - } + t.Run("skips when outputs lua already exists", func(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "outputs.lua") + require.NoError(t, os.WriteFile(path, []byte("-- keep\n"), 0o644)) + _, err := cd.mergeHyprlandMonitorSections(`x`, `monitor = DP-1, 1920x1080@144, 0x0, 1`, tmp) + require.NoError(t, err) + b, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "-- keep\n", string(b)) + }) +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpDir := t.TempDir() - result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig, tmpDir) +func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) { + got, err := hyprlangMonitorLineToLua(`monitor = DP-1, 1920x1080@144, 0x0, 1, transform, 1, vrr, 2, bitdepth, 10, cm, hdr, sdrbrightness, 1.2, sdrsaturation, 0.98`) + require.NoError(t, err) - if tt.wantError { - assert.Error(t, err) - return - } - - require.NoError(t, err) - - 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) - } - }) - } + assert.Contains(t, got, `output = "DP-1"`) + assert.Contains(t, got, `transform = 1`) + assert.Contains(t, got, `vrr = 2`) + assert.Contains(t, got, `bitdepth = 10`) + assert.Contains(t, got, `cm = "hdr"`) + assert.Contains(t, got, `sdrbrightness = 1.2`) + assert.Contains(t, got, `sdrsaturation = 0.98`) } func TestHyprlandConfigDeployment(t *testing.T) { @@ -398,6 +324,10 @@ func TestHyprlandConfigDeployment(t *testing.T) { cd := NewConfigDeployer(logChan) 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) require.NoError(t, err) @@ -408,12 +338,16 @@ func TestHyprlandConfigDeployment(t *testing.T) { content, err := os.ReadFile(result.Path) require.NoError(t, err) - assert.Contains(t, string(content), "# MONITOR CONFIG") - assert.Contains(t, string(content), "source = ./dms/binds.conf") - assert.Contains(t, string(content), "exec-once = ") + assert.Contains(t, string(content), `require("dms.binds")`) + assert.Contains(t, string(content), "DMS_STARTUP_BEGIN") + assert.Contains(t, string(content), "hl.config(") }) 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 monitor = DP-1, 1920x1080@144, 0x0, 1 monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5 @@ -422,11 +356,17 @@ general { gaps_in = 10 } ` - hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf") - err := os.MkdirAll(filepath.Dir(hyprPath), 0o755) + hyprPath := filepath.Join(td, ".config", "hypr", "hyprland.conf") + err = os.MkdirAll(filepath.Dir(hyprPath), 0o755) require.NoError(t, err) err = os.WriteFile(hyprPath, []byte(existingContent), 0o644) require.NoError(t, err) + dmsDir := filepath.Join(td, ".config", "hypr", "dms") + require.NoError(t, os.MkdirAll(dmsDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf"), []byte("bind = SUPER, T, exec, foot\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "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) require.NoError(t, err) @@ -440,13 +380,76 @@ general { backupContent, err := os.ReadFile(result.BackupPath) require.NoError(t, err) assert.Equal(t, existingContent, string(backupContent)) + assert.Contains(t, result.BackupPath, hyprlandBackupDirName) + assert.NoFileExists(t, hyprPath) + assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf")) + assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "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) require.NoError(t, err) - assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144") - assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60") - assert.Contains(t, string(newContent), "source = ./dms/binds.conf") - assert.NotContains(t, string(newContent), "monitor = eDP-2") + assert.Contains(t, string(newContent), `require("dms.binds")`) + + outputsPath := filepath.Join(td, ".config", "hypr", "dms", "outputs.lua") + 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) { - assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG") - assert.Contains(t, HyprlandConfig, "# STARTUP APPS") - assert.Contains(t, HyprlandConfig, "# INPUT CONFIG") - assert.Contains(t, HyprlandConfig, "source = ./dms/binds.conf") + assert.Contains(t, HyprlandLuaConfig, `require("dms.binds")`) + assert.Contains(t, HyprlandLuaConfig, "DMS_STARTUP_BEGIN") + assert.Contains(t, HyprlandLuaConfig, "hl.config(") + assert.Contains(t, HyprlandLuaConfig, "input =") } 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") }) + + 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") + } + } + }) } diff --git a/core/internal/config/embedded/hypr-binds-user.lua b/core/internal/config/embedded/hypr-binds-user.lua new file mode 100644 index 00000000..c410a95b --- /dev/null +++ b/core/internal/config/embedded/hypr-binds-user.lua @@ -0,0 +1 @@ +-- Optional per-user keybind overrides (managed by DMS). Loaded after default binds. diff --git a/core/internal/config/embedded/hypr-binds.conf b/core/internal/config/embedded/hypr-binds.conf deleted file mode 100644 index f7b74fb4..00000000 --- a/core/internal/config/embedded/hypr-binds.conf +++ /dev/null @@ -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 diff --git a/core/internal/config/embedded/hypr-binds.lua b/core/internal/config/embedded/hypr-binds.lua new file mode 100644 index 00000000..6b16c06e --- /dev/null +++ b/core/internal/config/embedded/hypr-binds.lua @@ -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" })) diff --git a/core/internal/config/embedded/hypr-colors.conf b/core/internal/config/embedded/hypr-colors.conf deleted file mode 100644 index 38bcb0ea..00000000 --- a/core/internal/config/embedded/hypr-colors.conf +++ /dev/null @@ -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 - } -} diff --git a/core/internal/config/embedded/hypr-colors.lua b/core/internal/config/embedded/hypr-colors.lua new file mode 100644 index 00000000..f0f451b0 --- /dev/null +++ b/core/internal/config/embedded/hypr-colors.lua @@ -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)", + }, + }, + }, +}) diff --git a/core/internal/config/embedded/hypr-cursor.lua b/core/internal/config/embedded/hypr-cursor.lua new file mode 100644 index 00000000..9c0548ab --- /dev/null +++ b/core/internal/config/embedded/hypr-cursor.lua @@ -0,0 +1 @@ +-- Cursor theme overrides. Deploy writes ~/.config/hypr/dms/cursor.lua diff --git a/core/internal/config/embedded/hypr-layout.conf b/core/internal/config/embedded/hypr-layout.conf deleted file mode 100644 index b9a8ad9c..00000000 --- a/core/internal/config/embedded/hypr-layout.conf +++ /dev/null @@ -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 -} diff --git a/core/internal/config/embedded/hypr-layout.lua b/core/internal/config/embedded/hypr-layout.lua new file mode 100644 index 00000000..a9aa3cc2 --- /dev/null +++ b/core/internal/config/embedded/hypr-layout.lua @@ -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, + }, +}) diff --git a/core/internal/config/embedded/hypr-outputs.lua b/core/internal/config/embedded/hypr-outputs.lua new file mode 100644 index 00000000..b401a0c1 --- /dev/null +++ b/core/internal/config/embedded/hypr-outputs.lua @@ -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" }) diff --git a/core/internal/config/embedded/hypr-windowrules.lua b/core/internal/config/embedded/hypr-windowrules.lua new file mode 100644 index 00000000..278150f6 --- /dev/null +++ b/core/internal/config/embedded/hypr-windowrules.lua @@ -0,0 +1 @@ +-- Window rules. Deploy writes ~/.config/hypr/dms/windowrules.lua diff --git a/core/internal/config/embedded/hyprland.conf b/core/internal/config/embedded/hyprland.conf deleted file mode 100644 index d9cadb26..00000000 --- a/core/internal/config/embedded/hyprland.conf +++ /dev/null @@ -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 diff --git a/core/internal/config/embedded/hyprland.lua b/core/internal/config/embedded/hyprland.lua new file mode 100644 index 00000000..f23695f1 --- /dev/null +++ b/core/internal/config/embedded/hyprland.lua @@ -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") diff --git a/core/internal/config/hyprland.go b/core/internal/config/hyprland.go index f6bb9649..27f9db6b 100644 --- a/core/internal/config/hyprland.go +++ b/core/internal/config/hyprland.go @@ -2,14 +2,26 @@ package config import _ "embed" -//go:embed embedded/hyprland.conf -var HyprlandConfig string +//go:embed embedded/hyprland.lua +var HyprlandLuaConfig string -//go:embed embedded/hypr-colors.conf -var HyprColorsConfig string +//go:embed embedded/hypr-colors.lua +var DMSColorsLuaConfig string -//go:embed embedded/hypr-layout.conf -var HyprLayoutConfig string +//go:embed embedded/hypr-layout.lua +var DMSLayoutLuaConfig string -//go:embed embedded/hypr-binds.conf -var HyprBindsConfig string +//go:embed embedded/hypr-binds.lua +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 diff --git a/core/internal/config/hyprland_lua.go b/core/internal/config/hyprland_lua.go new file mode 100644 index 00000000..52281965 --- /dev/null +++ b/core/internal/config/hyprland_lua.go @@ -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// 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) + } +} diff --git a/core/internal/keybinds/providers/hyprland.go b/core/internal/keybinds/providers/hyprland.go index e43a9ebf..30858218 100644 --- a/core/internal/keybinds/providers/hyprland.go +++ b/core/internal/keybinds/providers/hyprland.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" @@ -48,7 +49,7 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { h.parsed = true 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{ Title: "Hyprland Keybinds", @@ -88,7 +89,7 @@ func (h *HyprlandProvider) HasDMSBindsIncluded() bool { 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 if section.Name != "" { currentSubcat = section.Name @@ -96,12 +97,12 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory for _, kb := range section.Keybinds { category := h.categorizeByDispatcher(kb.Dispatcher) - bind := h.convertKeybind(&kb, currentSubcat, conflicts) + bind := h.convertKeybind(&kb, currentSubcat, conflicts, defaultKeys) categorizedBinds[category] = append(categorizedBinds[category], bind) } 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) rawAction := h.formatRawAction(kb.Dispatcher, kb.Params) desc := kb.Comment @@ -143,8 +144,15 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st } source := "config" - if strings.Contains(kb.Source, "dms/binds.conf") { + if isDMSBindsUserOverridePath(kb.Source) { 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{ @@ -154,9 +162,10 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st Subcategory: subcategory, Source: source, Flags: kb.Flags, + HasDefault: hasDefault, } - if source == "dms" && conflicts != nil { + if (source == "dms" || source == "dms-default") && conflicts != nil { normalizedKey := strings.ToLower(keyStr) if conflictKb, ok := conflicts[normalizedKey]; ok { bind.Conflict = &keybinds.Keybind{ @@ -188,9 +197,9 @@ func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string { func (h *HyprlandProvider) GetOverridePath() string { expanded, err := utils.ExpandPath(h.configPath) 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 { @@ -250,7 +259,16 @@ func (h *HyprlandProvider) RemoveBind(key string) error { if err != 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) delete(existingBinds, normalizedKey) return h.writeOverrideBinds(existingBinds) @@ -262,116 +280,12 @@ type hyprlandOverrideBind struct { 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 Options map[string]any + // Unbind: negative override (hl.unbind only, no rebind). + Unbind bool } func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) { - overridePath := 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, "+") + return readLuaOrHyprlangOverride(h.GetOverridePath()) } func (h *HyprlandProvider) getBindSortPriority(action string) int { @@ -420,78 +334,203 @@ func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverri }) 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 { - h.writeBindLine(&sb, bind) + writeLuaBindLine(&sb, bind) } return sb.String() } -func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) { - mods, key := h.parseKeyString(bind.Key) - dispatcher, params := h.parseAction(bind.Action) - - // Write bind type with flags (e.g., "bind", "binde", "bindel") - sb.WriteString("bind") - if bind.Flags != "" { - sb.WriteString(bind.Flags) +func formatLuaBindKey(internalKey string) string { + internalKey = strings.TrimSpace(internalKey) + parts := strings.Split(internalKey, "+") + for i := range parts { + parts[i] = normalizeLuaBindKeyPart(strings.TrimSpace(parts[i])) } - sb.WriteString(" = ") - 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") + return strings.Join(parts, " + ") } -func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) { - parts := strings.Split(keyStr, "+") - switch len(parts) { - case 0: - return "", keyStr - case 1: - return "", parts[0] +func normalizeLuaBindKeyPart(part string) string { + switch strings.ToLower(part) { + case "super", "mod4", "mainmod": + return "SUPER" + case "ctrl", "control": + return "CTRL" + 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: - 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) { - parts := strings.SplitN(action, " ", 2) - switch len(parts) { - case 0: - return action, "" - case 1: - dispatcher = parts[0] - default: - dispatcher = parts[0] - params = parts[1] +func luaExprToInternalAction(expr string) string { + d, p := luaExprToDispatcherParams(expr) + if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") { + return "exec " + p } - - // Convert internal spawn format to Hyprland's exec - if dispatcher == "spawn" { - dispatcher = "exec" + if p != "" { + return d + " " + p } - - return dispatcher, params + return d +} + +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 } diff --git a/core/internal/keybinds/providers/hyprland_parser.go b/core/internal/keybinds/providers/hyprland_parser.go index 04b5adac..4b8dc885 100644 --- a/core/internal/keybinds/providers/hyprland_parser.go +++ b/core/internal/keybinds/providers/hyprland_parser.go @@ -4,8 +4,10 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" + "github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" ) @@ -50,6 +52,8 @@ type HyprlandParser struct { bindOrder []string processedFiles map[string]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 { @@ -64,6 +68,8 @@ func NewHyprlandParser(configDir string) *HyprlandParser { bindMap: make(map[string]*HyprlandKeyBinding), bindOrder: []string{}, processedFiles: make(map[string]bool), + removedKeys: make(map[string]bool), + defaultDMSKeys: make(map[string]bool), } } @@ -292,6 +298,7 @@ type HyprlandParseResult struct { DMSBindsIncluded bool DMSStatus *HyprlandDMSStatus ConflictingConfigs map[string]*HyprlandKeyBinding + DefaultDMSKeys map[string]bool // keys with a DMS default in binds.{lua,conf} } type HyprlandDMSStatus struct { @@ -317,10 +324,10 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus { switch { case !p.dmsBindsExists: 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: 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: status.Effective = true status.OverriddenBy = p.bindsAfterDMS @@ -347,8 +354,11 @@ func (p *HyprlandParser) normalizeKey(key string) string { func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool { key := p.formatBindKey(kb) 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 { p.dmsBindKeys[normalizedKey] = true } else if p.dmsBindKeys[normalizedKey] { @@ -373,12 +383,21 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) { return nil, err } - dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf") - if _, err := os.Stat(dmsBindsPath); err == nil { + dmsBindsLua := filepath.Join(expandedDir, "dms", "binds.lua") + dmsBindsConf := filepath.Join(expandedDir, "dms", "binds.conf") + dmsBindsPath := "" + if _, err := os.Stat(dmsBindsLua); err == nil { 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, "") if err != nil { return nil, err @@ -387,10 +406,65 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) { if p.dmsBindsExists && !p.dmsProcessed { p.parseDMSBindsDirectly(dmsBindsPath, section) } + p.removeShadowedDMSBinds(section) + p.removeUnboundDMSBinds(section) 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(§ion.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 := §ion.Keybinds[i] + if isDMSBindsSourcePath(kb.Source) { + counts[p.normalizeKey(p.formatBindKey(kb))]++ + } + } + for i := range section.Children { + p.countDMSBinds(§ion.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(§ion.Children[i], counts) + } +} + func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) { absPath, err := filepath.Abs(filePath) if err != nil { @@ -407,6 +481,10 @@ func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*Hyp return nil, err } + if strings.EqualFold(filepath.Ext(absPath), ".lua") { + return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName) + } + prevSource := p.currentSource p.currentSource = absPath @@ -446,7 +524,7 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas } sourcePath := strings.TrimSpace(parts[1]) - isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf") + isDMSSource := isDMSBindsPrimarySourcePath(sourcePath) p.includeCount++ if isDMSSource { @@ -474,6 +552,17 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas } 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) if err != nil { return @@ -503,6 +592,124 @@ func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *Hyp 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 { parts := strings.SplitN(line, "=", 2) if len(parts) < 2 { @@ -623,5 +830,356 @@ func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) { DMSBindsIncluded: parser.dmsBindsIncluded, DMSStatus: parser.buildDMSStatus(), ConflictingConfigs: parser.conflictingConfigs, + DefaultDMSKeys: parser.defaultDMSKeys, }, 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 +} diff --git a/core/internal/keybinds/providers/hyprland_parser_test.go b/core/internal/keybinds/providers/hyprland_parser_test.go index c2872e5e..1b7be712 100644 --- a/core/internal/keybinds/providers/hyprland_parser_test.go +++ b/core/internal/keybinds/providers/hyprland_parser_test.go @@ -3,7 +3,10 @@ package providers import ( "os" "path/filepath" + "strings" "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" ) 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) { tests := []struct { name string diff --git a/core/internal/keybinds/providers/mangowc.go b/core/internal/keybinds/providers/mangowc.go index 348ee7c5..daa73499 100644 --- a/core/internal/keybinds/providers/mangowc.go +++ b/core/internal/keybinds/providers/mangowc.go @@ -141,7 +141,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st source := "config" 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{ @@ -151,7 +151,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st Source: source, } - if source == "dms" && conflicts != nil { + if source == "dms-default" && conflicts != nil { normalizedKey := strings.ToLower(keyStr) if conflictKb, ok := conflicts[normalizedKey]; ok { bind.Conflict = &keybinds.Keybind{ @@ -249,6 +249,10 @@ func (m *MangoWCProvider) RemoveBind(key string) error { return m.writeOverrideBinds(existingBinds) } +func (m *MangoWCProvider) ResetBind(key string) error { + return m.RemoveBind(key) +} + type mangowcOverrideBind struct { Key string Action string diff --git a/core/internal/keybinds/providers/niri.go b/core/internal/keybinds/providers/niri.go index c6a0f157..90d2880a 100644 --- a/core/internal/keybinds/providers/niri.go +++ b/core/internal/keybinds/providers/niri.go @@ -149,7 +149,7 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co source := "config" if strings.Contains(kb.Source, "dms/binds.kdl") { - source = "dms" + source = "dms-default" } bind := keybinds.Keybind{ @@ -165,7 +165,7 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co Repeat: kb.Repeat, } - if source == "dms" && conflicts != nil { + if source == "dms-default" && conflicts != nil { if conflictKb, ok := conflicts[keyStr]; ok { bind.Conflict = &keybinds.Keybind{ Key: keyStr, @@ -269,6 +269,10 @@ func (n *NiriProvider) RemoveBind(key string) error { return n.writeOverrideBinds(existingBinds) } +func (n *NiriProvider) ResetBind(key string) error { + return n.RemoveBind(key) +} + type overrideBind struct { Key string Action string diff --git a/core/internal/keybinds/types.go b/core/internal/keybinds/types.go index 109b53b6..c480474f 100644 --- a/core/internal/keybinds/types.go +++ b/core/internal/keybinds/types.go @@ -13,6 +13,7 @@ type Keybind struct { AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled Conflict *Keybind `json:"conflict,omitempty"` + HasDefault bool `json:"hasDefault,omitempty"` // override has a DMS default to revert to } type DMSBindsStatus struct { @@ -42,6 +43,11 @@ type Provider interface { type WritableProvider interface { Provider 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 + // ResetBind reverts a user override to its DMS default. On single-file + // providers this aliases to RemoveBind. + ResetBind(key string) error GetOverridePath() string } diff --git a/core/internal/luaconfig/lua.go b/core/internal/luaconfig/lua.go new file mode 100644 index 00000000..cfc6573b --- /dev/null +++ b/core/internal/luaconfig/lua.go @@ -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 +} diff --git a/core/internal/luaconfig/lua_test.go b/core/internal/luaconfig/lua_test.go new file mode 100644 index 00000000..faa858fb --- /dev/null +++ b/core/internal/luaconfig/lua_test.go @@ -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") + } +} diff --git a/core/internal/tui/views_config.go b/core/internal/tui/views_config.go index 14daff37..09b6978b 100644 --- a/core/internal/tui/views_config.go +++ b/core/internal/tui/views_config.go @@ -300,9 +300,14 @@ func (m Model) checkExistingConfigurations() tea.Cmd { Exists: niriExists, }) } 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 - 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 } configs = append(configs, ExistingConfigInfo{ diff --git a/core/internal/windowrules/providers/hyprland_parser.go b/core/internal/windowrules/providers/hyprland_parser.go index 918c4f92..7ed17939 100644 --- a/core/internal/windowrules/providers/hyprland_parser.go +++ b/core/internal/windowrules/providers/hyprland_parser.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules" ) @@ -24,6 +25,11 @@ type HyprlandWindowRule struct { Value string Source string RawLine string + + // CombinedActions is populated from single hl.window_rule({ … }) Lua calls where + // multiple actions apply together. When non-nil it takes precedence over Rule/Value + // in ConvertHyprlandRulesToWindowRules. + CombinedActions *windowrules.Actions `json:"-"` } type HyprlandRulesParser struct { @@ -32,19 +38,24 @@ type HyprlandRulesParser struct { rules []HyprlandWindowRule currentSource string dmsRulesExists bool + dmsPrimaryPath string // dms/windowrules.lua preferred, else dms/windowrules.conf when present dmsRulesIncluded bool includeCount int dmsIncludePos int rulesAfterDMS int dmsProcessed bool + + requireLineInMain int // hyprland.lua line (1-based) where require("dms.windowrules") occurs; else -1 + primaryHyprLua string // absolute path to ~/.config/hypr/hyprland.lua when that is the main config } func NewHyprlandRulesParser(configDir string) *HyprlandRulesParser { return &HyprlandRulesParser{ - configDir: configDir, - processedFiles: make(map[string]bool), - rules: []HyprlandWindowRule{}, - dmsIncludePos: -1, + configDir: configDir, + processedFiles: make(map[string]bool), + rules: []HyprlandWindowRule{}, + dmsIncludePos: -1, + requireLineInMain: -1, } } @@ -54,18 +65,35 @@ func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) { return nil, err } - dmsRulesPath := filepath.Join(expandedDir, "dms", "windowrules.conf") - if _, err := os.Stat(dmsRulesPath); err == nil { + dmsLua := filepath.Join(expandedDir, "dms", "windowrules.lua") + dmsConf := filepath.Join(expandedDir, "dms", "windowrules.conf") + + if _, err := os.Stat(dmsLua); err == nil { p.dmsRulesExists = true + p.dmsPrimaryPath = dmsLua + } else if _, err := os.Stat(dmsConf); err == nil { + p.dmsRulesExists = true + p.dmsPrimaryPath = dmsConf + } + + mainConfig, err := hyprlandMainConfigPath(expandedDir) + if err != nil { + return nil, err + } + + if strings.EqualFold(filepath.Ext(mainConfig), ".lua") { + p.probeRequireWindowrulesLine(mainConfig) + if ap, err := filepath.Abs(mainConfig); err == nil { + p.primaryHyprLua = ap + } } - mainConfig := filepath.Join(expandedDir, "hyprland.conf") if err := p.parseFile(mainConfig); err != nil { return nil, err } if p.dmsRulesExists && !p.dmsProcessed { - p.parseDMSRulesDirectly(dmsRulesPath) + p.parseDMSRulesDirectly(p.dmsPrimaryPath) } return p.rules, nil @@ -77,12 +105,21 @@ func (p *HyprlandRulesParser) parseDMSRulesDirectly(dmsRulesPath string) { return } - prevSource := p.currentSource - p.currentSource = dmsRulesPath + abs, err := filepath.Abs(dmsRulesPath) + if err != nil { + abs = dmsRulesPath + } - lines := strings.Split(string(data), "\n") - for _, line := range lines { - p.parseLine(line) + prevSource := p.currentSource + p.currentSource = abs + + if strings.EqualFold(filepath.Ext(abs), ".lua") { + p.parseLuaWindowRules(string(data), filepath.Dir(abs), abs, false) + } else { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + p.parseLine(line) + } } p.currentSource = prevSource @@ -105,6 +142,11 @@ func (p *HyprlandRulesParser) parseFile(filePath string) error { return nil } + if strings.EqualFold(filepath.Ext(absPath), ".lua") { + p.parseLuaWindowRules(string(data), filepath.Dir(absPath), absPath, true) + return nil + } + prevSource := p.currentSource p.currentSource = absPath @@ -131,7 +173,7 @@ func (p *HyprlandRulesParser) handleSource(line string, baseDir string) { } sourcePath := strings.TrimSpace(parts[1]) - isDMSSource := sourcePath == "dms/windowrules.conf" || strings.HasSuffix(sourcePath, "/dms/windowrules.conf") + isDMSSource := isDMSWindowRulesSourcePath(sourcePath) p.includeCount++ if isDMSSource { @@ -263,10 +305,10 @@ func (p *HyprlandRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus { switch { case !p.dmsRulesExists: status.Effective = false - status.StatusMessage = "dms/windowrules.conf does not exist" + status.StatusMessage = "dms window rules fragment (windowrules.lua / windowrules.conf) does not exist" case !p.dmsRulesIncluded: status.Effective = false - status.StatusMessage = "dms/windowrules.conf is not sourced in config" + status.StatusMessage = "dms window rules are not loaded (missing require/source for dms/windowrules)" case p.rulesAfterDMS > 0: status.Effective = true status.OverriddenBy = p.rulesAfterDMS @@ -367,7 +409,11 @@ func ConvertHyprlandRulesToWindowRules(hyprRules []HyprlandWindowRule) []windowr Initialised: hr.MatchInitialised, }, } - applyHyprlandRuleAction(&wr.Actions, hr.Rule, hr.Value) + if hr.CombinedActions != nil { + wr.Actions = *hr.CombinedActions + } else { + applyHyprlandRuleAction(&wr.Actions, hr.Rule, hr.Value) + } result = append(result, wr) } return result @@ -387,7 +433,7 @@ func (p *HyprlandWritableProvider) Name() string { func (p *HyprlandWritableProvider) GetOverridePath() string { expanded, _ := utils.ExpandPath(p.configDir) - return filepath.Join(expanded, "dms", "windowrules.conf") + return filepath.Join(expanded, "dms", "windowrules.lua") } func (p *HyprlandWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) { @@ -468,17 +514,154 @@ func (p *HyprlandWritableProvider) ReorderRules(ids []string) error { } var dmsRuleCommentRegex = regexp.MustCompile(`^#\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`) +var dmsRuleLuaHDRRegex = regexp.MustCompile(`^\s*--\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`) + +func hyprLuaBoolStr(b bool) string { + if b { + return "true" + } + return "false" +} + +func luaAppendMatch(mc windowrules.MatchCriteria, dst *[]string) { + if mc.AppID != "" { + *dst = append(*dst, fmt.Sprintf(`class = %s`, strconv.Quote(mc.AppID))) + } + if mc.Title != "" { + *dst = append(*dst, fmt.Sprintf(`title = %s`, strconv.Quote(mc.Title))) + } + if mc.XWayland != nil { + *dst = append(*dst, fmt.Sprintf(`xwayland = %s`, hyprLuaBoolStr(*mc.XWayland))) + } + if mc.IsFloating != nil { + *dst = append(*dst, fmt.Sprintf(`floating = %s`, hyprLuaBoolStr(*mc.IsFloating))) + } + if mc.Fullscreen != nil { + *dst = append(*dst, fmt.Sprintf(`fullscreen = %s`, hyprLuaBoolStr(*mc.Fullscreen))) + } + if mc.Pinned != nil { + *dst = append(*dst, fmt.Sprintf(`pinned = %s`, hyprLuaBoolStr(*mc.Pinned))) + } + if mc.Initialised != nil { + *dst = append(*dst, fmt.Sprintf(`initialised = %s`, hyprLuaBoolStr(*mc.Initialised))) + } +} + +func luaAppendActions(a windowrules.Actions, dst *[]string) { + if a.OpenFloating != nil && *a.OpenFloating { + *dst = append(*dst, `float = true`) + } + if a.Tile != nil && *a.Tile { + *dst = append(*dst, `tile = true`) + } + if a.OpenFullscreen != nil && *a.OpenFullscreen { + *dst = append(*dst, `fullscreen = true`) + } + if a.OpenMaximized != nil && *a.OpenMaximized { + *dst = append(*dst, `maximize = true`) + } + if a.NoFocus != nil && *a.NoFocus { + *dst = append(*dst, `no_focus = true`) + } + if a.NoBorder != nil && *a.NoBorder { + *dst = append(*dst, `noborder = true`) + } + if a.NoShadow != nil && *a.NoShadow { + *dst = append(*dst, `no_shadow = true`) + } + if a.NoDim != nil && *a.NoDim { + *dst = append(*dst, `no_dim = true`) + } + if a.NoBlur != nil && *a.NoBlur { + *dst = append(*dst, `no_blur = true`) + } + if a.NoAnim != nil && *a.NoAnim { + *dst = append(*dst, `no_anim = true`) + } + if a.NoRounding != nil && *a.NoRounding { + *dst = append(*dst, `norounding = true`) + } + if a.Pin != nil && *a.Pin { + *dst = append(*dst, `pin = true`) + } + if a.Opaque != nil && *a.Opaque { + *dst = append(*dst, `opaque = true`) + } + if a.ForcergbX != nil && *a.ForcergbX { + *dst = append(*dst, `force_rgbx = true`) + } + if a.Opacity != nil { + *dst = append(*dst, fmt.Sprintf(`opacity = %s`, strconv.FormatFloat(*a.Opacity, 'g', -1, 64))) + } + if a.Size != "" { + *dst = append(*dst, fmt.Sprintf(`size = %s`, strconv.Quote(a.Size))) + } + if a.Move != "" { + *dst = append(*dst, fmt.Sprintf(`move = %s`, strconv.Quote(a.Move))) + } + if a.Monitor != "" { + *dst = append(*dst, fmt.Sprintf(`monitor = %s`, strconv.Quote(a.Monitor))) + } + if a.Workspace != "" { + *dst = append(*dst, fmt.Sprintf(`workspace = %s`, strconv.Quote(a.Workspace))) + } + if a.CornerRadius != nil { + *dst = append(*dst, fmt.Sprintf(`rounding = %d`, *a.CornerRadius)) + } + if a.Idleinhibit != "" { + *dst = append(*dst, fmt.Sprintf(`idle_inhibit = %s`, strconv.Quote(a.Idleinhibit))) + } +} + +func formatLuaManagedHyprRule(rule windowrules.WindowRule) []string { + var matchParts []string + luaAppendMatch(rule.MatchCriteria, &matchParts) + var body []string + if len(matchParts) > 0 { + body = append(body, fmt.Sprintf(`match = { %s }`, strings.Join(matchParts, ", "))) + } + luaAppendActions(rule.Actions, &body) + + out := []string{fmt.Sprintf("-- DMS-RULE: id=%s, name=%s", rule.ID, rule.Name)} + if len(body) == 0 { + out = append(out, fmt.Sprintf("-- (no matchers/actions for rule %s)", rule.ID)) + } else { + out = append(out, fmt.Sprintf("hl.window_rule({ %s })", strings.Join(body, ", "))) + } + out = append(out, "") + return out +} func (p *HyprlandWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) { - rulesPath := p.GetOverridePath() - data, err := os.ReadFile(rulesPath) + luaPath := p.GetOverridePath() + expanded, err := utils.ExpandPath(p.configDir) if err != nil { - if os.IsNotExist(err) { - return []windowrules.WindowRule{}, nil - } + return nil, err + } + confPath := filepath.Join(expanded, "dms", "windowrules.conf") + + var data []byte + var loadedFrom string + + if data, err = os.ReadFile(luaPath); err == nil { + loadedFrom = luaPath + } else if !os.IsNotExist(err) { + return nil, err + } else if data, err = os.ReadFile(confPath); err == nil { + loadedFrom = confPath + } else if os.IsNotExist(err) { + return []windowrules.WindowRule{}, nil + } else { return nil, err } + if strings.EqualFold(filepath.Ext(loadedFrom), ".lua") { + return p.loadDMSRulesFromLua(data, luaPath) + } + return p.loadDMSRulesFromConf(data, loadedFrom) +} + +func (p *HyprlandWritableProvider) loadDMSRulesFromConf(data []byte, rulesPath string) ([]windowrules.WindowRule, error) { var rules []windowrules.WindowRule var currentID, currentName string lines := strings.Split(string(data), "\n") @@ -532,6 +715,78 @@ func (p *HyprlandWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, err return rules, nil } +func (p *HyprlandWritableProvider) loadDMSRulesFromLua(data []byte, rulesPath string) ([]windowrules.WindowRule, error) { + var rules []windowrules.WindowRule + lines := strings.Split(string(data), "\n") + + var curID, curName string + + for li := 0; li < len(lines); { + trimmed := strings.TrimSpace(lines[li]) + if strings.HasPrefix(trimmed, "--") { + if m := dmsRuleLuaHDRRegex.FindStringSubmatch(trimmed); m != nil { + curID, curName = m[1], m[2] + li++ + continue + } + } + + if strings.Contains(strings.ToLower(trimmed), hlWinRuleLower) { + tail := strings.Join(lines[li:], "\n") + idx := strings.Index(strings.ToLower(tail), hlWinRuleLower) + if idx < 0 { + li++ + continue + } + frag := tail[idx:] + tableArg, consumedFrag, ok := extractHlWindowRuleTableArg(frag) + if !ok { + li++ + continue + } + + idSnap := curID + nameSnap := curName + + if acts, mf, ok2 := parseHlWindowRuleLuaTable(tableArg); ok2 && acts != nil { + wr := windowrules.WindowRule{ + ID: idSnap, + Name: nameSnap, + Enabled: true, + Source: rulesPath, + MatchCriteria: luaMatchFieldsToCriteria(mf), + Actions: *acts, + } + if wr.ID == "" { + if wr.MatchCriteria.AppID != "" { + wr.ID = wr.MatchCriteria.AppID + } else { + wr.ID = wr.MatchCriteria.Title + } + } + rules = append(rules, wr) + } + curID = "" + curName = "" + + advance := strings.Count(tail[:idx+consumedFrag], "\n") + if advance == 0 { + li++ + } else { + li += advance + } + continue + } + if trimmed != "" && !strings.HasPrefix(trimmed, "--") { + curID = "" + curName = "" + } + li++ + } + + return rules, nil +} + func (p *HyprlandWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error { rulesPath := p.GetOverridePath() @@ -540,119 +795,571 @@ func (p *HyprlandWritableProvider) writeDMSRules(rules []windowrules.WindowRule) } var lines []string - lines = append(lines, "# DMS Window Rules - Managed by DankMaterialShell") - lines = append(lines, "# Do not edit manually - changes may be overwritten") + lines = append(lines, "-- DMS Window Rules — managed by DankMaterialShell") + lines = append(lines, "-- Do not edit manually; changes may be overwritten") lines = append(lines, "") for _, rule := range rules { - lines = append(lines, p.formatRuleLines(rule)...) + lines = append(lines, formatLuaManagedHyprRule(rule)...) } return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644) } -func (p *HyprlandWritableProvider) formatRuleLines(rule windowrules.WindowRule) []string { - var lines []string - lines = append(lines, fmt.Sprintf("# DMS-RULE: id=%s, name=%s", rule.ID, rule.Name)) - - var matchParts []string - if rule.MatchCriteria.AppID != "" { - matchParts = append(matchParts, fmt.Sprintf("class:%s", rule.MatchCriteria.AppID)) - } - if rule.MatchCriteria.Title != "" { - matchParts = append(matchParts, fmt.Sprintf("title:%s", rule.MatchCriteria.Title)) - } - if rule.MatchCriteria.XWayland != nil { - matchParts = append(matchParts, fmt.Sprintf("xwayland:%d", boolToInt(*rule.MatchCriteria.XWayland))) - } - if rule.MatchCriteria.IsFloating != nil { - matchParts = append(matchParts, fmt.Sprintf("floating:%d", boolToInt(*rule.MatchCriteria.IsFloating))) - } - if rule.MatchCriteria.Fullscreen != nil { - matchParts = append(matchParts, fmt.Sprintf("fullscreen:%d", boolToInt(*rule.MatchCriteria.Fullscreen))) - } - if rule.MatchCriteria.Pinned != nil { - matchParts = append(matchParts, fmt.Sprintf("pinned:%d", boolToInt(*rule.MatchCriteria.Pinned))) - } - - matchStr := strings.Join(matchParts, ", ") - a := rule.Actions - - if a.OpenFloating != nil && *a.OpenFloating { - lines = append(lines, fmt.Sprintf("windowrulev2 = float, %s", matchStr)) - } - if a.Tile != nil && *a.Tile { - lines = append(lines, fmt.Sprintf("windowrulev2 = tile, %s", matchStr)) - } - if a.OpenFullscreen != nil && *a.OpenFullscreen { - lines = append(lines, fmt.Sprintf("windowrulev2 = fullscreen, %s", matchStr)) - } - if a.OpenMaximized != nil && *a.OpenMaximized { - lines = append(lines, fmt.Sprintf("windowrulev2 = maximize, %s", matchStr)) - } - if a.NoFocus != nil && *a.NoFocus { - lines = append(lines, fmt.Sprintf("windowrulev2 = nofocus, %s", matchStr)) - } - if a.NoBorder != nil && *a.NoBorder { - lines = append(lines, fmt.Sprintf("windowrulev2 = noborder, %s", matchStr)) - } - if a.NoShadow != nil && *a.NoShadow { - lines = append(lines, fmt.Sprintf("windowrulev2 = noshadow, %s", matchStr)) - } - if a.NoDim != nil && *a.NoDim { - lines = append(lines, fmt.Sprintf("windowrulev2 = nodim, %s", matchStr)) - } - if a.NoBlur != nil && *a.NoBlur { - lines = append(lines, fmt.Sprintf("windowrulev2 = noblur, %s", matchStr)) - } - if a.NoAnim != nil && *a.NoAnim { - lines = append(lines, fmt.Sprintf("windowrulev2 = noanim, %s", matchStr)) - } - if a.NoRounding != nil && *a.NoRounding { - lines = append(lines, fmt.Sprintf("windowrulev2 = norounding, %s", matchStr)) - } - if a.Pin != nil && *a.Pin { - lines = append(lines, fmt.Sprintf("windowrulev2 = pin, %s", matchStr)) - } - if a.Opaque != nil && *a.Opaque { - lines = append(lines, fmt.Sprintf("windowrulev2 = opaque, %s", matchStr)) - } - if a.ForcergbX != nil && *a.ForcergbX { - lines = append(lines, fmt.Sprintf("windowrulev2 = forcergbx, %s", matchStr)) - } - if a.Opacity != nil { - lines = append(lines, fmt.Sprintf("windowrulev2 = opacity %.2f, %s", *a.Opacity, matchStr)) - } - if a.Size != "" { - lines = append(lines, fmt.Sprintf("windowrulev2 = size %s, %s", a.Size, matchStr)) - } - if a.Move != "" { - lines = append(lines, fmt.Sprintf("windowrulev2 = move %s, %s", a.Move, matchStr)) - } - if a.Monitor != "" { - lines = append(lines, fmt.Sprintf("windowrulev2 = monitor %s, %s", a.Monitor, matchStr)) - } - if a.Workspace != "" { - lines = append(lines, fmt.Sprintf("windowrulev2 = workspace %s, %s", a.Workspace, matchStr)) - } - if a.CornerRadius != nil { - lines = append(lines, fmt.Sprintf("windowrulev2 = rounding %d, %s", *a.CornerRadius, matchStr)) - } - if a.Idleinhibit != "" { - lines = append(lines, fmt.Sprintf("windowrulev2 = idleinhibit %s, %s", a.Idleinhibit, matchStr)) - } - - if len(lines) == 1 { - lines = append(lines, fmt.Sprintf("# (no actions defined for rule %s)", rule.ID)) - } - - lines = append(lines, "") - return lines -} - func boolToInt(b bool) int { if b { return 1 } return 0 } + +const hlWinRuleLower = "hl.window_rule" + +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 +} + +func isDMSWindowRulesSourcePath(sourcePath string) bool { + p := filepath.ToSlash(strings.TrimSpace(sourcePath)) + return p == "dms/windowrules.lua" || strings.HasSuffix(p, "/dms/windowrules.lua") || + p == "dms/windowrules.conf" || strings.HasSuffix(p, "/dms/windowrules.conf") || + p == "./dms/windowrules.lua" || p == "./dms/windowrules.conf" +} + +func isDMSWindowRulesRequireModule(mod string) bool { + return isDMSWindowRulesSourcePath(luaconfig.ModuleToRelPath(mod)) +} + +func (p *HyprlandRulesParser) probeRequireWindowrulesLine(mainLua string) { + data, err := os.ReadFile(mainLua) + if err != nil { + return + } + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if mod, ok := luaconfig.Require(line); ok && isDMSWindowRulesRequireModule(mod) { + p.requireLineInMain = i + 1 + return + } + } +} + +// luaMatchFields collects fields from Lua match={...} subtrees before copying into HyprlandWindowRule. +type luaMatchFields struct { + class string + title string + xwayland, floating, fullscreen, pinned, initialised *bool +} + +func (p *HyprlandRulesParser) parseLuaWindowRules(content, baseDir, absPath string, allowRequires bool) { + prev := p.currentSource + p.currentSource = absPath + defer func() { p.currentSource = prev }() + + lines := strings.Split(content, "\n") + rootDir := baseDir + if expanded, err := utils.ExpandPath(p.configDir); err == nil && expanded != "" { + rootDir = expanded + } + curAbs := absPath + if a, err := filepath.Abs(absPath); err == nil { + curAbs = a + } + mainAbs := "" + if p.primaryHyprLua != "" { + if a, err := filepath.Abs(p.primaryHyprLua); err == nil { + mainAbs = a + } + } + + for i := 0; i < len(lines); { + trimmed := strings.TrimSpace(lines[i]) + if trimmed == "" || strings.HasPrefix(trimmed, "--") { + i++ + continue + } + + if modules := luaconfig.Requires(trimmed); len(modules) > 0 && allowRequires { + for _, mod := range modules { + rel := luaconfig.ModuleToRelPath(mod) + if rel == "" { + continue + } + fullPath := luaconfig.ModuleToPath(rootDir, mod) + expanded, err := utils.ExpandPath(fullPath) + if err != nil { + continue + } + p.includeCount++ + if isDMSWindowRulesRequireModule(mod) { + p.dmsRulesIncluded = true + p.dmsIncludePos = p.includeCount + p.dmsProcessed = true + } + _ = p.parseFile(expanded) + } + i++ + continue + } + + lowTrim := strings.ToLower(trimmed) + if strings.Contains(lowTrim, hlWinRuleLower) { + tail := strings.Join(lines[i:], "\n") + idx := strings.Index(strings.ToLower(tail), hlWinRuleLower) + if idx < 0 { + i++ + continue + } + frag := tail[idx:] + tableArg, consumedFrag, ok := extractHlWindowRuleTableArg(frag) + if !ok { + i++ + continue + } + + startLine := i + strings.Count(tail[:idx], "\n") + 1 + if acts, mf, ok2 := parseHlWindowRuleLuaTable(tableArg); ok2 && acts != nil { + raw := strings.Join(strings.Fields(strings.ReplaceAll(strings.TrimSpace(frag[:consumedFrag]), "\n", " ")), " ") + if len(raw) > 240 { + raw = raw[:240] + "…" + } + hr := HyprlandWindowRule{ + Source: curAbs, + RawLine: raw, + CombinedActions: acts, + } + fillRuleFromLuaMatch(&hr, mf) + + p.rules = append(p.rules, hr) + + if p.requireLineInMain > 0 && mainAbs != "" && curAbs == mainAbs && startLine > p.requireLineInMain { + p.rulesAfterDMS++ + } + } + advance := strings.Count(tail[:idx+consumedFrag], "\n") + if advance == 0 { + i++ + } else { + i += advance + } + continue + } + + i++ + } +} + +func fillRuleFromLuaMatch(hr *HyprlandWindowRule, m luaMatchFields) { + hr.MatchClass = m.class + hr.MatchTitle = m.title + hr.MatchXWayland = m.xwayland + hr.MatchFloating = m.floating + hr.MatchFullscreen = m.fullscreen + hr.MatchPinned = m.pinned + hr.MatchInitialised = m.initialised +} + +func luaMatchFieldsToCriteria(m luaMatchFields) windowrules.MatchCriteria { + return windowrules.MatchCriteria{ + AppID: m.class, + Title: m.title, + XWayland: m.xwayland, + IsFloating: m.floating, + Fullscreen: m.fullscreen, + Pinned: m.pinned, + Initialised: m.initialised, + } +} + +// extractHlWindowRuleTableArg parses a fragment beginning with (optional prefix then) hl.window_rule( ... ). +// consumed is counted from frag[0] (caller adds idx offset when iterating). +func extractHlWindowRuleTableArg(frag string) (inner string, consumed int, ok bool) { + tagIdx := strings.Index(strings.ToLower(frag), hlWinRuleLower) + if tagIdx < 0 { + return "", 0, false + } + afterTag := frag[tagIdx+len(hlWinRuleLower):] + openIdx := strings.IndexByte(afterTag, '(') + if openIdx < 0 || (openIdx > 0 && strings.TrimSpace(afterTag[:openIdx]) != "") { + return "", 0, false + } + parenTail := afterTag[openIdx:] + body, endAfter, ok := extractBalancedParensFromOpen(parenTail, 0) + if !ok { + return "", 0, false + } + consumedFromFrag := tagIdx + openIdx + endAfter + return strings.TrimSpace(body), consumedFromFrag, true +} + +// extractBalancedParensFromOpen extracts inner string between '(' at openIdx and its matching ')'. +func extractBalancedParensFromOpen(s string, openIdx int) (inner string, endExclusive int, ok bool) { + if openIdx >= len(s) || s[openIdx] != '(' { + return "", 0, false + } + depth := 0 + inStr := byte(0) + esc := false + for i := openIdx; i < len(s); i++ { + c := s[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++ + if depth == 1 { + continue + } + case ')': + if depth > 0 { + depth-- + if depth == 0 { + return strings.TrimSpace(s[openIdx+1 : i]), i + 1, true + } + } + } + } + return "", 0, false +} + +func trimOuterBraces(s string) string { + s = strings.TrimSpace(s) + if len(s) >= 2 && s[0] == '{' && s[len(s)-1] == '}' { + return strings.TrimSpace(s[1 : len(s)-1]) + } + return s +} + +func splitTopLevelCommaLua(s string) []string { + var out []string + depth := 0 + inStr := byte(0) + esc := false + start := 0 + for i := 0; i < len(s); i++ { + c := s[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 '}', ')': + if depth > 0 { + depth-- + } + case ',': + if depth == 0 { + out = append(out, strings.TrimSpace(s[start:i])) + start = i + 1 + } + } + } + out = append(out, strings.TrimSpace(s[start:])) + return out +} + +func splitLuaKeyVal(seg string) (key, val string, ok bool) { + seg = strings.TrimSpace(seg) + if seg == "" { + return "", "", false + } + depth := 0 + inStr := byte(0) + esc := false + for i := 0; i < len(seg); i++ { + c := seg[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 '}', ')': + if depth > 0 { + depth-- + } + case '=': + if depth == 0 { + return strings.TrimSpace(seg[:i]), strings.TrimSpace(seg[i+1:]), true + } + } + } + return "", "", false +} + +func luaStringValue(s string) string { + s = strings.TrimSpace(s) + if len(s) >= 2 { + q0 := s[0] + q1 := s[len(s)-1] + if q0 == q1 && (q0 == '"' || q0 == '\'') { + if q0 == '"' { + if u, err := strconv.Unquote(s); err == nil { + return u + } + } else if len(s) >= 2 && q0 == '\'' { + v := strings.TrimSuffix(strings.TrimPrefix(s, "'"), "'") + v = strings.ReplaceAll(v, `\'`, `'`) + return v + } + } + } + return strings.Trim(strings.TrimSpace(s), `"'`) +} + +func luaBoolLike(s string) (val bool, ok bool) { + s = strings.TrimSpace(strings.ToLower(s)) + switch s { + case "true", "yes", "1": + return true, true + case "false", "no", "0": + return false, true + default: + return false, false + } +} + +func parseMatchLua(val string, m *luaMatchFields) { + body := trimOuterBraces(val) + segs := splitTopLevelCommaLua(body) + for _, seg := range segs { + k, v, ok := splitLuaKeyVal(seg) + if !ok { + continue + } + switch strings.TrimSpace(strings.ToLower(k)) { + case "class": + m.class = luaStringValue(v) + case "title": + m.title = luaStringValue(v) + case "xwayland": + if b, okb := luaBoolLike(v); okb { + m.xwayland = boolRef(b) + } + case "floating": + if b, okb := luaBoolLike(v); okb { + m.floating = boolRef(b) + } + case "fullscreen": + if b, okb := luaBoolLike(v); okb { + m.fullscreen = boolRef(b) + } + case "pinned": + if b, okb := luaBoolLike(v); okb { + m.pinned = boolRef(b) + } + case "initialised", "initialized": + if b, okb := luaBoolLike(v); okb { + m.initialised = boolRef(b) + } + } + } +} + +func boolRef(b bool) *bool { return &b } + +func applyLuaActionKey(a *windowrules.Actions, key, raw string) bool { + k := strings.TrimSpace(strings.ToLower(key)) + raw = strings.TrimSpace(raw) + switch k { + case "float": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.OpenFloating = &t + return true + } + case "tile": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.Tile = &t + return true + } + case "fullscreen": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.OpenFullscreen = &t + return true + } + case "maximize": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.OpenMaximized = &t + return true + } + case "nofocus", "no_focus", "no_initial_focus": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.NoFocus = &t + return true + } + case "noborder": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.NoBorder = &t + return true + } + case "noshadow", "no_shadow": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.NoShadow = &t + return true + } + case "nodim", "no_dim": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.NoDim = &t + return true + } + case "noblur", "no_blur": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.NoBlur = &t + return true + } + case "noanim", "no_anim": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.NoAnim = &t + return true + } + case "norounding": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.NoRounding = &t + return true + } + case "pin": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.Pin = &t + return true + } + case "opaque": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.Opaque = &t + return true + } + case "forcergbx", "force_rgbx": + if b, ok := luaBoolLike(raw); ok && b { + t := true + a.ForcergbX = &t + return true + } + case "opacity": + if f, err := strconv.ParseFloat(luaStringValue(raw), 64); err == nil { + a.Opacity = &f + return true + } + case "rounding": + if v := luaStringValue(raw); v != "" { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + a.CornerRadius = &n + return true + } + } + case "size": + a.Size = strings.TrimSpace(luaStringValue(raw)) + return true + case "move": + a.Move = strings.TrimSpace(luaStringValue(raw)) + return true + case "monitor": + a.Monitor = strings.TrimSpace(luaStringValue(raw)) + return true + case "workspace": + a.Workspace = strings.TrimSpace(luaStringValue(raw)) + return true + case "idleinhibit", "idle_inhibit": + a.Idleinhibit = strings.TrimSpace(luaStringValue(raw)) + return true + default: + // Unsupported keys are left to Hyprland; DMS only round-trips managed fields. + } + return false +} + +func parseHlWindowRuleLuaTable(inner string) (*windowrules.Actions, luaMatchFields, bool) { + body := trimOuterBraces(strings.TrimSpace(inner)) + if body == "" { + return nil, luaMatchFields{}, false + } + segs := splitTopLevelCommaLua(body) + var match luaMatchFields + var a windowrules.Actions + matchParsed := false + haveActions := false + + for _, seg := range segs { + k, v, ok := splitLuaKeyVal(seg) + if !ok { + continue + } + switch strings.TrimSpace(strings.ToLower(k)) { + case "match": + parseMatchLua(v, &match) + matchParsed = true + default: + if applyLuaActionKey(&a, k, v) { + haveActions = true + } + } + } + if !haveActions { + return nil, luaMatchFields{}, false + } + return &a, match, matchParsed || haveActions +} diff --git a/core/internal/windowrules/providers/hyprland_parser_test.go b/core/internal/windowrules/providers/hyprland_parser_test.go index 5341b4bf..3f42703a 100644 --- a/core/internal/windowrules/providers/hyprland_parser_test.go +++ b/core/internal/windowrules/providers/hyprland_parser_test.go @@ -3,7 +3,10 @@ package providers import ( "os" "path/filepath" + "strings" "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules" ) func TestParseWindowRuleV1(t *testing.T) { @@ -151,7 +154,7 @@ func TestHyprlandWritableProvider(t *testing.T) { 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 { 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) { if boolToInt(true) != 1 { t.Error("boolToInt(true) should be 1") diff --git a/docs/Hyprland_Lua_Migration.md b/docs/Hyprland_Lua_Migration.md new file mode 100644 index 00000000..3f3d77c7 --- /dev/null +++ b/docs/Hyprland_Lua_Migration.md @@ -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// +``` + +## 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//` 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// +``` + +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//` 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 ./... +``` diff --git a/quickshell/Common/ConfigIncludeResolve.js b/quickshell/Common/ConfigIncludeResolve.js new file mode 100644 index 00000000..4ba5b2ce --- /dev/null +++ b/quickshell/Common/ConfigIncludeResolve.js @@ -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("; "); +} diff --git a/quickshell/Modules/Greetd/README.md b/quickshell/Modules/Greetd/README.md index 4ac5a777..0538215a 100644 --- a/quickshell/Modules/Greetd/README.md +++ b/quickshell/Modules/Greetd/README.md @@ -178,7 +178,7 @@ sudo systemctl enable greetd #### Legacy installation (deprecated) 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` 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` diff --git a/quickshell/Modules/Greetd/assets/dms-hypr.conf b/quickshell/Modules/Greetd/assets/dms-hypr.conf index 33eefcdb..03f6063f 100644 --- a/quickshell/Modules/Greetd/assets/dms-hypr.conf +++ b/quickshell/Modules/Greetd/assets/dms-hypr.conf @@ -1,3 +1,4 @@ +# Deprecated: greetd expects Hyprland 0.55+ Lua; use `/etc/greetd/dms-hypr.lua` instead. env = DMS_RUN_GREETER,1 exec = sh -c "qs -p _DMS_PATH_; hyprctl dispatch exit" diff --git a/quickshell/Modules/Greetd/assets/dms-hypr.lua b/quickshell/Modules/Greetd/assets/dms-hypr.lua new file mode 100644 index 00000000..f875d637 --- /dev/null +++ b/quickshell/Modules/Greetd/assets/dms-hypr.lua @@ -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) diff --git a/quickshell/Modules/Greetd/assets/greet-hyprland.sh b/quickshell/Modules/Greetd/assets/greet-hyprland.sh index c23b4790..58fab7fb 100755 --- a/quickshell/Modules/Greetd/assets/greet-hyprland.sh +++ b/quickshell/Modules/Greetd/assets/greet-hyprland.sh @@ -5,7 +5,7 @@ export QT_QPA_PLATFORM=wayland export QT_WAYLAND_DISABLE_WINDOWDECORATION=1 export EGL_PLATFORM=gbm 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 - exec Hyprland -c /etc/greetd/dms-hypr.conf + exec Hyprland -c /etc/greetd/dms-hypr.lua fi diff --git a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml index 03defefd..378ab337 100644 --- a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml +++ b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml @@ -7,6 +7,7 @@ import Quickshell import Quickshell.Io import qs.Common import qs.Services +import "../../../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve Singleton { id: root @@ -1074,10 +1075,86 @@ Singleton { 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) { const result = {}; const lines = content.split("\n"); 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*$/); if (disableMatch) { const name = disableMatch[1].trim(); @@ -1269,10 +1346,10 @@ Singleton { }; case "hyprland": return { - "configFile": configDir + "/hypr/hyprland.conf", - "outputsFile": configDir + "/hypr/dms/outputs.conf", - "grepPattern": 'source.*dms/outputs.conf', - "includeLine": "source = ./dms/outputs.conf" + "configFile": configDir + "/hypr/hyprland.lua", + "outputsFile": configDir + "/hypr/dms/outputs.lua", + "grepPattern": "dms.outputs", + "includeLine": "require(\"dms.outputs\")" }; case "dwl": return { @@ -1296,7 +1373,7 @@ Singleton { 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; checkingInclude = true; @@ -1326,11 +1403,17 @@ Singleton { return; fixingInclude = true; - const outputsDir = paths.outputsFile.substring(0, paths.outputsFile.lastIndexOf("/")); const unixTime = Math.floor(Date.now() / 1000); 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; if (exitCode !== 0) return; diff --git a/quickshell/Modules/Settings/KeybindsTab.qml b/quickshell/Modules/Settings/KeybindsTab.qml index 804ce27a..eb0239b8 100644 --- a/quickshell/Modules/Settings/KeybindsTab.qml +++ b/quickshell/Modules/Settings/KeybindsTab.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import qs.Common +import qs.Modals.Common import qs.Services import qs.Widgets @@ -96,6 +97,32 @@ Item { 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() { if (showingNewBind) { showingNewBind = false; @@ -129,6 +156,10 @@ Item { onTriggered: keybindsTab._updateFiltered() } + ConfirmModal { + id: removeBindConfirm + } + Connections { target: KeybindsService function onBindsLoaded() { @@ -238,7 +269,7 @@ Item { } 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) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText @@ -336,7 +367,7 @@ Item { } 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: { if (warningBox.showSetup) return I18n.tr("Click 'Setup' to create %1 and add include to config.").arg(bindsFile); @@ -623,8 +654,11 @@ Item { } onRemoveBind: key => { const remainingKey = bindItem.keys.find(k => k.key !== key)?.key ?? ""; - KeybindsService.removeBind(key); - keybindsTab._editingKey = remainingKey; + keybindsTab.confirmRemoveBind(key, remainingKey); + } + onResetBind: key => { + const remainingKey = bindItem.keys.find(k => k.key !== key)?.key ?? ""; + keybindsTab.confirmResetBind(key, remainingKey); } onIsExpandedChanged: { if (!isExpanded || !keybindsTab._editingKey) diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index a418f3e2..b025c1bd 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -7,6 +7,7 @@ import qs.Modals.FileBrowser import qs.Services import qs.Widgets import qs.Modules.Settings.Widgets +import "../../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve Item { id: themeColorsTab @@ -39,10 +40,10 @@ Item { }; case "hyprland": return { - "configFile": configDir + "/hypr/hyprland.conf", - "cursorFile": configDir + "/hypr/dms/cursor.conf", - "grepPattern": 'source.*dms/cursor.conf', - "includeLine": "source = ./dms/cursor.conf" + "configFile": configDir + "/hypr/hyprland.lua", + "cursorFile": configDir + "/hypr/dms/cursor.lua", + "grepPattern": "dms.cursor", + "includeLine": "require(\"dms.cursor\")" }; case "dwl": return { @@ -66,7 +67,7 @@ Item { 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; checkingCursorInclude = true; @@ -95,10 +96,16 @@ Item { if (!paths) return; fixingCursorInclude = true; - const cursorDir = paths.cursorFile.substring(0, paths.cursorFile.lastIndexOf("/")); const unixTime = Math.floor(Date.now() / 1000); 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; if (exitCode !== 0) return; diff --git a/quickshell/Modules/Settings/WindowRulesTab.qml b/quickshell/Modules/Settings/WindowRulesTab.qml index 5e1310e4..ed731674 100644 --- a/quickshell/Modules/Settings/WindowRulesTab.qml +++ b/quickshell/Modules/Settings/WindowRulesTab.qml @@ -8,6 +8,7 @@ import Quickshell.Wayland import qs.Common import qs.Services import qs.Widgets +import "../../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve Item { id: root @@ -54,10 +55,10 @@ Item { }; case "hyprland": return { - "configFile": configDir + "/hypr/hyprland.conf", - "rulesFile": configDir + "/hypr/dms/windowrules.conf", - "grepPattern": 'source.*dms/windowrules.conf', - "includeLine": "source = ./dms/windowrules.conf" + "configFile": configDir + "/hypr/hyprland.lua", + "rulesFile": configDir + "/hypr/dms/windowrules.lua", + "grepPattern": "dms.windowrules", + "includeLine": "require(\"dms.windowrules\")" }; default: return null; @@ -135,7 +136,7 @@ Item { return; } - const filename = (compositor === "niri") ? "windowrules.kdl" : "windowrules.conf"; + const filename = (compositor === "niri") ? "windowrules.kdl" : "windowrules.lua"; checkingInclude = true; Proc.runCommand("check-windowrules-include", ["dms", "config", "resolve-include", compositor, filename], (output, exitCode) => { checkingInclude = false; @@ -162,10 +163,16 @@ Item { if (!paths) return; fixingInclude = true; - const rulesDir = paths.rulesFile.substring(0, paths.rulesFile.lastIndexOf("/")); const unixTime = Math.floor(Date.now() / 1000); 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; if (exitCode !== 0) return; @@ -252,7 +259,7 @@ Item { } 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 color: Theme.surfaceVariantText wrapMode: Text.WordWrap @@ -351,7 +358,7 @@ Item { } 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) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText diff --git a/quickshell/Services/HyprlandService.qml b/quickshell/Services/HyprlandService.qml index 295a1902..67617607 100644 --- a/quickshell/Services/HyprlandService.qml +++ b/quickshell/Services/HyprlandService.qml @@ -14,10 +14,10 @@ Singleton { readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) readonly property string hyprDmsDir: configDir + "/hypr/dms" - readonly property string outputsPath: hyprDmsDir + "/outputs.conf" - readonly property string layoutPath: hyprDmsDir + "/layout.conf" - readonly property string cursorPath: hyprDmsDir + "/cursor.conf" - readonly property string windowrulesPath: hyprDmsDir + "/windowrules.conf" + readonly property string outputsPath: hyprDmsDir + "/outputs.lua" + readonly property string layoutPath: hyprDmsDir + "/layout.lua" + readonly property string cursorPath: hyprDmsDir + "/cursor.lua" + readonly property string windowrulesPath: hyprDmsDir + "/windowrules.lua" property int _lastGapValue: -1 @@ -31,7 +31,7 @@ Singleton { function ensureWindowrulesConfig() { Proc.runCommand("hypr-ensure-windowrules", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && [ ! -f "${windowrulesPath}" ] && touch "${windowrulesPath}" || true`], (output, exitCode) => { 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; } + 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) { if (!outputsData || Object.keys(outputsData).length === 0) { if (callback) @@ -70,8 +82,7 @@ Singleton { } const settings = hyprlandSettings || SettingsData.hyprlandOutputSettings; - let lines = ["# Auto-generated by DMS - do not edit manually", ""]; - let monitorv2Blocks = []; + let lines = ["-- Auto-generated by DMS — do not edit manually", ""]; for (const outputName in outputsData) { const output = outputsData[outputName]; @@ -82,7 +93,7 @@ Singleton { const outputSettings = settings[identifier] || {}; if (outputSettings.disabled) { - lines.push("monitor = " + identifier + ", disable"); + lines.push(`hl.monitor({ output = ${luaQuoted(identifier)}, disabled = true })`); continue; } @@ -98,68 +109,42 @@ Singleton { const position = x + "x" + y; 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"); if (transform !== 0) - monitorLine += ", transform, " + transform; + parts.push(`transform = ${transform}`); if (output.vrr_supported) { const vrrMode = outputSettings.vrrFullscreenOnly ? 2 : (output.vrr_enabled ? 1 : 0); - monitorLine += ", vrr, " + vrrMode; + parts.push(`vrr = ${vrrMode}`); } if (output.mirror && output.mirror.length > 0) - monitorLine += ", mirror, " + output.mirror; + parts.push(`mirror = ${luaQuoted(output.mirror)}`); if (outputSettings.bitdepth && outputSettings.bitdepth !== 8) - monitorLine += ", bitdepth, " + outputSettings.bitdepth; + parts.push(`bitdepth = ${Number(outputSettings.bitdepth)}`); if (outputSettings.colorManagement && outputSettings.colorManagement !== "auto") - monitorLine += ", cm, " + outputSettings.colorManagement; + parts.push(`cm = ${luaQuoted(outputSettings.colorManagement)}`); 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) - 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) { - 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("hl.monitor({ " + parts.join(", ") + " })"); } lines.push(""); - const content = lines.join("\n"); 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 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 { - gaps_in = ${gaps} - gaps_out = ${gaps} - border_size = ${borderSize} -} - -decoration { - rounding = ${cornerRadius} -} +hl.config({ + general = { + gaps_in = ${gaps}, + gaps_out = ${gaps}, + border_size = ${borderSize}, + }, + decoration = { + rounding = ${cornerRadius}, + }, +}) `; 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; 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) log.warn("Failed to write cursor config:", output); }); @@ -289,32 +275,34 @@ decoration { const hasCursorSettings = hideOnKeyPress || hideOnTouch || inactiveTimeout > 0; 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) log.warn("Failed to write cursor config:", output); }); return; } - let lines = ["# Auto-generated by DMS - do not edit manually", ""]; + let lines = ["-- Auto-generated by DMS — do not edit manually", ""]; if (hasTheme) { - lines.push(`env = HYPRCURSOR_THEME,${themeName}`); - lines.push(`env = XCURSOR_THEME,${themeName}`); + lines.push(`hl.env("HYPRCURSOR_THEME", ${luaQuoted(themeName)})`); + lines.push(`hl.env("XCURSOR_THEME", ${luaQuoted(themeName)})`); } - lines.push(`env = HYPRCURSOR_SIZE,${size}`); - lines.push(`env = XCURSOR_SIZE,${size}`); + lines.push(`hl.env("HYPRCURSOR_SIZE", ${luaQuoted(String(size))})`); + lines.push(`hl.env("XCURSOR_SIZE", ${luaQuoted(String(size))})`); if (hasCursorSettings) { lines.push(""); - lines.push("cursor {"); + lines.push("hl.config({"); + lines.push("\tcursor = {"); if (hideOnKeyPress) - lines.push(" hide_on_key_press = true"); + lines.push("\t\thide_on_key_press = true,"); if (hideOnTouch) - lines.push(" hide_on_touch = true"); + lines.push("\t\thide_on_touch = true,"); if (inactiveTimeout > 0) - lines.push(` inactive_timeout = ${inactiveTimeout}`); - lines.push("}"); + lines.push(`\t\tinactive_timeout = ${inactiveTimeout},`); + lines.push("\t},"); + lines.push("})"); } lines.push(""); diff --git a/quickshell/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml index 25376bae..b9940bb6 100644 --- a/quickshell/Services/KeybindsService.qml +++ b/quickshell/Services/KeybindsService.qml @@ -7,6 +7,7 @@ import Quickshell import Quickshell.Io import qs.Common import qs.Services +import "../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve import "../Common/KeybindActions.js" as Actions Singleton { @@ -82,6 +83,7 @@ Singleton { case "niri": return compositorConfigDir + "/dms/binds.kdl"; case "hyprland": + return compositorConfigDir + "/dms/binds.lua"; case "mangowc": return compositorConfigDir + "/dms/binds.conf"; default: @@ -93,7 +95,7 @@ Singleton { case "niri": return compositorConfigDir + "/config.kdl"; case "hyprland": - return compositorConfigDir + "/hyprland.conf"; + return compositorConfigDir + "/hyprland.lua"; case "mangowc": return compositorConfigDir + "/config.conf"; default: @@ -247,8 +249,8 @@ Singleton { root.lastError = ""; root.dmsBindsIncluded = true; root.dmsBindsFixed(); - const bindsFile = root.currentProvider === "niri" ? "dms/binds.kdl" : "dms/binds.conf"; - ToastService.showInfo(I18n.tr("Binds include added"), I18n.tr("%1 is now included in config").arg(bindsFile), "", "keybinds"); + 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(bindsRel), "", "keybinds"); Qt.callLater(root.forceReload); } } @@ -262,13 +264,36 @@ Singleton { let script; switch (currentProvider) { 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; 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; 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; default: fixing = false; @@ -321,6 +346,7 @@ Singleton { "statusMessage": status.statusMessage ?? "" }; } + _maybeWarnHyprlandLegacyConf(); if (!_rawData?.binds) { _allBinds = {}; @@ -365,10 +391,13 @@ Singleton { for (var i = 0; i < binds.length; i++) { const bind = binds[i]; const action = bind.action || ""; + const sourceStr = bind.source || "config"; const keyData = { "key": bind.key || "", - "source": bind.source || "config", - "isOverride": bind.source === "dms", + "source": sourceStr, + "isOverride": sourceStr === "dms", + "isDMSManaged": sourceStr === "dms" || sourceStr === "dms-default", + "hasDefault": bind.hasDefault === true, "cooldownMs": bind.cooldownMs || 0, "flags": bind.flags || "", "allowWhenLocked": bind.allowWhenLocked || false, @@ -456,6 +485,19 @@ Singleton { _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) { if (!key) return; @@ -464,6 +506,14 @@ Singleton { bindRemoved(key); } + function resetBind(key) { + if (!key) + return; + removeProcess.command = ["dms", "keybinds", "reset", currentProvider, key]; + removeProcess.running = true; + bindRemoved(key); + } + function isDmsAction(action) { return Actions.isDmsAction(action); } diff --git a/quickshell/Widgets/KeybindItem.qml b/quickshell/Widgets/KeybindItem.qml index d768bede..cd62753a 100644 --- a/quickshell/Widgets/KeybindItem.qml +++ b/quickshell/Widgets/KeybindItem.qml @@ -66,13 +66,12 @@ Item { signal toggleExpand signal saveBind(string originalKey, var newData) signal removeBind(string key) + signal resetBind(string key) signal cancelEdit implicitHeight: contentColumn.implicitHeight height: implicitHeight - Component.onDestruction: _destroyShortcutInhibitor() - Component.onCompleted: { if (isNew && isExpanded) resetEdits(); @@ -831,9 +830,12 @@ Item { color: root._actionType === modelData.id ? Theme.surfaceContainerHighest : Theme.surfaceContainer border.color: root._actionType === modelData.id ? Theme.outline : (typeArea.containsMouse ? Theme.outlineVariant : "transparent") border.width: 1 + clip: true RowLayout { - anchors.centerIn: parent + anchors.fill: parent + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingS spacing: Theme.spacingXS DankIcon { @@ -843,10 +845,13 @@ Item { } StyledText { + Layout.fillWidth: true text: typeDelegate.modelData.label font.pixelSize: Theme.fontSizeSmall color: root._actionType === typeDelegate.modelData.id ? Theme.surfaceText : Theme.surfaceVariantText visible: typeDelegate.width > 100 + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter } } @@ -1763,10 +1768,19 @@ Item { iconName: "delete" iconSize: Theme.iconSize - 4 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) } + 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 { Layout.fillWidth: true } diff --git a/quickshell/matugen/configs/hyprland.toml b/quickshell/matugen/configs/hyprland.toml index 21553b87..eec32218 100644 --- a/quickshell/matugen/configs/hyprland.toml +++ b/quickshell/matugen/configs/hyprland.toml @@ -1,3 +1,3 @@ [templates.dmshyprland] -input_path = 'SHELL_DIR/matugen/templates/hypr-colors.conf' -output_path = 'CONFIG_DIR/hypr/dms/colors.conf' +input_path = 'SHELL_DIR/matugen/templates/hypr-colors.lua' +output_path = 'CONFIG_DIR/hypr/dms/colors.lua' diff --git a/quickshell/matugen/templates/hypr-colors.conf b/quickshell/matugen/templates/hypr-colors.conf deleted file mode 100644 index 730bbd88..00000000 --- a/quickshell/matugen/templates/hypr-colors.conf +++ /dev/null @@ -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 - } -} diff --git a/quickshell/matugen/templates/hypr-colors.lua b/quickshell/matugen/templates/hypr-colors.lua new file mode 100644 index 00000000..c1d51a99 --- /dev/null +++ b/quickshell/matugen/templates/hypr-colors.lua @@ -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}})", + }, + }, + }, +})