package providers import ( "os" "path/filepath" "regexp" "strconv" "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" ) const ( TitleRegex = "#+!" HideComment = "[hidden]" CommentBindPattern = "#/#" ) var ModSeparators = []rune{'+', ' '} type HyprlandKeyBinding struct { Mods []string `json:"mods"` Key string `json:"key"` Dispatcher string `json:"dispatcher"` Params string `json:"params"` Comment string `json:"comment"` Source string `json:"source"` Flags string `json:"flags"` // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press } type HyprlandSection struct { Children []HyprlandSection `json:"children"` Keybinds []HyprlandKeyBinding `json:"keybinds"` Name string `json:"name"` } type HyprlandParser struct { 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 removedKeys map[string]bool // bare hl.unbind targets (negative overrides) defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf} configFormat string readOnly bool } func NewHyprlandParser(configDir string) *HyprlandParser { return &HyprlandParser{ 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), removedKeys: make(map[string]bool), defaultDMSKeys: make(map[string]bool), } } func (p *HyprlandParser) ReadContent(directory string) error { expandedDir, err := utils.ExpandPath(directory) if err != nil { return err } info, err := os.Stat(expandedDir) if err != nil { return err } if !info.IsDir() { return os.ErrNotExist } confFiles, err := filepath.Glob(filepath.Join(expandedDir, "*.conf")) if err != nil { return err } if len(confFiles) == 0 { return os.ErrNotExist } var combinedContent []string for _, confFile := range confFiles { if fileInfo, err := os.Stat(confFile); err == nil && fileInfo.Mode().IsRegular() { data, err := os.ReadFile(confFile) if err == nil { combinedContent = append(combinedContent, string(data)) } } } if len(combinedContent) == 0 { return os.ErrNotExist } fullContent := strings.Join(combinedContent, "\n") p.contentLines = strings.Split(fullContent, "\n") return nil } func hyprlandAutogenerateComment(dispatcher, params string) string { switch dispatcher { case "resizewindow": return "Resize window" case "movewindow": if params == "" { return "Move window" } dirMap := map[string]string{ "l": "left", "r": "right", "u": "up", "d": "down", } if dir, ok := dirMap[params]; ok { return "move in " + dir + " direction" } return "move in null direction" case "pin": return "pin (show on all workspaces)" case "splitratio": return "Window split ratio " + params case "togglefloating": return "Float/unfloat window" case "resizeactive": return "Resize window by " + params case "killactive": return "Close window" case "fullscreen": fsMap := map[string]string{ "0": "fullscreen", "1": "maximization", "2": "fullscreen on Hyprland's side", } if fs, ok := fsMap[params]; ok { return "Toggle " + fs } return "Toggle null" case "fakefullscreen": return "Toggle fake fullscreen" case "workspace": switch params { case "+1": return "focus right" case "-1": return "focus left" } return "focus workspace " + params case "movefocus": dirMap := map[string]string{ "l": "left", "r": "right", "u": "up", "d": "down", } if dir, ok := dirMap[params]; ok { return "move focus " + dir } return "move focus null" case "swapwindow": dirMap := map[string]string{ "l": "left", "r": "right", "u": "up", "d": "down", } if dir, ok := dirMap[params]; ok { return "swap in " + dir + " direction" } return "swap in null direction" case "movetoworkspace": switch params { case "+1": return "move to right workspace (non-silent)" case "-1": return "move to left workspace (non-silent)" } return "move to workspace " + params + " (non-silent)" case "movetoworkspacesilent": switch params { case "+1": return "move to right workspace" case "-1": return "move to right workspace" } return "move to workspace " + params case "togglespecialworkspace": return "toggle special" case "exec": return params default: return "" } } func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding { line := p.contentLines[lineNumber] return p.parseBindLine(line) } func (p *HyprlandParser) getBindsRecursive(currentContent *HyprlandSection, scope int) *HyprlandSection { titleRegex := regexp.MustCompile(TitleRegex) for p.readingLine < len(p.contentLines) { line := p.contentLines[p.readingLine] loc := titleRegex.FindStringIndex(line) if loc != nil && loc[0] == 0 { headingScope := strings.Index(line, "!") if headingScope <= scope { p.readingLine-- return currentContent } sectionName := strings.TrimSpace(line[headingScope+1:]) p.readingLine++ childSection := &HyprlandSection{ Children: []HyprlandSection{}, Keybinds: []HyprlandKeyBinding{}, Name: sectionName, } result := p.getBindsRecursive(childSection, headingScope) currentContent.Children = append(currentContent.Children, *result) } else if strings.HasPrefix(line, CommentBindPattern) { keybind := p.getKeybindAtLine(p.readingLine) if keybind != nil { currentContent.Keybinds = append(currentContent.Keybinds, *keybind) } } else if line == "" || !strings.HasPrefix(strings.TrimSpace(line), "bind") { } else { keybind := p.getKeybindAtLine(p.readingLine) if keybind != nil { currentContent.Keybinds = append(currentContent.Keybinds, *keybind) } } p.readingLine++ } return currentContent } func (p *HyprlandParser) ParseKeys() *HyprlandSection { p.readingLine = 0 rootSection := &HyprlandSection{ Children: []HyprlandSection{}, Keybinds: []HyprlandKeyBinding{}, Name: "", } return p.getBindsRecursive(rootSection, 0) } func ParseHyprlandKeys(path string) (*HyprlandSection, error) { parser := NewHyprlandParser(path) if err := parser.ReadContent(path); err != nil { return nil, err } return parser.ParseKeys(), nil } type HyprlandParseResult struct { Section *HyprlandSection DMSBindsIncluded bool DMSStatus *HyprlandDMSStatus ConflictingConfigs map[string]*HyprlandKeyBinding DefaultDMSKeys map[string]bool // keys with a DMS default in binds.{lua,conf} } type HyprlandDMSStatus struct { Exists bool Included bool IncludePosition int TotalIncludes int BindsAfterDMS int Effective bool OverriddenBy int StatusMessage string ConfigFormat string ReadOnly bool } func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus { status := &HyprlandDMSStatus{ Exists: p.dmsBindsExists, Included: p.dmsBindsIncluded, IncludePosition: p.dmsIncludePos, TotalIncludes: p.includeCount, BindsAfterDMS: p.bindsAfterDMS, ConfigFormat: p.configFormat, ReadOnly: p.readOnly, } switch { case !p.dmsBindsExists: status.Effective = false status.StatusMessage = "dms/binds.lua (or legacy binds.conf) does not exist" case !p.dmsBindsIncluded: status.Effective = false status.StatusMessage = "dms binds are not loaded from Hyprland config (require / source)" 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 := isDMSBindsSourcePath(kb.Source) if isDMSBindsPrimarySourcePath(kb.Source) { p.defaultDMSKeys[normalizedKey] = true } 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 } dmsBindsLua := filepath.Join(expandedDir, "dms", "binds.lua") dmsBindsConf := filepath.Join(expandedDir, "dms", "binds.conf") dmsBindsPath := "" if _, err := os.Stat(dmsBindsLua); err == nil { p.dmsBindsExists = true dmsBindsPath = dmsBindsLua } else if _, err := os.Stat(dmsBindsConf); err == nil { p.dmsBindsExists = true dmsBindsPath = dmsBindsConf } mainConfig, err := hyprlandMainConfigPath(p.configDir) if err != nil { return nil, err } if strings.EqualFold(filepath.Ext(mainConfig), ".lua") { p.configFormat = "lua" p.readOnly = false } else { p.configFormat = "hyprlang" p.readOnly = true } section, err := p.parseFileWithSource(mainConfig, "") if err != nil { return nil, err } if p.dmsBindsExists && !p.dmsProcessed { p.parseDMSBindsDirectly(dmsBindsPath, section) } p.removeShadowedDMSBinds(section) p.removeUnboundDMSBinds(section) return section, nil } func (p *HyprlandParser) removeUnboundDMSBinds(section *HyprlandSection) { if len(p.removedKeys) == 0 { return } filtered := section.Keybinds[:0] for i := range section.Keybinds { kb := section.Keybinds[i] if isDMSBindsSourcePath(kb.Source) && p.removedKeys[p.normalizeKey(p.formatBindKey(&kb))] { continue } filtered = append(filtered, kb) } section.Keybinds = filtered for i := range section.Children { p.removeUnboundDMSBinds(§ion.Children[i]) } } func (p *HyprlandParser) removeShadowedDMSBinds(section *HyprlandSection) { counts := make(map[string]int) p.countDMSBinds(section, counts) p.filterShadowedDMSBinds(section, counts) } func (p *HyprlandParser) countDMSBinds(section *HyprlandSection, counts map[string]int) { for i := range section.Keybinds { kb := §ion.Keybinds[i] if isDMSBindsSourcePath(kb.Source) { counts[p.normalizeKey(p.formatBindKey(kb))]++ } } for i := range section.Children { p.countDMSBinds(§ion.Children[i], counts) } } func (p *HyprlandParser) filterShadowedDMSBinds(section *HyprlandSection, counts map[string]int) { filtered := section.Keybinds[:0] for i := range section.Keybinds { kb := section.Keybinds[i] key := p.normalizeKey(p.formatBindKey(&kb)) if isDMSBindsSourcePath(kb.Source) && counts[key] > 1 { counts[key]-- continue } filtered = append(filtered, kb) } section.Keybinds = filtered for i := range section.Children { p.filterShadowedDMSBinds(§ion.Children[i], counts) } } func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) { absPath, err := filepath.Abs(filePath) if err != nil { 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 } if strings.EqualFold(filepath.Ext(absPath), ".lua") { return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName) } prevSource := p.currentSource p.currentSource = absPath section := &HyprlandSection{Name: sectionName} lines := strings.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 := isDMSBindsPrimarySourcePath(sourcePath) 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) { if strings.EqualFold(filepath.Ext(dmsBindsPath), ".lua") { sub, err := p.parseLuaLinesFromPath(dmsBindsPath) if err != nil { return } section.Keybinds = append(section.Keybinds, sub.Keybinds...) section.Children = append(section.Children, sub.Children...) p.dmsProcessed = true return } data, err := os.ReadFile(dmsBindsPath) if err != nil { return } 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) parseLuaLinesFromPath(absPath string) (*HyprlandSection, error) { data, err := os.ReadFile(absPath) if err != nil { return nil, err } return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, "") } // parseLuaLines reads a Hyprland Lua config fragment: require() includes and hl.bind keybinds. func (p *HyprlandParser) parseLuaLines(content string, baseDir, absPath, sectionName string) (*HyprlandSection, error) { section := &HyprlandSection{Name: sectionName} prevSource := p.currentSource p.currentSource = absPath lines := strings.Split(content, "\n") boundInFile := make(map[string]bool) for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "--") || !strings.Contains(trimmed, "hl.bind") { continue } if kbc, _, _, ok := parseLuaBindInvocation(trimmed); ok { boundInFile[strings.ToLower(luaKeyComboToInternalKey(kbc))] = true } } rootDir := baseDir if expanded, err := utils.ExpandPath(p.configDir); err == nil && expanded != "" { rootDir = expanded } for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "--") { continue } if modules := luaconfig.Requires(trimmed); len(modules) > 0 { for _, mod := range modules { rel := luaconfig.ModuleToRelPath(mod) if rel == "" { continue } isDMS := isDMSBindsPrimarySourcePath(rel) p.includeCount++ if isDMS { p.dmsBindsIncluded = true p.dmsIncludePos = p.includeCount p.dmsProcessed = true } fullPath := luaconfig.ModuleToPath(rootDir, mod) expanded, err := utils.ExpandPath(fullPath) if err != nil { continue } includedSection, err := p.parseFileWithSource(expanded, "") if err != nil { continue } section.Children = append(section.Children, *includedSection) } continue } if strings.HasPrefix(trimmed, "hl.unbind") { if key, ok := parseLuaUnbindLine(trimmed); ok { normalized := strings.ToLower(key) if !boundInFile[normalized] { p.removedKeys[normalized] = true } } continue } if !strings.Contains(trimmed, "hl.bind") { continue } kbc, action, optSuffix, ok := parseLuaBindInvocation(trimmed) if !ok { continue } flags := luaBindOptFlags(optSuffix) desc := luaBindOptDescription(optSuffix) if desc == "" { desc = luaLineTrailingComment(line) } kb := luaKeyComboToBinding(kbc, action, p.currentSource, desc) kb.Flags = flags if p.addBind(kb) { section.Keybinds = append(section.Keybinds, *kb) } } p.currentSource = prevSource return section, nil } func luaBindOptFlags(optSuffix string) string { optSuffix = strings.TrimSpace(optSuffix) if optSuffix == "" { return "" } var flags string if strings.Contains(optSuffix, "repeating") { flags += "e" } if strings.Contains(optSuffix, "locked") { flags += "l" } if strings.Contains(optSuffix, "description") { flags += "d" } return flags } func luaBindOptDescription(optSuffix string) string { return luaTableStringField(optSuffix, "description") } func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding { parts := strings.SplitN(line, "=", 2) if len(parts) < 2 { return nil } // Extract bind type and flags from the left side of "=" bindType := strings.TrimSpace(parts[0]) flags := extractBindFlags(bindType) hasDescFlag := strings.Contains(flags, "d") keys := parts[1] keyParts := strings.SplitN(keys, "#", 2) keys = keyParts[0] var comment string if len(keyParts) > 1 { comment = strings.TrimSpace(keyParts[1]) } // For bindd, the format is: bindd = MODS, key, description, dispatcher, params // For regular binds: bind = MODS, key, dispatcher, params var minFields, descIndex, dispatcherIndex int if hasDescFlag { minFields = 4 // mods, key, description, dispatcher descIndex = 2 dispatcherIndex = 3 } else { minFields = 3 // mods, key, dispatcher dispatcherIndex = 2 } keyFields := strings.SplitN(keys, ",", minFields+2) // Allow for params if len(keyFields) < minFields { return nil } mods := strings.TrimSpace(keyFields[0]) key := strings.TrimSpace(keyFields[1]) var dispatcher, params string if hasDescFlag { // bindd format: description is in the bind itself if comment == "" { comment = strings.TrimSpace(keyFields[descIndex]) } dispatcher = strings.TrimSpace(keyFields[dispatcherIndex]) if len(keyFields) > dispatcherIndex+1 { paramParts := keyFields[dispatcherIndex+1:] params = strings.TrimSpace(strings.Join(paramParts, ",")) } } else { dispatcher = strings.TrimSpace(keyFields[dispatcherIndex]) if len(keyFields) > dispatcherIndex+1 { paramParts := keyFields[dispatcherIndex+1:] 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, Flags: flags, } } // extractBindFlags extracts the flags from a bind type string // e.g., "binde" -> "e", "bindel" -> "el", "bindd" -> "d" func extractBindFlags(bindType string) string { bindType = strings.TrimSpace(bindType) if !strings.HasPrefix(bindType, "bind") { return "" } return bindType[4:] // Everything after "bind" } 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, DefaultDMSKeys: parser.defaultDMSKeys, }, nil } func skipLuaWS(s string, i int) int { for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r') { i++ } return i } // parseLuaStringLiteral reads a Lua "..." or '...' starting at i (first quote). func parseLuaStringLiteral(line string, i int) (value string, next int, ok bool) { if i >= len(line) { return "", i, false } q := line[i] if q != '"' && q != '\'' { return "", i, false } i++ var sb strings.Builder for i < len(line) { c := line[i] if c == '\\' && i+1 < len(line) { i++ sb.WriteByte(line[i]) i++ continue } if c == q { return sb.String(), i + 1, true } sb.WriteByte(c) i++ } return "", i, false } // parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping at // the next top-level comma. It handles nested calls/tables and inline functions. func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok bool) { start = skipLuaWS(line, start) if start >= len(line) { return "", start, false } i := start parenDepth := 0 braceDepth := 0 bracketDepth := 0 functionDepth := 0 inStr := byte(0) esc := false for ; i < len(line); i++ { c := line[i] if inStr != 0 { if esc { esc = false continue } if c == '\\' && inStr == '"' { esc = true continue } if c == inStr { inStr = 0 } continue } if c == '[' && i+1 < len(line) && line[i+1] == '[' { if end := strings.Index(line[i+2:], "]]"); end >= 0 { i += end + 3 continue } return "", start, false } if luaWordAt(line, i, "function") { functionDepth++ i += len("function") - 1 continue } if luaWordAt(line, i, "end") && functionDepth > 0 { functionDepth-- i += len("end") - 1 continue } switch c { case '"', '\'': inStr = c case '(': parenDepth++ case ')': if parenDepth > 0 { parenDepth-- } case '{': braceDepth++ case '}': if braceDepth > 0 { braceDepth-- } case '[': bracketDepth++ case ']': if bracketDepth > 0 { bracketDepth-- } case ',': if parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && functionDepth == 0 { return strings.TrimSpace(line[start:i]), i, true } } } expr = strings.TrimSpace(line[start:i]) return expr, i, expr != "" } func luaWordAt(line string, idx int, word string) bool { if idx < 0 || idx+len(word) > len(line) || line[idx:idx+len(word)] != word { return false } before := idx == 0 || !isLuaIdentByte(line[idx-1]) afterIdx := idx + len(word) after := afterIdx >= len(line) || !isLuaIdentByte(line[afterIdx]) return before && after } func isLuaIdentByte(c byte) bool { return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') } // parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line. func parseLuaBindInvocation(line string) (keyCombo, actionExpr, optSuffix string, ok bool) { idx := strings.Index(line, "hl.bind") if idx < 0 { return "", "", "", false } i := idx + len("hl.bind") i = skipLuaWS(line, i) if i >= len(line) || line[i] != '(' { return "", "", "", false } i++ i = skipLuaWS(line, i) keyCombo, i, ok = parseLuaStringLiteral(line, i) if !ok { return "", "", "", false } i = skipLuaWS(line, i) if i >= len(line) || line[i] != ',' { return "", "", "", false } i++ i = skipLuaWS(line, i) actionExpr, i, ok = parseLuaFirstArgExpr(line, i) if !ok { return "", "", "", false } i = skipLuaWS(line, i) if i < len(line) && line[i] == ',' { optSuffix = strings.TrimSpace(line[i:]) } return keyCombo, strings.TrimSpace(actionExpr), optSuffix, true } func luaKeyComboToBinding(keyCombo, actionExpr, source, lineComment string) *HyprlandKeyBinding { keyCombo = strings.TrimSpace(keyCombo) mods, leaf := luaKeyComboToModsKey(keyCombo) dispatcher, params := luaExprToDispatcherParams(actionExpr) comment := lineComment if comment == "" { comment = hyprlandAutogenerateComment(dispatcher, params) } return &HyprlandKeyBinding{ Mods: mods, Key: leaf, Dispatcher: dispatcher, Params: params, Comment: comment, Source: source, Flags: "", } } func luaKeyComboToModsKey(combo string) (mods []string, leaf string) { parts := strings.Split(combo, "+") for i := range parts { parts[i] = strings.TrimSpace(parts[i]) } switch len(parts) { case 0: return nil, "" case 1: return nil, parts[0] default: return parts[:len(parts)-1], parts[len(parts)-1] } } func luaExprToDispatcherParams(expr string) (dispatcher, params string) { expr = strings.TrimSpace(expr) switch { case strings.HasPrefix(expr, "hl.dsp.exec_cmd("): arg := extractLuaCallStringArg(expr, "hl.dsp.exec_cmd") if arg != "" { if u, err := strconv.Unquote(arg); err == nil { if strings.HasPrefix(u, "hyprctl dispatch ") { return splitDispatchCommand(strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch "))) } return "exec", u } } return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd")) case strings.HasPrefix(expr, "hl.dsp.exec_raw("): return "execr", luaCallStringArgValue(expr, "hl.dsp.exec_raw") case strings.HasPrefix(expr, "hl.dispatch("): if arg := luaCallStringArgValue(expr, "hl.dispatch"); arg != "" { return splitDispatchCommand(arg) } return "", "" case strings.Contains(expr, "hl.exec_cmd("): if arg := luaEmbeddedCallStringArgValue(expr, "hl.exec_cmd"); strings.HasPrefix(arg, "hyprctl dispatch ") { return splitDispatchCommand(strings.TrimSpace(strings.TrimPrefix(arg, "hyprctl dispatch "))) } case strings.HasPrefix(expr, "hl.dsp.window.close("): if window := luaTableStringField(expr, "window"); window != "" { return "closewindow", window } if arg := luaCallStringArgValue(expr, "hl.dsp.window.close"); arg != "" { return "closewindow", arg } return "killactive", "" case strings.HasPrefix(expr, "hl.dsp.window.kill("): if window := luaTableStringField(expr, "window"); window != "" { return "killwindow", window } if arg := luaCallStringArgValue(expr, "hl.dsp.window.kill"); arg != "" { return "killwindow", arg } return "forcekillactive", "" case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("): switch luaTableStringField(expr, "mode") { case "maximized", "maximize": return "fullscreen", "1" case "fullscreen": return "fullscreen", "0" } return "fullscreen", luaTableStringField(expr, "mode") case strings.HasPrefix(expr, "hl.dsp.window.fullscreen_state("): internal := luaStringValue(luaTableScalarField(expr, "internal")) client := luaStringValue(luaTableScalarField(expr, "client")) return joinDispatcherParams("fullscreenstate", internal, client) case strings.HasPrefix(expr, "hl.dsp.window.float("): switch luaToggleActionToLegacy(luaTableStringField(expr, "action")) { case "on": return "setfloating", "" case "off": return "settiled", "" default: return "togglefloating", "" } case strings.HasPrefix(expr, "hl.dsp.window.pseudo("): action := luaToggleActionToLegacy(luaTableStringField(expr, "action")) if action == "" || action == "toggle" { return "pseudo", "" } return "pseudo", action case strings.HasPrefix(expr, "hl.dsp.window.pin("): if action := luaToggleActionToLegacy(luaTableStringField(expr, "action")); action != "" && action != "toggle" { return "pin", action } return "pin", "" case strings.Contains(expr, "hl.dsp.window.center()"): return "centerwindow", "" case strings.Contains(expr, "hl.dsp.window.bring_to_top()"): return "bringactivetotop", "" case strings.Contains(expr, "hl.dsp.window.toggle_swallow()"): return "toggleswallow", "" case strings.Contains(expr, "hl.dsp.group.toggle()"): return "togglegroup", "" case strings.Contains(expr, "hl.dsp.group.next()"): return "changegroupactive", "f" case strings.Contains(expr, "hl.dsp.group.prev()"): return "changegroupactive", "b" case strings.HasPrefix(expr, "hl.dsp.group.active("): return "changegroupactive", luaStringValue(luaTableScalarField(expr, "index")) case strings.HasPrefix(expr, "hl.dsp.group.move_window("): if forward, ok := luaTableBoolField(expr, "forward"); ok && !forward { return "movegroupwindow", "b" } return "movegroupwindow", "f" case strings.HasPrefix(expr, "hl.dsp.group.lock_active("): return "lockactivegroup", luaToggleActionToLockArg(luaTableStringField(expr, "action")) case strings.HasPrefix(expr, "hl.dsp.group.lock("): return "lockgroups", luaToggleActionToLockArg(luaTableStringField(expr, "action")) case strings.HasPrefix(expr, "hl.dsp.window.deny_from_group("): return "denywindowfromgroup", luaToggleActionToLegacy(luaTableStringField(expr, "action")) case strings.HasPrefix(expr, "hl.dsp.focus("): switch { case luaTableStringField(expr, "direction") != "": return "movefocus", luaTableStringField(expr, "direction") case luaTableStringField(expr, "monitor") != "": return "focusmonitor", luaTableStringField(expr, "monitor") case luaTableStringField(expr, "workspace") != "": if luaTableBoolFieldValue(expr, "on_current_monitor") { return "focusworkspaceoncurrentmonitor", luaTableStringField(expr, "workspace") } return "workspace", luaTableStringField(expr, "workspace") case luaTableStringField(expr, "window") != "": return "focuswindow", luaTableStringField(expr, "window") case luaTableBoolFieldValue(expr, "urgent_or_last"): return "focusurgentorlast", "" case luaTableBoolFieldValue(expr, "last"): return "focuscurrentorlast", "" } case strings.HasPrefix(expr, "hl.dsp.window.move("): switch { case luaTableScalarField(expr, "x") != "" || luaTableScalarField(expr, "y") != "": x := luaStringValue(luaTableScalarField(expr, "x")) y := luaStringValue(luaTableScalarField(expr, "y")) if x == "" { x = "0" } if y == "" { y = "0" } prefix := "" if raw, ok := luaTableBoolField(expr, "relative"); ok && !raw { prefix = "exact " } params := prefix + x + " " + y if window := luaTableStringField(expr, "window"); window != "" { return "movewindowpixel", params + "," + window } return "moveactive", params case luaTableStringField(expr, "into_group") != "": return "moveintogroup", luaTableStringField(expr, "into_group") case luaTableStringField(expr, "into_or_create_group") != "": return "moveintoorcreategroup", luaTableStringField(expr, "into_or_create_group") case luaTableBoolFieldValue(expr, "out_of_group"): return "moveoutofgroup", "" case luaTableStringField(expr, "out_of_group") != "": return "moveoutofgroup", luaTableStringField(expr, "out_of_group") case luaTableStringField(expr, "direction") != "": if luaTableBoolFieldValue(expr, "group_aware") { return "movewindoworgroup", luaTableStringField(expr, "direction") } return "movewindow", luaTableStringField(expr, "direction") case luaTableStringField(expr, "monitor") != "": return "movewindow", "mon:" + luaTableStringField(expr, "monitor") case luaTableStringField(expr, "workspace") != "": action := "movetoworkspace" if follow, ok := luaTableBoolField(expr, "follow"); ok && !follow { action = "movetoworkspacesilent" } return joinDispatcherParams(action, luaTableStringField(expr, "workspace"), luaTableStringField(expr, "window")) } case expr == "hl.dsp.window.drag()": return "movewindow", "" case expr == "hl.dsp.window.resize()": return "resizewindow", "" case strings.HasPrefix(expr, "hl.dsp.window.resize("): x := luaStringValue(luaTableScalarField(expr, "x")) y := luaStringValue(luaTableScalarField(expr, "y")) if x != "" || y != "" { if x == "" { x = "0" } if y == "" { y = "0" } prefix := "" if relative, ok := luaTableBoolField(expr, "relative"); ok && !relative { prefix = "exact " } params := prefix + x + " " + y if window := luaTableStringField(expr, "window"); window != "" { return "resizewindowpixel", params + "," + window } return "resizeactive", params } case strings.HasPrefix(expr, "hl.dsp.window.swap("): switch { case luaTableBoolFieldValue(expr, "next"): return "swapnext", "" case luaTableBoolFieldValue(expr, "prev"): return "swapnext", "prev" } return "swapwindow", luaTableStringField(expr, "direction") case strings.HasPrefix(expr, "hl.dsp.window.cycle_next("): parts := []string{} if next, ok := luaTableBoolField(expr, "next"); ok && !next { parts = append(parts, "prev") } if luaTableBoolFieldValue(expr, "tiled") { parts = append(parts, "tiled") } if luaTableBoolFieldValue(expr, "floating") { parts = append(parts, "floating") } return "cyclenext", strings.Join(parts, " ") case strings.HasPrefix(expr, "hl.dsp.window.signal("): signal := luaStringValue(luaTableScalarField(expr, "signal")) window := luaTableStringField(expr, "window") if window != "" { return joinDispatcherParams("signalwindow", window, signal) } return "signal", signal case strings.HasPrefix(expr, "hl.dsp.window.tag("): return joinDispatcherParams("tagwindow", luaTableStringField(expr, "tag"), luaTableStringField(expr, "window")) case strings.HasPrefix(expr, "hl.dsp.window.alter_zorder("): mode := luaTableStringField(expr, "mode") if mode == "" { mode = luaTableStringField(expr, "zheight") } return joinDispatcherParams("alterzorder", mode, luaTableStringField(expr, "window")) case strings.HasPrefix(expr, "hl.dsp.window.set_prop("): prop := luaTableStringField(expr, "prop") if prop == "" { prop = luaTableStringField(expr, "property") } return joinDispatcherParams("setprop", luaTableStringField(expr, "window"), prop, luaTableStringField(expr, "value")) case strings.HasPrefix(expr, "hl.dsp.workspace.rename("): return joinDispatcherParams("renameworkspace", luaTableStringField(expr, "workspace"), luaTableStringField(expr, "name")) case strings.HasPrefix(expr, "hl.dsp.workspace.move("): workspace := luaTableStringField(expr, "workspace") monitor := luaTableStringField(expr, "monitor") if workspace != "" { return joinDispatcherParams("moveworkspacetomonitor", workspace, monitor) } return "movecurrentworkspacetomonitor", monitor case strings.HasPrefix(expr, "hl.dsp.workspace.swap_monitors("): return joinDispatcherParams("swapactiveworkspaces", luaTableStringField(expr, "monitor1"), luaTableStringField(expr, "monitor2")) case strings.HasPrefix(expr, "hl.dsp.workspace.toggle_special("): return "togglespecialworkspace", luaCallStringArgValue(expr, "hl.dsp.workspace.toggle_special") case strings.HasPrefix(expr, "hl.dsp.layout("): if arg := luaCallStringArgValue(expr, "hl.dsp.layout"); arg != "" { return "layoutmsg", arg } case strings.HasPrefix(expr, "hl.dsp.dpms("): if action := luaTableStringField(expr, "action"); action != "" { switch action { case "enable": return "dpms", "on" case "disable": return "dpms", "off" } return "dpms", action } return "dpms", "" case strings.HasPrefix(expr, "hl.dsp.submap("): return "submap", luaCallStringArgValue(expr, "hl.dsp.submap") case strings.HasPrefix(expr, "hl.dsp.global("): return "global", luaCallStringArgValue(expr, "hl.dsp.global") case strings.HasPrefix(expr, "hl.dsp.event("): return "event", luaCallStringArgValue(expr, "hl.dsp.event") case strings.HasPrefix(expr, "hl.dsp.pass("): if window := luaTableStringField(expr, "window"); window != "" { return "pass", window } return "pass", luaCallStringArgValue(expr, "hl.dsp.pass") case strings.HasPrefix(expr, "hl.dsp.send_shortcut("): return joinDispatcherParams("sendshortcut", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "window")) case strings.HasPrefix(expr, "hl.dsp.send_key_state("): return joinDispatcherParams("sendkeystate", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "state"), luaTableStringField(expr, "window")) case strings.HasPrefix(expr, "hl.dsp.cursor.move_to_corner("): return "movecursortocorner", luaStringValue(luaTableScalarField(expr, "corner")) case strings.HasPrefix(expr, "hl.dsp.cursor.move("): return joinDispatcherParams("movecursor", luaStringValue(luaTableScalarField(expr, "x")), luaStringValue(luaTableScalarField(expr, "y"))) case strings.Contains(expr, "hl.dsp.force_renderer_reload()"): return "forcerendererreload", "" case strings.HasPrefix(expr, "hl.dsp.force_idle("): return "forceidle", luaCallScalarArgValue(expr, "hl.dsp.force_idle") case strings.Contains(expr, "hl.dsp.exit()"): return "exit", "" default: return expr, "" } return expr, "" } func splitDispatchCommand(command string) (dispatcher, params string) { command = strings.TrimSpace(command) if command == "" { return "", "" } parts := strings.SplitN(command, " ", 2) if len(parts) == 1 { return parts[0], "" } return parts[0], strings.TrimSpace(parts[1]) } func joinDispatcherParams(dispatcher string, values ...string) (string, string) { parts := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value != "" { parts = append(parts, value) } } return dispatcher, strings.Join(parts, " ") } func luaEmbeddedCallStringArgValue(expr, funcName string) string { idx := strings.Index(expr, funcName+"(") if idx < 0 { return "" } return luaCallStringArgValue(expr[idx:], funcName) } func luaCallScalarArgValue(callExpr, funcName string) string { callExpr = strings.TrimSpace(callExpr) prefix := funcName + "(" if !strings.HasPrefix(callExpr, prefix) { return "" } inner := strings.TrimSpace(callExpr[len(prefix):]) if inner == "" { return "" } if s := luaCallStringArgValue(callExpr, funcName); s != "" { return s } re := regexp.MustCompile(`^-?\d+(?:\.\d+)?`) return re.FindString(inner) } func luaToggleActionToLegacy(action string) string { switch strings.ToLower(strings.TrimSpace(action)) { case "on", "enable", "enabled", "set", "lock": return "on" case "off", "disable", "disabled", "unset", "unlock": return "off" default: return "toggle" } } func luaToggleActionToLockArg(action string) string { switch luaToggleActionToLegacy(action) { case "on": return "lock" case "off": return "unlock" default: return "toggle" } } func extractLuaCallStringArg(callExpr, funcName string) string { callExpr = strings.TrimSpace(callExpr) prefix := funcName + "(" if !strings.HasPrefix(callExpr, prefix) { return "" } inner := callExpr[len(prefix):] inner = strings.TrimSpace(inner) if len(inner) == 0 { return "" } switch inner[0] { case '"', '\'': s, _, ok := parseLuaStringLiteral(inner, 0) if ok { return strconv.Quote(s) } case '[': if strings.HasPrefix(inner, "[[") { if end := strings.Index(inner[2:], "]]"); end >= 0 { return strconv.Quote(inner[2 : 2+end]) } } } return "" } func luaCallStringArgValue(callExpr, funcName string) string { arg := extractLuaCallStringArg(callExpr, funcName) if arg == "" { return "" } u, err := strconv.Unquote(arg) if err != nil { return "" } return u } func luaTableStringField(expr, field string) string { return luaStringValue(luaTableScalarField(expr, field)) } func luaTableModsField(expr string) string { if mods := luaTableStringField(expr, "mods"); mods != "" { return mods } return luaTableStringField(expr, "mod") } func luaTableBoolFieldValue(expr, field string) bool { value, ok := luaTableBoolField(expr, field) return ok && value } func luaTableBoolField(expr, field string) (bool, bool) { raw := strings.ToLower(luaTableScalarField(expr, field)) switch raw { case "true": return true, true case "false": return false, true default: return false, false } } func luaTableScalarField(expr, field string) string { re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`) m := re.FindStringSubmatch(expr) if len(m) < 2 { return "" } return strings.TrimSpace(m[1]) } func luaStringValue(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } if strings.HasPrefix(raw, "[[") && strings.HasSuffix(raw, "]]") { return raw[2 : len(raw)-2] } if len(raw) >= 2 { q := raw[0] if (q == '"' || q == '\'') && raw[len(raw)-1] == q { if q == '"' { if u, err := strconv.Unquote(raw); err == nil { return u } } return strings.ReplaceAll(raw[1:len(raw)-1], `\'`, `'`) } } return raw } func luaLineTrailingComment(line string) string { inString := byte(0) escaped := false for i := 0; i < len(line)-1; i++ { c := line[i] if inString != 0 { if escaped { escaped = false continue } if c == '\\' && inString == '"' { escaped = true continue } if c == inString { inString = 0 } continue } if c == '"' || c == '\'' { inString = c continue } if c == '[' && line[i+1] == '[' { if end := strings.Index(line[i+2:], "]]"); end >= 0 { i += end + 3 continue } return "" } if c == '-' && line[i+1] == '-' { return strings.TrimSpace(line[i+2:]) } } return "" } func isDMSBindsSourcePath(p string) bool { p = filepath.ToSlash(strings.TrimSpace(p)) if isDMSBindsPrimarySourcePath(p) { return true } return isDMSBindsUserOverridePath(p) } func isDMSBindsUserOverridePath(p string) bool { p = filepath.ToSlash(strings.TrimSpace(p)) return p == "dms/binds-user.lua" || p == "./dms/binds-user.lua" || strings.HasSuffix(p, "/dms/binds-user.lua") } func isDMSBindsPrimarySourcePath(p string) bool { p = filepath.ToSlash(strings.TrimSpace(p)) if strings.Contains(p, "/dms/binds.lua") || strings.HasSuffix(p, "dms/binds.lua") || p == "dms/binds.lua" || p == "./dms/binds.lua" { return true } if strings.Contains(p, "/dms/binds.conf") || strings.HasSuffix(p, "dms/binds.conf") { return true } return p == "dms/binds.conf" || p == "./dms/binds.conf" } // hyprlandMainConfigPath returns hyprland.lua if present, else hyprland.conf if present. func hyprlandMainConfigPath(dir string) (string, error) { expandedDir, err := utils.ExpandPath(dir) if err != nil { return "", err } luaPath := filepath.Join(expandedDir, "hyprland.lua") if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() { return luaPath, nil } confPath := filepath.Join(expandedDir, "hyprland.conf") if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() { return confPath, nil } return "", os.ErrNotExist }