From a205df1bd6563f14baeaca631be064415fd99f97 Mon Sep 17 00:00:00 2001 From: bbedward Date: Wed, 7 Jan 2026 12:15:38 -0500 Subject: [PATCH] keybinds: initial support for writable hyprland and mangoWC fixes #1204 --- core/internal/config/embedded/hyprland.conf | 4 +- core/internal/keybinds/providers/hyprland.go | 361 +++++++- .../keybinds/providers/hyprland_parser.go | 334 ++++++- .../providers/hyprland_parser_test.go | 8 +- .../keybinds/providers/hyprland_test.go | 55 +- core/internal/keybinds/providers/mangowc.go | 357 +++++++- .../keybinds/providers/mangowc_parser.go | 346 +++++++- .../keybinds/providers/mangowc_parser_test.go | 10 +- .../keybinds/providers/mangowc_test.go | 15 +- quickshell/Common/KeybindActions.js | 833 ++++++++++++++++-- quickshell/DMSShellIPC.qml | 7 + quickshell/Modules/DankBar/DankBar.qml | 4 + quickshell/Modules/Settings/KeybindsTab.qml | 18 +- quickshell/Modules/Settings/PluginsTab.qml | 8 + quickshell/Services/KeybindsService.qml | 169 ++-- quickshell/Widgets/KeybindItem.qml | 130 +-- 16 files changed, 2372 insertions(+), 287 deletions(-) diff --git a/core/internal/config/embedded/hyprland.conf b/core/internal/config/embedded/hyprland.conf index 359d8231..9f138a9b 100644 --- a/core/internal/config/embedded/hyprland.conf +++ b/core/internal/config/embedded/hyprland.conf @@ -106,8 +106,8 @@ windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture windowrule = float on, match:class ^(zoom)$ # DMS windows floating by default -windowrule = float on, match:class ^(org.quickshell)$ -windowrule = opacity 0.9 0.9, match:float false, match:focus false +# ! Hyprland doesnt size these windows correctly so disabling by default here +# windowrule = float on, match:class ^(org.quickshell)$ layerrule = no_anim on, match:namespace ^(quickshell)$ diff --git a/core/internal/keybinds/providers/hyprland.go b/core/internal/keybinds/providers/hyprland.go index 8929ccd3..cbc58e84 100644 --- a/core/internal/keybinds/providers/hyprland.go +++ b/core/internal/keybinds/providers/hyprland.go @@ -2,45 +2,93 @@ package providers import ( "fmt" + "os" + "path/filepath" + "sort" "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" ) type HyprlandProvider struct { - configPath string + configPath string + dmsBindsIncluded bool + parsed bool } func NewHyprlandProvider(configPath string) *HyprlandProvider { if configPath == "" { - configPath = "$HOME/.config/hypr" + configPath = defaultHyprlandConfigDir() } return &HyprlandProvider{ configPath: configPath, } } +func defaultHyprlandConfigDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(configDir, "hypr") +} + func (h *HyprlandProvider) Name() string { return "hyprland" } func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { - section, err := ParseHyprlandKeys(h.configPath) + result, err := ParseHyprlandKeysWithDMS(h.configPath) if err != nil { return nil, fmt.Errorf("failed to parse hyprland config: %w", err) } - categorizedBinds := make(map[string][]keybinds.Keybind) - h.convertSection(section, "", categorizedBinds) + h.dmsBindsIncluded = result.DMSBindsIncluded + h.parsed = true - return &keybinds.CheatSheet{ - Title: "Hyprland Keybinds", - Provider: h.Name(), - Binds: categorizedBinds, - }, nil + categorizedBinds := make(map[string][]keybinds.Keybind) + h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs) + + sheet := &keybinds.CheatSheet{ + Title: "Hyprland Keybinds", + Provider: h.Name(), + Binds: categorizedBinds, + DMSBindsIncluded: result.DMSBindsIncluded, + } + + if result.DMSStatus != nil { + sheet.DMSStatus = &keybinds.DMSBindsStatus{ + Exists: result.DMSStatus.Exists, + Included: result.DMSStatus.Included, + IncludePosition: result.DMSStatus.IncludePosition, + TotalIncludes: result.DMSStatus.TotalIncludes, + BindsAfterDMS: result.DMSStatus.BindsAfterDMS, + Effective: result.DMSStatus.Effective, + OverriddenBy: result.DMSStatus.OverriddenBy, + StatusMessage: result.DMSStatus.StatusMessage, + } + } + + return sheet, nil } -func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind) { +func (h *HyprlandProvider) HasDMSBindsIncluded() bool { + if h.parsed { + return h.dmsBindsIncluded + } + + result, err := ParseHyprlandKeysWithDMS(h.configPath) + if err != nil { + return false + } + + h.dmsBindsIncluded = result.DMSBindsIncluded + h.parsed = true + return h.dmsBindsIncluded +} + +func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding) { currentSubcat := subcategory if section.Name != "" { currentSubcat = section.Name @@ -48,12 +96,12 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory for _, kb := range section.Keybinds { category := h.categorizeByDispatcher(kb.Dispatcher) - bind := h.convertKeybind(&kb, currentSubcat) + bind := h.convertKeybind(&kb, currentSubcat, conflicts) categorizedBinds[category] = append(categorizedBinds[category], bind) } for _, child := range section.Children { - h.convertSection(&child, currentSubcat, categorizedBinds) + h.convertSection(&child, currentSubcat, categorizedBinds, conflicts) } } @@ -85,8 +133,8 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string { } } -func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string) keybinds.Keybind { - key := h.formatKey(kb) +func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding) keybinds.Keybind { + keyStr := h.formatKey(kb) rawAction := h.formatRawAction(kb.Dispatcher, kb.Params) desc := kb.Comment @@ -94,12 +142,32 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st desc = rawAction } - return keybinds.Keybind{ - Key: key, + source := "config" + if strings.Contains(kb.Source, "dms/binds.conf") { + source = "dms" + } + + bind := keybinds.Keybind{ + Key: keyStr, Description: desc, Action: rawAction, Subcategory: subcategory, + Source: source, } + + if source == "dms" && conflicts != nil { + normalizedKey := strings.ToLower(keyStr) + if conflictKb, ok := conflicts[normalizedKey]; ok { + bind.Conflict = &keybinds.Keybind{ + Key: keyStr, + Description: conflictKb.Comment, + Action: h.formatRawAction(conflictKb.Dispatcher, conflictKb.Params), + Source: "config", + } + } + } + + return bind } func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string { @@ -115,3 +183,262 @@ func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string { parts = append(parts, kb.Key) return strings.Join(parts, "+") } + +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(expanded, "dms", "binds.conf") +} + +func (h *HyprlandProvider) validateAction(action string) error { + action = strings.TrimSpace(action) + switch { + case action == "": + return fmt.Errorf("action cannot be empty") + case action == "exec" || action == "exec ": + return fmt.Errorf("exec dispatcher requires arguments") + case strings.HasPrefix(action, "exec "): + rest := strings.TrimSpace(strings.TrimPrefix(action, "exec ")) + if rest == "" { + return fmt.Errorf("exec dispatcher requires arguments") + } + } + return nil +} + +func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error { + if err := h.validateAction(action); err != nil { + return err + } + + overridePath := h.GetOverridePath() + + if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil { + return fmt.Errorf("failed to create dms directory: %w", err) + } + + existingBinds, err := h.loadOverrideBinds() + if err != nil { + existingBinds = make(map[string]*hyprlandOverrideBind) + } + + normalizedKey := strings.ToLower(key) + existingBinds[normalizedKey] = &hyprlandOverrideBind{ + Key: key, + Action: action, + Description: description, + Options: options, + } + + return h.writeOverrideBinds(existingBinds) +} + +func (h *HyprlandProvider) RemoveBind(key string) error { + existingBinds, err := h.loadOverrideBinds() + if err != nil { + return nil + } + + normalizedKey := strings.ToLower(key) + delete(existingBinds, normalizedKey) + return h.writeOverrideBinds(existingBinds) +} + +type hyprlandOverrideBind struct { + Key string + Action string + Description string + Options map[string]any +} + +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 + } + + 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]) + } + + fields := strings.SplitN(bindContent, ",", 4) + if len(fields) < 3 { + continue + } + + mods := strings.TrimSpace(fields[0]) + keyName := strings.TrimSpace(fields[1]) + dispatcher := strings.TrimSpace(fields[2]) + + var params string + if len(fields) > 3 { + params = strings.TrimSpace(fields[3]) + } + + 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, + } + } + + return binds, nil +} + +func (h *HyprlandProvider) buildKeyString(mods, key string) string { + if mods == "" { + return key + } + + modList := strings.FieldsFunc(mods, func(r rune) bool { + return r == '+' || r == ' ' + }) + + parts := append(modList, key) + return strings.Join(parts, "+") +} + +func (h *HyprlandProvider) getBindSortPriority(action string) int { + switch { + case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"): + return 0 + case strings.Contains(action, "workspace"): + return 1 + case strings.Contains(action, "window") || strings.Contains(action, "focus") || + strings.Contains(action, "move") || strings.Contains(action, "swap") || + strings.Contains(action, "resize"): + return 2 + case strings.Contains(action, "monitor"): + return 3 + case strings.HasPrefix(action, "exec"): + return 4 + case action == "exit" || strings.Contains(action, "dpms"): + return 5 + default: + return 6 + } +} + +func (h *HyprlandProvider) writeOverrideBinds(binds map[string]*hyprlandOverrideBind) error { + overridePath := h.GetOverridePath() + content := h.generateBindsContent(binds) + return os.WriteFile(overridePath, []byte(content), 0644) +} + +func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverrideBind) string { + if len(binds) == 0 { + return "" + } + + bindList := make([]*hyprlandOverrideBind, 0, len(binds)) + for _, bind := range binds { + bindList = append(bindList, bind) + } + + sort.Slice(bindList, func(i, j int) bool { + pi, pj := h.getBindSortPriority(bindList[i].Action), h.getBindSortPriority(bindList[j].Action) + if pi != pj { + return pi < pj + } + return bindList[i].Key < bindList[j].Key + }) + + var sb strings.Builder + for _, bind := range bindList { + h.writeBindLine(&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) + + sb.WriteString("bind = ") + sb.WriteString(mods) + sb.WriteString(", ") + sb.WriteString(key) + sb.WriteString(", ") + sb.WriteString(dispatcher) + + if params != "" { + sb.WriteString(", ") + sb.WriteString(params) + } + + if bind.Description != "" { + sb.WriteString(" # ") + sb.WriteString(bind.Description) + } + + sb.WriteString("\n") +} + +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] + default: + return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1] + } +} + +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] + } + + // Convert internal spawn format to Hyprland's exec + if dispatcher == "spawn" { + dispatcher = "exec" + } + + return dispatcher, params +} diff --git a/core/internal/keybinds/providers/hyprland_parser.go b/core/internal/keybinds/providers/hyprland_parser.go index ac732119..6d1cc410 100644 --- a/core/internal/keybinds/providers/hyprland_parser.go +++ b/core/internal/keybinds/providers/hyprland_parser.go @@ -23,6 +23,7 @@ type HyprlandKeyBinding struct { Dispatcher string `json:"dispatcher"` Params string `json:"params"` Comment string `json:"comment"` + Source string `json:"source"` } type HyprlandSection struct { @@ -32,14 +33,36 @@ type HyprlandSection struct { } type HyprlandParser struct { - contentLines []string - readingLine int + contentLines []string + readingLine int + configDir string + currentSource string + dmsBindsExists bool + dmsBindsIncluded bool + includeCount int + dmsIncludePos int + bindsAfterDMS int + dmsBindKeys map[string]bool + configBindKeys map[string]bool + conflictingConfigs map[string]*HyprlandKeyBinding + bindMap map[string]*HyprlandKeyBinding + bindOrder []string + processedFiles map[string]bool + dmsProcessed bool } -func NewHyprlandParser() *HyprlandParser { +func NewHyprlandParser(configDir string) *HyprlandParser { return &HyprlandParser{ - contentLines: []string{}, - readingLine: 0, + contentLines: []string{}, + readingLine: 0, + configDir: configDir, + dmsIncludePos: -1, + dmsBindKeys: make(map[string]bool), + configBindKeys: make(map[string]bool), + conflictingConfigs: make(map[string]*HyprlandKeyBinding), + bindMap: make(map[string]*HyprlandKeyBinding), + bindOrder: []string{}, + processedFiles: make(map[string]bool), } } @@ -320,9 +343,308 @@ func (p *HyprlandParser) ParseKeys() *HyprlandSection { } func ParseHyprlandKeys(path string) (*HyprlandSection, error) { - parser := NewHyprlandParser() + parser := NewHyprlandParser(path) if err := parser.ReadContent(path); err != nil { return nil, err } return parser.ParseKeys(), nil } + +type HyprlandParseResult struct { + Section *HyprlandSection + DMSBindsIncluded bool + DMSStatus *HyprlandDMSStatus + ConflictingConfigs map[string]*HyprlandKeyBinding +} + +type HyprlandDMSStatus struct { + Exists bool + Included bool + IncludePosition int + TotalIncludes int + BindsAfterDMS int + Effective bool + OverriddenBy int + StatusMessage string +} + +func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus { + status := &HyprlandDMSStatus{ + Exists: p.dmsBindsExists, + Included: p.dmsBindsIncluded, + IncludePosition: p.dmsIncludePos, + TotalIncludes: p.includeCount, + BindsAfterDMS: p.bindsAfterDMS, + } + + switch { + case !p.dmsBindsExists: + status.Effective = false + status.StatusMessage = "dms/binds.conf does not exist" + case !p.dmsBindsIncluded: + status.Effective = false + status.StatusMessage = "dms/binds.conf is not sourced in config" + case p.bindsAfterDMS > 0: + status.Effective = true + status.OverriddenBy = p.bindsAfterDMS + status.StatusMessage = "Some DMS binds may be overridden by config binds" + default: + status.Effective = true + status.StatusMessage = "DMS binds are active" + } + + return status +} + +func (p *HyprlandParser) formatBindKey(kb *HyprlandKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (p *HyprlandParser) normalizeKey(key string) string { + return strings.ToLower(key) +} + +func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool { + key := p.formatBindKey(kb) + normalizedKey := p.normalizeKey(key) + isDMSBind := strings.Contains(kb.Source, "dms/binds.conf") + + if isDMSBind { + p.dmsBindKeys[normalizedKey] = true + } else if p.dmsBindKeys[normalizedKey] { + p.bindsAfterDMS++ + p.conflictingConfigs[normalizedKey] = kb + p.configBindKeys[normalizedKey] = true + return false + } else { + p.configBindKeys[normalizedKey] = true + } + + if _, exists := p.bindMap[normalizedKey]; !exists { + p.bindOrder = append(p.bindOrder, key) + } + p.bindMap[normalizedKey] = kb + return true +} + +func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) { + expandedDir, err := utils.ExpandPath(p.configDir) + if err != nil { + return nil, err + } + + dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf") + if _, err := os.Stat(dmsBindsPath); err == nil { + p.dmsBindsExists = true + } + + mainConfig := filepath.Join(expandedDir, "hyprland.conf") + section, err := p.parseFileWithSource(mainConfig, "") + if err != nil { + return nil, err + } + + if p.dmsBindsExists && !p.dmsProcessed { + p.parseDMSBindsDirectly(dmsBindsPath, section) + } + + return section, nil +} + +func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) { + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, err + } + + if p.processedFiles[absPath] { + return &HyprlandSection{Name: sectionName}, nil + } + p.processedFiles[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return nil, err + } + + prevSource := p.currentSource + p.currentSource = absPath + + section := &HyprlandSection{Name: sectionName} + lines := strings.Split(string(data), "\n") + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "source") { + p.handleSource(trimmed, section, filepath.Dir(absPath)) + continue + } + + if !strings.HasPrefix(trimmed, "bind") { + continue + } + + kb := p.parseBindLine(line) + if kb == nil { + continue + } + kb.Source = p.currentSource + if p.addBind(kb) { + section.Keybinds = append(section.Keybinds, *kb) + } + } + + p.currentSource = prevSource + return section, nil +} + +func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, baseDir string) { + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + return + } + + sourcePath := strings.TrimSpace(parts[1]) + isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf") + + p.includeCount++ + if isDMSSource { + p.dmsBindsIncluded = true + p.dmsIncludePos = p.includeCount + p.dmsProcessed = true + } + + fullPath := sourcePath + if !filepath.IsAbs(sourcePath) { + fullPath = filepath.Join(baseDir, sourcePath) + } + + expanded, err := utils.ExpandPath(fullPath) + if err != nil { + return + } + + includedSection, err := p.parseFileWithSource(expanded, "") + if err != nil { + return + } + + section.Children = append(section.Children, *includedSection) +} + +func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) { + data, err := os.ReadFile(dmsBindsPath) + if err != nil { + return + } + + prevSource := p.currentSource + p.currentSource = dmsBindsPath + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "bind") { + continue + } + + kb := p.parseBindLine(line) + if kb == nil { + continue + } + kb.Source = dmsBindsPath + if p.addBind(kb) { + section.Keybinds = append(section.Keybinds, *kb) + } + } + + p.currentSource = prevSource + p.dmsProcessed = true +} + +func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding { + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + return nil + } + + keys := parts[1] + keyParts := strings.SplitN(keys, "#", 2) + keys = keyParts[0] + + var comment string + if len(keyParts) > 1 { + comment = strings.TrimSpace(keyParts[1]) + } + + keyFields := strings.SplitN(keys, ",", 5) + if len(keyFields) < 3 { + return nil + } + + mods := strings.TrimSpace(keyFields[0]) + key := strings.TrimSpace(keyFields[1]) + dispatcher := strings.TrimSpace(keyFields[2]) + + var params string + if len(keyFields) > 3 { + paramParts := keyFields[3:] + params = strings.TrimSpace(strings.Join(paramParts, ",")) + } + + if comment != "" && strings.HasPrefix(comment, HideComment) { + return nil + } + + if comment == "" { + comment = hyprlandAutogenerateComment(dispatcher, params) + } + + var modList []string + if mods != "" { + modstring := mods + string(ModSeparators[0]) + idx := 0 + for index, char := range modstring { + isModSep := false + for _, sep := range ModSeparators { + if char == sep { + isModSep = true + break + } + } + if isModSep { + if index-idx > 1 { + modList = append(modList, modstring[idx:index]) + } + idx = index + 1 + } + } + } + + return &HyprlandKeyBinding{ + Mods: modList, + Key: key, + Dispatcher: dispatcher, + Params: params, + Comment: comment, + } +} + +func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) { + parser := NewHyprlandParser(path) + section, err := parser.ParseWithDMS() + if err != nil { + return nil, err + } + + return &HyprlandParseResult{ + Section: section, + DMSBindsIncluded: parser.dmsBindsIncluded, + DMSStatus: parser.buildDMSStatus(), + ConflictingConfigs: parser.conflictingConfigs, + }, nil +} diff --git a/core/internal/keybinds/providers/hyprland_parser_test.go b/core/internal/keybinds/providers/hyprland_parser_test.go index 87f7440c..cb40817b 100644 --- a/core/internal/keybinds/providers/hyprland_parser_test.go +++ b/core/internal/keybinds/providers/hyprland_parser_test.go @@ -130,7 +130,7 @@ func TestHyprlandGetKeybindAtLine(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - parser := NewHyprlandParser() + parser := NewHyprlandParser("") parser.contentLines = []string{tt.line} result := parser.getKeybindAtLine(0) @@ -285,7 +285,7 @@ func TestHyprlandReadContentMultipleFiles(t *testing.T) { t.Fatalf("Failed to write file2: %v", err) } - parser := NewHyprlandParser() + parser := NewHyprlandParser("") if err := parser.ReadContent(tmpDir); err != nil { t.Fatalf("ReadContent failed: %v", err) } @@ -343,7 +343,7 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) { t.Skip("Cannot create relative path") } - parser := NewHyprlandParser() + parser := NewHyprlandParser("") tildePathMatch := "~/" + relPath err = parser.ReadContent(tildePathMatch) @@ -353,7 +353,7 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) { } func TestHyprlandKeybindWithParamsContainingCommas(t *testing.T) { - parser := NewHyprlandParser() + parser := NewHyprlandParser("") parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"} result := parser.getKeybindAtLine(0) diff --git a/core/internal/keybinds/providers/hyprland_test.go b/core/internal/keybinds/providers/hyprland_test.go index 0ed9bbd5..3edf0b36 100644 --- a/core/internal/keybinds/providers/hyprland_test.go +++ b/core/internal/keybinds/providers/hyprland_test.go @@ -7,35 +7,30 @@ import ( ) func TestNewHyprlandProvider(t *testing.T) { - tests := []struct { - name string - configPath string - wantPath string - }{ - { - name: "custom path", - configPath: "/custom/path", - wantPath: "/custom/path", - }, - { - name: "empty path defaults", - configPath: "", - wantPath: "$HOME/.config/hypr", - }, - } + t.Run("custom path", func(t *testing.T) { + p := NewHyprlandProvider("/custom/path") + if p == nil { + t.Fatal("NewHyprlandProvider returned nil") + } + if p.configPath != "/custom/path" { + t.Errorf("configPath = %q, want %q", p.configPath, "/custom/path") + } + }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := NewHyprlandProvider(tt.configPath) - if p == nil { - t.Fatal("NewHyprlandProvider returned nil") - } - - if p.configPath != tt.wantPath { - t.Errorf("configPath = %q, want %q", p.configPath, tt.wantPath) - } - }) - } + t.Run("empty path defaults", func(t *testing.T) { + p := NewHyprlandProvider("") + if p == nil { + t.Fatal("NewHyprlandProvider returned nil") + } + configDir, err := os.UserConfigDir() + if err != nil { + t.Fatalf("UserConfigDir failed: %v", err) + } + expected := filepath.Join(configDir, "hypr") + if p.configPath != expected { + t.Errorf("configPath = %q, want %q", p.configPath, expected) + } + }) } func TestHyprlandProviderName(t *testing.T) { @@ -109,7 +104,7 @@ func TestHyprlandProviderGetCheatSheetError(t *testing.T) { func TestFormatKey(t *testing.T) { tmpDir := t.TempDir() - configFile := filepath.Join(tmpDir, "test.conf") + configFile := filepath.Join(tmpDir, "hyprland.conf") tests := []struct { name string @@ -163,7 +158,7 @@ func TestFormatKey(t *testing.T) { func TestDescriptionFallback(t *testing.T) { tmpDir := t.TempDir() - configFile := filepath.Join(tmpDir, "test.conf") + configFile := filepath.Join(tmpDir, "hyprland.conf") tests := []struct { name string diff --git a/core/internal/keybinds/providers/mangowc.go b/core/internal/keybinds/providers/mangowc.go index a7dce2e2..7e94fadc 100644 --- a/core/internal/keybinds/providers/mangowc.go +++ b/core/internal/keybinds/providers/mangowc.go @@ -2,46 +2,94 @@ package providers import ( "fmt" + "os" + "path/filepath" + "sort" "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" ) type MangoWCProvider struct { - configPath string + configPath string + dmsBindsIncluded bool + parsed bool } func NewMangoWCProvider(configPath string) *MangoWCProvider { if configPath == "" { - configPath = "$HOME/.config/mango" + configPath = defaultMangoWCConfigDir() } return &MangoWCProvider{ configPath: configPath, } } +func defaultMangoWCConfigDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(configDir, "mango") +} + func (m *MangoWCProvider) Name() string { return "mangowc" } func (m *MangoWCProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { - keybinds_list, err := ParseMangoWCKeys(m.configPath) + result, err := ParseMangoWCKeysWithDMS(m.configPath) if err != nil { return nil, fmt.Errorf("failed to parse mangowc config: %w", err) } + m.dmsBindsIncluded = result.DMSBindsIncluded + m.parsed = true + categorizedBinds := make(map[string][]keybinds.Keybind) - for _, kb := range keybinds_list { + for _, kb := range result.Keybinds { category := m.categorizeByCommand(kb.Command) - bind := m.convertKeybind(&kb) + bind := m.convertKeybind(&kb, result.ConflictingConfigs) categorizedBinds[category] = append(categorizedBinds[category], bind) } - return &keybinds.CheatSheet{ - Title: "MangoWC Keybinds", - Provider: m.Name(), - Binds: categorizedBinds, - }, nil + sheet := &keybinds.CheatSheet{ + Title: "MangoWC Keybinds", + Provider: m.Name(), + Binds: categorizedBinds, + DMSBindsIncluded: result.DMSBindsIncluded, + } + + if result.DMSStatus != nil { + sheet.DMSStatus = &keybinds.DMSBindsStatus{ + Exists: result.DMSStatus.Exists, + Included: result.DMSStatus.Included, + IncludePosition: result.DMSStatus.IncludePosition, + TotalIncludes: result.DMSStatus.TotalIncludes, + BindsAfterDMS: result.DMSStatus.BindsAfterDMS, + Effective: result.DMSStatus.Effective, + OverriddenBy: result.DMSStatus.OverriddenBy, + StatusMessage: result.DMSStatus.StatusMessage, + } + } + + return sheet, nil +} + +func (m *MangoWCProvider) HasDMSBindsIncluded() bool { + if m.parsed { + return m.dmsBindsIncluded + } + + result, err := ParseMangoWCKeysWithDMS(m.configPath) + if err != nil { + return false + } + + m.dmsBindsIncluded = result.DMSBindsIncluded + m.parsed = true + return m.dmsBindsIncluded } func (m *MangoWCProvider) categorizeByCommand(command string) string { @@ -82,8 +130,8 @@ func (m *MangoWCProvider) categorizeByCommand(command string) string { } } -func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding) keybinds.Keybind { - key := m.formatKey(kb) +func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[string]*MangoWCKeyBinding) keybinds.Keybind { + keyStr := m.formatKey(kb) rawAction := m.formatRawAction(kb.Command, kb.Params) desc := kb.Comment @@ -91,11 +139,31 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding) keybinds.Keybind desc = rawAction } - return keybinds.Keybind{ - Key: key, + source := "config" + if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") { + source = "dms" + } + + bind := keybinds.Keybind{ + Key: keyStr, Description: desc, Action: rawAction, + Source: source, } + + if source == "dms" && conflicts != nil { + normalizedKey := strings.ToLower(keyStr) + if conflictKb, ok := conflicts[normalizedKey]; ok { + bind.Conflict = &keybinds.Keybind{ + Key: keyStr, + Description: conflictKb.Comment, + Action: m.formatRawAction(conflictKb.Command, conflictKb.Params), + Source: "config", + } + } + } + + return bind } func (m *MangoWCProvider) formatRawAction(command, params string) string { @@ -111,3 +179,264 @@ func (m *MangoWCProvider) formatKey(kb *MangoWCKeyBinding) string { parts = append(parts, kb.Key) return strings.Join(parts, "+") } + +func (m *MangoWCProvider) GetOverridePath() string { + expanded, err := utils.ExpandPath(m.configPath) + if err != nil { + return filepath.Join(m.configPath, "dms", "binds.conf") + } + return filepath.Join(expanded, "dms", "binds.conf") +} + +func (m *MangoWCProvider) validateAction(action string) error { + action = strings.TrimSpace(action) + switch { + case action == "": + return fmt.Errorf("action cannot be empty") + case action == "spawn" || action == "spawn ": + return fmt.Errorf("spawn command requires arguments") + case action == "spawn_shell" || action == "spawn_shell ": + return fmt.Errorf("spawn_shell command requires arguments") + case strings.HasPrefix(action, "spawn "): + rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn ")) + if rest == "" { + return fmt.Errorf("spawn command requires arguments") + } + case strings.HasPrefix(action, "spawn_shell "): + rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn_shell ")) + if rest == "" { + return fmt.Errorf("spawn_shell command requires arguments") + } + } + return nil +} + +func (m *MangoWCProvider) SetBind(key, action, description string, options map[string]any) error { + if err := m.validateAction(action); err != nil { + return err + } + + overridePath := m.GetOverridePath() + + if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil { + return fmt.Errorf("failed to create dms directory: %w", err) + } + + existingBinds, err := m.loadOverrideBinds() + if err != nil { + existingBinds = make(map[string]*mangowcOverrideBind) + } + + normalizedKey := strings.ToLower(key) + existingBinds[normalizedKey] = &mangowcOverrideBind{ + Key: key, + Action: action, + Description: description, + Options: options, + } + + return m.writeOverrideBinds(existingBinds) +} + +func (m *MangoWCProvider) RemoveBind(key string) error { + existingBinds, err := m.loadOverrideBinds() + if err != nil { + return nil + } + + normalizedKey := strings.ToLower(key) + delete(existingBinds, normalizedKey) + return m.writeOverrideBinds(existingBinds) +} + +type mangowcOverrideBind struct { + Key string + Action string + Description string + Options map[string]any +} + +func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) { + overridePath := m.GetOverridePath() + binds := make(map[string]*mangowcOverrideBind) + + 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 + } + + 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]) + } + + fields := strings.SplitN(bindContent, ",", 4) + if len(fields) < 3 { + continue + } + + mods := strings.TrimSpace(fields[0]) + keyName := strings.TrimSpace(fields[1]) + command := strings.TrimSpace(fields[2]) + + var params string + if len(fields) > 3 { + params = strings.TrimSpace(fields[3]) + } + + keyStr := m.buildKeyString(mods, keyName) + normalizedKey := strings.ToLower(keyStr) + action := command + if params != "" { + action = command + " " + params + } + + binds[normalizedKey] = &mangowcOverrideBind{ + Key: keyStr, + Action: action, + Description: comment, + } + } + + return binds, nil +} + +func (m *MangoWCProvider) buildKeyString(mods, key string) string { + if mods == "" || strings.EqualFold(mods, "none") { + return key + } + + modList := strings.FieldsFunc(mods, func(r rune) bool { + return r == '+' || r == ' ' + }) + + parts := append(modList, key) + return strings.Join(parts, "+") +} + +func (m *MangoWCProvider) getBindSortPriority(action string) int { + switch { + case strings.HasPrefix(action, "spawn") && strings.Contains(action, "dms"): + return 0 + case strings.Contains(action, "view") || strings.Contains(action, "tag"): + return 1 + case strings.Contains(action, "focus") || strings.Contains(action, "exchange") || + strings.Contains(action, "resize") || strings.Contains(action, "move"): + return 2 + case strings.Contains(action, "mon"): + return 3 + case strings.HasPrefix(action, "spawn"): + return 4 + case action == "quit" || action == "reload_config": + return 5 + default: + return 6 + } +} + +func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error { + overridePath := m.GetOverridePath() + content := m.generateBindsContent(binds) + return os.WriteFile(overridePath, []byte(content), 0644) +} + +func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string { + if len(binds) == 0 { + return "" + } + + bindList := make([]*mangowcOverrideBind, 0, len(binds)) + for _, bind := range binds { + bindList = append(bindList, bind) + } + + sort.Slice(bindList, func(i, j int) bool { + pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action) + if pi != pj { + return pi < pj + } + return bindList[i].Key < bindList[j].Key + }) + + var sb strings.Builder + for _, bind := range bindList { + m.writeBindLine(&sb, bind) + } + + return sb.String() +} + +func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) { + mods, key := m.parseKeyString(bind.Key) + command, params := m.parseAction(bind.Action) + + sb.WriteString("bind=") + if mods == "" { + sb.WriteString("none") + } else { + sb.WriteString(mods) + } + sb.WriteString(",") + sb.WriteString(key) + sb.WriteString(",") + sb.WriteString(command) + + if params != "" { + sb.WriteString(",") + sb.WriteString(params) + } + + if bind.Description != "" { + sb.WriteString(" # ") + sb.WriteString(bind.Description) + } + + sb.WriteString("\n") +} + +func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) { + parts := strings.Split(keyStr, "+") + switch len(parts) { + case 0: + return "", keyStr + case 1: + return "", parts[0] + default: + return strings.Join(parts[:len(parts)-1], "+"), parts[len(parts)-1] + } +} + +func (m *MangoWCProvider) parseAction(action string) (command, params string) { + parts := strings.SplitN(action, " ", 2) + switch len(parts) { + case 0: + return action, "" + case 1: + return parts[0], "" + default: + return parts[0], parts[1] + } +} diff --git a/core/internal/keybinds/providers/mangowc_parser.go b/core/internal/keybinds/providers/mangowc_parser.go index d61048c9..3627d791 100644 --- a/core/internal/keybinds/providers/mangowc_parser.go +++ b/core/internal/keybinds/providers/mangowc_parser.go @@ -21,17 +21,40 @@ type MangoWCKeyBinding struct { Command string `json:"command"` Params string `json:"params"` Comment string `json:"comment"` + Source string `json:"source"` } type MangoWCParser struct { - contentLines []string - readingLine int + contentLines []string + readingLine int + configDir string + currentSource string + dmsBindsExists bool + dmsBindsIncluded bool + includeCount int + dmsIncludePos int + bindsAfterDMS int + dmsBindKeys map[string]bool + configBindKeys map[string]bool + conflictingConfigs map[string]*MangoWCKeyBinding + bindMap map[string]*MangoWCKeyBinding + bindOrder []string + processedFiles map[string]bool + dmsProcessed bool } -func NewMangoWCParser() *MangoWCParser { +func NewMangoWCParser(configDir string) *MangoWCParser { return &MangoWCParser{ - contentLines: []string{}, - readingLine: 0, + contentLines: []string{}, + readingLine: 0, + configDir: configDir, + dmsIncludePos: -1, + dmsBindKeys: make(map[string]bool), + configBindKeys: make(map[string]bool), + conflictingConfigs: make(map[string]*MangoWCKeyBinding), + bindMap: make(map[string]*MangoWCKeyBinding), + bindOrder: []string{}, + processedFiles: make(map[string]bool), } } @@ -294,9 +317,320 @@ func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding { } func ParseMangoWCKeys(path string) ([]MangoWCKeyBinding, error) { - parser := NewMangoWCParser() + parser := NewMangoWCParser(path) if err := parser.ReadContent(path); err != nil { return nil, err } return parser.ParseKeys(), nil } + +type MangoWCParseResult struct { + Keybinds []MangoWCKeyBinding + DMSBindsIncluded bool + DMSStatus *MangoWCDMSStatus + ConflictingConfigs map[string]*MangoWCKeyBinding +} + +type MangoWCDMSStatus struct { + Exists bool + Included bool + IncludePosition int + TotalIncludes int + BindsAfterDMS int + Effective bool + OverriddenBy int + StatusMessage string +} + +func (p *MangoWCParser) buildDMSStatus() *MangoWCDMSStatus { + status := &MangoWCDMSStatus{ + Exists: p.dmsBindsExists, + Included: p.dmsBindsIncluded, + IncludePosition: p.dmsIncludePos, + TotalIncludes: p.includeCount, + BindsAfterDMS: p.bindsAfterDMS, + } + + switch { + case !p.dmsBindsExists: + status.Effective = false + status.StatusMessage = "dms/binds.conf does not exist" + case !p.dmsBindsIncluded: + status.Effective = false + status.StatusMessage = "dms/binds.conf is not sourced in config" + case p.bindsAfterDMS > 0: + status.Effective = true + status.OverriddenBy = p.bindsAfterDMS + status.StatusMessage = "Some DMS binds may be overridden by config binds" + default: + status.Effective = true + status.StatusMessage = "DMS binds are active" + } + + return status +} + +func (p *MangoWCParser) formatBindKey(kb *MangoWCKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (p *MangoWCParser) normalizeKey(key string) string { + return strings.ToLower(key) +} + +func (p *MangoWCParser) addBind(kb *MangoWCKeyBinding) { + key := p.formatBindKey(kb) + normalizedKey := p.normalizeKey(key) + isDMSBind := strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(os.PathSeparator)+"binds.conf") + + if isDMSBind { + p.dmsBindKeys[normalizedKey] = true + } else if p.dmsBindKeys[normalizedKey] { + p.bindsAfterDMS++ + p.conflictingConfigs[normalizedKey] = kb + p.configBindKeys[normalizedKey] = true + return + } else { + p.configBindKeys[normalizedKey] = true + } + + if _, exists := p.bindMap[normalizedKey]; !exists { + p.bindOrder = append(p.bindOrder, key) + } + p.bindMap[normalizedKey] = kb +} + +func (p *MangoWCParser) ParseWithDMS() ([]MangoWCKeyBinding, error) { + expandedDir, err := utils.ExpandPath(p.configDir) + if err != nil { + return nil, err + } + + dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf") + if _, err := os.Stat(dmsBindsPath); err == nil { + p.dmsBindsExists = true + } + + mainConfig := filepath.Join(expandedDir, "config.conf") + if _, err := os.Stat(mainConfig); os.IsNotExist(err) { + mainConfig = filepath.Join(expandedDir, "mango.conf") + } + + _, err = p.parseFileWithSource(mainConfig) + if err != nil { + return nil, err + } + + if p.dmsBindsExists && !p.dmsProcessed { + p.parseDMSBindsDirectly(dmsBindsPath) + } + + var keybinds []MangoWCKeyBinding + for _, key := range p.bindOrder { + normalizedKey := p.normalizeKey(key) + if kb, exists := p.bindMap[normalizedKey]; exists { + keybinds = append(keybinds, *kb) + } + } + + return keybinds, nil +} + +func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBinding, error) { + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, err + } + + if p.processedFiles[absPath] { + return nil, nil + } + p.processedFiles[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return nil, err + } + + prevSource := p.currentSource + p.currentSource = absPath + + var keybinds []MangoWCKeyBinding + lines := strings.Split(string(data), "\n") + + for lineNum, line := range lines { + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "source") { + p.handleSource(trimmed, filepath.Dir(absPath), &keybinds) + continue + } + + if !strings.HasPrefix(trimmed, "bind") { + continue + } + + kb := p.getKeybindAtLineContent(line, lineNum) + if kb == nil { + continue + } + kb.Source = p.currentSource + p.addBind(kb) + keybinds = append(keybinds, *kb) + } + + p.currentSource = prevSource + return keybinds, nil +} + +func (p *MangoWCParser) handleSource(line, baseDir string, keybinds *[]MangoWCKeyBinding) { + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + return + } + + sourcePath := strings.TrimSpace(parts[1]) + isDMSSource := sourcePath == "dms/binds.conf" || sourcePath == "./dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf") + + p.includeCount++ + if isDMSSource { + p.dmsBindsIncluded = true + p.dmsIncludePos = p.includeCount + p.dmsProcessed = true + } + + fullPath := sourcePath + if !filepath.IsAbs(sourcePath) { + fullPath = filepath.Join(baseDir, sourcePath) + } + + expanded, err := utils.ExpandPath(fullPath) + if err != nil { + return + } + + includedBinds, err := p.parseFileWithSource(expanded) + if err != nil { + return + } + + *keybinds = append(*keybinds, includedBinds...) +} + +func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyBinding { + data, err := os.ReadFile(dmsBindsPath) + if err != nil { + return nil + } + + prevSource := p.currentSource + p.currentSource = dmsBindsPath + + var keybinds []MangoWCKeyBinding + lines := strings.Split(string(data), "\n") + + for lineNum, line := range lines { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "bind") { + continue + } + + kb := p.getKeybindAtLineContent(line, lineNum) + if kb == nil { + continue + } + kb.Source = dmsBindsPath + p.addBind(kb) + keybinds = append(keybinds, *kb) + } + + p.currentSource = prevSource + p.dmsProcessed = true + return keybinds +} + +func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding { + bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`) + matches := bindMatch.FindStringSubmatch(line) + if len(matches) < 3 { + return nil + } + + content := matches[2] + parts := strings.SplitN(content, "#", 2) + keys := parts[0] + + var comment string + if len(parts) > 1 { + comment = strings.TrimSpace(parts[1]) + } + + if strings.HasPrefix(comment, MangoWCHideComment) { + return nil + } + + keyFields := strings.SplitN(keys, ",", 4) + if len(keyFields) < 3 { + return nil + } + + mods := strings.TrimSpace(keyFields[0]) + key := strings.TrimSpace(keyFields[1]) + command := strings.TrimSpace(keyFields[2]) + + var params string + if len(keyFields) > 3 { + params = strings.TrimSpace(keyFields[3]) + } + + if comment == "" { + comment = mangowcAutogenerateComment(command, params) + } + + var modList []string + if mods != "" && !strings.EqualFold(mods, "none") { + modstring := mods + string(MangoWCModSeparators[0]) + idx := 0 + for index, char := range modstring { + isModSep := false + for _, sep := range MangoWCModSeparators { + if char == sep { + isModSep = true + break + } + } + if isModSep { + if index-idx > 1 { + modList = append(modList, modstring[idx:index]) + } + idx = index + 1 + } + } + } + + return &MangoWCKeyBinding{ + Mods: modList, + Key: key, + Command: command, + Params: params, + Comment: comment, + } +} + +func ParseMangoWCKeysWithDMS(path string) (*MangoWCParseResult, error) { + parser := NewMangoWCParser(path) + keybinds, err := parser.ParseWithDMS() + if err != nil { + return nil, err + } + + return &MangoWCParseResult{ + Keybinds: keybinds, + DMSBindsIncluded: parser.dmsBindsIncluded, + DMSStatus: parser.buildDMSStatus(), + ConflictingConfigs: parser.conflictingConfigs, + }, nil +} diff --git a/core/internal/keybinds/providers/mangowc_parser_test.go b/core/internal/keybinds/providers/mangowc_parser_test.go index cf2441a6..fc83cdb3 100644 --- a/core/internal/keybinds/providers/mangowc_parser_test.go +++ b/core/internal/keybinds/providers/mangowc_parser_test.go @@ -172,7 +172,7 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - parser := NewMangoWCParser() + parser := NewMangoWCParser("") parser.contentLines = []string{tt.line} result := parser.getKeybindAtLine(0) @@ -283,7 +283,7 @@ func TestMangoWCReadContentMultipleFiles(t *testing.T) { t.Fatalf("Failed to write file2: %v", err) } - parser := NewMangoWCParser() + parser := NewMangoWCParser("") if err := parser.ReadContent(tmpDir); err != nil { t.Fatalf("ReadContent failed: %v", err) } @@ -304,7 +304,7 @@ func TestMangoWCReadContentSingleFile(t *testing.T) { t.Fatalf("Failed to write config: %v", err) } - parser := NewMangoWCParser() + parser := NewMangoWCParser("") if err := parser.ReadContent(configFile); err != nil { t.Fatalf("ReadContent failed: %v", err) } @@ -362,7 +362,7 @@ func TestMangoWCReadContentWithTildeExpansion(t *testing.T) { t.Skip("Cannot create relative path") } - parser := NewMangoWCParser() + parser := NewMangoWCParser("") tildePathMatch := "~/" + relPath err = parser.ReadContent(tildePathMatch) @@ -419,7 +419,7 @@ func TestMangoWCInvalidBindLines(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - parser := NewMangoWCParser() + parser := NewMangoWCParser("") parser.contentLines = []string{tt.line} result := parser.getKeybindAtLine(0) diff --git a/core/internal/keybinds/providers/mangowc_test.go b/core/internal/keybinds/providers/mangowc_test.go index 77f869a9..ce167eb4 100644 --- a/core/internal/keybinds/providers/mangowc_test.go +++ b/core/internal/keybinds/providers/mangowc_test.go @@ -15,8 +15,17 @@ func TestMangoWCProviderName(t *testing.T) { func TestMangoWCProviderDefaultPath(t *testing.T) { provider := NewMangoWCProvider("") - if provider.configPath != "$HOME/.config/mango" { - t.Errorf("configPath = %q, want %q", provider.configPath, "$HOME/.config/mango") + configDir, err := os.UserConfigDir() + if err != nil { + // Fall back to testing for non-empty path + if provider.configPath == "" { + t.Error("configPath should not be empty") + } + return + } + expected := filepath.Join(configDir, "mango") + if provider.configPath != expected { + t.Errorf("configPath = %q, want %q", provider.configPath, expected) } } @@ -174,7 +183,7 @@ func TestMangoWCConvertKeybind(t *testing.T) { provider := NewMangoWCProvider("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := provider.convertKeybind(tt.keybind) + result := provider.convertKeybind(tt.keybind, nil) if result.Key != tt.wantKey { t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey) } diff --git a/quickshell/Common/KeybindActions.js b/quickshell/Common/KeybindActions.js index 3e263662..f3de5352 100644 --- a/quickshell/Common/KeybindActions.js +++ b/quickshell/Common/KeybindActions.js @@ -103,7 +103,7 @@ const DMS_ACTIONS = [ { id: "spawn dms ipc call wallpaper prev", label: "Wallpaper: Previous" } ]; -const COMPOSITOR_ACTIONS = { +const NIRI_ACTIONS = { "Window": [ { id: "close-window", label: "Close Window" }, { id: "fullscreen-window", label: "Fullscreen" }, @@ -179,9 +179,246 @@ const COMPOSITOR_ACTIONS = { ] }; -const CATEGORY_ORDER = ["DMS", "Execute", "Workspace", "Window", "Monitor", "Screenshot", "System", "Overview", "Alt-Tab", "Other"]; +const MANGOWC_ACTIONS = { + "Window": [ + { id: "killclient", label: "Close Window" }, + { id: "focuslast", label: "Focus Last Window" }, + { id: "focusstack next", label: "Focus Next in Stack" }, + { id: "focusstack prev", label: "Focus Previous in Stack" }, + { id: "focusdir left", label: "Focus Left" }, + { id: "focusdir right", label: "Focus Right" }, + { id: "focusdir up", label: "Focus Up" }, + { id: "focusdir down", label: "Focus Down" }, + { id: "exchange_client left", label: "Swap Left" }, + { id: "exchange_client right", label: "Swap Right" }, + { id: "exchange_client up", label: "Swap Up" }, + { id: "exchange_client down", label: "Swap Down" }, + { id: "exchange_stack_client next", label: "Swap Next in Stack" }, + { id: "exchange_stack_client prev", label: "Swap Previous in Stack" }, + { id: "togglefloating", label: "Toggle Floating" }, + { id: "togglefullscreen", label: "Toggle Fullscreen" }, + { id: "togglefakefullscreen", label: "Toggle Fake Fullscreen" }, + { id: "togglemaximizescreen", label: "Toggle Maximize" }, + { id: "toggleglobal", label: "Toggle Global (Sticky)" }, + { id: "toggleoverlay", label: "Toggle Overlay" }, + { id: "minimized", label: "Minimize Window" }, + { id: "restore_minimized", label: "Restore Minimized" }, + { id: "toggle_render_border", label: "Toggle Border" }, + { id: "centerwin", label: "Center Window" }, + { id: "zoom", label: "Swap with Master" } + ], + "Move/Resize": [ + { id: "smartmovewin left", label: "Smart Move Left" }, + { id: "smartmovewin right", label: "Smart Move Right" }, + { id: "smartmovewin up", label: "Smart Move Up" }, + { id: "smartmovewin down", label: "Smart Move Down" }, + { id: "smartresizewin left", label: "Smart Resize Left" }, + { id: "smartresizewin right", label: "Smart Resize Right" }, + { id: "smartresizewin up", label: "Smart Resize Up" }, + { id: "smartresizewin down", label: "Smart Resize Down" }, + { id: "movewin", label: "Move Window (x,y)" }, + { id: "resizewin", label: "Resize Window (w,h)" } + ], + "Tags": [ + { id: "view", label: "View Tag" }, + { id: "viewtoleft", label: "View Left Tag" }, + { id: "viewtoright", label: "View Right Tag" }, + { id: "viewtoleft_have_client", label: "View Left (with client)" }, + { id: "viewtoright_have_client", label: "View Right (with client)" }, + { id: "viewcrossmon", label: "View Cross-Monitor" }, + { id: "tag", label: "Move to Tag" }, + { id: "tagsilent", label: "Move to Tag (silent)" }, + { id: "tagtoleft", label: "Move to Left Tag" }, + { id: "tagtoright", label: "Move to Right Tag" }, + { id: "tagcrossmon", label: "Move Cross-Monitor" }, + { id: "toggletag", label: "Toggle Tag on Window" }, + { id: "toggleview", label: "Toggle Tag View" }, + { id: "comboview", label: "Combo View Tags" } + ], + "Layout": [ + { id: "setlayout", label: "Set Layout" }, + { id: "switch_layout", label: "Cycle Layouts" }, + { id: "set_proportion", label: "Set Proportion" }, + { id: "switch_proportion_preset", label: "Cycle Proportion Presets" }, + { id: "incnmaster +1", label: "Increase Masters" }, + { id: "incnmaster -1", label: "Decrease Masters" }, + { id: "setmfact", label: "Set Master Factor" }, + { id: "incgaps", label: "Adjust Gaps" }, + { id: "togglegaps", label: "Toggle Gaps" } + ], + "Monitor": [ + { id: "focusmon left", label: "Focus Monitor Left" }, + { id: "focusmon right", label: "Focus Monitor Right" }, + { id: "focusmon up", label: "Focus Monitor Up" }, + { id: "focusmon down", label: "Focus Monitor Down" }, + { id: "tagmon left", label: "Move to Monitor Left" }, + { id: "tagmon right", label: "Move to Monitor Right" }, + { id: "tagmon up", label: "Move to Monitor Up" }, + { id: "tagmon down", label: "Move to Monitor Down" }, + { id: "disable_monitor", label: "Disable Monitor" }, + { id: "enable_monitor", label: "Enable Monitor" }, + { id: "toggle_monitor", label: "Toggle Monitor" }, + { id: "create_virtual_output", label: "Create Virtual Output" }, + { id: "destroy_all_virtual_output", label: "Destroy Virtual Outputs" } + ], + "Scratchpad": [ + { id: "toggle_scratchpad", label: "Toggle Scratchpad" }, + { id: "toggle_name_scratchpad", label: "Toggle Named Scratchpad" } + ], + "Overview": [ + { id: "toggleoverview", label: "Toggle Overview" } + ], + "System": [ + { id: "reload_config", label: "Reload Config" }, + { id: "quit", label: "Quit MangoWC" }, + { id: "setkeymode", label: "Set Keymode" }, + { id: "switch_keyboard_layout", label: "Switch Keyboard Layout" }, + { id: "setoption", label: "Set Option" }, + { id: "toggle_trackpad_enable", label: "Toggle Trackpad" } + ] +}; -const ACTION_ARGS = { +const HYPRLAND_ACTIONS = { + "Window": [ + { id: "killactive", label: "Close Window" }, + { id: "forcekillactive", label: "Force Kill Window" }, + { id: "closewindow", label: "Close Window (by selector)" }, + { id: "killwindow", label: "Kill Window (by selector)" }, + { id: "togglefloating", label: "Toggle Floating" }, + { id: "setfloating", label: "Set Floating" }, + { id: "settiled", label: "Set Tiled" }, + { id: "fullscreen", label: "Toggle Fullscreen" }, + { id: "fullscreenstate", label: "Set Fullscreen State" }, + { id: "pin", label: "Pin Window" }, + { id: "centerwindow", label: "Center Window" }, + { id: "resizeactive", label: "Resize Active Window" }, + { id: "moveactive", label: "Move Active Window" }, + { id: "resizewindowpixel", label: "Resize Window (pixels)" }, + { id: "movewindowpixel", label: "Move Window (pixels)" }, + { id: "alterzorder", label: "Change Z-Order" }, + { id: "bringactivetotop", label: "Bring to Top" }, + { id: "setprop", label: "Set Window Property" }, + { id: "toggleswallow", label: "Toggle Swallow" } + ], + "Focus": [ + { id: "movefocus l", label: "Focus Left" }, + { id: "movefocus r", label: "Focus Right" }, + { id: "movefocus u", label: "Focus Up" }, + { id: "movefocus d", label: "Focus Down" }, + { id: "movefocus", label: "Move Focus (direction)" }, + { id: "cyclenext", label: "Cycle Next Window" }, + { id: "cyclenext prev", label: "Cycle Previous Window" }, + { id: "focuswindow", label: "Focus Window (by selector)" }, + { id: "focuscurrentorlast", label: "Focus Current or Last" }, + { id: "focusurgentorlast", label: "Focus Urgent or Last" } + ], + "Move": [ + { id: "movewindow l", label: "Move Window Left" }, + { id: "movewindow r", label: "Move Window Right" }, + { id: "movewindow u", label: "Move Window Up" }, + { id: "movewindow d", label: "Move Window Down" }, + { id: "movewindow", label: "Move Window (direction)" }, + { id: "swapwindow l", label: "Swap Left" }, + { id: "swapwindow r", label: "Swap Right" }, + { id: "swapwindow u", label: "Swap Up" }, + { id: "swapwindow d", label: "Swap Down" }, + { id: "swapwindow", label: "Swap Window (direction)" }, + { id: "swapnext", label: "Swap with Next" }, + { id: "swapnext prev", label: "Swap with Previous" }, + { id: "movecursortocorner", label: "Move Cursor to Corner" }, + { id: "movecursor", label: "Move Cursor (x,y)" } + ], + "Workspace": [ + { id: "workspace", label: "Focus Workspace" }, + { id: "workspace +1", label: "Next Workspace" }, + { id: "workspace -1", label: "Previous Workspace" }, + { id: "workspace e+1", label: "Next Open Workspace" }, + { id: "workspace e-1", label: "Previous Open Workspace" }, + { id: "workspace previous", label: "Previous Visited Workspace" }, + { id: "workspace previous_per_monitor", label: "Previous on Monitor" }, + { id: "workspace empty", label: "First Empty Workspace" }, + { id: "movetoworkspace", label: "Move to Workspace" }, + { id: "movetoworkspace +1", label: "Move to Next Workspace" }, + { id: "movetoworkspace -1", label: "Move to Previous Workspace" }, + { id: "movetoworkspacesilent", label: "Move to Workspace (silent)" }, + { id: "movetoworkspacesilent +1", label: "Move to Next (silent)" }, + { id: "movetoworkspacesilent -1", label: "Move to Previous (silent)" }, + { id: "togglespecialworkspace", label: "Toggle Special Workspace" }, + { id: "focusworkspaceoncurrentmonitor", label: "Focus Workspace on Current Monitor" }, + { id: "renameworkspace", label: "Rename Workspace" } + ], + "Monitor": [ + { id: "focusmonitor l", label: "Focus Monitor Left" }, + { id: "focusmonitor r", label: "Focus Monitor Right" }, + { id: "focusmonitor u", label: "Focus Monitor Up" }, + { id: "focusmonitor d", label: "Focus Monitor Down" }, + { id: "focusmonitor +1", label: "Focus Next Monitor" }, + { id: "focusmonitor -1", label: "Focus Previous Monitor" }, + { id: "focusmonitor", label: "Focus Monitor (by selector)" }, + { id: "movecurrentworkspacetomonitor", label: "Move Workspace to Monitor" }, + { id: "moveworkspacetomonitor", label: "Move Specific Workspace to Monitor" }, + { id: "swapactiveworkspaces", label: "Swap Active Workspaces" } + ], + "Groups": [ + { id: "togglegroup", label: "Toggle Group" }, + { id: "changegroupactive f", label: "Next in Group" }, + { id: "changegroupactive b", label: "Previous in Group" }, + { id: "changegroupactive", label: "Change Active in Group" }, + { id: "moveintogroup l", label: "Move into Group Left" }, + { id: "moveintogroup r", label: "Move into Group Right" }, + { id: "moveintogroup u", label: "Move into Group Up" }, + { id: "moveintogroup d", label: "Move into Group Down" }, + { id: "moveoutofgroup", label: "Move out of Group" }, + { id: "movewindoworgroup l", label: "Move Window/Group Left" }, + { id: "movewindoworgroup r", label: "Move Window/Group Right" }, + { id: "movewindoworgroup u", label: "Move Window/Group Up" }, + { id: "movewindoworgroup d", label: "Move Window/Group Down" }, + { id: "movegroupwindow f", label: "Swap Forward in Group" }, + { id: "movegroupwindow b", label: "Swap Backward in Group" }, + { id: "lockgroups lock", label: "Lock All Groups" }, + { id: "lockgroups unlock", label: "Unlock All Groups" }, + { id: "lockgroups toggle", label: "Toggle Groups Lock" }, + { id: "lockactivegroup lock", label: "Lock Active Group" }, + { id: "lockactivegroup unlock", label: "Unlock Active Group" }, + { id: "lockactivegroup toggle", label: "Toggle Active Group Lock" }, + { id: "denywindowfromgroup on", label: "Deny Window from Group" }, + { id: "denywindowfromgroup off", label: "Allow Window in Group" }, + { id: "denywindowfromgroup toggle", label: "Toggle Deny from Group" }, + { id: "setignoregrouplock on", label: "Ignore Group Lock" }, + { id: "setignoregrouplock off", label: "Respect Group Lock" }, + { id: "setignoregrouplock toggle", label: "Toggle Ignore Group Lock" } + ], + "Layout": [ + { id: "splitratio", label: "Adjust Split Ratio" } + ], + "System": [ + { id: "exit", label: "Exit Hyprland" }, + { id: "forcerendererreload", label: "Force Renderer Reload" }, + { id: "dpms on", label: "DPMS On" }, + { id: "dpms off", label: "DPMS Off" }, + { id: "dpms toggle", label: "DPMS Toggle" }, + { id: "forceidle", label: "Force Idle" }, + { id: "submap", label: "Enter Submap" }, + { id: "submap reset", label: "Reset Submap" }, + { id: "global", label: "Global Shortcut" }, + { id: "event", label: "Emit Custom Event" } + ], + "Pass-through": [ + { id: "pass", label: "Pass Key to Window" }, + { id: "sendshortcut", label: "Send Shortcut to Window" }, + { id: "sendkeystate", label: "Send Key State" } + ] +}; + +const COMPOSITOR_ACTIONS = { + niri: NIRI_ACTIONS, + mangowc: MANGOWC_ACTIONS, + hyprland: HYPRLAND_ACTIONS +}; + +const CATEGORY_ORDER = ["DMS", "Execute", "Workspace", "Tags", "Window", "Move/Resize", "Focus", "Move", "Layout", "Groups", "Monitor", "Scratchpad", "Screenshot", "System", "Pass-through", "Overview", "Alt-Tab", "Other"]; + +const NIRI_ACTION_ARGS = { "set-column-width": { args: [{ name: "value", type: "text", label: "Width", placeholder: "+10%, -10%, 50%" }] }, @@ -220,6 +457,253 @@ const ACTION_ARGS = { } }; +const MANGOWC_ACTION_ARGS = { + "view": { + args: [ + { name: "tag", type: "number", label: "Tag", placeholder: "1-9" }, + { name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" } + ] + }, + "tag": { + args: [ + { name: "tag", type: "number", label: "Tag", placeholder: "1-9" }, + { name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" } + ] + }, + "tagsilent": { + args: [ + { name: "tag", type: "number", label: "Tag", placeholder: "1-9" }, + { name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" } + ] + }, + "toggletag": { + args: [ + { name: "tag", type: "number", label: "Tag", placeholder: "1-9" }, + { name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" } + ] + }, + "toggleview": { + args: [ + { name: "tag", type: "number", label: "Tag", placeholder: "1-9" }, + { name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" } + ] + }, + "comboview": { + args: [{ name: "tags", type: "text", label: "Tags", placeholder: "1,2,3" }] + }, + "setlayout": { + args: [{ name: "layout", type: "text", label: "Layout", placeholder: "tile, monocle, grid, deck" }] + }, + "set_proportion": { + args: [{ name: "value", type: "text", label: "Proportion", placeholder: "0.5, +0.1, -0.1" }] + }, + "setmfact": { + args: [{ name: "value", type: "text", label: "Factor", placeholder: "+0.05, -0.05" }] + }, + "incgaps": { + args: [{ name: "value", type: "number", label: "Amount", placeholder: "+5, -5" }] + }, + "movewin": { + args: [{ name: "value", type: "text", label: "Position", placeholder: "x,y or +10,+10" }] + }, + "resizewin": { + args: [{ name: "value", type: "text", label: "Size", placeholder: "w,h or +10,+10" }] + }, + "setkeymode": { + args: [{ name: "mode", type: "text", label: "Mode", placeholder: "default, custom" }] + }, + "setoption": { + args: [{ name: "option", type: "text", label: "Option", placeholder: "option_name value" }] + }, + "toggle_name_scratchpad": { + args: [{ name: "name", type: "text", label: "Name", placeholder: "scratchpad name" }] + }, + "incnmaster": { + args: [{ name: "value", type: "number", label: "Amount", placeholder: "+1, -1" }] + } +}; + +const HYPRLAND_ACTION_ARGS = { + "workspace": { + args: [{ name: "value", type: "text", label: "Workspace", placeholder: "1, +1, -1, name:..." }] + }, + "movetoworkspace": { + args: [ + { name: "workspace", type: "text", label: "Workspace", placeholder: "1, +1, special:name" }, + { name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" } + ] + }, + "movetoworkspacesilent": { + args: [ + { name: "workspace", type: "text", label: "Workspace", placeholder: "1, +1, special:name" }, + { name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" } + ] + }, + "focusworkspaceoncurrentmonitor": { + args: [{ name: "value", type: "text", label: "Workspace", placeholder: "1, +1, name:..." }] + }, + "togglespecialworkspace": { + args: [{ name: "name", type: "text", label: "Name (optional)", placeholder: "scratchpad" }] + }, + "focusmonitor": { + args: [{ name: "value", type: "text", label: "Monitor", placeholder: "l, r, +1, DP-1" }] + }, + "movecurrentworkspacetomonitor": { + args: [{ name: "monitor", type: "text", label: "Monitor", placeholder: "l, r, DP-1" }] + }, + "moveworkspacetomonitor": { + args: [ + { name: "workspace", type: "text", label: "Workspace", placeholder: "1, name:..." }, + { name: "monitor", type: "text", label: "Monitor", placeholder: "DP-1" } + ] + }, + "swapactiveworkspaces": { + args: [ + { name: "monitor1", type: "text", label: "Monitor 1", placeholder: "DP-1" }, + { name: "monitor2", type: "text", label: "Monitor 2", placeholder: "DP-2" } + ] + }, + "renameworkspace": { + args: [ + { name: "id", type: "number", label: "Workspace ID", placeholder: "1" }, + { name: "name", type: "text", label: "New Name", placeholder: "work" } + ] + }, + "fullscreen": { + args: [{ name: "mode", type: "text", label: "Mode", placeholder: "0=full, 1=max, 2=fake" }] + }, + "fullscreenstate": { + args: [ + { name: "internal", type: "text", label: "Internal", placeholder: "-1, 0, 1, 2, 3" }, + { name: "client", type: "text", label: "Client", placeholder: "-1, 0, 1, 2, 3" } + ] + }, + "resizeactive": { + args: [{ name: "value", type: "text", label: "Size", placeholder: "10 -10, 20% 0" }] + }, + "moveactive": { + args: [{ name: "value", type: "text", label: "Position", placeholder: "10 -10, exact 100 100" }] + }, + "resizewindowpixel": { + args: [ + { name: "size", type: "text", label: "Size", placeholder: "100 100" }, + { name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" } + ] + }, + "movewindowpixel": { + args: [ + { name: "position", type: "text", label: "Position", placeholder: "100 100" }, + { name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" } + ] + }, + "splitratio": { + args: [{ name: "value", type: "text", label: "Ratio", placeholder: "+0.1, -0.1, exact 0.5" }] + }, + "closewindow": { + args: [{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }] + }, + "killwindow": { + args: [{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }] + }, + "focuswindow": { + args: [{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }] + }, + "tagwindow": { + args: [ + { name: "tag", type: "text", label: "Tag", placeholder: "+mytag, -mytag" }, + { name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" } + ] + }, + "alterzorder": { + args: [ + { name: "zheight", type: "text", label: "Z-Height", placeholder: "top, bottom" }, + { name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" } + ] + }, + "setprop": { + args: [ + { name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }, + { name: "property", type: "text", label: "Property", placeholder: "opaque, alpha..." }, + { name: "value", type: "text", label: "Value", placeholder: "1, toggle" } + ] + }, + "signal": { + args: [{ name: "signal", type: "number", label: "Signal", placeholder: "9" }] + }, + "signalwindow": { + args: [ + { name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }, + { name: "signal", type: "number", label: "Signal", placeholder: "9" } + ] + }, + "submap": { + args: [{ name: "name", type: "text", label: "Submap Name", placeholder: "resize, reset" }] + }, + "global": { + args: [{ name: "name", type: "text", label: "Shortcut Name", placeholder: "app:action" }] + }, + "event": { + args: [{ name: "data", type: "text", label: "Event Data", placeholder: "custom data" }] + }, + "pass": { + args: [{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }] + }, + "sendshortcut": { + args: [ + { name: "mod", type: "text", label: "Modifier", placeholder: "SUPER, ALT" }, + { name: "key", type: "text", label: "Key", placeholder: "F4" }, + { name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" } + ] + }, + "sendkeystate": { + args: [ + { name: "mod", type: "text", label: "Modifier", placeholder: "SUPER" }, + { name: "key", type: "text", label: "Key", placeholder: "a" }, + { name: "state", type: "text", label: "State", placeholder: "down, repeat, up" }, + { name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" } + ] + }, + "forceidle": { + args: [{ name: "seconds", type: "number", label: "Seconds", placeholder: "300" }] + }, + "movecursortocorner": { + args: [{ name: "corner", type: "number", label: "Corner", placeholder: "0-3 (BL, BR, TR, TL)" }] + }, + "movecursor": { + args: [ + { name: "x", type: "number", label: "X", placeholder: "100" }, + { name: "y", type: "number", label: "Y", placeholder: "100" } + ] + }, + "changegroupactive": { + args: [{ name: "direction", type: "text", label: "Direction/Index", placeholder: "f, b, or index" }] + }, + "movefocus": { + args: [{ name: "direction", type: "text", label: "Direction", placeholder: "l, r, u, d" }] + }, + "movewindow": { + args: [{ name: "direction", type: "text", label: "Direction/Monitor", placeholder: "l, r, mon:DP-1" }] + }, + "swapwindow": { + args: [{ name: "direction", type: "text", label: "Direction", placeholder: "l, r, u, d" }] + }, + "moveintogroup": { + args: [{ name: "direction", type: "text", label: "Direction", placeholder: "l, r, u, d" }] + }, + "movewindoworgroup": { + args: [{ name: "direction", type: "text", label: "Direction", placeholder: "l, r, u, d" }] + }, + "cyclenext": { + args: [{ name: "options", type: "text", label: "Options", placeholder: "prev, tiled, floating" }] + } +}; + +const ACTION_ARGS = { + niri: NIRI_ACTION_ARGS, + mangowc: MANGOWC_ACTION_ARGS, + hyprland: HYPRLAND_ACTION_ARGS +}; + const DMS_ACTION_ARGS = { "audio increment": { base: "spawn dms ipc call audio increment", @@ -287,12 +771,18 @@ function getDmsActions(isNiri, isHyprland) { return result; } -function getCompositorCategories() { - return Object.keys(COMPOSITOR_ACTIONS); +function getCompositorCategories(compositor) { + var actions = COMPOSITOR_ACTIONS[compositor]; + if (!actions) + return []; + return Object.keys(actions); } -function getCompositorActions(category) { - return COMPOSITOR_ACTIONS[category] || []; +function getCompositorActions(compositor, category) { + var actions = COMPOSITOR_ACTIONS[compositor]; + if (!actions) + return []; + return actions[category] || []; } function getCategoryOrder() { @@ -307,9 +797,12 @@ function findDmsAction(actionId) { return null; } -function findCompositorAction(actionId) { - for (const cat in COMPOSITOR_ACTIONS) { - const acts = COMPOSITOR_ACTIONS[cat]; +function findCompositorAction(compositor, actionId) { + var actions = COMPOSITOR_ACTIONS[compositor]; + if (!actions) + return null; + for (const cat in actions) { + const acts = actions[cat]; for (let i = 0; i < acts.length; i++) { if (acts[i].id === actionId) return acts[i]; @@ -318,7 +811,7 @@ function findCompositorAction(actionId) { return null; } -function getActionLabel(action) { +function getActionLabel(action, compositor) { if (!action) return ""; @@ -326,10 +819,15 @@ function getActionLabel(action) { if (dmsAct) return dmsAct.label; - var base = action.split(" ")[0]; - var compAct = findCompositorAction(base); - if (compAct) - return compAct.label; + if (compositor) { + var compAct = findCompositorAction(compositor, action); + if (compAct) + return compAct.label; + var base = action.split(" ")[0]; + compAct = findCompositorAction(compositor, base); + if (compAct) + return compAct.label; + } if (action.startsWith("spawn sh -c ")) return action.slice(12).replace(/^["']|["']$/g, ""); @@ -343,7 +841,7 @@ function getActionType(action) { return "compositor"; if (action.startsWith("spawn dms ipc call ")) return "dms"; - if (action.startsWith("spawn sh -c ") || action.startsWith("spawn bash -c ")) + if (action.startsWith("spawn sh -c ") || action.startsWith("spawn bash -c ") || action.startsWith("spawn_shell ")) return "shell"; if (action.startsWith("spawn ")) return "spawn"; @@ -364,16 +862,21 @@ function isValidAction(action) { case "spawn ": case "spawn sh -c \"\"": case "spawn sh -c ''": + case "spawn_shell": + case "spawn_shell ": return false; } return true; } -function isKnownCompositorAction(action) { - if (!action) +function isKnownCompositorAction(compositor, action) { + if (!action || !compositor) return false; + var found = findCompositorAction(compositor, action); + if (found) + return true; var base = action.split(" ")[0]; - return findCompositorAction(base) !== null; + return findCompositorAction(compositor, base) !== null; } function buildSpawnAction(command, args) { @@ -385,9 +888,11 @@ function buildSpawnAction(command, args) { return "spawn " + parts.join(" "); } -function buildShellAction(shellCmd) { +function buildShellAction(compositor, shellCmd) { if (!shellCmd) return ""; + if (compositor === "mangowc") + return "spawn_shell " + shellCmd; return "spawn sh -c \"" + shellCmd.replace(/"/g, "\\\"") + "\""; } @@ -405,21 +910,25 @@ function parseSpawnCommand(action) { function parseShellCommand(action) { if (!action) return ""; - if (!action.startsWith("spawn sh -c ")) - return ""; - var content = action.slice(12); - if ((content.startsWith('"') && content.endsWith('"')) || (content.startsWith("'") && content.endsWith("'"))) - content = content.slice(1, -1); - return content.replace(/\\"/g, "\""); + if (action.startsWith("spawn sh -c ")) { + var content = action.slice(12); + if ((content.startsWith('"') && content.endsWith('"')) || (content.startsWith("'") && content.endsWith("'"))) + content = content.slice(1, -1); + return content.replace(/\\"/g, "\""); + } + if (action.startsWith("spawn_shell ")) + return action.slice(12); + return ""; } -function getActionArgConfig(action) { +function getActionArgConfig(compositor, action) { if (!action) return null; var baseAction = action.split(" ")[0]; - if (ACTION_ARGS[baseAction]) - return { type: "compositor", base: baseAction, config: ACTION_ARGS[baseAction] }; + var compositorArgs = ACTION_ARGS[compositor]; + if (compositorArgs && compositorArgs[baseAction]) + return { type: "compositor", base: baseAction, config: compositorArgs[baseAction] }; for (var key in DMS_ACTION_ARGS) { if (action.startsWith(DMS_ACTION_ARGS[key].base)) @@ -429,7 +938,7 @@ function getActionArgConfig(action) { return null; } -function parseCompositorActionArgs(action) { +function parseCompositorActionArgs(compositor, action) { if (!action) return { base: "", args: {} }; @@ -437,44 +946,144 @@ function parseCompositorActionArgs(action) { var base = parts[0]; var args = {}; - if (!ACTION_ARGS[base]) + var compositorArgs = ACTION_ARGS[compositor]; + if (!compositorArgs || !compositorArgs[base]) return { base: action, args: {} }; + var argConfig = compositorArgs[base]; var argParts = parts.slice(1); - switch (base) { - case "move-column-to-workspace": - for (var i = 0; i < argParts.length; i++) { - if (argParts[i] === "focus=true" || argParts[i] === "focus=false") { - args.focus = argParts[i] === "focus=true"; - } else if (!args.index) { - args.index = argParts[i]; + switch (compositor) { + case "niri": + switch (base) { + case "move-column-to-workspace": + for (var i = 0; i < argParts.length; i++) { + if (argParts[i] === "focus=true" || argParts[i] === "focus=false") { + args.focus = argParts[i] === "focus=true"; + } else if (!args.index) { + args.index = argParts[i]; + } + } + break; + case "move-column-to-workspace-down": + case "move-column-to-workspace-up": + for (var k = 0; k < argParts.length; k++) { + if (argParts[k] === "focus=true" || argParts[k] === "focus=false") + args.focus = argParts[k] === "focus=true"; + } + break; + default: + if (base.startsWith("screenshot")) { + for (var j = 0; j < argParts.length; j++) { + var kv = argParts[j].split("="); + if (kv.length === 2) + args[kv[0]] = kv[1] === "true"; + } + } else if (argParts.length > 0) { + args.value = argParts.join(" "); } } break; - case "move-column-to-workspace-down": - case "move-column-to-workspace-up": - for (var k = 0; k < argParts.length; k++) { - if (argParts[k] === "focus=true" || argParts[k] === "focus=false") - args.focus = argParts[k] === "focus=true"; + case "mangowc": + if (argConfig.args && argConfig.args.length > 0 && argParts.length > 0) { + var paramStr = argParts.join(" "); + var paramValues = paramStr.split(","); + for (var m = 0; m < argConfig.args.length && m < paramValues.length; m++) { + args[argConfig.args[m].name] = paramValues[m]; + } + } + break; + case "hyprland": + if (argConfig.args && argConfig.args.length > 0) { + switch (base) { + case "resizewindowpixel": + case "movewindowpixel": + var commaIdx = argParts.join(" ").indexOf(","); + if (commaIdx !== -1) { + var fullStr = argParts.join(" "); + args[argConfig.args[0].name] = fullStr.substring(0, commaIdx); + args[argConfig.args[1].name] = fullStr.substring(commaIdx + 1); + } else if (argParts.length > 0) { + args[argConfig.args[0].name] = argParts.join(" "); + } + break; + case "movetoworkspace": + case "movetoworkspacesilent": + case "tagwindow": + case "alterzorder": + if (argParts.length >= 2) { + args[argConfig.args[0].name] = argParts[0]; + args[argConfig.args[1].name] = argParts.slice(1).join(" "); + } else if (argParts.length === 1) { + args[argConfig.args[0].name] = argParts[0]; + } + break; + case "moveworkspacetomonitor": + case "swapactiveworkspaces": + case "renameworkspace": + case "fullscreenstate": + case "movecursor": + if (argParts.length >= 2) { + args[argConfig.args[0].name] = argParts[0]; + args[argConfig.args[1].name] = argParts[1]; + } else if (argParts.length === 1) { + args[argConfig.args[0].name] = argParts[0]; + } + break; + case "setprop": + if (argParts.length >= 3) { + args.window = argParts[0]; + args.property = argParts[1]; + args.value = argParts.slice(2).join(" "); + } else if (argParts.length === 2) { + args.window = argParts[0]; + args.property = argParts[1]; + } + break; + case "sendshortcut": + if (argParts.length >= 3) { + args.mod = argParts[0]; + args.key = argParts[1]; + args.window = argParts.slice(2).join(" "); + } else if (argParts.length >= 2) { + args.mod = argParts[0]; + args.key = argParts[1]; + } + break; + case "sendkeystate": + if (argParts.length >= 4) { + args.mod = argParts[0]; + args.key = argParts[1]; + args.state = argParts[2]; + args.window = argParts.slice(3).join(" "); + } + break; + case "signalwindow": + if (argParts.length >= 2) { + args.window = argParts[0]; + args.signal = argParts[1]; + } + break; + default: + if (argParts.length > 0) { + if (argConfig.args.length === 1) { + args[argConfig.args[0].name] = argParts.join(" "); + } else { + args.value = argParts.join(" "); + } + } + } } break; default: - if (base.startsWith("screenshot")) { - for (var j = 0; j < argParts.length; j++) { - var kv = argParts[j].split("="); - if (kv.length === 2) - args[kv[0]] = kv[1] === "true"; - } - } else if (argParts.length > 0) { + if (argParts.length > 0) args.value = argParts.join(" "); - } } return { base: base, args: args }; } -function buildCompositorAction(base, args) { +function buildCompositorAction(compositor, base, args) { if (!base) return ""; @@ -483,29 +1092,111 @@ function buildCompositorAction(base, args) { if (!args || Object.keys(args).length === 0) return base; - switch (base) { - case "move-column-to-workspace": - if (args.index) - parts.push(args.index); - if (args.focus === false) - parts.push("focus=false"); + switch (compositor) { + case "niri": + switch (base) { + case "move-column-to-workspace": + if (args.index) + parts.push(args.index); + if (args.focus === false) + parts.push("focus=false"); + break; + case "move-column-to-workspace-down": + case "move-column-to-workspace-up": + if (args.focus === false) + parts.push("focus=false"); + break; + default: + if (base.startsWith("screenshot")) { + if (args["show-pointer"] === true) + parts.push("show-pointer=true"); + if (args["write-to-disk"] === true) + parts.push("write-to-disk=true"); + } else if (args.value) { + parts.push(args.value); + } else if (args.index) { + parts.push(args.index); + } + } break; - case "move-column-to-workspace-down": - case "move-column-to-workspace-up": - if (args.focus === false) - parts.push("focus=false"); - break; - default: - if (base.startsWith("screenshot")) { - if (args["show-pointer"] === true) - parts.push("show-pointer=true"); - if (args["write-to-disk"] === true) - parts.push("write-to-disk=true"); + case "mangowc": + var compositorArgs = ACTION_ARGS.mangowc; + if (compositorArgs && compositorArgs[base] && compositorArgs[base].args) { + var argConfig = compositorArgs[base].args; + var argValues = []; + for (var i = 0; i < argConfig.length; i++) { + var argDef = argConfig[i]; + var val = args[argDef.name]; + if (val === undefined || val === "") + val = argDef.default || ""; + if (val === "" && argValues.length === 0) + continue; + argValues.push(val); + } + if (argValues.length > 0) + parts.push(argValues.join(",")); } else if (args.value) { parts.push(args.value); - } else if (args.index) { - parts.push(args.index); } + break; + case "hyprland": + var hyprArgs = ACTION_ARGS.hyprland; + if (hyprArgs && hyprArgs[base] && hyprArgs[base].args) { + var hyprConfig = hyprArgs[base].args; + switch (base) { + case "resizewindowpixel": + case "movewindowpixel": + if (args[hyprConfig[0].name]) + parts.push(args[hyprConfig[0].name]); + if (args[hyprConfig[1].name]) + parts[parts.length - 1] += "," + args[hyprConfig[1].name]; + break; + case "setprop": + if (args.window) + parts.push(args.window); + if (args.property) + parts.push(args.property); + if (args.value) + parts.push(args.value); + break; + case "sendshortcut": + if (args.mod) + parts.push(args.mod); + if (args.key) + parts.push(args.key); + if (args.window) + parts.push(args.window); + break; + case "sendkeystate": + if (args.mod) + parts.push(args.mod); + if (args.key) + parts.push(args.key); + if (args.state) + parts.push(args.state); + if (args.window) + parts.push(args.window); + break; + case "signalwindow": + if (args.window) + parts.push(args.window); + if (args.signal) + parts.push(args.signal); + break; + default: + for (var j = 0; j < hyprConfig.length; j++) { + var hVal = args[hyprConfig[j].name]; + if (hVal !== undefined && hVal !== "") + parts.push(hVal); + } + } + } else if (args.value) { + parts.push(args.value); + } + break; + default: + if (args.value) + parts.push(args.value); } return parts.join(" "); diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index 1cd54d1d..f22226c9 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -189,6 +189,13 @@ Item { if (CompositorService.isNiri && NiriService.currentOutput) { return NiriService.currentOutput; } + if ((CompositorService.isSway || CompositorService.isScroll) && I3.workspaces?.values) { + const focusedWs = I3.workspaces.values.find(ws => ws.focused === true); + return focusedWs?.monitor?.name || ""; + } + if (CompositorService.isDwl && DwlService.activeOutput) { + return DwlService.activeOutput; + } return ""; } diff --git a/quickshell/Modules/DankBar/DankBar.qml b/quickshell/Modules/DankBar/DankBar.qml index 4f58268b..8b97fdd6 100644 --- a/quickshell/Modules/DankBar/DankBar.qml +++ b/quickshell/Modules/DankBar/DankBar.qml @@ -99,6 +99,8 @@ Item { } else if (CompositorService.isSway || CompositorService.isScroll) { const focusedWs = I3.workspaces?.values?.find(ws => ws.focused === true); focusedScreenName = focusedWs?.monitor?.name || ""; + } else if (CompositorService.isDwl && DwlService.activeOutput) { + focusedScreenName = DwlService.activeOutput; } if (!focusedScreenName && barVariants.instances.length > 0) { @@ -126,6 +128,8 @@ Item { } else if (CompositorService.isSway || CompositorService.isScroll) { const focusedWs = I3.workspaces?.values?.find(ws => ws.focused === true); focusedScreenName = focusedWs?.monitor?.name || ""; + } else if (CompositorService.isDwl && DwlService.activeOutput) { + focusedScreenName = DwlService.activeOutput; } if (!focusedScreenName && barVariants.instances.length > 0) { diff --git a/quickshell/Modules/Settings/KeybindsTab.qml b/quickshell/Modules/Settings/KeybindsTab.qml index 4591188a..b0d4b1ee 100644 --- a/quickshell/Modules/Settings/KeybindsTab.qml +++ b/quickshell/Modules/Settings/KeybindsTab.qml @@ -136,12 +136,12 @@ Item { } } - function _ensureNiriProvider() { + function _ensureCurrentProvider() { if (!KeybindsService.available) return; const cachedProvider = KeybindsService.keybinds?.provider; - if (cachedProvider !== "niri" || KeybindsService._dataVersion === 0) { - KeybindsService.currentProvider = "niri"; + const targetProvider = KeybindsService.currentProvider; + if (cachedProvider !== targetProvider || KeybindsService._dataVersion === 0) { KeybindsService.loadBinds(); return; } @@ -152,13 +152,13 @@ Item { } } - Component.onCompleted: _ensureNiriProvider() + Component.onCompleted: _ensureCurrentProvider() onVisibleChanged: { if (!visible) return; Qt.callLater(scrollToTop); - _ensureNiriProvider(); + _ensureCurrentProvider(); } DankFlickable { @@ -213,7 +213,8 @@ Item { } StyledText { - text: I18n.tr("Click any shortcut to edit. Changes save to dms/binds.kdl") + readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : "dms/binds.conf" + text: I18n.tr("Click any shortcut to edit. Changes save to %1").arg(bindsFile) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText wrapMode: Text.WordWrap @@ -310,11 +311,12 @@ Item { } StyledText { + readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : "dms/binds.conf" text: { if (warningBox.showSetup) - return I18n.tr("Click 'Setup' to create dms/binds.kdl and add include to config.kdl."); + return I18n.tr("Click 'Setup' to create %1 and add include to config.").arg(bindsFile); if (warningBox.showError) - return I18n.tr("dms/binds.kdl exists but is not included in config.kdl. Custom keybinds will not work until this is fixed."); + return I18n.tr("%1 exists but is not included in config. Custom keybinds will not work until this is fixed.").arg(bindsFile); if (warningBox.showWarning) { const count = warningBox.status.overriddenBy; return I18n.tr("%1 DMS bind(s) may be overridden by config binds that come after the include.").arg(count); diff --git a/quickshell/Modules/Settings/PluginsTab.qml b/quickshell/Modules/Settings/PluginsTab.qml index 75701499..9468be57 100644 --- a/quickshell/Modules/Settings/PluginsTab.qml +++ b/quickshell/Modules/Settings/PluginsTab.qml @@ -352,6 +352,14 @@ FocusScope { } refreshPluginList(); } + function onPluginDataChanged(pluginId) { + var plugin = PluginService.availablePlugins[pluginId]; + if (!plugin || !PluginService.isPluginLoaded(pluginId)) + return; + var isLauncher = plugin.type === "launcher" || (plugin.capabilities && plugin.capabilities.includes("launcher")); + if (isLauncher) + PluginService.reloadPlugin(pluginId); + } } Connections { diff --git a/quickshell/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml index aa5d59f7..bb14cb17 100644 --- a/quickshell/Services/KeybindsService.qml +++ b/quickshell/Services/KeybindsService.qml @@ -1,11 +1,12 @@ pragma Singleton -pragma ComponentBehavior: Bound +pragma ComponentBehavior import QtCore import QtQuick import Quickshell import Quickshell.Io -import Quickshell.Wayland // ! Even though qmlls says this is unused, it is wrong +import Quickshell.Wayland +// ! Even though qmlls says this is unused, it is wrong import qs.Common import "../Common/KeybindActions.js" as Actions @@ -26,14 +27,24 @@ Singleton { } } - property bool available: CompositorService.isNiri && shortcutInhibitorAvailable - property string currentProvider: "niri" + property bool available: (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl) && shortcutInhibitorAvailable + property string currentProvider: { + if (CompositorService.isNiri) + return "niri"; + if (CompositorService.isHyprland) + return "hyprland"; + if (CompositorService.isDwl) + return "mangowc"; + return ""; + } readonly property string cheatsheetProvider: { if (CompositorService.isNiri) return "niri"; if (CompositorService.isHyprland) return "hyprland"; + if (CompositorService.isDwl) + return "mangowc"; return ""; } property bool cheatsheetAvailable: cheatsheetProvider !== "" @@ -47,14 +58,14 @@ Singleton { property bool dmsBindsIncluded: true property var dmsStatus: ({ - exists: true, - included: true, - includePosition: -1, - totalIncludes: 0, - bindsAfterDms: 0, - effective: true, - overriddenBy: 0, - statusMessage: "" + "exists": true, + "included": true, + "includePosition": -1, + "totalIncludes": 0, + "bindsAfterDms": 0, + "effective": true, + "overriddenBy": 0, + "statusMessage": "" }) property var _rawData: null @@ -67,7 +78,41 @@ Singleton { readonly property var categoryOrder: Actions.getCategoryOrder() readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) - readonly property string dmsBindsPath: configDir + "/niri/dms/binds.kdl" + readonly property string compositorConfigDir: { + switch (currentProvider) { + case "niri": + return configDir + "/niri"; + case "hyprland": + return configDir + "/hypr"; + case "mangowc": + return configDir + "/mango"; + default: + return ""; + } + } + readonly property string dmsBindsPath: { + switch (currentProvider) { + case "niri": + return compositorConfigDir + "/dms/binds.kdl"; + case "hyprland": + case "mangowc": + return compositorConfigDir + "/dms/binds.conf"; + default: + return ""; + } + } + readonly property string mainConfigPath: { + switch (currentProvider) { + case "niri": + return compositorConfigDir + "/config.kdl"; + case "hyprland": + return compositorConfigDir + "/hyprland.conf"; + case "mangowc": + return compositorConfigDir + "/config.conf"; + default: + return ""; + } + } readonly property var actionTypes: Actions.getActionTypes() readonly property var dmsActions: getDmsActions() @@ -215,19 +260,33 @@ Singleton { root.lastError = ""; root.dmsBindsIncluded = true; root.dmsBindsFixed(); - ToastService.showSuccess(I18n.tr("Binds include added"), I18n.tr("dms/binds.kdl is now included in config.kdl"), "", "keybinds"); + 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"); Qt.callLater(root.forceReload); } } function fixDmsBindsInclude() { - if (fixing || dmsBindsIncluded) + if (fixing || dmsBindsIncluded || !compositorConfigDir) return; fixing = true; - const niriConfigDir = configDir + "/niri"; const timestamp = Math.floor(Date.now() / 1000); - const backupPath = `${niriConfigDir}/config.kdl.dmsbackup${timestamp}`; - const script = `mkdir -p "${niriConfigDir}/dms" && touch "${niriConfigDir}/dms/binds.kdl" && cp "${niriConfigDir}/config.kdl" "${backupPath}" && echo 'include "dms/binds.kdl"' >> "${niriConfigDir}/config.kdl"`; + const backupPath = `${mainConfigPath}.dmsbackup${timestamp}`; + 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}"`; + break; + case "hyprland": + script = `mkdir -p "${compositorConfigDir}/dms" && touch "${compositorConfigDir}/dms/binds.conf" && cp "${mainConfigPath}" "${backupPath}" && echo 'source = dms/binds.conf' >> "${mainConfigPath}"`; + break; + case "mangowc": + script = `mkdir -p "${compositorConfigDir}/dms" && touch "${compositorConfigDir}/dms/binds.conf" && cp "${mainConfigPath}" "${backupPath}" && echo 'source = ./dms/binds.conf' >> "${mainConfigPath}"`; + break; + default: + fixing = false; + return; + } fixProcess.command = ["sh", "-c", script]; fixProcess.running = true; } @@ -261,21 +320,19 @@ Singleton { function _processData() { keybinds = _rawData || {}; - if (currentProvider === "niri") { - dmsBindsIncluded = _rawData?.dmsBindsIncluded ?? true; - const status = _rawData?.dmsStatus; - if (status) { - dmsStatus = { - exists: status.exists ?? true, - included: status.included ?? true, - includePosition: status.includePosition ?? -1, - totalIncludes: status.totalIncludes ?? 0, - bindsAfterDms: status.bindsAfterDms ?? 0, - effective: status.effective ?? true, - overriddenBy: status.overriddenBy ?? 0, - statusMessage: status.statusMessage ?? "" - }; - } + dmsBindsIncluded = _rawData?.dmsBindsIncluded ?? true; + const status = _rawData?.dmsStatus; + if (status) { + dmsStatus = { + "exists": status.exists ?? true, + "included": status.included ?? true, + "includePosition": status.includePosition ?? -1, + "totalIncludes": status.totalIncludes ?? 0, + "bindsAfterDms": status.bindsAfterDms ?? 0, + "effective": status.effective ?? true, + "overriddenBy": status.overriddenBy ?? 0, + "statusMessage": status.statusMessage ?? "" + }; } if (!_rawData?.binds) { @@ -292,7 +349,7 @@ Singleton { const bindsData = _rawData.binds; for (const cat in bindsData) { const binds = bindsData[cat]; - for (let i = 0; i < binds.length; i++) { + for (var i = 0; i < binds.length; i++) { const bind = binds[i]; const targetCat = Actions.isDmsAction(bind.action) ? "DMS" : cat; if (!processed[targetCat]) @@ -309,19 +366,19 @@ Singleton { const grouped = []; const actionMap = {}; - for (let ci = 0; ci < sortedCats.length; ci++) { + for (var ci = 0; ci < sortedCats.length; ci++) { const category = sortedCats[ci]; const binds = processed[category]; if (!binds) continue; - for (let i = 0; i < binds.length; i++) { + for (var i = 0; i < binds.length; i++) { const bind = binds[i]; const action = bind.action || ""; const keyData = { - key: bind.key || "", - source: bind.source || "config", - isOverride: bind.source === "dms", - cooldownMs: bind.cooldownMs || 0 + "key": bind.key || "", + "source": bind.source || "config", + "isOverride": bind.source === "dms", + "cooldownMs": bind.cooldownMs || 0 }; if (actionMap[action]) { actionMap[action].keys.push(keyData); @@ -331,11 +388,11 @@ Singleton { actionMap[action].conflict = bind.conflict; } else { const entry = { - category: category, - action: action, - desc: bind.desc || "", - keys: [keyData], - conflict: bind.conflict || null + "category": category, + "action": action, + "desc": bind.desc || "", + "keys": [keyData], + "conflict": bind.conflict || null }; actionMap[action] = entry; grouped.push(entry); @@ -346,19 +403,19 @@ Singleton { const list = []; for (const cat of sortedCats) { list.push({ - id: "cat:" + cat, - type: "category", - name: cat + "id": "cat:" + cat, + "type": "category", + "name": cat }); const binds = processed[cat]; if (!binds) continue; for (const bind of binds) list.push({ - id: "bind:" + bind.key, - type: "bind", - key: bind.key, - desc: bind.desc + "id": "bind:" + bind.key, + "type": "bind", + "key": bind.key, + "desc": bind.desc }); } @@ -413,15 +470,15 @@ Singleton { } function getActionLabel(action) { - return Actions.getActionLabel(action); + return Actions.getActionLabel(action, currentProvider); } function getCompositorCategories() { - return Actions.getCompositorCategories(); + return Actions.getCompositorCategories(currentProvider); } function getCompositorActions(category) { - return Actions.getCompositorActions(category); + return Actions.getCompositorActions(currentProvider, category); } function getDmsActions() { @@ -433,7 +490,7 @@ Singleton { } function buildShellAction(shellCmd) { - return Actions.buildShellAction(shellCmd); + return Actions.buildShellAction(currentProvider, shellCmd); } function parseSpawnCommand(action) { diff --git a/quickshell/Widgets/KeybindItem.qml b/quickshell/Widgets/KeybindItem.qml index a5afb570..5f885275 100644 --- a/quickshell/Widgets/KeybindItem.qml +++ b/quickshell/Widgets/KeybindItem.qml @@ -1,4 +1,4 @@ -pragma ComponentBehavior: Bound +pragma ComponentBehavior import QtQuick import QtQuick.Layouts @@ -42,7 +42,7 @@ Item { readonly property var keys: bindData.keys || [] readonly property bool hasOverride: { - for (let i = 0; i < keys.length; i++) { + for (var i = 0; i < keys.length; i++) { if (keys[i].isOverride) return true; } @@ -92,7 +92,7 @@ Item { } function restoreToKey(keyToFind) { - for (let i = 0; i < keys.length; i++) { + for (var i = 0; i < keys.length; i++) { if (keys[i].key === keyToFind) { editingKeyIndex = i; editKey = keyToFind; @@ -106,7 +106,7 @@ Item { } hasChanges = false; _actionType = Actions.getActionType(editAction); - useCustomCompositor = _actionType === "compositor" && editAction && !Actions.isKnownCompositorAction(editAction); + useCustomCompositor = _actionType === "compositor" && editAction && !Actions.isKnownCompositorAction(KeybindsService.currentProvider, editAction); return; } } @@ -126,7 +126,7 @@ Item { editCooldownMs = editingKeyIndex >= 0 ? (keys[editingKeyIndex].cooldownMs || 0) : 0; hasChanges = false; _actionType = Actions.getActionType(editAction); - useCustomCompositor = _actionType === "compositor" && editAction && !Actions.isKnownCompositorAction(editAction); + useCustomCompositor = _actionType === "compositor" && editAction && !Actions.isKnownCompositorAction(KeybindsService.currentProvider, editAction); } function startAddingNewKey() { @@ -177,10 +177,10 @@ Item { desc = expandedLoader.item.currentTitle; _savedCooldownMs = editCooldownMs; saveBind(origKey, { - key: editKey, - action: editAction, - desc: desc, - cooldownMs: editCooldownMs + "key": editKey, + "action": editAction, + "desc": desc, + "cooldownMs": editCooldownMs }); hasChanges = false; addingNewKey = false; @@ -189,15 +189,14 @@ Item { function _createShortcutInhibitor() { if (!_shortcutInhibitorAvailable || _shortcutInhibitor) return; - const qmlString = ` - import QtQuick - import Quickshell.Wayland + import QtQuick + import Quickshell.Wayland - ShortcutInhibitor { - enabled: false - window: null - } + ShortcutInhibitor { + enabled: false + window: null + } `; _shortcutInhibitor = Qt.createQmlObject(qmlString, root, "KeybindItem.ShortcutInhibitor"); @@ -207,18 +206,21 @@ Item { function _destroyShortcutInhibitor() { if (_shortcutInhibitor) { + _shortcutInhibitor.enabled = false; _shortcutInhibitor.destroy(); _shortcutInhibitor = null; } } function startRecording() { + _destroyShortcutInhibitor(); _createShortcutInhibitor(); recording = true; } function stopRecording() { recording = false; + _destroyShortcutInhibitor(); } Column { @@ -617,7 +619,6 @@ Item { Keys.onPressed: event => { if (!root.recording) return; - event.accepted = true; switch (event.key) { @@ -654,7 +655,7 @@ Item { } root.updateEdit({ - key: KeyUtils.formatToken(mods, key) + "key": KeyUtils.formatToken(mods, key) }); root.stopRecording(); } @@ -699,9 +700,8 @@ Item { if (!wheelKey) return; - root.updateEdit({ - key: KeyUtils.formatToken(mods, wheelKey) + "key": KeyUtils.formatToken(mods, wheelKey) }); root.stopRecording(); } @@ -824,26 +824,26 @@ Item { switch (typeDelegate.modelData.id) { case "dms": root.updateEdit({ - action: KeybindsService.dmsActions[0].id, - desc: KeybindsService.dmsActions[0].label + "action": KeybindsService.dmsActions[0].id, + "desc": KeybindsService.dmsActions[0].label }); break; case "compositor": root.updateEdit({ - action: "close-window", - desc: "Close Window" + "action": "close-window", + "desc": "Close Window" }); break; case "spawn": root.updateEdit({ - action: "spawn ", - desc: "" + "action": "spawn ", + "desc": "" }); break; case "shell": root.updateEdit({ - action: "spawn sh -c \"\"", - desc: "" + "action": "spawn sh -c \"\"", + "desc": "" }); break; } @@ -890,8 +890,8 @@ Item { for (const act of actions) { if (act.label === value) { root.updateEdit({ - action: act.id, - desc: act.label + "action": act.id, + "desc": act.label }); return; } @@ -905,12 +905,12 @@ Item { Layout.fillWidth: true spacing: Theme.spacingM - readonly property var argConfig: Actions.getActionArgConfig(root.editAction) + readonly property var argConfig: Actions.getActionArgConfig(KeybindsService.currentProvider, root.editAction) readonly property var parsedArgs: argConfig?.type === "dms" ? Actions.parseDmsActionArgs(root.editAction) : null readonly property var dmsActionArgs: Actions.getDmsActionArgs() - readonly property bool hasAmountArg: parsedArgs?.base ? (dmsActionArgs?.[parsedArgs.base]?.args?.some(a => a.name === "amount") ?? false) : false - readonly property bool hasDeviceArg: parsedArgs?.base ? (dmsActionArgs?.[parsedArgs.base]?.args?.some(a => a.name === "device") ?? false) : false - readonly property bool hasTabArg: parsedArgs?.base ? (dmsActionArgs?.[parsedArgs.base]?.args?.some(a => a.name === "tab") ?? false) : false + readonly property bool hasAmountArg: parsedArgs?.base ? (dmsActionArgs[parsedArgs.base]?.args?.some(a => a.name === "amount") ?? false) : false + readonly property bool hasDeviceArg: parsedArgs?.base ? (dmsActionArgs[parsedArgs.base]?.args?.some(a => a.name === "device") ?? false) : false + readonly property bool hasTabArg: parsedArgs?.base ? (dmsActionArgs[parsedArgs.base]?.args?.some(a => a.name === "tab") ?? false) : false visible: root._actionType === "dms" && argConfig?.type === "dms" @@ -949,7 +949,7 @@ Item { const newArgs = Object.assign({}, dmsArgsRow.parsedArgs.args); newArgs.amount = text || "5"; root.updateEdit({ - action: Actions.buildDmsAction(dmsArgsRow.parsedArgs.base, newArgs) + "action": Actions.buildDmsAction(dmsArgsRow.parsedArgs.base, newArgs) }); } } @@ -997,7 +997,7 @@ Item { const newArgs = Object.assign({}, dmsArgsRow.parsedArgs.args); newArgs.device = text; root.updateEdit({ - action: Actions.buildDmsAction(dmsArgsRow.parsedArgs.base, newArgs) + "action": Actions.buildDmsAction(dmsArgsRow.parsedArgs.base, newArgs) }); } } @@ -1054,7 +1054,7 @@ Item { break; } root.updateEdit({ - action: Actions.buildDmsAction(dmsArgsRow.parsedArgs.base, newArgs) + "action": Actions.buildDmsAction(dmsArgsRow.parsedArgs.base, newArgs) }); } } @@ -1104,8 +1104,8 @@ Item { for (const act of actions) { if (act.label === value) { root.updateEdit({ - action: act.id, - desc: act.label + "action": act.id, + "desc": act.label }); return; } @@ -1146,10 +1146,10 @@ Item { id: optionsRow Layout.fillWidth: true spacing: Theme.spacingM - visible: root._actionType === "compositor" && !root.useCustomCompositor && Actions.getActionArgConfig(root.editAction) + visible: root._actionType === "compositor" && !root.useCustomCompositor && Actions.getActionArgConfig(KeybindsService.currentProvider, root.editAction) - readonly property var argConfig: Actions.getActionArgConfig(root.editAction) - readonly property var parsedArgs: Actions.parseCompositorActionArgs(root.editAction) + readonly property var argConfig: Actions.getActionArgConfig(KeybindsService.currentProvider, root.editAction) + readonly property var parsedArgs: Actions.parseCompositorActionArgs(KeybindsService.currentProvider, root.editAction) StyledText { text: I18n.tr("Options") @@ -1167,6 +1167,9 @@ Item { id: argValueField Layout.fillWidth: true Layout.preferredHeight: root._inputHeight + + readonly property string _argName: optionsRow.argConfig?.config?.args[0]?.name || "value" + visible: { const cfg = optionsRow.argConfig; if (!cfg?.config?.args) @@ -1174,19 +1177,20 @@ Item { const firstArg = cfg.config.args[0]; return firstArg && (firstArg.type === "text" || firstArg.type === "number"); } - placeholderText: optionsRow.argConfig?.config?.args?.[0]?.placeholder || "" + placeholderText: optionsRow.argConfig?.config?.args[0]?.placeholder || "" Connections { target: optionsRow function onParsedArgsChanged() { - const newText = optionsRow.parsedArgs?.args?.value || optionsRow.parsedArgs?.args?.index || ""; + const argName = argValueField._argName; + const newText = optionsRow.parsedArgs?.args[argName] || ""; if (argValueField.text !== newText) argValueField.text = newText; } } Component.onCompleted: { - text = optionsRow.parsedArgs?.args?.value || optionsRow.parsedArgs?.args?.index || ""; + text = optionsRow.parsedArgs?.args[_argName] || ""; } onEditingFinished: { @@ -1194,15 +1198,10 @@ Item { if (!cfg) return; const parsed = optionsRow.parsedArgs; - const args = {}; - if (cfg.config.args[0]?.type === "number") - args.index = text; - else - args.value = text; - if (parsed?.args?.focus === false) - args.focus = false; + const args = Object.assign({}, parsed?.args || {}); + args[_argName] = text; root.updateEdit({ - action: Actions.buildCompositorAction(parsed?.base || cfg.base, args) + "action": Actions.buildCompositorAction(KeybindsService.currentProvider, parsed?.base || cfg.base, args) }); } } @@ -1236,7 +1235,7 @@ Item { if (!newChecked) args.focus = false; root.updateEdit({ - action: Actions.buildCompositorAction(cfg.base, args) + "action": Actions.buildCompositorAction(KeybindsService.currentProvider, cfg.base, args) }); } } @@ -1257,14 +1256,14 @@ Item { DankToggle { id: showPointerToggle - checked: optionsRow.parsedArgs?.args?.["show-pointer"] === true + checked: optionsRow.parsedArgs?.args["show-pointer"] === true onToggled: newChecked => { const parsed = optionsRow.parsedArgs; const base = parsed?.base || "screenshot"; const args = Object.assign({}, parsed?.args || {}); args["show-pointer"] = newChecked; root.updateEdit({ - action: Actions.buildCompositorAction(base, args) + "action": Actions.buildCompositorAction(KeybindsService.currentProvider, base, args) }); } } @@ -1282,14 +1281,14 @@ Item { DankToggle { id: writeToDiskToggle - checked: optionsRow.parsedArgs?.args?.["write-to-disk"] === true + checked: optionsRow.parsedArgs?.args["write-to-disk"] === true onToggled: newChecked => { const parsed = optionsRow.parsedArgs; const base = parsed?.base || "screenshot-screen"; const args = Object.assign({}, parsed?.args || {}); args["write-to-disk"] = newChecked; root.updateEdit({ - action: Actions.buildCompositorAction(base, args) + "action": Actions.buildCompositorAction(KeybindsService.currentProvider, base, args) }); } } @@ -1327,7 +1326,7 @@ Item { if (root._actionType !== "compositor") return; root.updateEdit({ - action: text + "action": text }); } } @@ -1359,8 +1358,8 @@ Item { onClicked: { root.useCustomCompositor = false; root.updateEdit({ - action: "close-window", - desc: "Close Window" + "action": "close-window", + "desc": "Close Window" }); } } @@ -1393,7 +1392,7 @@ Item { const parts = text.trim().split(" ").filter(p => p); const action = parts.length > 0 ? "spawn " + parts.join(" ") : "spawn "; root.updateEdit({ - action: action + "action": action }); } } @@ -1422,7 +1421,7 @@ Item { if (root._actionType !== "shell") return; root.updateEdit({ - action: Actions.buildShellAction(text) + "action": Actions.buildShellAction(KeybindsService.currentProvider, text) }); } } @@ -1447,7 +1446,7 @@ Item { placeholderText: I18n.tr("Hotkey overlay title (optional)") text: root.editDesc onTextChanged: root.updateEdit({ - desc: text + "desc": text }) } } @@ -1455,6 +1454,7 @@ Item { RowLayout { Layout.fillWidth: true spacing: Theme.spacingM + visible: KeybindsService.currentProvider === "niri" StyledText { text: I18n.tr("Cooldown") @@ -1487,7 +1487,7 @@ Item { const val = parseInt(text) || 0; if (val !== root.editCooldownMs) root.updateEdit({ - cooldownMs: val + "cooldownMs": val }); } }