1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-08 12:13:31 -04:00

feat(Hyprland): Introduce Lua support for Hyprland configurations

- Note: We do not convert your existing conf configs to lua. This update only reflects DMS defaults state
- Updated README.md to reflect changes
- Updated Keyboard shortcut support
This commit is contained in:
purian23
2026-05-18 13:06:58 -04:00
parent 8dd891f93a
commit 0b55bf5dac
48 changed files with 3756 additions and 1057 deletions
+179 -110
View File
@@ -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 {
+168 -132
View File
@@ -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")
}
}
})
}
@@ -0,0 +1 @@
-- Optional per-user keybind overrides (managed by DMS). Loaded after default binds.
@@ -1,165 +0,0 @@
# === Application Launchers ===
bind = SUPER, T, exec, {{TERMINAL_COMMAND}}
bind = SUPER, space, exec, dms ipc call spotlight toggle
bind = SUPER, V, exec, dms ipc call clipboard toggle
bind = SUPER, M, exec, dms ipc call processlist focusOrToggle
bind = SUPER, comma, exec, dms ipc call settings focusOrToggle
bind = SUPER, N, exec, dms ipc call notifications toggle
bind = SUPER SHIFT, N, exec, dms ipc call notepad toggle
bind = SUPER, Y, exec, dms ipc call dankdash wallpaper
bind = SUPER, TAB, exec, dms ipc call hypr toggleOverview
bind = SUPER, X, exec, dms ipc call powermenu toggle
# === Cheat sheet
bind = SUPER SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
# === Security ===
bind = SUPER ALT, L, exec, dms ipc call lock lock
bind = SUPER SHIFT, E, exit
bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle
# === Audio Controls ===
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
bindl = , XF86AudioMute, exec, dms ipc call audio mute
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
bindl = , XF86AudioNext, exec, dms ipc call mpris next
bindel = CTRL, XF86AudioRaiseVolume, exec, dms ipc call mpris increment 3
bindel = CTRL, XF86AudioLowerVolume, exec, dms ipc call mpris decrement 3
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
# === Window Management ===
bind = SUPER, Q, killactive
bind = SUPER, F, fullscreen, 1
bind = SUPER SHIFT, F, fullscreen, 0
bind = SUPER SHIFT, T, togglefloating
bind = SUPER, W, togglegroup
bind = SUPER SHIFT, W, exec, dms ipc call window-rules toggle
# === Focus Navigation ===
bind = SUPER, left, movefocus, l
bind = SUPER, down, movefocus, d
bind = SUPER, up, movefocus, u
bind = SUPER, right, movefocus, r
bind = SUPER, H, movefocus, l
bind = SUPER, J, movefocus, d
bind = SUPER, K, movefocus, u
bind = SUPER, L, movefocus, r
# === Window Movement ===
bind = SUPER SHIFT, left, movewindow, l
bind = SUPER SHIFT, down, movewindow, d
bind = SUPER SHIFT, up, movewindow, u
bind = SUPER SHIFT, right, movewindow, r
bind = SUPER SHIFT, H, movewindow, l
bind = SUPER SHIFT, J, movewindow, d
bind = SUPER SHIFT, K, movewindow, u
bind = SUPER SHIFT, L, movewindow, r
# === Column Navigation ===
bind = SUPER, Home, focuswindow, first
bind = SUPER, End, focuswindow, last
# === Monitor Navigation ===
bind = SUPER CTRL, left, focusmonitor, l
bind = SUPER CTRL, right, focusmonitor, r
bind = SUPER CTRL, H, focusmonitor, l
bind = SUPER CTRL, J, focusmonitor, d
bind = SUPER CTRL, K, focusmonitor, u
bind = SUPER CTRL, L, focusmonitor, r
# === Move to Monitor ===
bind = SUPER SHIFT CTRL, left, movewindow, mon:l
bind = SUPER SHIFT CTRL, down, movewindow, mon:d
bind = SUPER SHIFT CTRL, up, movewindow, mon:u
bind = SUPER SHIFT CTRL, right, movewindow, mon:r
bind = SUPER SHIFT CTRL, H, movewindow, mon:l
bind = SUPER SHIFT CTRL, J, movewindow, mon:d
bind = SUPER SHIFT CTRL, K, movewindow, mon:u
bind = SUPER SHIFT CTRL, L, movewindow, mon:r
# === Workspace Navigation ===
bind = SUPER, Page_Down, workspace, e+1
bind = SUPER, Page_Up, workspace, e-1
bind = SUPER, U, workspace, e+1
bind = SUPER, I, workspace, e-1
bind = SUPER CTRL, down, movetoworkspace, e+1
bind = SUPER CTRL, up, movetoworkspace, e-1
bind = SUPER CTRL, U, movetoworkspace, e+1
bind = SUPER CTRL, I, movetoworkspace, e-1
# === Workspace Management ===
bind = CTRL SHIFT, R, exec, dms ipc call workspace-rename open
# === Move Workspaces ===
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1
bind = SUPER SHIFT, U, movetoworkspace, e+1
bind = SUPER SHIFT, I, movetoworkspace, e-1
# === Mouse Wheel Navigation ===
bind = SUPER, mouse_down, workspace, e+1
bind = SUPER, mouse_up, workspace, e-1
bind = SUPER CTRL, mouse_down, movetoworkspace, e+1
bind = SUPER CTRL, mouse_up, movetoworkspace, e-1
# === Numbered Workspaces ===
bind = SUPER, 1, workspace, 1
bind = SUPER, 2, workspace, 2
bind = SUPER, 3, workspace, 3
bind = SUPER, 4, workspace, 4
bind = SUPER, 5, workspace, 5
bind = SUPER, 6, workspace, 6
bind = SUPER, 7, workspace, 7
bind = SUPER, 8, workspace, 8
bind = SUPER, 9, workspace, 9
# === Move to Numbered Workspaces ===
bind = SUPER SHIFT, 1, movetoworkspace, 1
bind = SUPER SHIFT, 2, movetoworkspace, 2
bind = SUPER SHIFT, 3, movetoworkspace, 3
bind = SUPER SHIFT, 4, movetoworkspace, 4
bind = SUPER SHIFT, 5, movetoworkspace, 5
bind = SUPER SHIFT, 6, movetoworkspace, 6
bind = SUPER SHIFT, 7, movetoworkspace, 7
bind = SUPER SHIFT, 8, movetoworkspace, 8
bind = SUPER SHIFT, 9, movetoworkspace, 9
# === Column Management ===
bind = SUPER, bracketleft, layoutmsg, preselect l
bind = SUPER, bracketright, layoutmsg, preselect r
# === Sizing & Layout ===
bind = SUPER, R, layoutmsg, togglesplit
bind = SUPER CTRL, F, resizeactive, exact 100% 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = SUPER, mouse:272, Move window, movewindow
bindmd = SUPER, mouse:273, Resize window, resizewindow
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindd = SUPER, code:20, Expand window left, resizeactive, -100 0
bindd = SUPER, code:21, Shrink window left, resizeactive, 100 0
# === Manual Sizing ===
binde = SUPER, minus, resizeactive, -10% 0
binde = SUPER, equal, resizeactive, 10% 0
binde = SUPER SHIFT, minus, resizeactive, 0 -10%
binde = SUPER SHIFT, equal, resizeactive, 0 10%
# === Screenshots ===
bind = , Print, exec, dms screenshot
bind = CTRL, Print, exec, dms screenshot full
bind = ALT, Print, exec, dms screenshot window
# === Display Profiles ===
bind = SUPER, P, exec, dms ipc outputs cycleProfile
# === System Controls ===
bind = SUPER SHIFT, P, dpms, toggle
@@ -0,0 +1,166 @@
-- DMS default keybinds (Hyprland 0.55+ Lua)
-- === Application Launchers ===
hl.bind("SUPER + T", hl.dsp.exec_cmd("{{TERMINAL_COMMAND}}"))
hl.bind("SUPER + space", hl.dsp.exec_cmd("dms ipc call spotlight toggle"))
hl.bind("SUPER + V", hl.dsp.exec_cmd("dms ipc call clipboard toggle"))
hl.bind("SUPER + M", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
hl.bind("SUPER + comma", hl.dsp.exec_cmd("dms ipc call settings focusOrToggle"))
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notifications toggle"))
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper"))
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
-- === Cheat sheet
hl.bind("SUPER + SHIFT + Slash", hl.dsp.exec_cmd("dms ipc call keybinds toggle hyprland"))
-- === Security ===
hl.bind("SUPER + ALT + L", hl.dsp.exec_cmd("dms ipc call lock lock"))
hl.bind("SUPER + SHIFT + E", hl.dsp.exit())
hl.bind("CTRL + ALT + Delete", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
-- === Audio Controls ===
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("dms ipc call audio increment 3"), { locked = true, repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("dms ipc call audio decrement 3"), { locked = true, repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("dms ipc call audio mute"), { locked = true })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("dms ipc call audio micmute"), { locked = true })
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("dms ipc call mpris playPause"), { locked = true })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("dms ipc call mpris playPause"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("dms ipc call mpris previous"), { locked = true })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("dms ipc call mpris next"), { locked = true })
hl.bind("CTRL + XF86AudioRaiseVolume", hl.dsp.exec_cmd("dms ipc call mpris increment 3"), { locked = true, repeating = true })
hl.bind("CTRL + XF86AudioLowerVolume", hl.dsp.exec_cmd("dms ipc call mpris decrement 3"), { locked = true, repeating = true })
-- === Brightness Controls ===
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]]), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd([[dms ipc call brightness decrement 5 ""]]), { locked = true, repeating = true })
-- === Window Management ===
hl.bind("SUPER + Q", hl.dsp.window.kill())
hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))
hl.bind("SUPER + SHIFT + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))
hl.bind("SUPER + SHIFT + T", hl.dsp.window.float({ action = "toggle" }))
hl.bind("SUPER + W", hl.dsp.group.toggle())
hl.bind("SUPER + SHIFT + W", hl.dsp.exec_cmd("dms ipc call window-rules toggle"))
-- === Focus Navigation ===
hl.bind("SUPER + left", hl.dsp.focus({ direction = "l" }))
hl.bind("SUPER + down", hl.dsp.focus({ direction = "d" }))
hl.bind("SUPER + up", hl.dsp.focus({ direction = "u" }))
hl.bind("SUPER + right", hl.dsp.focus({ direction = "r" }))
hl.bind("SUPER + H", hl.dsp.focus({ direction = "l" }))
hl.bind("SUPER + J", hl.dsp.focus({ direction = "d" }))
hl.bind("SUPER + K", hl.dsp.focus({ direction = "u" }))
hl.bind("SUPER + L", hl.dsp.focus({ direction = "r" }))
-- === Window Movement ===
hl.bind("SUPER + SHIFT + left", hl.dsp.window.move({ direction = "l" }))
hl.bind("SUPER + SHIFT + down", hl.dsp.window.move({ direction = "d" }))
hl.bind("SUPER + SHIFT + up", hl.dsp.window.move({ direction = "u" }))
hl.bind("SUPER + SHIFT + right", hl.dsp.window.move({ direction = "r" }))
hl.bind("SUPER + SHIFT + H", hl.dsp.window.move({ direction = "l" }))
hl.bind("SUPER + SHIFT + J", hl.dsp.window.move({ direction = "d" }))
hl.bind("SUPER + SHIFT + K", hl.dsp.window.move({ direction = "u" }))
hl.bind("SUPER + SHIFT + L", hl.dsp.window.move({ direction = "r" }))
-- === Column Navigation ===
hl.bind("SUPER + Home", hl.dsp.focus({ window = "first" }))
hl.bind("SUPER + End", hl.dsp.focus({ window = "last" }))
-- === Monitor Navigation ===
hl.bind("SUPER + CTRL + left", hl.dsp.focus({ monitor = "l" }))
hl.bind("SUPER + CTRL + right", hl.dsp.focus({ monitor = "r" }))
hl.bind("SUPER + CTRL + H", hl.dsp.focus({ monitor = "l" }))
hl.bind("SUPER + CTRL + J", hl.dsp.focus({ monitor = "d" }))
hl.bind("SUPER + CTRL + K", hl.dsp.focus({ monitor = "u" }))
hl.bind("SUPER + CTRL + L", hl.dsp.focus({ monitor = "r" }))
-- === Move to Monitor ===
hl.bind("SUPER + SHIFT + CTRL + left", hl.dsp.window.move({ monitor = "l" }))
hl.bind("SUPER + SHIFT + CTRL + down", hl.dsp.window.move({ monitor = "d" }))
hl.bind("SUPER + SHIFT + CTRL + up", hl.dsp.window.move({ monitor = "u" }))
hl.bind("SUPER + SHIFT + CTRL + right", hl.dsp.window.move({ monitor = "r" }))
hl.bind("SUPER + SHIFT + CTRL + H", hl.dsp.window.move({ monitor = "l" }))
hl.bind("SUPER + SHIFT + CTRL + J", hl.dsp.window.move({ monitor = "d" }))
hl.bind("SUPER + SHIFT + CTRL + K", hl.dsp.window.move({ monitor = "u" }))
hl.bind("SUPER + SHIFT + CTRL + L", hl.dsp.window.move({ monitor = "r" }))
-- === Workspace Navigation ===
hl.bind("SUPER + Page_Down", hl.dsp.focus({ workspace = "e+1" }))
hl.bind("SUPER + Page_Up", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + U", hl.dsp.focus({ workspace = "e+1" }))
hl.bind("SUPER + I", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + up", hl.dsp.window.move({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + U", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + I", hl.dsp.window.move({ workspace = "e-1" }))
-- === Workspace Management ===
hl.bind("CTRL + SHIFT + R", hl.dsp.exec_cmd("dms ipc call workspace-rename open"))
-- === Move Workspaces ===
hl.bind("SUPER + SHIFT + Page_Down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + SHIFT + Page_Up", hl.dsp.window.move({ workspace = "e-1" }))
hl.bind("SUPER + SHIFT + U", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + SHIFT + I", hl.dsp.window.move({ workspace = "e-1" }))
-- === Mouse Wheel Navigation ===
hl.bind("SUPER + mouse_down", hl.dsp.focus({ workspace = "e+1" }))
hl.bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + mouse_down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + mouse_up", hl.dsp.window.move({ workspace = "e-1" }))
-- === Numbered Workspaces ===
hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))
hl.bind("SUPER + 2", hl.dsp.focus({ workspace = "2" }))
hl.bind("SUPER + 3", hl.dsp.focus({ workspace = "3" }))
hl.bind("SUPER + 4", hl.dsp.focus({ workspace = "4" }))
hl.bind("SUPER + 5", hl.dsp.focus({ workspace = "5" }))
hl.bind("SUPER + 6", hl.dsp.focus({ workspace = "6" }))
hl.bind("SUPER + 7", hl.dsp.focus({ workspace = "7" }))
hl.bind("SUPER + 8", hl.dsp.focus({ workspace = "8" }))
hl.bind("SUPER + 9", hl.dsp.focus({ workspace = "9" }))
-- === Move to Numbered Workspaces ===
hl.bind("SUPER + SHIFT + 1", hl.dsp.window.move({ workspace = "1" }))
hl.bind("SUPER + SHIFT + 2", hl.dsp.window.move({ workspace = "2" }))
hl.bind("SUPER + SHIFT + 3", hl.dsp.window.move({ workspace = "3" }))
hl.bind("SUPER + SHIFT + 4", hl.dsp.window.move({ workspace = "4" }))
hl.bind("SUPER + SHIFT + 5", hl.dsp.window.move({ workspace = "5" }))
hl.bind("SUPER + SHIFT + 6", hl.dsp.window.move({ workspace = "6" }))
hl.bind("SUPER + SHIFT + 7", hl.dsp.window.move({ workspace = "7" }))
hl.bind("SUPER + SHIFT + 8", hl.dsp.window.move({ workspace = "8" }))
hl.bind("SUPER + SHIFT + 9", hl.dsp.window.move({ workspace = "9" }))
-- === Column Management ===
hl.bind("SUPER + bracketleft", hl.dsp.layout("preselect l"))
hl.bind("SUPER + bracketright", hl.dsp.layout("preselect r"))
-- === Sizing & Layout ===
hl.bind("SUPER + R", hl.dsp.layout("togglesplit"))
hl.bind("SUPER + CTRL + F", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive exact 100% 100%]]))
-- === Move/resize windows with mainMod + LMB/RMB and dragging ===
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" })
hl.bind("SUPER + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window" })
hl.bind("SUPER + code:20", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { description = "Expand window left" })
hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" })
-- === Manual Sizing ===
hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })
hl.bind("SUPER + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 10% 0]]), { repeating = true })
hl.bind("SUPER + SHIFT + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 -10%]]), { repeating = true })
hl.bind("SUPER + SHIFT + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 10%]]), { repeating = true })
-- === Screenshots ===
hl.bind("Print", hl.dsp.exec_cmd("dms screenshot"))
hl.bind("CTRL + Print", hl.dsp.exec_cmd("dms screenshot full"))
hl.bind("ALT + Print", hl.dsp.exec_cmd("dms screenshot window"))
-- === Display Profiles ===
hl.bind("SUPER + P", hl.dsp.exec_cmd("dms ipc outputs cycleProfile"))
-- === System Controls ===
hl.bind("SUPER + SHIFT + P", hl.dsp.dpms({ action = "toggle" }))
@@ -1,25 +0,0 @@
# ! Auto-generated file. Do not edit directly.
# Remove source = ./dms/colors.conf from your config to override.
$primary = rgb(d0bcff)
$outline = rgb(948f99)
$error = rgb(f2b8b5)
general {
col.active_border = $primary
col.inactive_border = $outline
}
group {
col.border_active = $primary
col.border_inactive = $outline
col.border_locked_active = $error
col.border_locked_inactive = $outline
groupbar {
col.active = $primary
col.inactive = $outline
col.locked_active = $error
col.locked_inactive = $outline
}
}
@@ -0,0 +1,27 @@
-- ! Auto-generated file. Do not edit directly.
-- Regenerate via DMS theme tools or remove require("dms.colors") from hyprland.lua to override.
hl.config({
general = {
col = {
active_border = "rgb(d0bcff)",
inactive_border = "rgb(948f99)",
},
},
group = {
col = {
border_active = "rgb(d0bcff)",
border_inactive = "rgb(948f99)",
border_locked_active = "rgb(f2b8b5)",
border_locked_inactive = "rgb(948f99)",
},
groupbar = {
col = {
active = "rgb(d0bcff)",
inactive = "rgb(948f99)",
locked_active = "rgb(f2b8b5)",
locked_inactive = "rgb(948f99)",
},
},
},
})
@@ -0,0 +1 @@
-- Cursor theme overrides. Deploy writes ~/.config/hypr/dms/cursor.lua
@@ -1,11 +0,0 @@
# Auto-generated by DMS - do not edit manually
general {
gaps_in = 4
gaps_out = 4
border_size = 2
}
decoration {
rounding = 12
}
@@ -0,0 +1,12 @@
-- Auto-generated by DMS — do not edit manually
hl.config({
general = {
gaps_in = 4,
gaps_out = 4,
border_size = 2,
},
decoration = {
rounding = 12,
},
})
@@ -0,0 +1,3 @@
-- Per-output monitor rules — embedded sibling of the legacy outputs.conf fragment. Deploy writes ~/.config/hypr/dms/outputs.lua
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
@@ -0,0 +1 @@
-- Window rules. Deploy writes ~/.config/hypr/dms/windowrules.lua
-117
View File
@@ -1,117 +0,0 @@
# Hyprland Configuration
# https://wiki.hypr.land/Configuring/
# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
monitor = , preferred,auto,auto
# ==================
# STARTUP APPS
# ==================
exec-once = dbus-update-activation-environment --systemd --all
exec-once = systemctl --user start hyprland-session.target
# ==================
# INPUT CONFIG
# ==================
input {
kb_layout = us
numlock_by_default = true
}
# ==================
# GENERAL LAYOUT
# ==================
general {
gaps_in = 5
gaps_out = 5
border_size = 2
layout = dwindle
}
# ==================
# DECORATION
# ==================
decoration {
rounding = 12
active_opacity = 1.0
inactive_opacity = 1.0
shadow {
enabled = true
range = 30
render_power = 5
offset = 0 5
color = rgba(00000070)
}
}
# ==================
# ANIMATIONS
# ==================
animations {
enabled = true
animation = windowsIn, 1, 3, default
animation = windowsOut, 1, 3, default
animation = workspaces, 1, 5, default
animation = windowsMove, 1, 4, default
animation = fade, 1, 3, default
animation = border, 1, 3, default
}
# ==================
# LAYOUTS
# ==================
dwindle {
preserve_split = true
}
master {
mfact = 0.5
}
# ==================
# MISC
# ==================
misc {
disable_hyprland_logo = true
disable_splash_rendering = true
}
# ==================
# WINDOW RULES
# ==================
windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$
windowrule = rounding 12, match:class ^(org\.gnome\.)
windowrule = tile on, match:class ^(gnome-control-center)$
windowrule = tile on, match:class ^(pavucontrol)$
windowrule = tile on, match:class ^(nm-connection-editor)$
windowrule = float on, match:class ^(org\.gnome\.Calculator)$
windowrule = float on, match:class ^(gnome-calculator)$
windowrule = float on, match:class ^(galculator)$
windowrule = float on, match:class ^(blueman-manager)$
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
windowrule = float on, match:class ^(xdg-desktop-portal)$
windowrule = no_initial_focus on, match:class ^(steam)$, match:title ^(notificationtoasts)
windowrule = pin on, match:class ^(steam)$, match:title ^(notificationtoasts)
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
windowrule = float on, match:class ^(zoom)$
layerrule = no_anim on, match:namespace ^(quickshell)$
layerrule = no_anim on, match:namespace ^dms:.*
source = ./dms/colors.conf
source = ./dms/outputs.conf
source = ./dms/layout.conf
source = ./dms/cursor.conf
source = ./dms/binds.conf
@@ -0,0 +1,84 @@
-- Hyprland configuration (Lua) — https://wiki.hypr.land/Configuring/Start/
hl.config({ autogenerated = false })
-- DMS_STARTUP_BEGIN
hl.on("hyprland.start", function()
hl.exec_cmd("dbus-update-activation-environment --systemd --all")
hl.exec_cmd("systemctl --user start hyprland-session.target")
end)
-- DMS_STARTUP_END
hl.config({
input = {
kb_layout = "us",
numlock_by_default = true,
},
general = {
gaps_in = 5,
gaps_out = 5,
border_size = 2,
layout = "dwindle",
},
decoration = {
rounding = 12,
active_opacity = 1.0,
inactive_opacity = 1.0,
shadow = {
enabled = true,
range = 30,
render_power = 5,
offset = "0 5",
color = "rgba(00000070)",
},
},
misc = {
disable_hyprland_logo = true,
disable_splash_rendering = true,
},
dwindle = {
preserve_split = true,
},
master = {
mfact = 0.5,
},
})
hl.animation({ leaf = "windowsIn", enabled = true, speed = 3, bezier = "default" })
hl.animation({ leaf = "windowsOut", enabled = true, speed = 3, bezier = "default" })
hl.animation({ leaf = "workspaces", enabled = true, speed = 5, bezier = "default" })
hl.animation({ leaf = "windowsMove", enabled = true, speed = 4, bezier = "default" })
hl.animation({ leaf = "fade", enabled = true, speed = 3, bezier = "default" })
hl.animation({ leaf = "border", enabled = true, speed = 3, bezier = "default" })
hl.window_rule({ match = { class = "^(org\\.wezfurlong\\.wezterm)$" }, tile = true })
hl.window_rule({ match = { class = "^(org\\.gnome\\.)" }, rounding = 12 })
hl.window_rule({ match = { class = "^(gnome-control-center)$" }, tile = true })
hl.window_rule({ match = { class = "^(pavucontrol)$" }, tile = true })
hl.window_rule({ match = { class = "^(nm-connection-editor)$" }, tile = true })
hl.window_rule({ match = { class = "^(org\\.gnome\\.Calculator)$" }, float = true })
hl.window_rule({ match = { class = "^(gnome-calculator)$" }, float = true })
hl.window_rule({ match = { class = "^(galculator)$" }, float = true })
hl.window_rule({ match = { class = "^(blueman-manager)$" }, float = true })
hl.window_rule({ match = { class = "^(org\\.gnome\\.Nautilus)$" }, float = true })
hl.window_rule({ match = { class = "^(xdg-desktop-portal)$" }, float = true })
hl.window_rule({
match = { class = "^(steam)$", title = "^(notificationtoasts)" },
no_initial_focus = true,
pin = true,
})
hl.window_rule({
match = { class = "^(firefox)$", title = "^(Picture-in-Picture)$" },
float = true,
})
hl.window_rule({ match = { class = "^(zoom)$" }, float = true })
hl.layer_rule({ match = { namespace = "^(quickshell)$" }, no_anim = true })
hl.layer_rule({ match = { namespace = "^dms:.*" }, no_anim = true })
require("dms.colors")
require("dms.outputs")
require("dms.layout")
require("dms.cursor")
require("dms.binds")
require("dms.binds-user")
require("dms.windowrules")
+20 -8
View File
@@ -2,14 +2,26 @@ package config
import _ "embed"
//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
+169
View File
@@ -0,0 +1,169 @@
package config
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
const (
hyprlandStartupBegin = "-- DMS_STARTUP_BEGIN"
hyprlandStartupEnd = "-- DMS_STARTUP_END"
)
func extractHyprlangMonitorLines(hyprlang string) []string {
re := regexp.MustCompile(`(?m)^\s*#?\s*monitor\s*=.*$`)
return re.FindAllString(hyprlang, -1)
}
func hyprlangMonitorLineToLua(line string) (string, error) {
re := regexp.MustCompile(`(?i)^\s*#?\s*monitor\s*=\s*(.*)\s*$`)
m := re.FindStringSubmatch(line)
if m == nil {
return "", fmt.Errorf("not a monitor line")
}
rest := strings.TrimSpace(m[1])
parts := strings.Split(rest, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
if len(parts) < 4 {
if len(parts) == 2 && strings.EqualFold(parts[1], "disable") {
return fmt.Sprintf(`hl.monitor({ output = %s, disabled = true })`, strconv.Quote(parts[0])), nil
}
return "", fmt.Errorf("expected at least 4 comma-separated fields")
}
out := parts[0]
mode := parts[1]
pos := parts[2]
scaleStr := parts[3]
scaleField := formatMonitorScaleLua(scaleStr)
fields := []string{
fmt.Sprintf("output = %s", strconv.Quote(out)),
fmt.Sprintf("mode = %s", strconv.Quote(mode)),
fmt.Sprintf("position = %s", strconv.Quote(pos)),
scaleField,
}
for i := 4; i < len(parts); i += 2 {
key := strings.ToLower(strings.TrimSpace(parts[i]))
if key == "" {
continue
}
if i+1 >= len(parts) {
fields = append(fields, fmt.Sprintf("%s = true", hyprlangMonitorOptionToLuaKey(key)))
continue
}
val := strings.TrimSpace(parts[i+1])
if converted, ok := formatMonitorOptionLua(key, val); ok {
fields = append(fields, converted)
}
}
return fmt.Sprintf(`hl.monitor({ %s })`, strings.Join(fields, ", ")), nil
}
func formatMonitorScaleLua(scaleStr string) string {
if scaleStr == "auto" {
return `scale = "auto"`
}
if f, err := strconv.ParseFloat(scaleStr, 64); err == nil {
return fmt.Sprintf(`scale = %g`, f)
}
return fmt.Sprintf(`scale = %s`, strconv.Quote(scaleStr))
}
func hyprlangMonitorOptionToLuaKey(key string) string {
switch strings.ToLower(strings.TrimSpace(key)) {
case "10bit":
return "bitdepth"
default:
return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(key)), "-", "_")
}
}
func formatMonitorOptionLua(key, val string) (string, bool) {
luaKey := hyprlangMonitorOptionToLuaKey(key)
switch luaKey {
case "transform", "vrr", "bitdepth", "supports_wide_color", "supports_hdr", "sdr_max_luminance", "max_luminance", "max_avg_luminance":
if _, err := strconv.Atoi(val); err == nil {
return fmt.Sprintf("%s = %s", luaKey, val), true
}
case "sdrbrightness", "sdrsaturation", "sdr_min_luminance", "min_luminance":
if _, err := strconv.ParseFloat(val, 64); err == nil {
return fmt.Sprintf("%s = %s", luaKey, val), true
}
case "cm", "sdr_eotf", "icc", "mirror":
return fmt.Sprintf("%s = %s", luaKey, strconv.Quote(val)), true
}
return "", false
}
func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string {
start := strings.Index(config, hyprlandStartupBegin)
end := strings.Index(config, hyprlandStartupEnd)
if start == -1 || end == -1 || end <= start {
return config
}
endClose := end + len(hyprlandStartupEnd)
replacement := hyprlandStartupBegin + "\n" +
`hl.env("QT_QPA_PLATFORM", "wayland;xcb")` + "\n" +
`hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto")` + "\n" +
`hl.env("QT_QPA_PLATFORMTHEME", "gtk3")` + "\n" +
`hl.env("QT_QPA_PLATFORMTHEME_QT6", "gtk3")` + "\n" +
fmt.Sprintf(`hl.env("TERMINAL", %s)`, strconv.Quote(terminalCommand)) + "\n\n" +
`hl.on("hyprland.start", function()` + "\n" +
` hl.exec_cmd("dms run")` + "\n" +
`end)` + "\n" +
hyprlandStartupEnd
return config[:start] + replacement + config[endClose:]
}
func readExistingHyprlandConfig(configDir string) (data string, sourcePath string, err error) {
luaPath := filepath.Join(configDir, "hyprland.lua")
if b, e := os.ReadFile(luaPath); e == nil {
return string(b), luaPath, nil
} else if !os.IsNotExist(e) {
return "", "", e
}
confPath := filepath.Join(configDir, "hyprland.conf")
if b, e := os.ReadFile(confPath); e == nil {
return string(b), confPath, nil
} else if !os.IsNotExist(e) {
return "", "", e
}
return "", "", nil
}
// CleanupStrayHyprlandConfFile moves a stray ~/.config/hypr/hyprland.conf
// into .dms-backups/<timestamp>/ when running under Hyprland. Hyprland 0.55
// auto-generates hyprland.conf when launched without -c, so this is invoked
// from dms run startup to keep the active config tree single-file.
func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
return
}
home := os.Getenv("HOME")
if home == "" {
return
}
configDir := filepath.Join(home, ".config", "hypr")
confPath := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(confPath); err != nil {
return
}
ts := time.Now().Format("2006-01-02_15-04-05")
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, "hyprland.conf")
if err := moveHyprlandConfigFile(confPath, dst); err != nil {
if logFn != nil {
logFn("Could not move stray hyprland.conf: %v", err)
}
return
}
if logFn != nil {
logFn("Moved stray hyprland.conf to %s", dst)
}
}