mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-24 13:32:50 -05:00
keybinds: initial support for writable hyprland and mangoWC
fixes #1204
This commit is contained in:
@@ -106,8 +106,8 @@ windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture
|
||||
windowrule = float on, match:class ^(zoom)$
|
||||
|
||||
# DMS windows floating by default
|
||||
windowrule = float on, match:class ^(org.quickshell)$
|
||||
windowrule = opacity 0.9 0.9, match:float false, match:focus false
|
||||
# ! Hyprland doesnt size these windows correctly so disabling by default here
|
||||
# windowrule = float on, match:class ^(org.quickshell)$
|
||||
|
||||
layerrule = no_anim on, match:namespace ^(quickshell)$
|
||||
|
||||
|
||||
@@ -2,45 +2,93 @@ package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
type HyprlandProvider struct {
|
||||
configPath string
|
||||
configPath string
|
||||
dmsBindsIncluded bool
|
||||
parsed bool
|
||||
}
|
||||
|
||||
func NewHyprlandProvider(configPath string) *HyprlandProvider {
|
||||
if configPath == "" {
|
||||
configPath = "$HOME/.config/hypr"
|
||||
configPath = defaultHyprlandConfigDir()
|
||||
}
|
||||
return &HyprlandProvider{
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultHyprlandConfigDir() string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(configDir, "hypr")
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) Name() string {
|
||||
return "hyprland"
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
||||
section, err := ParseHyprlandKeys(h.configPath)
|
||||
result, err := ParseHyprlandKeysWithDMS(h.configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse hyprland config: %w", err)
|
||||
}
|
||||
|
||||
categorizedBinds := make(map[string][]keybinds.Keybind)
|
||||
h.convertSection(section, "", categorizedBinds)
|
||||
h.dmsBindsIncluded = result.DMSBindsIncluded
|
||||
h.parsed = true
|
||||
|
||||
return &keybinds.CheatSheet{
|
||||
Title: "Hyprland Keybinds",
|
||||
Provider: h.Name(),
|
||||
Binds: categorizedBinds,
|
||||
}, nil
|
||||
categorizedBinds := make(map[string][]keybinds.Keybind)
|
||||
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs)
|
||||
|
||||
sheet := &keybinds.CheatSheet{
|
||||
Title: "Hyprland Keybinds",
|
||||
Provider: h.Name(),
|
||||
Binds: categorizedBinds,
|
||||
DMSBindsIncluded: result.DMSBindsIncluded,
|
||||
}
|
||||
|
||||
if result.DMSStatus != nil {
|
||||
sheet.DMSStatus = &keybinds.DMSBindsStatus{
|
||||
Exists: result.DMSStatus.Exists,
|
||||
Included: result.DMSStatus.Included,
|
||||
IncludePosition: result.DMSStatus.IncludePosition,
|
||||
TotalIncludes: result.DMSStatus.TotalIncludes,
|
||||
BindsAfterDMS: result.DMSStatus.BindsAfterDMS,
|
||||
Effective: result.DMSStatus.Effective,
|
||||
OverriddenBy: result.DMSStatus.OverriddenBy,
|
||||
StatusMessage: result.DMSStatus.StatusMessage,
|
||||
}
|
||||
}
|
||||
|
||||
return sheet, nil
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind) {
|
||||
func (h *HyprlandProvider) HasDMSBindsIncluded() bool {
|
||||
if h.parsed {
|
||||
return h.dmsBindsIncluded
|
||||
}
|
||||
|
||||
result, err := ParseHyprlandKeysWithDMS(h.configPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
h.dmsBindsIncluded = result.DMSBindsIncluded
|
||||
h.parsed = true
|
||||
return h.dmsBindsIncluded
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding) {
|
||||
currentSubcat := subcategory
|
||||
if section.Name != "" {
|
||||
currentSubcat = section.Name
|
||||
@@ -48,12 +96,12 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
|
||||
|
||||
for _, kb := range section.Keybinds {
|
||||
category := h.categorizeByDispatcher(kb.Dispatcher)
|
||||
bind := h.convertKeybind(&kb, currentSubcat)
|
||||
bind := h.convertKeybind(&kb, currentSubcat, conflicts)
|
||||
categorizedBinds[category] = append(categorizedBinds[category], bind)
|
||||
}
|
||||
|
||||
for _, child := range section.Children {
|
||||
h.convertSection(&child, currentSubcat, categorizedBinds)
|
||||
h.convertSection(&child, currentSubcat, categorizedBinds, conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +133,8 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string) keybinds.Keybind {
|
||||
key := h.formatKey(kb)
|
||||
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding) keybinds.Keybind {
|
||||
keyStr := h.formatKey(kb)
|
||||
rawAction := h.formatRawAction(kb.Dispatcher, kb.Params)
|
||||
desc := kb.Comment
|
||||
|
||||
@@ -94,12 +142,32 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
|
||||
desc = rawAction
|
||||
}
|
||||
|
||||
return keybinds.Keybind{
|
||||
Key: key,
|
||||
source := "config"
|
||||
if strings.Contains(kb.Source, "dms/binds.conf") {
|
||||
source = "dms"
|
||||
}
|
||||
|
||||
bind := keybinds.Keybind{
|
||||
Key: keyStr,
|
||||
Description: desc,
|
||||
Action: rawAction,
|
||||
Subcategory: subcategory,
|
||||
Source: source,
|
||||
}
|
||||
|
||||
if source == "dms" && conflicts != nil {
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
if conflictKb, ok := conflicts[normalizedKey]; ok {
|
||||
bind.Conflict = &keybinds.Keybind{
|
||||
Key: keyStr,
|
||||
Description: conflictKb.Comment,
|
||||
Action: h.formatRawAction(conflictKb.Dispatcher, conflictKb.Params),
|
||||
Source: "config",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bind
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string {
|
||||
@@ -115,3 +183,262 @@ func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
|
||||
parts = append(parts, kb.Key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) GetOverridePath() string {
|
||||
expanded, err := utils.ExpandPath(h.configPath)
|
||||
if err != nil {
|
||||
return filepath.Join(h.configPath, "dms", "binds.conf")
|
||||
}
|
||||
return filepath.Join(expanded, "dms", "binds.conf")
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) validateAction(action string) error {
|
||||
action = strings.TrimSpace(action)
|
||||
switch {
|
||||
case action == "":
|
||||
return fmt.Errorf("action cannot be empty")
|
||||
case action == "exec" || action == "exec ":
|
||||
return fmt.Errorf("exec dispatcher requires arguments")
|
||||
case strings.HasPrefix(action, "exec "):
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(action, "exec "))
|
||||
if rest == "" {
|
||||
return fmt.Errorf("exec dispatcher requires arguments")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
|
||||
if err := h.validateAction(action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
overridePath := h.GetOverridePath()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create dms directory: %w", err)
|
||||
}
|
||||
|
||||
existingBinds, err := h.loadOverrideBinds()
|
||||
if err != nil {
|
||||
existingBinds = make(map[string]*hyprlandOverrideBind)
|
||||
}
|
||||
|
||||
normalizedKey := strings.ToLower(key)
|
||||
existingBinds[normalizedKey] = &hyprlandOverrideBind{
|
||||
Key: key,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Options: options,
|
||||
}
|
||||
|
||||
return h.writeOverrideBinds(existingBinds)
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) RemoveBind(key string) error {
|
||||
existingBinds, err := h.loadOverrideBinds()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedKey := strings.ToLower(key)
|
||||
delete(existingBinds, normalizedKey)
|
||||
return h.writeOverrideBinds(existingBinds)
|
||||
}
|
||||
|
||||
type hyprlandOverrideBind struct {
|
||||
Key string
|
||||
Action string
|
||||
Description string
|
||||
Options map[string]any
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
|
||||
overridePath := h.GetOverridePath()
|
||||
binds := make(map[string]*hyprlandOverrideBind)
|
||||
|
||||
data, err := os.ReadFile(overridePath)
|
||||
if os.IsNotExist(err) {
|
||||
return binds, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(line, "bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(parts[1])
|
||||
commentParts := strings.SplitN(content, "#", 2)
|
||||
bindContent := strings.TrimSpace(commentParts[0])
|
||||
|
||||
var comment string
|
||||
if len(commentParts) > 1 {
|
||||
comment = strings.TrimSpace(commentParts[1])
|
||||
}
|
||||
|
||||
fields := strings.SplitN(bindContent, ",", 4)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
mods := strings.TrimSpace(fields[0])
|
||||
keyName := strings.TrimSpace(fields[1])
|
||||
dispatcher := strings.TrimSpace(fields[2])
|
||||
|
||||
var params string
|
||||
if len(fields) > 3 {
|
||||
params = strings.TrimSpace(fields[3])
|
||||
}
|
||||
|
||||
keyStr := h.buildKeyString(mods, keyName)
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
action := dispatcher
|
||||
if params != "" {
|
||||
action = dispatcher + " " + params
|
||||
}
|
||||
|
||||
binds[normalizedKey] = &hyprlandOverrideBind{
|
||||
Key: keyStr,
|
||||
Action: action,
|
||||
Description: comment,
|
||||
}
|
||||
}
|
||||
|
||||
return binds, nil
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) buildKeyString(mods, key string) string {
|
||||
if mods == "" {
|
||||
return key
|
||||
}
|
||||
|
||||
modList := strings.FieldsFunc(mods, func(r rune) bool {
|
||||
return r == '+' || r == ' '
|
||||
})
|
||||
|
||||
parts := append(modList, key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) getBindSortPriority(action string) int {
|
||||
switch {
|
||||
case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"):
|
||||
return 0
|
||||
case strings.Contains(action, "workspace"):
|
||||
return 1
|
||||
case strings.Contains(action, "window") || strings.Contains(action, "focus") ||
|
||||
strings.Contains(action, "move") || strings.Contains(action, "swap") ||
|
||||
strings.Contains(action, "resize"):
|
||||
return 2
|
||||
case strings.Contains(action, "monitor"):
|
||||
return 3
|
||||
case strings.HasPrefix(action, "exec"):
|
||||
return 4
|
||||
case action == "exit" || strings.Contains(action, "dpms"):
|
||||
return 5
|
||||
default:
|
||||
return 6
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) writeOverrideBinds(binds map[string]*hyprlandOverrideBind) error {
|
||||
overridePath := h.GetOverridePath()
|
||||
content := h.generateBindsContent(binds)
|
||||
return os.WriteFile(overridePath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverrideBind) string {
|
||||
if len(binds) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
bindList := make([]*hyprlandOverrideBind, 0, len(binds))
|
||||
for _, bind := range binds {
|
||||
bindList = append(bindList, bind)
|
||||
}
|
||||
|
||||
sort.Slice(bindList, func(i, j int) bool {
|
||||
pi, pj := h.getBindSortPriority(bindList[i].Action), h.getBindSortPriority(bindList[j].Action)
|
||||
if pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
return bindList[i].Key < bindList[j].Key
|
||||
})
|
||||
|
||||
var sb strings.Builder
|
||||
for _, bind := range bindList {
|
||||
h.writeBindLine(&sb, bind)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
|
||||
mods, key := h.parseKeyString(bind.Key)
|
||||
dispatcher, params := h.parseAction(bind.Action)
|
||||
|
||||
sb.WriteString("bind = ")
|
||||
sb.WriteString(mods)
|
||||
sb.WriteString(", ")
|
||||
sb.WriteString(key)
|
||||
sb.WriteString(", ")
|
||||
sb.WriteString(dispatcher)
|
||||
|
||||
if params != "" {
|
||||
sb.WriteString(", ")
|
||||
sb.WriteString(params)
|
||||
}
|
||||
|
||||
if bind.Description != "" {
|
||||
sb.WriteString(" # ")
|
||||
sb.WriteString(bind.Description)
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) {
|
||||
parts := strings.Split(keyStr, "+")
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return "", keyStr
|
||||
case 1:
|
||||
return "", parts[0]
|
||||
default:
|
||||
return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) parseAction(action string) (dispatcher, params string) {
|
||||
parts := strings.SplitN(action, " ", 2)
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return action, ""
|
||||
case 1:
|
||||
dispatcher = parts[0]
|
||||
default:
|
||||
dispatcher = parts[0]
|
||||
params = parts[1]
|
||||
}
|
||||
|
||||
// Convert internal spawn format to Hyprland's exec
|
||||
if dispatcher == "spawn" {
|
||||
dispatcher = "exec"
|
||||
}
|
||||
|
||||
return dispatcher, params
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type HyprlandKeyBinding struct {
|
||||
Dispatcher string `json:"dispatcher"`
|
||||
Params string `json:"params"`
|
||||
Comment string `json:"comment"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type HyprlandSection struct {
|
||||
@@ -32,14 +33,36 @@ type HyprlandSection struct {
|
||||
}
|
||||
|
||||
type HyprlandParser struct {
|
||||
contentLines []string
|
||||
readingLine int
|
||||
contentLines []string
|
||||
readingLine int
|
||||
configDir string
|
||||
currentSource string
|
||||
dmsBindsExists bool
|
||||
dmsBindsIncluded bool
|
||||
includeCount int
|
||||
dmsIncludePos int
|
||||
bindsAfterDMS int
|
||||
dmsBindKeys map[string]bool
|
||||
configBindKeys map[string]bool
|
||||
conflictingConfigs map[string]*HyprlandKeyBinding
|
||||
bindMap map[string]*HyprlandKeyBinding
|
||||
bindOrder []string
|
||||
processedFiles map[string]bool
|
||||
dmsProcessed bool
|
||||
}
|
||||
|
||||
func NewHyprlandParser() *HyprlandParser {
|
||||
func NewHyprlandParser(configDir string) *HyprlandParser {
|
||||
return &HyprlandParser{
|
||||
contentLines: []string{},
|
||||
readingLine: 0,
|
||||
contentLines: []string{},
|
||||
readingLine: 0,
|
||||
configDir: configDir,
|
||||
dmsIncludePos: -1,
|
||||
dmsBindKeys: make(map[string]bool),
|
||||
configBindKeys: make(map[string]bool),
|
||||
conflictingConfigs: make(map[string]*HyprlandKeyBinding),
|
||||
bindMap: make(map[string]*HyprlandKeyBinding),
|
||||
bindOrder: []string{},
|
||||
processedFiles: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,9 +343,308 @@ func (p *HyprlandParser) ParseKeys() *HyprlandSection {
|
||||
}
|
||||
|
||||
func ParseHyprlandKeys(path string) (*HyprlandSection, error) {
|
||||
parser := NewHyprlandParser()
|
||||
parser := NewHyprlandParser(path)
|
||||
if err := parser.ReadContent(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parser.ParseKeys(), nil
|
||||
}
|
||||
|
||||
type HyprlandParseResult struct {
|
||||
Section *HyprlandSection
|
||||
DMSBindsIncluded bool
|
||||
DMSStatus *HyprlandDMSStatus
|
||||
ConflictingConfigs map[string]*HyprlandKeyBinding
|
||||
}
|
||||
|
||||
type HyprlandDMSStatus struct {
|
||||
Exists bool
|
||||
Included bool
|
||||
IncludePosition int
|
||||
TotalIncludes int
|
||||
BindsAfterDMS int
|
||||
Effective bool
|
||||
OverriddenBy int
|
||||
StatusMessage string
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
|
||||
status := &HyprlandDMSStatus{
|
||||
Exists: p.dmsBindsExists,
|
||||
Included: p.dmsBindsIncluded,
|
||||
IncludePosition: p.dmsIncludePos,
|
||||
TotalIncludes: p.includeCount,
|
||||
BindsAfterDMS: p.bindsAfterDMS,
|
||||
}
|
||||
|
||||
switch {
|
||||
case !p.dmsBindsExists:
|
||||
status.Effective = false
|
||||
status.StatusMessage = "dms/binds.conf does not exist"
|
||||
case !p.dmsBindsIncluded:
|
||||
status.Effective = false
|
||||
status.StatusMessage = "dms/binds.conf is not sourced in config"
|
||||
case p.bindsAfterDMS > 0:
|
||||
status.Effective = true
|
||||
status.OverriddenBy = p.bindsAfterDMS
|
||||
status.StatusMessage = "Some DMS binds may be overridden by config binds"
|
||||
default:
|
||||
status.Effective = true
|
||||
status.StatusMessage = "DMS binds are active"
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) formatBindKey(kb *HyprlandKeyBinding) string {
|
||||
parts := make([]string, 0, len(kb.Mods)+1)
|
||||
parts = append(parts, kb.Mods...)
|
||||
parts = append(parts, kb.Key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) normalizeKey(key string) string {
|
||||
return strings.ToLower(key)
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool {
|
||||
key := p.formatBindKey(kb)
|
||||
normalizedKey := p.normalizeKey(key)
|
||||
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf")
|
||||
|
||||
if isDMSBind {
|
||||
p.dmsBindKeys[normalizedKey] = true
|
||||
} else if p.dmsBindKeys[normalizedKey] {
|
||||
p.bindsAfterDMS++
|
||||
p.conflictingConfigs[normalizedKey] = kb
|
||||
p.configBindKeys[normalizedKey] = true
|
||||
return false
|
||||
} else {
|
||||
p.configBindKeys[normalizedKey] = true
|
||||
}
|
||||
|
||||
if _, exists := p.bindMap[normalizedKey]; !exists {
|
||||
p.bindOrder = append(p.bindOrder, key)
|
||||
}
|
||||
p.bindMap[normalizedKey] = kb
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
|
||||
expandedDir, err := utils.ExpandPath(p.configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
|
||||
if _, err := os.Stat(dmsBindsPath); err == nil {
|
||||
p.dmsBindsExists = true
|
||||
}
|
||||
|
||||
mainConfig := filepath.Join(expandedDir, "hyprland.conf")
|
||||
section, err := p.parseFileWithSource(mainConfig, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.dmsBindsExists && !p.dmsProcessed {
|
||||
p.parseDMSBindsDirectly(dmsBindsPath, section)
|
||||
}
|
||||
|
||||
return section, nil
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.processedFiles[absPath] {
|
||||
return &HyprlandSection{Name: sectionName}, nil
|
||||
}
|
||||
p.processedFiles[absPath] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prevSource := p.currentSource
|
||||
p.currentSource = absPath
|
||||
|
||||
section := &HyprlandSection{Name: sectionName}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(trimmed, "source") {
|
||||
p.handleSource(trimmed, section, filepath.Dir(absPath))
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(trimmed, "bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
kb := p.parseBindLine(line)
|
||||
if kb == nil {
|
||||
continue
|
||||
}
|
||||
kb.Source = p.currentSource
|
||||
if p.addBind(kb) {
|
||||
section.Keybinds = append(section.Keybinds, *kb)
|
||||
}
|
||||
}
|
||||
|
||||
p.currentSource = prevSource
|
||||
return section, nil
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, baseDir string) {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
sourcePath := strings.TrimSpace(parts[1])
|
||||
isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
|
||||
|
||||
p.includeCount++
|
||||
if isDMSSource {
|
||||
p.dmsBindsIncluded = true
|
||||
p.dmsIncludePos = p.includeCount
|
||||
p.dmsProcessed = true
|
||||
}
|
||||
|
||||
fullPath := sourcePath
|
||||
if !filepath.IsAbs(sourcePath) {
|
||||
fullPath = filepath.Join(baseDir, sourcePath)
|
||||
}
|
||||
|
||||
expanded, err := utils.ExpandPath(fullPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
includedSection, err := p.parseFileWithSource(expanded, "")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
section.Children = append(section.Children, *includedSection)
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) {
|
||||
data, err := os.ReadFile(dmsBindsPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
prevSource := p.currentSource
|
||||
p.currentSource = dmsBindsPath
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
kb := p.parseBindLine(line)
|
||||
if kb == nil {
|
||||
continue
|
||||
}
|
||||
kb.Source = dmsBindsPath
|
||||
if p.addBind(kb) {
|
||||
section.Keybinds = append(section.Keybinds, *kb)
|
||||
}
|
||||
}
|
||||
|
||||
p.currentSource = prevSource
|
||||
p.dmsProcessed = true
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := parts[1]
|
||||
keyParts := strings.SplitN(keys, "#", 2)
|
||||
keys = keyParts[0]
|
||||
|
||||
var comment string
|
||||
if len(keyParts) > 1 {
|
||||
comment = strings.TrimSpace(keyParts[1])
|
||||
}
|
||||
|
||||
keyFields := strings.SplitN(keys, ",", 5)
|
||||
if len(keyFields) < 3 {
|
||||
return nil
|
||||
}
|
||||
|
||||
mods := strings.TrimSpace(keyFields[0])
|
||||
key := strings.TrimSpace(keyFields[1])
|
||||
dispatcher := strings.TrimSpace(keyFields[2])
|
||||
|
||||
var params string
|
||||
if len(keyFields) > 3 {
|
||||
paramParts := keyFields[3:]
|
||||
params = strings.TrimSpace(strings.Join(paramParts, ","))
|
||||
}
|
||||
|
||||
if comment != "" && strings.HasPrefix(comment, HideComment) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if comment == "" {
|
||||
comment = hyprlandAutogenerateComment(dispatcher, params)
|
||||
}
|
||||
|
||||
var modList []string
|
||||
if mods != "" {
|
||||
modstring := mods + string(ModSeparators[0])
|
||||
idx := 0
|
||||
for index, char := range modstring {
|
||||
isModSep := false
|
||||
for _, sep := range ModSeparators {
|
||||
if char == sep {
|
||||
isModSep = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isModSep {
|
||||
if index-idx > 1 {
|
||||
modList = append(modList, modstring[idx:index])
|
||||
}
|
||||
idx = index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &HyprlandKeyBinding{
|
||||
Mods: modList,
|
||||
Key: key,
|
||||
Dispatcher: dispatcher,
|
||||
Params: params,
|
||||
Comment: comment,
|
||||
}
|
||||
}
|
||||
|
||||
func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
|
||||
parser := NewHyprlandParser(path)
|
||||
section, err := parser.ParseWithDMS()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &HyprlandParseResult{
|
||||
Section: section,
|
||||
DMSBindsIncluded: parser.dmsBindsIncluded,
|
||||
DMSStatus: parser.buildDMSStatus(),
|
||||
ConflictingConfigs: parser.conflictingConfigs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestHyprlandGetKeybindAtLine(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := NewHyprlandParser()
|
||||
parser := NewHyprlandParser("")
|
||||
parser.contentLines = []string{tt.line}
|
||||
result := parser.getKeybindAtLine(0)
|
||||
|
||||
@@ -285,7 +285,7 @@ func TestHyprlandReadContentMultipleFiles(t *testing.T) {
|
||||
t.Fatalf("Failed to write file2: %v", err)
|
||||
}
|
||||
|
||||
parser := NewHyprlandParser()
|
||||
parser := NewHyprlandParser("")
|
||||
if err := parser.ReadContent(tmpDir); err != nil {
|
||||
t.Fatalf("ReadContent failed: %v", err)
|
||||
}
|
||||
@@ -343,7 +343,7 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) {
|
||||
t.Skip("Cannot create relative path")
|
||||
}
|
||||
|
||||
parser := NewHyprlandParser()
|
||||
parser := NewHyprlandParser("")
|
||||
tildePathMatch := "~/" + relPath
|
||||
err = parser.ReadContent(tildePathMatch)
|
||||
|
||||
@@ -353,7 +353,7 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHyprlandKeybindWithParamsContainingCommas(t *testing.T) {
|
||||
parser := NewHyprlandParser()
|
||||
parser := NewHyprlandParser("")
|
||||
parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"}
|
||||
|
||||
result := parser.getKeybindAtLine(0)
|
||||
|
||||
@@ -7,35 +7,30 @@ import (
|
||||
)
|
||||
|
||||
func TestNewHyprlandProvider(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configPath string
|
||||
wantPath string
|
||||
}{
|
||||
{
|
||||
name: "custom path",
|
||||
configPath: "/custom/path",
|
||||
wantPath: "/custom/path",
|
||||
},
|
||||
{
|
||||
name: "empty path defaults",
|
||||
configPath: "",
|
||||
wantPath: "$HOME/.config/hypr",
|
||||
},
|
||||
}
|
||||
t.Run("custom path", func(t *testing.T) {
|
||||
p := NewHyprlandProvider("/custom/path")
|
||||
if p == nil {
|
||||
t.Fatal("NewHyprlandProvider returned nil")
|
||||
}
|
||||
if p.configPath != "/custom/path" {
|
||||
t.Errorf("configPath = %q, want %q", p.configPath, "/custom/path")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := NewHyprlandProvider(tt.configPath)
|
||||
if p == nil {
|
||||
t.Fatal("NewHyprlandProvider returned nil")
|
||||
}
|
||||
|
||||
if p.configPath != tt.wantPath {
|
||||
t.Errorf("configPath = %q, want %q", p.configPath, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("empty path defaults", func(t *testing.T) {
|
||||
p := NewHyprlandProvider("")
|
||||
if p == nil {
|
||||
t.Fatal("NewHyprlandProvider returned nil")
|
||||
}
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserConfigDir failed: %v", err)
|
||||
}
|
||||
expected := filepath.Join(configDir, "hypr")
|
||||
if p.configPath != expected {
|
||||
t.Errorf("configPath = %q, want %q", p.configPath, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHyprlandProviderName(t *testing.T) {
|
||||
@@ -109,7 +104,7 @@ func TestHyprlandProviderGetCheatSheetError(t *testing.T) {
|
||||
|
||||
func TestFormatKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configFile := filepath.Join(tmpDir, "test.conf")
|
||||
configFile := filepath.Join(tmpDir, "hyprland.conf")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -163,7 +158,7 @@ func TestFormatKey(t *testing.T) {
|
||||
|
||||
func TestDescriptionFallback(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configFile := filepath.Join(tmpDir, "test.conf")
|
||||
configFile := filepath.Join(tmpDir, "hyprland.conf")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -2,46 +2,94 @@ package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
type MangoWCProvider struct {
|
||||
configPath string
|
||||
configPath string
|
||||
dmsBindsIncluded bool
|
||||
parsed bool
|
||||
}
|
||||
|
||||
func NewMangoWCProvider(configPath string) *MangoWCProvider {
|
||||
if configPath == "" {
|
||||
configPath = "$HOME/.config/mango"
|
||||
configPath = defaultMangoWCConfigDir()
|
||||
}
|
||||
return &MangoWCProvider{
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultMangoWCConfigDir() string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(configDir, "mango")
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) Name() string {
|
||||
return "mangowc"
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
||||
keybinds_list, err := ParseMangoWCKeys(m.configPath)
|
||||
result, err := ParseMangoWCKeysWithDMS(m.configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse mangowc config: %w", err)
|
||||
}
|
||||
|
||||
m.dmsBindsIncluded = result.DMSBindsIncluded
|
||||
m.parsed = true
|
||||
|
||||
categorizedBinds := make(map[string][]keybinds.Keybind)
|
||||
for _, kb := range keybinds_list {
|
||||
for _, kb := range result.Keybinds {
|
||||
category := m.categorizeByCommand(kb.Command)
|
||||
bind := m.convertKeybind(&kb)
|
||||
bind := m.convertKeybind(&kb, result.ConflictingConfigs)
|
||||
categorizedBinds[category] = append(categorizedBinds[category], bind)
|
||||
}
|
||||
|
||||
return &keybinds.CheatSheet{
|
||||
Title: "MangoWC Keybinds",
|
||||
Provider: m.Name(),
|
||||
Binds: categorizedBinds,
|
||||
}, nil
|
||||
sheet := &keybinds.CheatSheet{
|
||||
Title: "MangoWC Keybinds",
|
||||
Provider: m.Name(),
|
||||
Binds: categorizedBinds,
|
||||
DMSBindsIncluded: result.DMSBindsIncluded,
|
||||
}
|
||||
|
||||
if result.DMSStatus != nil {
|
||||
sheet.DMSStatus = &keybinds.DMSBindsStatus{
|
||||
Exists: result.DMSStatus.Exists,
|
||||
Included: result.DMSStatus.Included,
|
||||
IncludePosition: result.DMSStatus.IncludePosition,
|
||||
TotalIncludes: result.DMSStatus.TotalIncludes,
|
||||
BindsAfterDMS: result.DMSStatus.BindsAfterDMS,
|
||||
Effective: result.DMSStatus.Effective,
|
||||
OverriddenBy: result.DMSStatus.OverriddenBy,
|
||||
StatusMessage: result.DMSStatus.StatusMessage,
|
||||
}
|
||||
}
|
||||
|
||||
return sheet, nil
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) HasDMSBindsIncluded() bool {
|
||||
if m.parsed {
|
||||
return m.dmsBindsIncluded
|
||||
}
|
||||
|
||||
result, err := ParseMangoWCKeysWithDMS(m.configPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
m.dmsBindsIncluded = result.DMSBindsIncluded
|
||||
m.parsed = true
|
||||
return m.dmsBindsIncluded
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) categorizeByCommand(command string) string {
|
||||
@@ -82,8 +130,8 @@ func (m *MangoWCProvider) categorizeByCommand(command string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding) keybinds.Keybind {
|
||||
key := m.formatKey(kb)
|
||||
func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[string]*MangoWCKeyBinding) keybinds.Keybind {
|
||||
keyStr := m.formatKey(kb)
|
||||
rawAction := m.formatRawAction(kb.Command, kb.Params)
|
||||
desc := kb.Comment
|
||||
|
||||
@@ -91,11 +139,31 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding) keybinds.Keybind
|
||||
desc = rawAction
|
||||
}
|
||||
|
||||
return keybinds.Keybind{
|
||||
Key: key,
|
||||
source := "config"
|
||||
if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") {
|
||||
source = "dms"
|
||||
}
|
||||
|
||||
bind := keybinds.Keybind{
|
||||
Key: keyStr,
|
||||
Description: desc,
|
||||
Action: rawAction,
|
||||
Source: source,
|
||||
}
|
||||
|
||||
if source == "dms" && conflicts != nil {
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
if conflictKb, ok := conflicts[normalizedKey]; ok {
|
||||
bind.Conflict = &keybinds.Keybind{
|
||||
Key: keyStr,
|
||||
Description: conflictKb.Comment,
|
||||
Action: m.formatRawAction(conflictKb.Command, conflictKb.Params),
|
||||
Source: "config",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bind
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) formatRawAction(command, params string) string {
|
||||
@@ -111,3 +179,264 @@ func (m *MangoWCProvider) formatKey(kb *MangoWCKeyBinding) string {
|
||||
parts = append(parts, kb.Key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) GetOverridePath() string {
|
||||
expanded, err := utils.ExpandPath(m.configPath)
|
||||
if err != nil {
|
||||
return filepath.Join(m.configPath, "dms", "binds.conf")
|
||||
}
|
||||
return filepath.Join(expanded, "dms", "binds.conf")
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) validateAction(action string) error {
|
||||
action = strings.TrimSpace(action)
|
||||
switch {
|
||||
case action == "":
|
||||
return fmt.Errorf("action cannot be empty")
|
||||
case action == "spawn" || action == "spawn ":
|
||||
return fmt.Errorf("spawn command requires arguments")
|
||||
case action == "spawn_shell" || action == "spawn_shell ":
|
||||
return fmt.Errorf("spawn_shell command requires arguments")
|
||||
case strings.HasPrefix(action, "spawn "):
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn "))
|
||||
if rest == "" {
|
||||
return fmt.Errorf("spawn command requires arguments")
|
||||
}
|
||||
case strings.HasPrefix(action, "spawn_shell "):
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn_shell "))
|
||||
if rest == "" {
|
||||
return fmt.Errorf("spawn_shell command requires arguments")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) SetBind(key, action, description string, options map[string]any) error {
|
||||
if err := m.validateAction(action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
overridePath := m.GetOverridePath()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create dms directory: %w", err)
|
||||
}
|
||||
|
||||
existingBinds, err := m.loadOverrideBinds()
|
||||
if err != nil {
|
||||
existingBinds = make(map[string]*mangowcOverrideBind)
|
||||
}
|
||||
|
||||
normalizedKey := strings.ToLower(key)
|
||||
existingBinds[normalizedKey] = &mangowcOverrideBind{
|
||||
Key: key,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Options: options,
|
||||
}
|
||||
|
||||
return m.writeOverrideBinds(existingBinds)
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) RemoveBind(key string) error {
|
||||
existingBinds, err := m.loadOverrideBinds()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedKey := strings.ToLower(key)
|
||||
delete(existingBinds, normalizedKey)
|
||||
return m.writeOverrideBinds(existingBinds)
|
||||
}
|
||||
|
||||
type mangowcOverrideBind struct {
|
||||
Key string
|
||||
Action string
|
||||
Description string
|
||||
Options map[string]any
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) {
|
||||
overridePath := m.GetOverridePath()
|
||||
binds := make(map[string]*mangowcOverrideBind)
|
||||
|
||||
data, err := os.ReadFile(overridePath)
|
||||
if os.IsNotExist(err) {
|
||||
return binds, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(line, "bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(parts[1])
|
||||
commentParts := strings.SplitN(content, "#", 2)
|
||||
bindContent := strings.TrimSpace(commentParts[0])
|
||||
|
||||
var comment string
|
||||
if len(commentParts) > 1 {
|
||||
comment = strings.TrimSpace(commentParts[1])
|
||||
}
|
||||
|
||||
fields := strings.SplitN(bindContent, ",", 4)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
mods := strings.TrimSpace(fields[0])
|
||||
keyName := strings.TrimSpace(fields[1])
|
||||
command := strings.TrimSpace(fields[2])
|
||||
|
||||
var params string
|
||||
if len(fields) > 3 {
|
||||
params = strings.TrimSpace(fields[3])
|
||||
}
|
||||
|
||||
keyStr := m.buildKeyString(mods, keyName)
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
action := command
|
||||
if params != "" {
|
||||
action = command + " " + params
|
||||
}
|
||||
|
||||
binds[normalizedKey] = &mangowcOverrideBind{
|
||||
Key: keyStr,
|
||||
Action: action,
|
||||
Description: comment,
|
||||
}
|
||||
}
|
||||
|
||||
return binds, nil
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) buildKeyString(mods, key string) string {
|
||||
if mods == "" || strings.EqualFold(mods, "none") {
|
||||
return key
|
||||
}
|
||||
|
||||
modList := strings.FieldsFunc(mods, func(r rune) bool {
|
||||
return r == '+' || r == ' '
|
||||
})
|
||||
|
||||
parts := append(modList, key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) getBindSortPriority(action string) int {
|
||||
switch {
|
||||
case strings.HasPrefix(action, "spawn") && strings.Contains(action, "dms"):
|
||||
return 0
|
||||
case strings.Contains(action, "view") || strings.Contains(action, "tag"):
|
||||
return 1
|
||||
case strings.Contains(action, "focus") || strings.Contains(action, "exchange") ||
|
||||
strings.Contains(action, "resize") || strings.Contains(action, "move"):
|
||||
return 2
|
||||
case strings.Contains(action, "mon"):
|
||||
return 3
|
||||
case strings.HasPrefix(action, "spawn"):
|
||||
return 4
|
||||
case action == "quit" || action == "reload_config":
|
||||
return 5
|
||||
default:
|
||||
return 6
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error {
|
||||
overridePath := m.GetOverridePath()
|
||||
content := m.generateBindsContent(binds)
|
||||
return os.WriteFile(overridePath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string {
|
||||
if len(binds) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
bindList := make([]*mangowcOverrideBind, 0, len(binds))
|
||||
for _, bind := range binds {
|
||||
bindList = append(bindList, bind)
|
||||
}
|
||||
|
||||
sort.Slice(bindList, func(i, j int) bool {
|
||||
pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action)
|
||||
if pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
return bindList[i].Key < bindList[j].Key
|
||||
})
|
||||
|
||||
var sb strings.Builder
|
||||
for _, bind := range bindList {
|
||||
m.writeBindLine(&sb, bind)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) {
|
||||
mods, key := m.parseKeyString(bind.Key)
|
||||
command, params := m.parseAction(bind.Action)
|
||||
|
||||
sb.WriteString("bind=")
|
||||
if mods == "" {
|
||||
sb.WriteString("none")
|
||||
} else {
|
||||
sb.WriteString(mods)
|
||||
}
|
||||
sb.WriteString(",")
|
||||
sb.WriteString(key)
|
||||
sb.WriteString(",")
|
||||
sb.WriteString(command)
|
||||
|
||||
if params != "" {
|
||||
sb.WriteString(",")
|
||||
sb.WriteString(params)
|
||||
}
|
||||
|
||||
if bind.Description != "" {
|
||||
sb.WriteString(" # ")
|
||||
sb.WriteString(bind.Description)
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) {
|
||||
parts := strings.Split(keyStr, "+")
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return "", keyStr
|
||||
case 1:
|
||||
return "", parts[0]
|
||||
default:
|
||||
return strings.Join(parts[:len(parts)-1], "+"), parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) parseAction(action string) (command, params string) {
|
||||
parts := strings.SplitN(action, " ", 2)
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return action, ""
|
||||
case 1:
|
||||
return parts[0], ""
|
||||
default:
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,17 +21,40 @@ type MangoWCKeyBinding struct {
|
||||
Command string `json:"command"`
|
||||
Params string `json:"params"`
|
||||
Comment string `json:"comment"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type MangoWCParser struct {
|
||||
contentLines []string
|
||||
readingLine int
|
||||
contentLines []string
|
||||
readingLine int
|
||||
configDir string
|
||||
currentSource string
|
||||
dmsBindsExists bool
|
||||
dmsBindsIncluded bool
|
||||
includeCount int
|
||||
dmsIncludePos int
|
||||
bindsAfterDMS int
|
||||
dmsBindKeys map[string]bool
|
||||
configBindKeys map[string]bool
|
||||
conflictingConfigs map[string]*MangoWCKeyBinding
|
||||
bindMap map[string]*MangoWCKeyBinding
|
||||
bindOrder []string
|
||||
processedFiles map[string]bool
|
||||
dmsProcessed bool
|
||||
}
|
||||
|
||||
func NewMangoWCParser() *MangoWCParser {
|
||||
func NewMangoWCParser(configDir string) *MangoWCParser {
|
||||
return &MangoWCParser{
|
||||
contentLines: []string{},
|
||||
readingLine: 0,
|
||||
contentLines: []string{},
|
||||
readingLine: 0,
|
||||
configDir: configDir,
|
||||
dmsIncludePos: -1,
|
||||
dmsBindKeys: make(map[string]bool),
|
||||
configBindKeys: make(map[string]bool),
|
||||
conflictingConfigs: make(map[string]*MangoWCKeyBinding),
|
||||
bindMap: make(map[string]*MangoWCKeyBinding),
|
||||
bindOrder: []string{},
|
||||
processedFiles: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,9 +317,320 @@ func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
|
||||
}
|
||||
|
||||
func ParseMangoWCKeys(path string) ([]MangoWCKeyBinding, error) {
|
||||
parser := NewMangoWCParser()
|
||||
parser := NewMangoWCParser(path)
|
||||
if err := parser.ReadContent(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parser.ParseKeys(), nil
|
||||
}
|
||||
|
||||
type MangoWCParseResult struct {
|
||||
Keybinds []MangoWCKeyBinding
|
||||
DMSBindsIncluded bool
|
||||
DMSStatus *MangoWCDMSStatus
|
||||
ConflictingConfigs map[string]*MangoWCKeyBinding
|
||||
}
|
||||
|
||||
type MangoWCDMSStatus struct {
|
||||
Exists bool
|
||||
Included bool
|
||||
IncludePosition int
|
||||
TotalIncludes int
|
||||
BindsAfterDMS int
|
||||
Effective bool
|
||||
OverriddenBy int
|
||||
StatusMessage string
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) buildDMSStatus() *MangoWCDMSStatus {
|
||||
status := &MangoWCDMSStatus{
|
||||
Exists: p.dmsBindsExists,
|
||||
Included: p.dmsBindsIncluded,
|
||||
IncludePosition: p.dmsIncludePos,
|
||||
TotalIncludes: p.includeCount,
|
||||
BindsAfterDMS: p.bindsAfterDMS,
|
||||
}
|
||||
|
||||
switch {
|
||||
case !p.dmsBindsExists:
|
||||
status.Effective = false
|
||||
status.StatusMessage = "dms/binds.conf does not exist"
|
||||
case !p.dmsBindsIncluded:
|
||||
status.Effective = false
|
||||
status.StatusMessage = "dms/binds.conf is not sourced in config"
|
||||
case p.bindsAfterDMS > 0:
|
||||
status.Effective = true
|
||||
status.OverriddenBy = p.bindsAfterDMS
|
||||
status.StatusMessage = "Some DMS binds may be overridden by config binds"
|
||||
default:
|
||||
status.Effective = true
|
||||
status.StatusMessage = "DMS binds are active"
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) formatBindKey(kb *MangoWCKeyBinding) string {
|
||||
parts := make([]string, 0, len(kb.Mods)+1)
|
||||
parts = append(parts, kb.Mods...)
|
||||
parts = append(parts, kb.Key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) normalizeKey(key string) string {
|
||||
return strings.ToLower(key)
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) addBind(kb *MangoWCKeyBinding) {
|
||||
key := p.formatBindKey(kb)
|
||||
normalizedKey := p.normalizeKey(key)
|
||||
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(os.PathSeparator)+"binds.conf")
|
||||
|
||||
if isDMSBind {
|
||||
p.dmsBindKeys[normalizedKey] = true
|
||||
} else if p.dmsBindKeys[normalizedKey] {
|
||||
p.bindsAfterDMS++
|
||||
p.conflictingConfigs[normalizedKey] = kb
|
||||
p.configBindKeys[normalizedKey] = true
|
||||
return
|
||||
} else {
|
||||
p.configBindKeys[normalizedKey] = true
|
||||
}
|
||||
|
||||
if _, exists := p.bindMap[normalizedKey]; !exists {
|
||||
p.bindOrder = append(p.bindOrder, key)
|
||||
}
|
||||
p.bindMap[normalizedKey] = kb
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) ParseWithDMS() ([]MangoWCKeyBinding, error) {
|
||||
expandedDir, err := utils.ExpandPath(p.configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
|
||||
if _, err := os.Stat(dmsBindsPath); err == nil {
|
||||
p.dmsBindsExists = true
|
||||
}
|
||||
|
||||
mainConfig := filepath.Join(expandedDir, "config.conf")
|
||||
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
|
||||
mainConfig = filepath.Join(expandedDir, "mango.conf")
|
||||
}
|
||||
|
||||
_, err = p.parseFileWithSource(mainConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.dmsBindsExists && !p.dmsProcessed {
|
||||
p.parseDMSBindsDirectly(dmsBindsPath)
|
||||
}
|
||||
|
||||
var keybinds []MangoWCKeyBinding
|
||||
for _, key := range p.bindOrder {
|
||||
normalizedKey := p.normalizeKey(key)
|
||||
if kb, exists := p.bindMap[normalizedKey]; exists {
|
||||
keybinds = append(keybinds, *kb)
|
||||
}
|
||||
}
|
||||
|
||||
return keybinds, nil
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBinding, error) {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.processedFiles[absPath] {
|
||||
return nil, nil
|
||||
}
|
||||
p.processedFiles[absPath] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prevSource := p.currentSource
|
||||
p.currentSource = absPath
|
||||
|
||||
var keybinds []MangoWCKeyBinding
|
||||
lines := strings.Split(string(data), "\n")
|
||||
|
||||
for lineNum, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(trimmed, "source") {
|
||||
p.handleSource(trimmed, filepath.Dir(absPath), &keybinds)
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(trimmed, "bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
kb := p.getKeybindAtLineContent(line, lineNum)
|
||||
if kb == nil {
|
||||
continue
|
||||
}
|
||||
kb.Source = p.currentSource
|
||||
p.addBind(kb)
|
||||
keybinds = append(keybinds, *kb)
|
||||
}
|
||||
|
||||
p.currentSource = prevSource
|
||||
return keybinds, nil
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) handleSource(line, baseDir string, keybinds *[]MangoWCKeyBinding) {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
sourcePath := strings.TrimSpace(parts[1])
|
||||
isDMSSource := sourcePath == "dms/binds.conf" || sourcePath == "./dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
|
||||
|
||||
p.includeCount++
|
||||
if isDMSSource {
|
||||
p.dmsBindsIncluded = true
|
||||
p.dmsIncludePos = p.includeCount
|
||||
p.dmsProcessed = true
|
||||
}
|
||||
|
||||
fullPath := sourcePath
|
||||
if !filepath.IsAbs(sourcePath) {
|
||||
fullPath = filepath.Join(baseDir, sourcePath)
|
||||
}
|
||||
|
||||
expanded, err := utils.ExpandPath(fullPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
includedBinds, err := p.parseFileWithSource(expanded)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
*keybinds = append(*keybinds, includedBinds...)
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyBinding {
|
||||
data, err := os.ReadFile(dmsBindsPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
prevSource := p.currentSource
|
||||
p.currentSource = dmsBindsPath
|
||||
|
||||
var keybinds []MangoWCKeyBinding
|
||||
lines := strings.Split(string(data), "\n")
|
||||
|
||||
for lineNum, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
kb := p.getKeybindAtLineContent(line, lineNum)
|
||||
if kb == nil {
|
||||
continue
|
||||
}
|
||||
kb.Source = dmsBindsPath
|
||||
p.addBind(kb)
|
||||
keybinds = append(keybinds, *kb)
|
||||
}
|
||||
|
||||
p.currentSource = prevSource
|
||||
p.dmsProcessed = true
|
||||
return keybinds
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding {
|
||||
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
|
||||
matches := bindMatch.FindStringSubmatch(line)
|
||||
if len(matches) < 3 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content := matches[2]
|
||||
parts := strings.SplitN(content, "#", 2)
|
||||
keys := parts[0]
|
||||
|
||||
var comment string
|
||||
if len(parts) > 1 {
|
||||
comment = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
if strings.HasPrefix(comment, MangoWCHideComment) {
|
||||
return nil
|
||||
}
|
||||
|
||||
keyFields := strings.SplitN(keys, ",", 4)
|
||||
if len(keyFields) < 3 {
|
||||
return nil
|
||||
}
|
||||
|
||||
mods := strings.TrimSpace(keyFields[0])
|
||||
key := strings.TrimSpace(keyFields[1])
|
||||
command := strings.TrimSpace(keyFields[2])
|
||||
|
||||
var params string
|
||||
if len(keyFields) > 3 {
|
||||
params = strings.TrimSpace(keyFields[3])
|
||||
}
|
||||
|
||||
if comment == "" {
|
||||
comment = mangowcAutogenerateComment(command, params)
|
||||
}
|
||||
|
||||
var modList []string
|
||||
if mods != "" && !strings.EqualFold(mods, "none") {
|
||||
modstring := mods + string(MangoWCModSeparators[0])
|
||||
idx := 0
|
||||
for index, char := range modstring {
|
||||
isModSep := false
|
||||
for _, sep := range MangoWCModSeparators {
|
||||
if char == sep {
|
||||
isModSep = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isModSep {
|
||||
if index-idx > 1 {
|
||||
modList = append(modList, modstring[idx:index])
|
||||
}
|
||||
idx = index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &MangoWCKeyBinding{
|
||||
Mods: modList,
|
||||
Key: key,
|
||||
Command: command,
|
||||
Params: params,
|
||||
Comment: comment,
|
||||
}
|
||||
}
|
||||
|
||||
func ParseMangoWCKeysWithDMS(path string) (*MangoWCParseResult, error) {
|
||||
parser := NewMangoWCParser(path)
|
||||
keybinds, err := parser.ParseWithDMS()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MangoWCParseResult{
|
||||
Keybinds: keybinds,
|
||||
DMSBindsIncluded: parser.dmsBindsIncluded,
|
||||
DMSStatus: parser.buildDMSStatus(),
|
||||
ConflictingConfigs: parser.conflictingConfigs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := NewMangoWCParser()
|
||||
parser := NewMangoWCParser("")
|
||||
parser.contentLines = []string{tt.line}
|
||||
result := parser.getKeybindAtLine(0)
|
||||
|
||||
@@ -283,7 +283,7 @@ func TestMangoWCReadContentMultipleFiles(t *testing.T) {
|
||||
t.Fatalf("Failed to write file2: %v", err)
|
||||
}
|
||||
|
||||
parser := NewMangoWCParser()
|
||||
parser := NewMangoWCParser("")
|
||||
if err := parser.ReadContent(tmpDir); err != nil {
|
||||
t.Fatalf("ReadContent failed: %v", err)
|
||||
}
|
||||
@@ -304,7 +304,7 @@ func TestMangoWCReadContentSingleFile(t *testing.T) {
|
||||
t.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
parser := NewMangoWCParser()
|
||||
parser := NewMangoWCParser("")
|
||||
if err := parser.ReadContent(configFile); err != nil {
|
||||
t.Fatalf("ReadContent failed: %v", err)
|
||||
}
|
||||
@@ -362,7 +362,7 @@ func TestMangoWCReadContentWithTildeExpansion(t *testing.T) {
|
||||
t.Skip("Cannot create relative path")
|
||||
}
|
||||
|
||||
parser := NewMangoWCParser()
|
||||
parser := NewMangoWCParser("")
|
||||
tildePathMatch := "~/" + relPath
|
||||
err = parser.ReadContent(tildePathMatch)
|
||||
|
||||
@@ -419,7 +419,7 @@ func TestMangoWCInvalidBindLines(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := NewMangoWCParser()
|
||||
parser := NewMangoWCParser("")
|
||||
parser.contentLines = []string{tt.line}
|
||||
result := parser.getKeybindAtLine(0)
|
||||
|
||||
|
||||
@@ -15,8 +15,17 @@ func TestMangoWCProviderName(t *testing.T) {
|
||||
|
||||
func TestMangoWCProviderDefaultPath(t *testing.T) {
|
||||
provider := NewMangoWCProvider("")
|
||||
if provider.configPath != "$HOME/.config/mango" {
|
||||
t.Errorf("configPath = %q, want %q", provider.configPath, "$HOME/.config/mango")
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
// Fall back to testing for non-empty path
|
||||
if provider.configPath == "" {
|
||||
t.Error("configPath should not be empty")
|
||||
}
|
||||
return
|
||||
}
|
||||
expected := filepath.Join(configDir, "mango")
|
||||
if provider.configPath != expected {
|
||||
t.Errorf("configPath = %q, want %q", provider.configPath, expected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +183,7 @@ func TestMangoWCConvertKeybind(t *testing.T) {
|
||||
provider := NewMangoWCProvider("")
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := provider.convertKeybind(tt.keybind)
|
||||
result := provider.convertKeybind(tt.keybind, nil)
|
||||
if result.Key != tt.wantKey {
|
||||
t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user