mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-08 12:13:31 -04:00
feat(Hyprland): Introduce Lua support for Hyprland configurations
- Note: We do not convert your existing conf configs to lua. This update only reflects DMS defaults state - Updated README.md to reflect changes - Updated Keyboard shortcut support
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
@@ -48,7 +49,7 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
||||
h.parsed = true
|
||||
|
||||
categorizedBinds := make(map[string][]keybinds.Keybind)
|
||||
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs)
|
||||
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs, result.DefaultDMSKeys)
|
||||
|
||||
sheet := &keybinds.CheatSheet{
|
||||
Title: "Hyprland Keybinds",
|
||||
@@ -88,7 +89,7 @@ func (h *HyprlandProvider) HasDMSBindsIncluded() bool {
|
||||
return h.dmsBindsIncluded
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding) {
|
||||
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding, defaultKeys map[string]bool) {
|
||||
currentSubcat := subcategory
|
||||
if section.Name != "" {
|
||||
currentSubcat = section.Name
|
||||
@@ -96,12 +97,12 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
|
||||
|
||||
for _, kb := range section.Keybinds {
|
||||
category := h.categorizeByDispatcher(kb.Dispatcher)
|
||||
bind := h.convertKeybind(&kb, currentSubcat, conflicts)
|
||||
bind := h.convertKeybind(&kb, currentSubcat, conflicts, defaultKeys)
|
||||
categorizedBinds[category] = append(categorizedBinds[category], bind)
|
||||
}
|
||||
|
||||
for _, child := range section.Children {
|
||||
h.convertSection(&child, currentSubcat, categorizedBinds, conflicts)
|
||||
h.convertSection(&child, currentSubcat, categorizedBinds, conflicts, defaultKeys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +134,7 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding) keybinds.Keybind {
|
||||
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding, defaultKeys map[string]bool) keybinds.Keybind {
|
||||
keyStr := h.formatKey(kb)
|
||||
rawAction := h.formatRawAction(kb.Dispatcher, kb.Params)
|
||||
desc := kb.Comment
|
||||
@@ -143,8 +144,15 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
|
||||
}
|
||||
|
||||
source := "config"
|
||||
if strings.Contains(kb.Source, "dms/binds.conf") {
|
||||
if isDMSBindsUserOverridePath(kb.Source) {
|
||||
source = "dms"
|
||||
} else if isDMSBindsPrimarySourcePath(kb.Source) {
|
||||
source = "dms-default"
|
||||
}
|
||||
|
||||
hasDefault := false
|
||||
if source == "dms" && defaultKeys != nil {
|
||||
hasDefault = defaultKeys[strings.ToLower(keyStr)]
|
||||
}
|
||||
|
||||
bind := keybinds.Keybind{
|
||||
@@ -154,9 +162,10 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
|
||||
Subcategory: subcategory,
|
||||
Source: source,
|
||||
Flags: kb.Flags,
|
||||
HasDefault: hasDefault,
|
||||
}
|
||||
|
||||
if source == "dms" && conflicts != nil {
|
||||
if (source == "dms" || source == "dms-default") && conflicts != nil {
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
if conflictKb, ok := conflicts[normalizedKey]; ok {
|
||||
bind.Conflict = &keybinds.Keybind{
|
||||
@@ -188,9 +197,9 @@ func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
|
||||
func (h *HyprlandProvider) GetOverridePath() string {
|
||||
expanded, err := utils.ExpandPath(h.configPath)
|
||||
if err != nil {
|
||||
return filepath.Join(h.configPath, "dms", "binds.conf")
|
||||
return filepath.Join(h.configPath, "dms", "binds-user.lua")
|
||||
}
|
||||
return filepath.Join(expanded, "dms", "binds.conf")
|
||||
return filepath.Join(expanded, "dms", "binds-user.lua")
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) validateAction(action string) error {
|
||||
@@ -250,7 +259,16 @@ func (h *HyprlandProvider) RemoveBind(key string) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
normalizedKey := strings.ToLower(key)
|
||||
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: key, Unbind: true}
|
||||
return h.writeOverrideBinds(existingBinds)
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) ResetBind(key string) error {
|
||||
existingBinds, err := h.loadOverrideBinds()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
normalizedKey := strings.ToLower(key)
|
||||
delete(existingBinds, normalizedKey)
|
||||
return h.writeOverrideBinds(existingBinds)
|
||||
@@ -262,116 +280,12 @@ type hyprlandOverrideBind struct {
|
||||
Description string
|
||||
Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
|
||||
Options map[string]any
|
||||
// Unbind: negative override (hl.unbind only, no rebind).
|
||||
Unbind bool
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
|
||||
overridePath := h.GetOverridePath()
|
||||
binds := make(map[string]*hyprlandOverrideBind)
|
||||
|
||||
data, err := os.ReadFile(overridePath)
|
||||
if os.IsNotExist(err) {
|
||||
return binds, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(line, "bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract flags from bind type
|
||||
bindType := strings.TrimSpace(parts[0])
|
||||
flags := extractBindFlags(bindType)
|
||||
hasDescFlag := strings.Contains(flags, "d")
|
||||
|
||||
content := strings.TrimSpace(parts[1])
|
||||
commentParts := strings.SplitN(content, "#", 2)
|
||||
bindContent := strings.TrimSpace(commentParts[0])
|
||||
|
||||
var comment string
|
||||
if len(commentParts) > 1 {
|
||||
comment = strings.TrimSpace(commentParts[1])
|
||||
}
|
||||
|
||||
// For bindd, format is: mods, key, description, dispatcher, params
|
||||
var minFields, descIndex, dispatcherIndex int
|
||||
if hasDescFlag {
|
||||
minFields = 4
|
||||
descIndex = 2
|
||||
dispatcherIndex = 3
|
||||
} else {
|
||||
minFields = 3
|
||||
dispatcherIndex = 2
|
||||
}
|
||||
|
||||
fields := strings.SplitN(bindContent, ",", minFields+2)
|
||||
if len(fields) < minFields {
|
||||
continue
|
||||
}
|
||||
|
||||
mods := strings.TrimSpace(fields[0])
|
||||
keyName := strings.TrimSpace(fields[1])
|
||||
|
||||
var dispatcher, params string
|
||||
if hasDescFlag {
|
||||
if comment == "" {
|
||||
comment = strings.TrimSpace(fields[descIndex])
|
||||
}
|
||||
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
|
||||
if len(fields) > dispatcherIndex+1 {
|
||||
paramParts := fields[dispatcherIndex+1:]
|
||||
params = strings.TrimSpace(strings.Join(paramParts, ","))
|
||||
}
|
||||
} else {
|
||||
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
|
||||
if len(fields) > dispatcherIndex+1 {
|
||||
paramParts := fields[dispatcherIndex+1:]
|
||||
params = strings.TrimSpace(strings.Join(paramParts, ","))
|
||||
}
|
||||
}
|
||||
|
||||
keyStr := h.buildKeyString(mods, keyName)
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
action := dispatcher
|
||||
if params != "" {
|
||||
action = dispatcher + " " + params
|
||||
}
|
||||
|
||||
binds[normalizedKey] = &hyprlandOverrideBind{
|
||||
Key: keyStr,
|
||||
Action: action,
|
||||
Description: comment,
|
||||
Flags: flags,
|
||||
}
|
||||
}
|
||||
|
||||
return binds, nil
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) buildKeyString(mods, key string) string {
|
||||
if mods == "" {
|
||||
return key
|
||||
}
|
||||
|
||||
modList := strings.FieldsFunc(mods, func(r rune) bool {
|
||||
return r == '+' || r == ' '
|
||||
})
|
||||
|
||||
parts := append(modList, key)
|
||||
return strings.Join(parts, "+")
|
||||
return readLuaOrHyprlangOverride(h.GetOverridePath())
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) getBindSortPriority(action string) int {
|
||||
@@ -420,78 +334,203 @@ func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverri
|
||||
})
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("-- DMS user keybind overrides (edit via Control Center or dms; do not remove this header)\n\n")
|
||||
for _, bind := range bindList {
|
||||
h.writeBindLine(&sb, bind)
|
||||
writeLuaBindLine(&sb, bind)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
|
||||
mods, key := h.parseKeyString(bind.Key)
|
||||
dispatcher, params := h.parseAction(bind.Action)
|
||||
|
||||
// Write bind type with flags (e.g., "bind", "binde", "bindel")
|
||||
sb.WriteString("bind")
|
||||
if bind.Flags != "" {
|
||||
sb.WriteString(bind.Flags)
|
||||
func formatLuaBindKey(internalKey string) string {
|
||||
internalKey = strings.TrimSpace(internalKey)
|
||||
parts := strings.Split(internalKey, "+")
|
||||
for i := range parts {
|
||||
parts[i] = normalizeLuaBindKeyPart(strings.TrimSpace(parts[i]))
|
||||
}
|
||||
sb.WriteString(" = ")
|
||||
sb.WriteString(mods)
|
||||
sb.WriteString(", ")
|
||||
sb.WriteString(key)
|
||||
sb.WriteString(", ")
|
||||
|
||||
// For bindd (description flag), include description before dispatcher
|
||||
if strings.Contains(bind.Flags, "d") && bind.Description != "" {
|
||||
sb.WriteString(bind.Description)
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
|
||||
sb.WriteString(dispatcher)
|
||||
|
||||
if params != "" {
|
||||
sb.WriteString(", ")
|
||||
sb.WriteString(params)
|
||||
}
|
||||
|
||||
// Only add comment if not using bindd (which has inline description)
|
||||
if bind.Description != "" && !strings.Contains(bind.Flags, "d") {
|
||||
sb.WriteString(" # ")
|
||||
sb.WriteString(bind.Description)
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
return strings.Join(parts, " + ")
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) {
|
||||
parts := strings.Split(keyStr, "+")
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return "", keyStr
|
||||
case 1:
|
||||
return "", parts[0]
|
||||
func normalizeLuaBindKeyPart(part string) string {
|
||||
switch strings.ToLower(part) {
|
||||
case "super", "mod4", "mainmod":
|
||||
return "SUPER"
|
||||
case "ctrl", "control":
|
||||
return "CTRL"
|
||||
case "shift":
|
||||
return "SHIFT"
|
||||
case "alt", "mod1":
|
||||
return "ALT"
|
||||
}
|
||||
if len(part) == 1 {
|
||||
return strings.ToUpper(part)
|
||||
}
|
||||
return part
|
||||
}
|
||||
|
||||
func luaActionStringFromHyprlangAction(action string) string {
|
||||
action = strings.TrimSpace(action)
|
||||
if strings.HasPrefix(action, "spawn ") {
|
||||
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimSpace(strings.TrimPrefix(action, "spawn "))))
|
||||
}
|
||||
if strings.HasPrefix(action, "exec ") {
|
||||
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimPrefix(action, "exec ")))
|
||||
}
|
||||
switch action {
|
||||
case "killactive":
|
||||
return `hl.dsp.window.kill()`
|
||||
case "togglefloating":
|
||||
return `hl.dsp.window.float({ action = "toggle" })`
|
||||
case "exit":
|
||||
return `hl.dsp.exit()`
|
||||
default:
|
||||
return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1]
|
||||
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) parseAction(action string) (dispatcher, params string) {
|
||||
parts := strings.SplitN(action, " ", 2)
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return action, ""
|
||||
case 1:
|
||||
dispatcher = parts[0]
|
||||
default:
|
||||
dispatcher = parts[0]
|
||||
params = parts[1]
|
||||
func luaExprToInternalAction(expr string) string {
|
||||
d, p := luaExprToDispatcherParams(expr)
|
||||
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
|
||||
return "exec " + p
|
||||
}
|
||||
|
||||
// Convert internal spawn format to Hyprland's exec
|
||||
if dispatcher == "spawn" {
|
||||
dispatcher = "exec"
|
||||
if p != "" {
|
||||
return d + " " + p
|
||||
}
|
||||
|
||||
return dispatcher, params
|
||||
return d
|
||||
}
|
||||
|
||||
func luaBindOptions(bind *hyprlandOverrideBind) []string {
|
||||
var opts []string
|
||||
if strings.Contains(bind.Flags, "l") {
|
||||
opts = append(opts, "locked = true")
|
||||
}
|
||||
if strings.Contains(bind.Flags, "e") {
|
||||
opts = append(opts, "repeating = true")
|
||||
}
|
||||
if bind.Description != "" && strings.Contains(bind.Flags, "d") {
|
||||
opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description)))
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
|
||||
key := formatLuaBindKey(bind.Key)
|
||||
if bind.Unbind {
|
||||
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
|
||||
sb.WriteByte('\n')
|
||||
return
|
||||
}
|
||||
expr := luaActionStringFromHyprlangAction(bind.Action)
|
||||
opts := luaBindOptions(bind)
|
||||
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
|
||||
sb.WriteByte('\n')
|
||||
if len(opts) > 0 {
|
||||
fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
|
||||
} else {
|
||||
if bind.Description != "" {
|
||||
fmt.Fprintf(sb, `hl.bind("%s", %s) -- %s`, key, expr, bind.Description)
|
||||
} else {
|
||||
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
|
||||
}
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "--") {
|
||||
return nil, false
|
||||
}
|
||||
kbc, actionExpr, optSuffix, ok := parseLuaBindInvocation(line)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
internalKey := luaKeyComboToInternalKey(kbc)
|
||||
|
||||
action := luaExprToInternalAction(actionExpr)
|
||||
flags := luaBindOptFlags(optSuffix)
|
||||
description := luaBindOptDescription(optSuffix)
|
||||
return &hyprlandOverrideBind{
|
||||
Key: internalKey,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Flags: flags,
|
||||
}, true
|
||||
}
|
||||
|
||||
func parseLuaUnbindLine(line string) (string, bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "hl.unbind") {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(line[len("hl.unbind"):])
|
||||
if !strings.HasPrefix(rest, "(") {
|
||||
return "", false
|
||||
}
|
||||
rest = rest[1:]
|
||||
combo, _, ok := parseLuaStringLiteral(rest, 0)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return luaKeyComboToInternalKey(combo), true
|
||||
}
|
||||
|
||||
func luaKeyComboToInternalKey(combo string) string {
|
||||
parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(combo, "+", " "), " ", " "))
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, error) {
|
||||
binds := make(map[string]*hyprlandOverrideBind)
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return binds, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
parser := NewHyprlandParser("")
|
||||
pendingUnbinds := make(map[string]string)
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "--") {
|
||||
continue
|
||||
}
|
||||
if key, ok := parseLuaUnbindLine(line); ok {
|
||||
pendingUnbinds[strings.ToLower(key)] = key
|
||||
continue
|
||||
}
|
||||
if kb, ok := parseLuaBindOverrideLine(line); ok {
|
||||
normalizedKey := strings.ToLower(kb.Key)
|
||||
binds[normalizedKey] = kb
|
||||
delete(pendingUnbinds, normalizedKey)
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "bind") {
|
||||
continue
|
||||
}
|
||||
kb := parser.parseBindLine(line)
|
||||
if kb == nil {
|
||||
continue
|
||||
}
|
||||
keyStr := parser.formatBindKey(kb)
|
||||
action := kb.Dispatcher
|
||||
if kb.Params != "" {
|
||||
action = kb.Dispatcher + " " + kb.Params
|
||||
}
|
||||
flags := kb.Flags
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
binds[normalizedKey] = &hyprlandOverrideBind{
|
||||
Key: keyStr,
|
||||
Action: action,
|
||||
Description: kb.Comment,
|
||||
Flags: flags,
|
||||
}
|
||||
delete(pendingUnbinds, normalizedKey)
|
||||
}
|
||||
for normKey, origKey := range pendingUnbinds {
|
||||
binds[normKey] = &hyprlandOverrideBind{Key: origKey, Unbind: true}
|
||||
}
|
||||
return binds, nil
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
@@ -50,6 +52,8 @@ type HyprlandParser struct {
|
||||
bindOrder []string
|
||||
processedFiles map[string]bool
|
||||
dmsProcessed bool
|
||||
removedKeys map[string]bool // bare hl.unbind targets (negative overrides)
|
||||
defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf}
|
||||
}
|
||||
|
||||
func NewHyprlandParser(configDir string) *HyprlandParser {
|
||||
@@ -64,6 +68,8 @@ func NewHyprlandParser(configDir string) *HyprlandParser {
|
||||
bindMap: make(map[string]*HyprlandKeyBinding),
|
||||
bindOrder: []string{},
|
||||
processedFiles: make(map[string]bool),
|
||||
removedKeys: make(map[string]bool),
|
||||
defaultDMSKeys: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +298,7 @@ type HyprlandParseResult struct {
|
||||
DMSBindsIncluded bool
|
||||
DMSStatus *HyprlandDMSStatus
|
||||
ConflictingConfigs map[string]*HyprlandKeyBinding
|
||||
DefaultDMSKeys map[string]bool // keys with a DMS default in binds.{lua,conf}
|
||||
}
|
||||
|
||||
type HyprlandDMSStatus struct {
|
||||
@@ -317,10 +324,10 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
|
||||
switch {
|
||||
case !p.dmsBindsExists:
|
||||
status.Effective = false
|
||||
status.StatusMessage = "dms/binds.conf does not exist"
|
||||
status.StatusMessage = "dms/binds.lua (or legacy binds.conf) does not exist"
|
||||
case !p.dmsBindsIncluded:
|
||||
status.Effective = false
|
||||
status.StatusMessage = "dms/binds.conf is not sourced in config"
|
||||
status.StatusMessage = "dms binds are not loaded from Hyprland config (require / source)"
|
||||
case p.bindsAfterDMS > 0:
|
||||
status.Effective = true
|
||||
status.OverriddenBy = p.bindsAfterDMS
|
||||
@@ -347,8 +354,11 @@ func (p *HyprlandParser) normalizeKey(key string) string {
|
||||
func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool {
|
||||
key := p.formatBindKey(kb)
|
||||
normalizedKey := p.normalizeKey(key)
|
||||
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf")
|
||||
isDMSBind := isDMSBindsSourcePath(kb.Source)
|
||||
|
||||
if isDMSBindsPrimarySourcePath(kb.Source) {
|
||||
p.defaultDMSKeys[normalizedKey] = true
|
||||
}
|
||||
if isDMSBind {
|
||||
p.dmsBindKeys[normalizedKey] = true
|
||||
} else if p.dmsBindKeys[normalizedKey] {
|
||||
@@ -373,12 +383,21 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
|
||||
if _, err := os.Stat(dmsBindsPath); err == nil {
|
||||
dmsBindsLua := filepath.Join(expandedDir, "dms", "binds.lua")
|
||||
dmsBindsConf := filepath.Join(expandedDir, "dms", "binds.conf")
|
||||
dmsBindsPath := ""
|
||||
if _, err := os.Stat(dmsBindsLua); err == nil {
|
||||
p.dmsBindsExists = true
|
||||
dmsBindsPath = dmsBindsLua
|
||||
} else if _, err := os.Stat(dmsBindsConf); err == nil {
|
||||
p.dmsBindsExists = true
|
||||
dmsBindsPath = dmsBindsConf
|
||||
}
|
||||
|
||||
mainConfig := filepath.Join(expandedDir, "hyprland.conf")
|
||||
mainConfig, err := hyprlandMainConfigPath(p.configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
section, err := p.parseFileWithSource(mainConfig, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -387,10 +406,65 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
|
||||
if p.dmsBindsExists && !p.dmsProcessed {
|
||||
p.parseDMSBindsDirectly(dmsBindsPath, section)
|
||||
}
|
||||
p.removeShadowedDMSBinds(section)
|
||||
p.removeUnboundDMSBinds(section)
|
||||
|
||||
return section, nil
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) removeUnboundDMSBinds(section *HyprlandSection) {
|
||||
if len(p.removedKeys) == 0 {
|
||||
return
|
||||
}
|
||||
filtered := section.Keybinds[:0]
|
||||
for i := range section.Keybinds {
|
||||
kb := section.Keybinds[i]
|
||||
if isDMSBindsSourcePath(kb.Source) && p.removedKeys[p.normalizeKey(p.formatBindKey(&kb))] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, kb)
|
||||
}
|
||||
section.Keybinds = filtered
|
||||
for i := range section.Children {
|
||||
p.removeUnboundDMSBinds(§ion.Children[i])
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) removeShadowedDMSBinds(section *HyprlandSection) {
|
||||
counts := make(map[string]int)
|
||||
p.countDMSBinds(section, counts)
|
||||
p.filterShadowedDMSBinds(section, counts)
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) countDMSBinds(section *HyprlandSection, counts map[string]int) {
|
||||
for i := range section.Keybinds {
|
||||
kb := §ion.Keybinds[i]
|
||||
if isDMSBindsSourcePath(kb.Source) {
|
||||
counts[p.normalizeKey(p.formatBindKey(kb))]++
|
||||
}
|
||||
}
|
||||
for i := range section.Children {
|
||||
p.countDMSBinds(§ion.Children[i], counts)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) filterShadowedDMSBinds(section *HyprlandSection, counts map[string]int) {
|
||||
filtered := section.Keybinds[:0]
|
||||
for i := range section.Keybinds {
|
||||
kb := section.Keybinds[i]
|
||||
key := p.normalizeKey(p.formatBindKey(&kb))
|
||||
if isDMSBindsSourcePath(kb.Source) && counts[key] > 1 {
|
||||
counts[key]--
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, kb)
|
||||
}
|
||||
section.Keybinds = filtered
|
||||
for i := range section.Children {
|
||||
p.filterShadowedDMSBinds(§ion.Children[i], counts)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
@@ -407,6 +481,10 @@ func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*Hyp
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.EqualFold(filepath.Ext(absPath), ".lua") {
|
||||
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName)
|
||||
}
|
||||
|
||||
prevSource := p.currentSource
|
||||
p.currentSource = absPath
|
||||
|
||||
@@ -446,7 +524,7 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas
|
||||
}
|
||||
|
||||
sourcePath := strings.TrimSpace(parts[1])
|
||||
isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
|
||||
isDMSSource := isDMSBindsPrimarySourcePath(sourcePath)
|
||||
|
||||
p.includeCount++
|
||||
if isDMSSource {
|
||||
@@ -474,6 +552,17 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) {
|
||||
if strings.EqualFold(filepath.Ext(dmsBindsPath), ".lua") {
|
||||
sub, err := p.parseLuaLinesFromPath(dmsBindsPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
section.Keybinds = append(section.Keybinds, sub.Keybinds...)
|
||||
section.Children = append(section.Children, sub.Children...)
|
||||
p.dmsProcessed = true
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(dmsBindsPath)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -503,6 +592,124 @@ func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *Hyp
|
||||
p.dmsProcessed = true
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) parseLuaLinesFromPath(absPath string) (*HyprlandSection, error) {
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, "")
|
||||
}
|
||||
|
||||
// parseLuaLines reads a Hyprland Lua config fragment: require() includes and hl.bind keybinds.
|
||||
func (p *HyprlandParser) parseLuaLines(content string, baseDir, absPath, sectionName string) (*HyprlandSection, error) {
|
||||
section := &HyprlandSection{Name: sectionName}
|
||||
prevSource := p.currentSource
|
||||
p.currentSource = absPath
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
boundInFile := make(map[string]bool)
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "--") || !strings.Contains(trimmed, "hl.bind") {
|
||||
continue
|
||||
}
|
||||
if kbc, _, _, ok := parseLuaBindInvocation(trimmed); ok {
|
||||
boundInFile[strings.ToLower(luaKeyComboToInternalKey(kbc))] = true
|
||||
}
|
||||
}
|
||||
rootDir := baseDir
|
||||
if expanded, err := utils.ExpandPath(p.configDir); err == nil && expanded != "" {
|
||||
rootDir = expanded
|
||||
}
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "--") {
|
||||
continue
|
||||
}
|
||||
|
||||
if modules := luaconfig.Requires(trimmed); len(modules) > 0 {
|
||||
for _, mod := range modules {
|
||||
rel := luaconfig.ModuleToRelPath(mod)
|
||||
if rel == "" {
|
||||
continue
|
||||
}
|
||||
isDMS := isDMSBindsPrimarySourcePath(rel)
|
||||
p.includeCount++
|
||||
if isDMS {
|
||||
p.dmsBindsIncluded = true
|
||||
p.dmsIncludePos = p.includeCount
|
||||
p.dmsProcessed = true
|
||||
}
|
||||
fullPath := luaconfig.ModuleToPath(rootDir, mod)
|
||||
expanded, err := utils.ExpandPath(fullPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
includedSection, err := p.parseFileWithSource(expanded, "")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
section.Children = append(section.Children, *includedSection)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "hl.unbind") {
|
||||
if key, ok := parseLuaUnbindLine(trimmed); ok {
|
||||
normalized := strings.ToLower(key)
|
||||
if !boundInFile[normalized] {
|
||||
p.removedKeys[normalized] = true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.Contains(trimmed, "hl.bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
kbc, action, optSuffix, ok := parseLuaBindInvocation(trimmed)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
flags := luaBindOptFlags(optSuffix)
|
||||
desc := luaBindOptDescription(optSuffix)
|
||||
if desc == "" {
|
||||
desc = luaLineTrailingComment(line)
|
||||
}
|
||||
kb := luaKeyComboToBinding(kbc, action, p.currentSource, desc)
|
||||
kb.Flags = flags
|
||||
if p.addBind(kb) {
|
||||
section.Keybinds = append(section.Keybinds, *kb)
|
||||
}
|
||||
}
|
||||
|
||||
p.currentSource = prevSource
|
||||
return section, nil
|
||||
}
|
||||
|
||||
func luaBindOptFlags(optSuffix string) string {
|
||||
optSuffix = strings.TrimSpace(optSuffix)
|
||||
if optSuffix == "" {
|
||||
return ""
|
||||
}
|
||||
var flags string
|
||||
if strings.Contains(optSuffix, "repeating") {
|
||||
flags += "e"
|
||||
}
|
||||
if strings.Contains(optSuffix, "locked") {
|
||||
flags += "l"
|
||||
}
|
||||
if strings.Contains(optSuffix, "description") {
|
||||
flags += "d"
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func luaBindOptDescription(optSuffix string) string {
|
||||
return luaTableStringField(optSuffix, "description")
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
@@ -623,5 +830,356 @@ func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
|
||||
DMSBindsIncluded: parser.dmsBindsIncluded,
|
||||
DMSStatus: parser.buildDMSStatus(),
|
||||
ConflictingConfigs: parser.conflictingConfigs,
|
||||
DefaultDMSKeys: parser.defaultDMSKeys,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func skipLuaWS(s string, i int) int {
|
||||
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r') {
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// parseLuaStringLiteral reads a Lua "..." or '...' starting at i (first quote).
|
||||
func parseLuaStringLiteral(line string, i int) (value string, next int, ok bool) {
|
||||
if i >= len(line) {
|
||||
return "", i, false
|
||||
}
|
||||
q := line[i]
|
||||
if q != '"' && q != '\'' {
|
||||
return "", i, false
|
||||
}
|
||||
i++
|
||||
var sb strings.Builder
|
||||
for i < len(line) {
|
||||
c := line[i]
|
||||
if c == '\\' && i+1 < len(line) {
|
||||
i++
|
||||
sb.WriteByte(line[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == q {
|
||||
return sb.String(), i + 1, true
|
||||
}
|
||||
sb.WriteByte(c)
|
||||
i++
|
||||
}
|
||||
return "", i, false
|
||||
}
|
||||
|
||||
// parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping when parentheses
|
||||
// opened from the first '(' are balanced (handles nested () and {} and double-quoted strings).
|
||||
func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok bool) {
|
||||
start = skipLuaWS(line, start)
|
||||
if start >= len(line) {
|
||||
return "", start, false
|
||||
}
|
||||
// Find first '(' of the call (e.g. hl.dsp.exec_cmd(...)
|
||||
firstParen := strings.IndexByte(line[start:], '(')
|
||||
if firstParen < 0 {
|
||||
return "", start, false
|
||||
}
|
||||
i := start + firstParen
|
||||
depth := 0
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
exprStart := start
|
||||
for ; i < len(line); i++ {
|
||||
c := line[i]
|
||||
if inStr != 0 {
|
||||
if esc {
|
||||
esc = false
|
||||
continue
|
||||
}
|
||||
if c == '\\' && inStr == '"' {
|
||||
esc = true
|
||||
continue
|
||||
}
|
||||
if c == inStr {
|
||||
inStr = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"', '\'':
|
||||
inStr = c
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return strings.TrimSpace(line[exprStart : i+1]), i + 1, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", start, false
|
||||
}
|
||||
|
||||
// parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line.
|
||||
func parseLuaBindInvocation(line string) (keyCombo, actionExpr, optSuffix string, ok bool) {
|
||||
idx := strings.Index(line, "hl.bind")
|
||||
if idx < 0 {
|
||||
return "", "", "", false
|
||||
}
|
||||
i := idx + len("hl.bind")
|
||||
i = skipLuaWS(line, i)
|
||||
if i >= len(line) || line[i] != '(' {
|
||||
return "", "", "", false
|
||||
}
|
||||
i++
|
||||
i = skipLuaWS(line, i)
|
||||
keyCombo, i, ok = parseLuaStringLiteral(line, i)
|
||||
if !ok {
|
||||
return "", "", "", false
|
||||
}
|
||||
i = skipLuaWS(line, i)
|
||||
if i >= len(line) || line[i] != ',' {
|
||||
return "", "", "", false
|
||||
}
|
||||
i++
|
||||
i = skipLuaWS(line, i)
|
||||
actionExpr, i, ok = parseLuaFirstArgExpr(line, i)
|
||||
if !ok {
|
||||
return "", "", "", false
|
||||
}
|
||||
i = skipLuaWS(line, i)
|
||||
if i < len(line) && line[i] == ',' {
|
||||
optSuffix = strings.TrimSpace(line[i:])
|
||||
}
|
||||
return keyCombo, strings.TrimSpace(actionExpr), optSuffix, true
|
||||
}
|
||||
|
||||
func luaKeyComboToBinding(keyCombo, actionExpr, source, lineComment string) *HyprlandKeyBinding {
|
||||
keyCombo = strings.TrimSpace(keyCombo)
|
||||
mods, leaf := luaKeyComboToModsKey(keyCombo)
|
||||
dispatcher, params := luaExprToDispatcherParams(actionExpr)
|
||||
comment := lineComment
|
||||
if comment == "" {
|
||||
comment = hyprlandAutogenerateComment(dispatcher, params)
|
||||
}
|
||||
return &HyprlandKeyBinding{
|
||||
Mods: mods,
|
||||
Key: leaf,
|
||||
Dispatcher: dispatcher,
|
||||
Params: params,
|
||||
Comment: comment,
|
||||
Source: source,
|
||||
Flags: "",
|
||||
}
|
||||
}
|
||||
|
||||
func luaKeyComboToModsKey(combo string) (mods []string, leaf string) {
|
||||
parts := strings.Split(combo, "+")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return nil, ""
|
||||
case 1:
|
||||
return nil, parts[0]
|
||||
default:
|
||||
return parts[:len(parts)-1], parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
|
||||
expr = strings.TrimSpace(expr)
|
||||
switch {
|
||||
case strings.HasPrefix(expr, "hl.dsp.exec_cmd("):
|
||||
arg := extractLuaCallStringArg(expr, "hl.dsp.exec_cmd")
|
||||
if arg != "" {
|
||||
if u, err := strconv.Unquote(arg); err == nil {
|
||||
if strings.HasPrefix(u, "hyprctl dispatch ") {
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch "))
|
||||
parts := strings.SplitN(rest, " ", 2)
|
||||
if len(parts) == 1 {
|
||||
return parts[0], ""
|
||||
}
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
return "exec", u
|
||||
}
|
||||
}
|
||||
return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd"))
|
||||
case strings.Contains(expr, "hl.dsp.window.kill()"):
|
||||
return "killactive", ""
|
||||
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("):
|
||||
switch luaTableStringField(expr, "mode") {
|
||||
case "maximized", "maximize":
|
||||
return "fullscreen", "1"
|
||||
case "fullscreen":
|
||||
return "fullscreen", "0"
|
||||
}
|
||||
return "fullscreen", luaTableStringField(expr, "mode")
|
||||
case strings.HasPrefix(expr, "hl.dsp.window.float("):
|
||||
return "togglefloating", ""
|
||||
case strings.Contains(expr, "hl.dsp.group.toggle()"):
|
||||
return "togglegroup", ""
|
||||
case strings.HasPrefix(expr, "hl.dsp.focus("):
|
||||
switch {
|
||||
case luaTableStringField(expr, "direction") != "":
|
||||
return "movefocus", luaTableStringField(expr, "direction")
|
||||
case luaTableStringField(expr, "monitor") != "":
|
||||
return "focusmonitor", luaTableStringField(expr, "monitor")
|
||||
case luaTableStringField(expr, "workspace") != "":
|
||||
return "workspace", luaTableStringField(expr, "workspace")
|
||||
case luaTableStringField(expr, "window") != "":
|
||||
return "focuswindow", luaTableStringField(expr, "window")
|
||||
}
|
||||
case strings.HasPrefix(expr, "hl.dsp.window.move("):
|
||||
switch {
|
||||
case luaTableStringField(expr, "direction") != "":
|
||||
return "movewindow", luaTableStringField(expr, "direction")
|
||||
case luaTableStringField(expr, "monitor") != "":
|
||||
return "movewindow", "mon:" + luaTableStringField(expr, "monitor")
|
||||
case luaTableStringField(expr, "workspace") != "":
|
||||
return "movetoworkspace", luaTableStringField(expr, "workspace")
|
||||
}
|
||||
case expr == "hl.dsp.window.drag()":
|
||||
return "movewindow", ""
|
||||
case expr == "hl.dsp.window.resize()":
|
||||
return "resizewindow", ""
|
||||
case strings.HasPrefix(expr, "hl.dsp.window.resize("):
|
||||
x := luaStringValue(luaTableScalarField(expr, "x"))
|
||||
y := luaStringValue(luaTableScalarField(expr, "y"))
|
||||
if x != "" || y != "" {
|
||||
if x == "" {
|
||||
x = "0"
|
||||
}
|
||||
if y == "" {
|
||||
y = "0"
|
||||
}
|
||||
return "resizeactive", x + " " + y
|
||||
}
|
||||
case strings.HasPrefix(expr, "hl.dsp.layout("):
|
||||
arg := extractLuaCallStringArg(expr, "hl.dsp.layout")
|
||||
if arg != "" {
|
||||
if u, err := strconv.Unquote(arg); err == nil {
|
||||
return "layoutmsg", u
|
||||
}
|
||||
}
|
||||
case strings.HasPrefix(expr, "hl.dsp.dpms("):
|
||||
if action := luaTableStringField(expr, "action"); action != "" {
|
||||
return "dpms", action
|
||||
}
|
||||
case strings.Contains(expr, "hl.dsp.exit()"):
|
||||
return "exit", ""
|
||||
default:
|
||||
return "exec", "hyprctl dispatch lua:" + expr
|
||||
}
|
||||
return "exec", "hyprctl dispatch lua:" + expr
|
||||
}
|
||||
|
||||
func extractLuaCallStringArg(callExpr, funcName string) string {
|
||||
callExpr = strings.TrimSpace(callExpr)
|
||||
prefix := funcName + "("
|
||||
if !strings.HasPrefix(callExpr, prefix) {
|
||||
return ""
|
||||
}
|
||||
inner := callExpr[len(prefix):]
|
||||
inner = strings.TrimSpace(inner)
|
||||
if len(inner) == 0 {
|
||||
return ""
|
||||
}
|
||||
switch inner[0] {
|
||||
case '"', '\'':
|
||||
s, _, ok := parseLuaStringLiteral(inner, 0)
|
||||
if ok {
|
||||
return strconv.Quote(s)
|
||||
}
|
||||
case '[':
|
||||
if strings.HasPrefix(inner, "[[") {
|
||||
if end := strings.Index(inner[2:], "]]"); end >= 0 {
|
||||
return strconv.Quote(inner[2 : 2+end])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func luaTableStringField(expr, field string) string {
|
||||
return luaStringValue(luaTableScalarField(expr, field))
|
||||
}
|
||||
|
||||
func luaTableScalarField(expr, field string) string {
|
||||
re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`)
|
||||
m := re.FindStringSubmatch(expr)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
func luaStringValue(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(raw, "[[") && strings.HasSuffix(raw, "]]") {
|
||||
return raw[2 : len(raw)-2]
|
||||
}
|
||||
if len(raw) >= 2 {
|
||||
q := raw[0]
|
||||
if (q == '"' || q == '\'') && raw[len(raw)-1] == q {
|
||||
if q == '"' {
|
||||
if u, err := strconv.Unquote(raw); err == nil {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return strings.ReplaceAll(raw[1:len(raw)-1], `\'`, `'`)
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func luaLineTrailingComment(line string) string {
|
||||
if idx := strings.Index(line, "--"); idx >= 0 {
|
||||
return strings.TrimSpace(line[idx+2:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isDMSBindsSourcePath(p string) bool {
|
||||
p = filepath.ToSlash(strings.TrimSpace(p))
|
||||
if isDMSBindsPrimarySourcePath(p) {
|
||||
return true
|
||||
}
|
||||
return isDMSBindsUserOverridePath(p)
|
||||
}
|
||||
|
||||
func isDMSBindsUserOverridePath(p string) bool {
|
||||
p = filepath.ToSlash(strings.TrimSpace(p))
|
||||
return p == "dms/binds-user.lua" || p == "./dms/binds-user.lua" ||
|
||||
strings.HasSuffix(p, "/dms/binds-user.lua")
|
||||
}
|
||||
|
||||
func isDMSBindsPrimarySourcePath(p string) bool {
|
||||
p = filepath.ToSlash(strings.TrimSpace(p))
|
||||
if strings.Contains(p, "/dms/binds.lua") || strings.HasSuffix(p, "dms/binds.lua") || p == "dms/binds.lua" || p == "./dms/binds.lua" {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(p, "/dms/binds.conf") || strings.HasSuffix(p, "dms/binds.conf") {
|
||||
return true
|
||||
}
|
||||
return p == "dms/binds.conf" || p == "./dms/binds.conf"
|
||||
}
|
||||
|
||||
// hyprlandMainConfigPath returns hyprland.lua if present, else hyprland.conf if present.
|
||||
func hyprlandMainConfigPath(dir string) (string, error) {
|
||||
expandedDir, err := utils.ExpandPath(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
luaPath := filepath.Join(expandedDir, "hyprland.lua")
|
||||
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
|
||||
return luaPath, nil
|
||||
}
|
||||
confPath := filepath.Join(expandedDir, "hyprland.conf")
|
||||
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
|
||||
return confPath, nil
|
||||
}
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ package providers
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
)
|
||||
|
||||
func TestHyprlandAutogenerateComment(t *testing.T) {
|
||||
@@ -60,6 +63,341 @@ func TestHyprlandAutogenerateComment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) {
|
||||
tests := []struct {
|
||||
expr string
|
||||
wantDispatcher string
|
||||
wantParams string
|
||||
}{
|
||||
{`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`},
|
||||
{`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"},
|
||||
{`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"},
|
||||
{`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"},
|
||||
{`hl.dsp.window.resize({ x = "-10%", y = 0, relative = true })`, "resizeactive", "-10% 0"},
|
||||
{`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"},
|
||||
{`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.expr, func(t *testing.T) {
|
||||
gotDispatcher, gotParams := luaExprToDispatcherParams(tt.expr)
|
||||
if gotDispatcher != tt.wantDispatcher || gotParams != tt.wantParams {
|
||||
t.Fatalf("luaExprToDispatcherParams() = %q, %q; want %q, %q", gotDispatcher, gotParams, tt.wantDispatcher, tt.wantParams)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLuaBindLineOptionsInsideCall(t *testing.T) {
|
||||
var sb strings.Builder
|
||||
writeLuaBindLine(&sb, &hyprlandOverrideBind{
|
||||
Key: "Super+k",
|
||||
Action: "exec kitty",
|
||||
Description: "Open terminal",
|
||||
Flags: "led",
|
||||
})
|
||||
|
||||
want := `hl.unbind("SUPER + K")
|
||||
hl.bind("SUPER + K", hl.dsp.exec_cmd("kitty"), { locked = true, repeating = true, description = "Open terminal" })`
|
||||
if got := strings.TrimSpace(sb.String()); got != want {
|
||||
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLuaBindLineMapsSpawnActionForHyprland(t *testing.T) {
|
||||
var sb strings.Builder
|
||||
writeLuaBindLine(&sb, &hyprlandOverrideBind{
|
||||
Key: "Super+n",
|
||||
Action: "spawn dms ipc call notepad toggle",
|
||||
Description: "Notepad: Toggle",
|
||||
})
|
||||
|
||||
want := `hl.unbind("SUPER + N")
|
||||
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle`
|
||||
if got := strings.TrimSpace(sb.String()); got != want {
|
||||
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
|
||||
require("dms.binds")
|
||||
require("dms.binds-user")
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(`hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(`hl.bind("SUPER + T", hl.dsp.exec_cmd("foot"), { description = "User terminal" })`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := ParseHyprlandKeysWithDMS(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var found []HyprlandKeyBinding
|
||||
var walk func(HyprlandSection)
|
||||
walk = func(section HyprlandSection) {
|
||||
for _, kb := range section.Keybinds {
|
||||
if strings.EqualFold(strings.Join(append(kb.Mods, kb.Key), "+"), "SUPER+T") {
|
||||
found = append(found, kb)
|
||||
}
|
||||
}
|
||||
for _, child := range section.Children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(*result.Section)
|
||||
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("expected one effective SUPER+T bind, got %d: %#v", len(found), found)
|
||||
}
|
||||
if found[0].Params != "foot" || found[0].Comment != "User terminal" {
|
||||
t.Fatalf("expected user override bind, got %#v", found[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLuaBindLineEmitsUnbindOnlyForNegativeOverride(t *testing.T) {
|
||||
var sb strings.Builder
|
||||
writeLuaBindLine(&sb, &hyprlandOverrideBind{Key: "Super+i", Unbind: true})
|
||||
|
||||
want := `hl.unbind("SUPER + I")`
|
||||
if got := strings.TrimSpace(sb.String()); got != want {
|
||||
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLuaOverrideRecognizesLoneUnbindAsNegativeOverride(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
overridePath := filepath.Join(tmpDir, "binds-user.lua")
|
||||
contents := `-- DMS user keybind overrides
|
||||
hl.unbind("SUPER + I")
|
||||
hl.unbind("SUPER + N")
|
||||
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
||||
`
|
||||
if err := os.WriteFile(overridePath, []byte(contents), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
binds, err := readLuaOrHyprlangOverride(overridePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, ok := binds["super+i"]
|
||||
if !ok {
|
||||
t.Fatalf("expected SUPER+I entry in override map, got: %#v", binds)
|
||||
}
|
||||
if !got.Unbind {
|
||||
t.Fatalf("expected SUPER+I to be marked Unbind, got: %#v", got)
|
||||
}
|
||||
if rebind, ok := binds["super+n"]; !ok || rebind.Unbind {
|
||||
t.Fatalf("expected SUPER+N to be a normal rebind override, got: %#v", rebind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParserDropsDMSDefaultsSuppressedByBindsUserUnbind(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
|
||||
require("dms.binds")
|
||||
require("dms.binds-user")
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(
|
||||
`hl.bind("SUPER + I", hl.dsp.focus({ workspace = "e-1" }))
|
||||
hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
|
||||
), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(`hl.unbind("SUPER + I")`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := ParseHyprlandKeysWithDMS(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var keys []string
|
||||
var walk func(HyprlandSection)
|
||||
walk = func(section HyprlandSection) {
|
||||
for _, kb := range section.Keybinds {
|
||||
keys = append(keys, strings.ToUpper(strings.Join(append(kb.Mods, kb.Key), "+")))
|
||||
}
|
||||
for _, child := range section.Children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(*result.Section)
|
||||
|
||||
for _, k := range keys {
|
||||
if k == "SUPER+I" {
|
||||
t.Fatalf("expected SUPER+I to be suppressed by binds-user.lua unbind, got: %v", keys)
|
||||
}
|
||||
}
|
||||
foundT := false
|
||||
for _, k := range keys {
|
||||
if k == "SUPER+T" {
|
||||
foundT = true
|
||||
}
|
||||
}
|
||||
if !foundT {
|
||||
t.Fatalf("expected SUPER+T to remain (only SUPER+I was unbound), got: %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
provider := NewHyprlandProvider(tmpDir)
|
||||
if err := provider.RemoveBind("SUPER+I"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `hl.unbind("SUPER + I")`) {
|
||||
t.Fatalf("expected negative override hl.unbind line, got:\n%s", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), `hl.bind("SUPER + I"`) {
|
||||
t.Fatalf("expected NO hl.bind for SUPER+I, got:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
override := `hl.unbind("SUPER + N")
|
||||
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
provider := NewHyprlandProvider(tmpDir)
|
||||
if err := provider.RemoveBind("SUPER+N"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `hl.unbind("SUPER + N")`) {
|
||||
t.Fatalf("expected negative override hl.unbind line, got:\n%s", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), `hl.bind("SUPER + N"`) {
|
||||
t.Fatalf("expected NO hl.bind for SUPER+N after remove, got:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandResetBindRevertsExistingOverrideToDefault(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
override := `hl.unbind("SUPER + N")
|
||||
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
provider := NewHyprlandProvider(tmpDir)
|
||||
if err := provider.ResetBind("SUPER+N"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), `SUPER + N`) {
|
||||
t.Fatalf("expected SUPER+N to be fully removed (revert to default), got:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandHasDefaultSetForOverrideOfDefaultKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
|
||||
require("dms.binds")
|
||||
require("dms.binds-user")
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(
|
||||
`hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
|
||||
), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(
|
||||
`hl.unbind("SUPER + T")
|
||||
hl.bind("SUPER + T", hl.dsp.exec_cmd("foot"))
|
||||
hl.bind("SUPER + Z", hl.dsp.exec_cmd("custom"))`,
|
||||
), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
provider := NewHyprlandProvider(tmpDir)
|
||||
sheet, err := provider.GetCheatSheet()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var foundT, foundZ *keybinds.Keybind
|
||||
for _, group := range sheet.Binds {
|
||||
for i := range group {
|
||||
kb := group[i]
|
||||
keyUpper := strings.ToUpper(kb.Key)
|
||||
if keyUpper == "SUPER+T" {
|
||||
foundT = &group[i]
|
||||
}
|
||||
if keyUpper == "SUPER+Z" {
|
||||
foundZ = &group[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if foundT == nil {
|
||||
t.Fatalf("expected SUPER+T override in cheatsheet")
|
||||
}
|
||||
if !foundT.HasDefault {
|
||||
t.Fatalf("expected SUPER+T HasDefault=true (default exists in binds.lua), got %+v", foundT)
|
||||
}
|
||||
if foundZ == nil {
|
||||
t.Fatalf("expected SUPER+Z (user-only) in cheatsheet")
|
||||
}
|
||||
if foundZ.HasDefault {
|
||||
t.Fatalf("expected SUPER+Z HasDefault=false (no default), got %+v", foundZ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandGetKeybindAtLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -141,7 +141,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
|
||||
|
||||
source := "config"
|
||||
if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") {
|
||||
source = "dms"
|
||||
source = "dms-default"
|
||||
}
|
||||
|
||||
bind := keybinds.Keybind{
|
||||
@@ -151,7 +151,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
|
||||
Source: source,
|
||||
}
|
||||
|
||||
if source == "dms" && conflicts != nil {
|
||||
if source == "dms-default" && conflicts != nil {
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
if conflictKb, ok := conflicts[normalizedKey]; ok {
|
||||
bind.Conflict = &keybinds.Keybind{
|
||||
@@ -249,6 +249,10 @@ func (m *MangoWCProvider) RemoveBind(key string) error {
|
||||
return m.writeOverrideBinds(existingBinds)
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) ResetBind(key string) error {
|
||||
return m.RemoveBind(key)
|
||||
}
|
||||
|
||||
type mangowcOverrideBind struct {
|
||||
Key string
|
||||
Action string
|
||||
|
||||
@@ -149,7 +149,7 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
|
||||
|
||||
source := "config"
|
||||
if strings.Contains(kb.Source, "dms/binds.kdl") {
|
||||
source = "dms"
|
||||
source = "dms-default"
|
||||
}
|
||||
|
||||
bind := keybinds.Keybind{
|
||||
@@ -165,7 +165,7 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
|
||||
Repeat: kb.Repeat,
|
||||
}
|
||||
|
||||
if source == "dms" && conflicts != nil {
|
||||
if source == "dms-default" && conflicts != nil {
|
||||
if conflictKb, ok := conflicts[keyStr]; ok {
|
||||
bind.Conflict = &keybinds.Keybind{
|
||||
Key: keyStr,
|
||||
@@ -269,6 +269,10 @@ func (n *NiriProvider) RemoveBind(key string) error {
|
||||
return n.writeOverrideBinds(existingBinds)
|
||||
}
|
||||
|
||||
func (n *NiriProvider) ResetBind(key string) error {
|
||||
return n.RemoveBind(key)
|
||||
}
|
||||
|
||||
type overrideBind struct {
|
||||
Key string
|
||||
Action string
|
||||
|
||||
@@ -13,6 +13,7 @@ type Keybind struct {
|
||||
AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled
|
||||
Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled
|
||||
Conflict *Keybind `json:"conflict,omitempty"`
|
||||
HasDefault bool `json:"hasDefault,omitempty"` // override has a DMS default to revert to
|
||||
}
|
||||
|
||||
type DMSBindsStatus struct {
|
||||
@@ -42,6 +43,11 @@ type Provider interface {
|
||||
type WritableProvider interface {
|
||||
Provider
|
||||
SetBind(key, action, description string, options map[string]any) error
|
||||
// RemoveBind removes the bind. Hyprland writes a negative override to
|
||||
// dms/binds-user.lua; single-file providers delete the line.
|
||||
RemoveBind(key string) error
|
||||
// ResetBind reverts a user override to its DMS default. On single-file
|
||||
// providers this aliases to RemoveBind.
|
||||
ResetBind(key string) error
|
||||
GetOverridePath() string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user