mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-24 21:42:51 -05:00
628 lines
15 KiB
Go
628 lines
15 KiB
Go
package providers
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
|
)
|
|
|
|
const (
|
|
TitleRegex = "#+!"
|
|
HideComment = "[hidden]"
|
|
CommentBindPattern = "#/#"
|
|
)
|
|
|
|
var ModSeparators = []rune{'+', ' '}
|
|
|
|
type HyprlandKeyBinding struct {
|
|
Mods []string `json:"mods"`
|
|
Key string `json:"key"`
|
|
Dispatcher string `json:"dispatcher"`
|
|
Params string `json:"params"`
|
|
Comment string `json:"comment"`
|
|
Source string `json:"source"`
|
|
Flags string `json:"flags"` // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
|
|
}
|
|
|
|
type HyprlandSection struct {
|
|
Children []HyprlandSection `json:"children"`
|
|
Keybinds []HyprlandKeyBinding `json:"keybinds"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type HyprlandParser struct {
|
|
contentLines []string
|
|
readingLine int
|
|
configDir string
|
|
currentSource string
|
|
dmsBindsExists bool
|
|
dmsBindsIncluded bool
|
|
includeCount int
|
|
dmsIncludePos int
|
|
bindsAfterDMS int
|
|
dmsBindKeys map[string]bool
|
|
configBindKeys map[string]bool
|
|
conflictingConfigs map[string]*HyprlandKeyBinding
|
|
bindMap map[string]*HyprlandKeyBinding
|
|
bindOrder []string
|
|
processedFiles map[string]bool
|
|
dmsProcessed bool
|
|
}
|
|
|
|
func NewHyprlandParser(configDir string) *HyprlandParser {
|
|
return &HyprlandParser{
|
|
contentLines: []string{},
|
|
readingLine: 0,
|
|
configDir: configDir,
|
|
dmsIncludePos: -1,
|
|
dmsBindKeys: make(map[string]bool),
|
|
configBindKeys: make(map[string]bool),
|
|
conflictingConfigs: make(map[string]*HyprlandKeyBinding),
|
|
bindMap: make(map[string]*HyprlandKeyBinding),
|
|
bindOrder: []string{},
|
|
processedFiles: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
func (p *HyprlandParser) ReadContent(directory string) error {
|
|
expandedDir, err := utils.ExpandPath(directory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
info, err := os.Stat(expandedDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
return os.ErrNotExist
|
|
}
|
|
|
|
confFiles, err := filepath.Glob(filepath.Join(expandedDir, "*.conf"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(confFiles) == 0 {
|
|
return os.ErrNotExist
|
|
}
|
|
|
|
var combinedContent []string
|
|
for _, confFile := range confFiles {
|
|
if fileInfo, err := os.Stat(confFile); err == nil && fileInfo.Mode().IsRegular() {
|
|
data, err := os.ReadFile(confFile)
|
|
if err == nil {
|
|
combinedContent = append(combinedContent, string(data))
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(combinedContent) == 0 {
|
|
return os.ErrNotExist
|
|
}
|
|
|
|
fullContent := strings.Join(combinedContent, "\n")
|
|
p.contentLines = strings.Split(fullContent, "\n")
|
|
return nil
|
|
}
|
|
|
|
func hyprlandAutogenerateComment(dispatcher, params string) string {
|
|
switch dispatcher {
|
|
case "resizewindow":
|
|
return "Resize window"
|
|
|
|
case "movewindow":
|
|
if params == "" {
|
|
return "Move window"
|
|
}
|
|
dirMap := map[string]string{
|
|
"l": "left",
|
|
"r": "right",
|
|
"u": "up",
|
|
"d": "down",
|
|
}
|
|
if dir, ok := dirMap[params]; ok {
|
|
return "move in " + dir + " direction"
|
|
}
|
|
return "move in null direction"
|
|
|
|
case "pin":
|
|
return "pin (show on all workspaces)"
|
|
|
|
case "splitratio":
|
|
return "Window split ratio " + params
|
|
|
|
case "togglefloating":
|
|
return "Float/unfloat window"
|
|
|
|
case "resizeactive":
|
|
return "Resize window by " + params
|
|
|
|
case "killactive":
|
|
return "Close window"
|
|
|
|
case "fullscreen":
|
|
fsMap := map[string]string{
|
|
"0": "fullscreen",
|
|
"1": "maximization",
|
|
"2": "fullscreen on Hyprland's side",
|
|
}
|
|
if fs, ok := fsMap[params]; ok {
|
|
return "Toggle " + fs
|
|
}
|
|
return "Toggle null"
|
|
|
|
case "fakefullscreen":
|
|
return "Toggle fake fullscreen"
|
|
|
|
case "workspace":
|
|
switch params {
|
|
case "+1":
|
|
return "focus right"
|
|
case "-1":
|
|
return "focus left"
|
|
}
|
|
return "focus workspace " + params
|
|
case "movefocus":
|
|
dirMap := map[string]string{
|
|
"l": "left",
|
|
"r": "right",
|
|
"u": "up",
|
|
"d": "down",
|
|
}
|
|
if dir, ok := dirMap[params]; ok {
|
|
return "move focus " + dir
|
|
}
|
|
return "move focus null"
|
|
|
|
case "swapwindow":
|
|
dirMap := map[string]string{
|
|
"l": "left",
|
|
"r": "right",
|
|
"u": "up",
|
|
"d": "down",
|
|
}
|
|
if dir, ok := dirMap[params]; ok {
|
|
return "swap in " + dir + " direction"
|
|
}
|
|
return "swap in null direction"
|
|
|
|
case "movetoworkspace":
|
|
switch params {
|
|
case "+1":
|
|
return "move to right workspace (non-silent)"
|
|
case "-1":
|
|
return "move to left workspace (non-silent)"
|
|
}
|
|
return "move to workspace " + params + " (non-silent)"
|
|
case "movetoworkspacesilent":
|
|
switch params {
|
|
case "+1":
|
|
return "move to right workspace"
|
|
case "-1":
|
|
return "move to right workspace"
|
|
}
|
|
return "move to workspace " + params
|
|
|
|
case "togglespecialworkspace":
|
|
return "toggle special"
|
|
|
|
case "exec":
|
|
return params
|
|
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding {
|
|
line := p.contentLines[lineNumber]
|
|
return p.parseBindLine(line)
|
|
}
|
|
|
|
func (p *HyprlandParser) getBindsRecursive(currentContent *HyprlandSection, scope int) *HyprlandSection {
|
|
titleRegex := regexp.MustCompile(TitleRegex)
|
|
|
|
for p.readingLine < len(p.contentLines) {
|
|
line := p.contentLines[p.readingLine]
|
|
|
|
loc := titleRegex.FindStringIndex(line)
|
|
if loc != nil && loc[0] == 0 {
|
|
headingScope := strings.Index(line, "!")
|
|
|
|
if headingScope <= scope {
|
|
p.readingLine--
|
|
return currentContent
|
|
}
|
|
|
|
sectionName := strings.TrimSpace(line[headingScope+1:])
|
|
p.readingLine++
|
|
|
|
childSection := &HyprlandSection{
|
|
Children: []HyprlandSection{},
|
|
Keybinds: []HyprlandKeyBinding{},
|
|
Name: sectionName,
|
|
}
|
|
result := p.getBindsRecursive(childSection, headingScope)
|
|
currentContent.Children = append(currentContent.Children, *result)
|
|
|
|
} else if strings.HasPrefix(line, CommentBindPattern) {
|
|
keybind := p.getKeybindAtLine(p.readingLine)
|
|
if keybind != nil {
|
|
currentContent.Keybinds = append(currentContent.Keybinds, *keybind)
|
|
}
|
|
|
|
} else if line == "" || !strings.HasPrefix(strings.TrimSpace(line), "bind") {
|
|
|
|
} else {
|
|
keybind := p.getKeybindAtLine(p.readingLine)
|
|
if keybind != nil {
|
|
currentContent.Keybinds = append(currentContent.Keybinds, *keybind)
|
|
}
|
|
}
|
|
|
|
p.readingLine++
|
|
}
|
|
|
|
return currentContent
|
|
}
|
|
|
|
func (p *HyprlandParser) ParseKeys() *HyprlandSection {
|
|
p.readingLine = 0
|
|
rootSection := &HyprlandSection{
|
|
Children: []HyprlandSection{},
|
|
Keybinds: []HyprlandKeyBinding{},
|
|
Name: "",
|
|
}
|
|
return p.getBindsRecursive(rootSection, 0)
|
|
}
|
|
|
|
func ParseHyprlandKeys(path string) (*HyprlandSection, error) {
|
|
parser := NewHyprlandParser(path)
|
|
if err := parser.ReadContent(path); err != nil {
|
|
return nil, err
|
|
}
|
|
return parser.ParseKeys(), nil
|
|
}
|
|
|
|
type HyprlandParseResult struct {
|
|
Section *HyprlandSection
|
|
DMSBindsIncluded bool
|
|
DMSStatus *HyprlandDMSStatus
|
|
ConflictingConfigs map[string]*HyprlandKeyBinding
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Extract bind type and flags from the left side of "="
|
|
bindType := strings.TrimSpace(parts[0])
|
|
flags := extractBindFlags(bindType)
|
|
hasDescFlag := strings.Contains(flags, "d")
|
|
|
|
keys := parts[1]
|
|
keyParts := strings.SplitN(keys, "#", 2)
|
|
keys = keyParts[0]
|
|
|
|
var comment string
|
|
if len(keyParts) > 1 {
|
|
comment = strings.TrimSpace(keyParts[1])
|
|
}
|
|
|
|
// For bindd, the format is: bindd = MODS, key, description, dispatcher, params
|
|
// For regular binds: bind = MODS, key, dispatcher, params
|
|
var minFields, descIndex, dispatcherIndex int
|
|
if hasDescFlag {
|
|
minFields = 4 // mods, key, description, dispatcher
|
|
descIndex = 2
|
|
dispatcherIndex = 3
|
|
} else {
|
|
minFields = 3 // mods, key, dispatcher
|
|
dispatcherIndex = 2
|
|
}
|
|
|
|
keyFields := strings.SplitN(keys, ",", minFields+2) // Allow for params
|
|
if len(keyFields) < minFields {
|
|
return nil
|
|
}
|
|
|
|
mods := strings.TrimSpace(keyFields[0])
|
|
key := strings.TrimSpace(keyFields[1])
|
|
|
|
var dispatcher, params string
|
|
if hasDescFlag {
|
|
// bindd format: description is in the bind itself
|
|
if comment == "" {
|
|
comment = strings.TrimSpace(keyFields[descIndex])
|
|
}
|
|
dispatcher = strings.TrimSpace(keyFields[dispatcherIndex])
|
|
if len(keyFields) > dispatcherIndex+1 {
|
|
paramParts := keyFields[dispatcherIndex+1:]
|
|
params = strings.TrimSpace(strings.Join(paramParts, ","))
|
|
}
|
|
} else {
|
|
dispatcher = strings.TrimSpace(keyFields[dispatcherIndex])
|
|
if len(keyFields) > dispatcherIndex+1 {
|
|
paramParts := keyFields[dispatcherIndex+1:]
|
|
params = strings.TrimSpace(strings.Join(paramParts, ","))
|
|
}
|
|
}
|
|
|
|
if comment != "" && strings.HasPrefix(comment, HideComment) {
|
|
return nil
|
|
}
|
|
|
|
if comment == "" {
|
|
comment = hyprlandAutogenerateComment(dispatcher, params)
|
|
}
|
|
|
|
var modList []string
|
|
if mods != "" {
|
|
modstring := mods + string(ModSeparators[0])
|
|
idx := 0
|
|
for index, char := range modstring {
|
|
isModSep := false
|
|
for _, sep := range ModSeparators {
|
|
if char == sep {
|
|
isModSep = true
|
|
break
|
|
}
|
|
}
|
|
if isModSep {
|
|
if index-idx > 1 {
|
|
modList = append(modList, modstring[idx:index])
|
|
}
|
|
idx = index + 1
|
|
}
|
|
}
|
|
}
|
|
|
|
return &HyprlandKeyBinding{
|
|
Mods: modList,
|
|
Key: key,
|
|
Dispatcher: dispatcher,
|
|
Params: params,
|
|
Comment: comment,
|
|
Flags: flags,
|
|
}
|
|
}
|
|
|
|
// extractBindFlags extracts the flags from a bind type string
|
|
// e.g., "binde" -> "e", "bindel" -> "el", "bindd" -> "d"
|
|
func extractBindFlags(bindType string) string {
|
|
bindType = strings.TrimSpace(bindType)
|
|
if !strings.HasPrefix(bindType, "bind") {
|
|
return ""
|
|
}
|
|
return bindType[4:] // Everything after "bind"
|
|
}
|
|
|
|
func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
|
|
parser := NewHyprlandParser(path)
|
|
section, err := parser.ParseWithDMS()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &HyprlandParseResult{
|
|
Section: section,
|
|
DMSBindsIncluded: parser.dmsBindsIncluded,
|
|
DMSStatus: parser.buildDMSStatus(),
|
|
ConflictingConfigs: parser.conflictingConfigs,
|
|
}, nil
|
|
}
|