1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-08 04:09:15 -04:00
Files
DankMaterialShell/core/internal/windowrules/providers/hyprland_parser.go
T
purian23 0b55bf5dac 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
2026-05-18 13:06:58 -04:00

1366 lines
33 KiB
Go

package providers
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
type HyprlandWindowRule struct {
MatchClass string
MatchTitle string
MatchXWayland *bool
MatchFloating *bool
MatchFullscreen *bool
MatchPinned *bool
MatchInitialised *bool
Rule string
Value string
Source string
RawLine string
// CombinedActions is populated from single hl.window_rule({ … }) Lua calls where
// multiple actions apply together. When non-nil it takes precedence over Rule/Value
// in ConvertHyprlandRulesToWindowRules.
CombinedActions *windowrules.Actions `json:"-"`
}
type HyprlandRulesParser struct {
configDir string
processedFiles map[string]bool
rules []HyprlandWindowRule
currentSource string
dmsRulesExists bool
dmsPrimaryPath string // dms/windowrules.lua preferred, else dms/windowrules.conf when present
dmsRulesIncluded bool
includeCount int
dmsIncludePos int
rulesAfterDMS int
dmsProcessed bool
requireLineInMain int // hyprland.lua line (1-based) where require("dms.windowrules") occurs; else -1
primaryHyprLua string // absolute path to ~/.config/hypr/hyprland.lua when that is the main config
}
func NewHyprlandRulesParser(configDir string) *HyprlandRulesParser {
return &HyprlandRulesParser{
configDir: configDir,
processedFiles: make(map[string]bool),
rules: []HyprlandWindowRule{},
dmsIncludePos: -1,
requireLineInMain: -1,
}
}
func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) {
expandedDir, err := utils.ExpandPath(p.configDir)
if err != nil {
return nil, err
}
dmsLua := filepath.Join(expandedDir, "dms", "windowrules.lua")
dmsConf := filepath.Join(expandedDir, "dms", "windowrules.conf")
if _, err := os.Stat(dmsLua); err == nil {
p.dmsRulesExists = true
p.dmsPrimaryPath = dmsLua
} else if _, err := os.Stat(dmsConf); err == nil {
p.dmsRulesExists = true
p.dmsPrimaryPath = dmsConf
}
mainConfig, err := hyprlandMainConfigPath(expandedDir)
if err != nil {
return nil, err
}
if strings.EqualFold(filepath.Ext(mainConfig), ".lua") {
p.probeRequireWindowrulesLine(mainConfig)
if ap, err := filepath.Abs(mainConfig); err == nil {
p.primaryHyprLua = ap
}
}
if err := p.parseFile(mainConfig); err != nil {
return nil, err
}
if p.dmsRulesExists && !p.dmsProcessed {
p.parseDMSRulesDirectly(p.dmsPrimaryPath)
}
return p.rules, nil
}
func (p *HyprlandRulesParser) parseDMSRulesDirectly(dmsRulesPath string) {
data, err := os.ReadFile(dmsRulesPath)
if err != nil {
return
}
abs, err := filepath.Abs(dmsRulesPath)
if err != nil {
abs = dmsRulesPath
}
prevSource := p.currentSource
p.currentSource = abs
if strings.EqualFold(filepath.Ext(abs), ".lua") {
p.parseLuaWindowRules(string(data), filepath.Dir(abs), abs, false)
} else {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
p.parseLine(line)
}
}
p.currentSource = prevSource
p.dmsProcessed = true
}
func (p *HyprlandRulesParser) parseFile(filePath string) error {
absPath, err := filepath.Abs(filePath)
if err != nil {
return err
}
if p.processedFiles[absPath] {
return nil
}
p.processedFiles[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return nil
}
if strings.EqualFold(filepath.Ext(absPath), ".lua") {
p.parseLuaWindowRules(string(data), filepath.Dir(absPath), absPath, true)
return nil
}
prevSource := p.currentSource
p.currentSource = absPath
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "source") {
p.handleSource(trimmed, filepath.Dir(absPath))
continue
}
p.parseLine(line)
}
p.currentSource = prevSource
return nil
}
func (p *HyprlandRulesParser) handleSource(line string, baseDir string) {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return
}
sourcePath := strings.TrimSpace(parts[1])
isDMSSource := isDMSWindowRulesSourcePath(sourcePath)
p.includeCount++
if isDMSSource {
p.dmsRulesIncluded = 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
}
_ = p.parseFile(expanded)
}
func (p *HyprlandRulesParser) parseLine(line string) {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "windowrule") {
rule := p.parseWindowRuleLine(trimmed)
if rule != nil {
rule.Source = p.currentSource
p.rules = append(p.rules, *rule)
}
}
}
var windowRuleV2Regex = regexp.MustCompile(`^windowrulev?2?\s*=\s*(.+)$`)
func (p *HyprlandRulesParser) parseWindowRuleLine(line string) *HyprlandWindowRule {
matches := windowRuleV2Regex.FindStringSubmatch(line)
if len(matches) < 2 {
return nil
}
content := strings.TrimSpace(matches[1])
isV2 := strings.HasPrefix(line, "windowrulev2")
rule := &HyprlandWindowRule{
RawLine: line,
}
if isV2 {
p.parseWindowRuleV2(content, rule)
} else {
p.parseWindowRuleV1(content, rule)
}
return rule
}
func (p *HyprlandRulesParser) parseWindowRuleV1(content string, rule *HyprlandWindowRule) {
parts := strings.SplitN(content, ",", 2)
if len(parts) < 2 {
return
}
rule.Rule = strings.TrimSpace(parts[0])
rule.MatchClass = strings.TrimSpace(parts[1])
}
func (p *HyprlandRulesParser) parseWindowRuleV2(content string, rule *HyprlandWindowRule) {
parts := strings.SplitN(content, ",", 2)
if len(parts) < 2 {
return
}
ruleAndValue := strings.TrimSpace(parts[0])
matchPart := strings.TrimSpace(parts[1])
if idx := strings.Index(ruleAndValue, " "); idx > 0 {
rule.Rule = ruleAndValue[:idx]
rule.Value = strings.TrimSpace(ruleAndValue[idx+1:])
} else {
rule.Rule = ruleAndValue
}
matchPairs := strings.Split(matchPart, ",")
for _, pair := range matchPairs {
pair = strings.TrimSpace(pair)
if colonIdx := strings.Index(pair, ":"); colonIdx > 0 {
key := strings.TrimSpace(pair[:colonIdx])
value := strings.TrimSpace(pair[colonIdx+1:])
switch key {
case "class":
rule.MatchClass = value
case "title":
rule.MatchTitle = value
case "xwayland":
b := value == "1" || value == "true"
rule.MatchXWayland = &b
case "floating":
b := value == "1" || value == "true"
rule.MatchFloating = &b
case "fullscreen":
b := value == "1" || value == "true"
rule.MatchFullscreen = &b
case "pinned":
b := value == "1" || value == "true"
rule.MatchPinned = &b
case "initialised", "initialized":
b := value == "1" || value == "true"
rule.MatchInitialised = &b
}
}
}
}
func (p *HyprlandRulesParser) HasDMSRulesIncluded() bool {
return p.dmsRulesIncluded
}
func (p *HyprlandRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus {
status := &windowrules.DMSRulesStatus{
Exists: p.dmsRulesExists,
Included: p.dmsRulesIncluded,
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
RulesAfterDMS: p.rulesAfterDMS,
}
switch {
case !p.dmsRulesExists:
status.Effective = false
status.StatusMessage = "dms window rules fragment (windowrules.lua / windowrules.conf) does not exist"
case !p.dmsRulesIncluded:
status.Effective = false
status.StatusMessage = "dms window rules are not loaded (missing require/source for dms/windowrules)"
case p.rulesAfterDMS > 0:
status.Effective = true
status.OverriddenBy = p.rulesAfterDMS
status.StatusMessage = "Some DMS rules may be overridden by config rules"
default:
status.Effective = true
status.StatusMessage = "DMS window rules are active"
}
return status
}
type HyprlandRulesParseResult struct {
Rules []HyprlandWindowRule
DMSRulesIncluded bool
DMSStatus *windowrules.DMSRulesStatus
}
func ParseHyprlandWindowRules(configDir string) (*HyprlandRulesParseResult, error) {
parser := NewHyprlandRulesParser(configDir)
rules, err := parser.Parse()
if err != nil {
return nil, err
}
return &HyprlandRulesParseResult{
Rules: rules,
DMSRulesIncluded: parser.HasDMSRulesIncluded(),
DMSStatus: parser.buildDMSStatus(),
}, nil
}
func applyHyprlandRuleAction(actions *windowrules.Actions, rule, value string) {
t := true
switch rule {
case "float":
actions.OpenFloating = &t
case "tile":
actions.Tile = &t
case "fullscreen":
actions.OpenFullscreen = &t
case "maximize":
actions.OpenMaximized = &t
case "nofocus":
actions.NoFocus = &t
case "noborder":
actions.NoBorder = &t
case "noshadow":
actions.NoShadow = &t
case "nodim":
actions.NoDim = &t
case "noblur":
actions.NoBlur = &t
case "noanim":
actions.NoAnim = &t
case "norounding":
actions.NoRounding = &t
case "pin":
actions.Pin = &t
case "opaque":
actions.Opaque = &t
case "forcergbx":
actions.ForcergbX = &t
case "opacity":
if f, err := strconv.ParseFloat(value, 64); err == nil {
actions.Opacity = &f
}
case "size":
actions.Size = value
case "move":
actions.Move = value
case "monitor":
actions.Monitor = value
case "workspace":
actions.Workspace = value
case "idleinhibit":
actions.Idleinhibit = value
case "rounding":
if i, err := strconv.Atoi(value); err == nil {
actions.CornerRadius = &i
}
}
}
func ConvertHyprlandRulesToWindowRules(hyprRules []HyprlandWindowRule) []windowrules.WindowRule {
result := make([]windowrules.WindowRule, 0, len(hyprRules))
for i, hr := range hyprRules {
wr := windowrules.WindowRule{
ID: strconv.Itoa(i),
Enabled: true,
Source: hr.Source,
MatchCriteria: windowrules.MatchCriteria{
AppID: hr.MatchClass,
Title: hr.MatchTitle,
XWayland: hr.MatchXWayland,
IsFloating: hr.MatchFloating,
Fullscreen: hr.MatchFullscreen,
Pinned: hr.MatchPinned,
Initialised: hr.MatchInitialised,
},
}
if hr.CombinedActions != nil {
wr.Actions = *hr.CombinedActions
} else {
applyHyprlandRuleAction(&wr.Actions, hr.Rule, hr.Value)
}
result = append(result, wr)
}
return result
}
type HyprlandWritableProvider struct {
configDir string
}
func NewHyprlandWritableProvider(configDir string) *HyprlandWritableProvider {
return &HyprlandWritableProvider{configDir: configDir}
}
func (p *HyprlandWritableProvider) Name() string {
return "hyprland"
}
func (p *HyprlandWritableProvider) GetOverridePath() string {
expanded, _ := utils.ExpandPath(p.configDir)
return filepath.Join(expanded, "dms", "windowrules.lua")
}
func (p *HyprlandWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
result, err := ParseHyprlandWindowRules(p.configDir)
if err != nil {
return nil, err
}
return &windowrules.RuleSet{
Title: "Hyprland Window Rules",
Provider: "hyprland",
Rules: ConvertHyprlandRulesToWindowRules(result.Rules),
DMSRulesIncluded: result.DMSRulesIncluded,
DMSStatus: result.DMSStatus,
}, nil
}
func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error {
rules, err := p.LoadDMSRules()
if err != nil {
rules = []windowrules.WindowRule{}
}
found := false
for i, r := range rules {
if r.ID == rule.ID {
rules[i] = rule
found = true
break
}
}
if !found {
rules = append(rules, rule)
}
return p.writeDMSRules(rules)
}
func (p *HyprlandWritableProvider) RemoveRule(id string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
newRules := make([]windowrules.WindowRule, 0, len(rules))
for _, r := range rules {
if r.ID != id {
newRules = append(newRules, r)
}
}
return p.writeDMSRules(newRules)
}
func (p *HyprlandWritableProvider) ReorderRules(ids []string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
ruleMap := make(map[string]windowrules.WindowRule)
for _, r := range rules {
ruleMap[r.ID] = r
}
newRules := make([]windowrules.WindowRule, 0, len(ids))
for _, id := range ids {
if r, ok := ruleMap[id]; ok {
newRules = append(newRules, r)
delete(ruleMap, id)
}
}
for _, r := range ruleMap {
newRules = append(newRules, r)
}
return p.writeDMSRules(newRules)
}
var dmsRuleCommentRegex = regexp.MustCompile(`^#\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`)
var dmsRuleLuaHDRRegex = regexp.MustCompile(`^\s*--\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`)
func hyprLuaBoolStr(b bool) string {
if b {
return "true"
}
return "false"
}
func luaAppendMatch(mc windowrules.MatchCriteria, dst *[]string) {
if mc.AppID != "" {
*dst = append(*dst, fmt.Sprintf(`class = %s`, strconv.Quote(mc.AppID)))
}
if mc.Title != "" {
*dst = append(*dst, fmt.Sprintf(`title = %s`, strconv.Quote(mc.Title)))
}
if mc.XWayland != nil {
*dst = append(*dst, fmt.Sprintf(`xwayland = %s`, hyprLuaBoolStr(*mc.XWayland)))
}
if mc.IsFloating != nil {
*dst = append(*dst, fmt.Sprintf(`floating = %s`, hyprLuaBoolStr(*mc.IsFloating)))
}
if mc.Fullscreen != nil {
*dst = append(*dst, fmt.Sprintf(`fullscreen = %s`, hyprLuaBoolStr(*mc.Fullscreen)))
}
if mc.Pinned != nil {
*dst = append(*dst, fmt.Sprintf(`pinned = %s`, hyprLuaBoolStr(*mc.Pinned)))
}
if mc.Initialised != nil {
*dst = append(*dst, fmt.Sprintf(`initialised = %s`, hyprLuaBoolStr(*mc.Initialised)))
}
}
func luaAppendActions(a windowrules.Actions, dst *[]string) {
if a.OpenFloating != nil && *a.OpenFloating {
*dst = append(*dst, `float = true`)
}
if a.Tile != nil && *a.Tile {
*dst = append(*dst, `tile = true`)
}
if a.OpenFullscreen != nil && *a.OpenFullscreen {
*dst = append(*dst, `fullscreen = true`)
}
if a.OpenMaximized != nil && *a.OpenMaximized {
*dst = append(*dst, `maximize = true`)
}
if a.NoFocus != nil && *a.NoFocus {
*dst = append(*dst, `no_focus = true`)
}
if a.NoBorder != nil && *a.NoBorder {
*dst = append(*dst, `noborder = true`)
}
if a.NoShadow != nil && *a.NoShadow {
*dst = append(*dst, `no_shadow = true`)
}
if a.NoDim != nil && *a.NoDim {
*dst = append(*dst, `no_dim = true`)
}
if a.NoBlur != nil && *a.NoBlur {
*dst = append(*dst, `no_blur = true`)
}
if a.NoAnim != nil && *a.NoAnim {
*dst = append(*dst, `no_anim = true`)
}
if a.NoRounding != nil && *a.NoRounding {
*dst = append(*dst, `norounding = true`)
}
if a.Pin != nil && *a.Pin {
*dst = append(*dst, `pin = true`)
}
if a.Opaque != nil && *a.Opaque {
*dst = append(*dst, `opaque = true`)
}
if a.ForcergbX != nil && *a.ForcergbX {
*dst = append(*dst, `force_rgbx = true`)
}
if a.Opacity != nil {
*dst = append(*dst, fmt.Sprintf(`opacity = %s`, strconv.FormatFloat(*a.Opacity, 'g', -1, 64)))
}
if a.Size != "" {
*dst = append(*dst, fmt.Sprintf(`size = %s`, strconv.Quote(a.Size)))
}
if a.Move != "" {
*dst = append(*dst, fmt.Sprintf(`move = %s`, strconv.Quote(a.Move)))
}
if a.Monitor != "" {
*dst = append(*dst, fmt.Sprintf(`monitor = %s`, strconv.Quote(a.Monitor)))
}
if a.Workspace != "" {
*dst = append(*dst, fmt.Sprintf(`workspace = %s`, strconv.Quote(a.Workspace)))
}
if a.CornerRadius != nil {
*dst = append(*dst, fmt.Sprintf(`rounding = %d`, *a.CornerRadius))
}
if a.Idleinhibit != "" {
*dst = append(*dst, fmt.Sprintf(`idle_inhibit = %s`, strconv.Quote(a.Idleinhibit)))
}
}
func formatLuaManagedHyprRule(rule windowrules.WindowRule) []string {
var matchParts []string
luaAppendMatch(rule.MatchCriteria, &matchParts)
var body []string
if len(matchParts) > 0 {
body = append(body, fmt.Sprintf(`match = { %s }`, strings.Join(matchParts, ", ")))
}
luaAppendActions(rule.Actions, &body)
out := []string{fmt.Sprintf("-- DMS-RULE: id=%s, name=%s", rule.ID, rule.Name)}
if len(body) == 0 {
out = append(out, fmt.Sprintf("-- (no matchers/actions for rule %s)", rule.ID))
} else {
out = append(out, fmt.Sprintf("hl.window_rule({ %s })", strings.Join(body, ", ")))
}
out = append(out, "")
return out
}
func (p *HyprlandWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) {
luaPath := p.GetOverridePath()
expanded, err := utils.ExpandPath(p.configDir)
if err != nil {
return nil, err
}
confPath := filepath.Join(expanded, "dms", "windowrules.conf")
var data []byte
var loadedFrom string
if data, err = os.ReadFile(luaPath); err == nil {
loadedFrom = luaPath
} else if !os.IsNotExist(err) {
return nil, err
} else if data, err = os.ReadFile(confPath); err == nil {
loadedFrom = confPath
} else if os.IsNotExist(err) {
return []windowrules.WindowRule{}, nil
} else {
return nil, err
}
if strings.EqualFold(filepath.Ext(loadedFrom), ".lua") {
return p.loadDMSRulesFromLua(data, luaPath)
}
return p.loadDMSRulesFromConf(data, loadedFrom)
}
func (p *HyprlandWritableProvider) loadDMSRulesFromConf(data []byte, rulesPath string) ([]windowrules.WindowRule, error) {
var rules []windowrules.WindowRule
var currentID, currentName string
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if matches := dmsRuleCommentRegex.FindStringSubmatch(trimmed); matches != nil {
currentID = matches[1]
currentName = matches[2]
continue
}
if strings.HasPrefix(trimmed, "windowrulev2") {
parser := NewHyprlandRulesParser(p.configDir)
hrule := parser.parseWindowRuleLine(trimmed)
if hrule == nil {
continue
}
wr := windowrules.WindowRule{
ID: currentID,
Name: currentName,
Enabled: true,
Source: rulesPath,
MatchCriteria: windowrules.MatchCriteria{
AppID: hrule.MatchClass,
Title: hrule.MatchTitle,
XWayland: hrule.MatchXWayland,
IsFloating: hrule.MatchFloating,
Fullscreen: hrule.MatchFullscreen,
Pinned: hrule.MatchPinned,
Initialised: hrule.MatchInitialised,
},
}
applyHyprlandRuleAction(&wr.Actions, hrule.Rule, hrule.Value)
if wr.ID == "" {
wr.ID = hrule.MatchClass
if wr.ID == "" {
wr.ID = hrule.MatchTitle
}
}
rules = append(rules, wr)
currentID = ""
currentName = ""
}
}
return rules, nil
}
func (p *HyprlandWritableProvider) loadDMSRulesFromLua(data []byte, rulesPath string) ([]windowrules.WindowRule, error) {
var rules []windowrules.WindowRule
lines := strings.Split(string(data), "\n")
var curID, curName string
for li := 0; li < len(lines); {
trimmed := strings.TrimSpace(lines[li])
if strings.HasPrefix(trimmed, "--") {
if m := dmsRuleLuaHDRRegex.FindStringSubmatch(trimmed); m != nil {
curID, curName = m[1], m[2]
li++
continue
}
}
if strings.Contains(strings.ToLower(trimmed), hlWinRuleLower) {
tail := strings.Join(lines[li:], "\n")
idx := strings.Index(strings.ToLower(tail), hlWinRuleLower)
if idx < 0 {
li++
continue
}
frag := tail[idx:]
tableArg, consumedFrag, ok := extractHlWindowRuleTableArg(frag)
if !ok {
li++
continue
}
idSnap := curID
nameSnap := curName
if acts, mf, ok2 := parseHlWindowRuleLuaTable(tableArg); ok2 && acts != nil {
wr := windowrules.WindowRule{
ID: idSnap,
Name: nameSnap,
Enabled: true,
Source: rulesPath,
MatchCriteria: luaMatchFieldsToCriteria(mf),
Actions: *acts,
}
if wr.ID == "" {
if wr.MatchCriteria.AppID != "" {
wr.ID = wr.MatchCriteria.AppID
} else {
wr.ID = wr.MatchCriteria.Title
}
}
rules = append(rules, wr)
}
curID = ""
curName = ""
advance := strings.Count(tail[:idx+consumedFrag], "\n")
if advance == 0 {
li++
} else {
li += advance
}
continue
}
if trimmed != "" && !strings.HasPrefix(trimmed, "--") {
curID = ""
curName = ""
}
li++
}
return rules, nil
}
func (p *HyprlandWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error {
rulesPath := p.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(rulesPath), 0755); err != nil {
return err
}
var lines []string
lines = append(lines, "-- DMS Window Rules — managed by DankMaterialShell")
lines = append(lines, "-- Do not edit manually; changes may be overwritten")
lines = append(lines, "")
for _, rule := range rules {
lines = append(lines, formatLuaManagedHyprRule(rule)...)
}
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
const hlWinRuleLower = "hl.window_rule"
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
}
func isDMSWindowRulesSourcePath(sourcePath string) bool {
p := filepath.ToSlash(strings.TrimSpace(sourcePath))
return p == "dms/windowrules.lua" || strings.HasSuffix(p, "/dms/windowrules.lua") ||
p == "dms/windowrules.conf" || strings.HasSuffix(p, "/dms/windowrules.conf") ||
p == "./dms/windowrules.lua" || p == "./dms/windowrules.conf"
}
func isDMSWindowRulesRequireModule(mod string) bool {
return isDMSWindowRulesSourcePath(luaconfig.ModuleToRelPath(mod))
}
func (p *HyprlandRulesParser) probeRequireWindowrulesLine(mainLua string) {
data, err := os.ReadFile(mainLua)
if err != nil {
return
}
lines := strings.Split(string(data), "\n")
for i, line := range lines {
if mod, ok := luaconfig.Require(line); ok && isDMSWindowRulesRequireModule(mod) {
p.requireLineInMain = i + 1
return
}
}
}
// luaMatchFields collects fields from Lua match={...} subtrees before copying into HyprlandWindowRule.
type luaMatchFields struct {
class string
title string
xwayland, floating, fullscreen, pinned, initialised *bool
}
func (p *HyprlandRulesParser) parseLuaWindowRules(content, baseDir, absPath string, allowRequires bool) {
prev := p.currentSource
p.currentSource = absPath
defer func() { p.currentSource = prev }()
lines := strings.Split(content, "\n")
rootDir := baseDir
if expanded, err := utils.ExpandPath(p.configDir); err == nil && expanded != "" {
rootDir = expanded
}
curAbs := absPath
if a, err := filepath.Abs(absPath); err == nil {
curAbs = a
}
mainAbs := ""
if p.primaryHyprLua != "" {
if a, err := filepath.Abs(p.primaryHyprLua); err == nil {
mainAbs = a
}
}
for i := 0; i < len(lines); {
trimmed := strings.TrimSpace(lines[i])
if trimmed == "" || strings.HasPrefix(trimmed, "--") {
i++
continue
}
if modules := luaconfig.Requires(trimmed); len(modules) > 0 && allowRequires {
for _, mod := range modules {
rel := luaconfig.ModuleToRelPath(mod)
if rel == "" {
continue
}
fullPath := luaconfig.ModuleToPath(rootDir, mod)
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
continue
}
p.includeCount++
if isDMSWindowRulesRequireModule(mod) {
p.dmsRulesIncluded = true
p.dmsIncludePos = p.includeCount
p.dmsProcessed = true
}
_ = p.parseFile(expanded)
}
i++
continue
}
lowTrim := strings.ToLower(trimmed)
if strings.Contains(lowTrim, hlWinRuleLower) {
tail := strings.Join(lines[i:], "\n")
idx := strings.Index(strings.ToLower(tail), hlWinRuleLower)
if idx < 0 {
i++
continue
}
frag := tail[idx:]
tableArg, consumedFrag, ok := extractHlWindowRuleTableArg(frag)
if !ok {
i++
continue
}
startLine := i + strings.Count(tail[:idx], "\n") + 1
if acts, mf, ok2 := parseHlWindowRuleLuaTable(tableArg); ok2 && acts != nil {
raw := strings.Join(strings.Fields(strings.ReplaceAll(strings.TrimSpace(frag[:consumedFrag]), "\n", " ")), " ")
if len(raw) > 240 {
raw = raw[:240] + "…"
}
hr := HyprlandWindowRule{
Source: curAbs,
RawLine: raw,
CombinedActions: acts,
}
fillRuleFromLuaMatch(&hr, mf)
p.rules = append(p.rules, hr)
if p.requireLineInMain > 0 && mainAbs != "" && curAbs == mainAbs && startLine > p.requireLineInMain {
p.rulesAfterDMS++
}
}
advance := strings.Count(tail[:idx+consumedFrag], "\n")
if advance == 0 {
i++
} else {
i += advance
}
continue
}
i++
}
}
func fillRuleFromLuaMatch(hr *HyprlandWindowRule, m luaMatchFields) {
hr.MatchClass = m.class
hr.MatchTitle = m.title
hr.MatchXWayland = m.xwayland
hr.MatchFloating = m.floating
hr.MatchFullscreen = m.fullscreen
hr.MatchPinned = m.pinned
hr.MatchInitialised = m.initialised
}
func luaMatchFieldsToCriteria(m luaMatchFields) windowrules.MatchCriteria {
return windowrules.MatchCriteria{
AppID: m.class,
Title: m.title,
XWayland: m.xwayland,
IsFloating: m.floating,
Fullscreen: m.fullscreen,
Pinned: m.pinned,
Initialised: m.initialised,
}
}
// extractHlWindowRuleTableArg parses a fragment beginning with (optional prefix then) hl.window_rule( ... ).
// consumed is counted from frag[0] (caller adds idx offset when iterating).
func extractHlWindowRuleTableArg(frag string) (inner string, consumed int, ok bool) {
tagIdx := strings.Index(strings.ToLower(frag), hlWinRuleLower)
if tagIdx < 0 {
return "", 0, false
}
afterTag := frag[tagIdx+len(hlWinRuleLower):]
openIdx := strings.IndexByte(afterTag, '(')
if openIdx < 0 || (openIdx > 0 && strings.TrimSpace(afterTag[:openIdx]) != "") {
return "", 0, false
}
parenTail := afterTag[openIdx:]
body, endAfter, ok := extractBalancedParensFromOpen(parenTail, 0)
if !ok {
return "", 0, false
}
consumedFromFrag := tagIdx + openIdx + endAfter
return strings.TrimSpace(body), consumedFromFrag, true
}
// extractBalancedParensFromOpen extracts inner string between '(' at openIdx and its matching ')'.
func extractBalancedParensFromOpen(s string, openIdx int) (inner string, endExclusive int, ok bool) {
if openIdx >= len(s) || s[openIdx] != '(' {
return "", 0, false
}
depth := 0
inStr := byte(0)
esc := false
for i := openIdx; i < len(s); i++ {
c := s[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++
if depth == 1 {
continue
}
case ')':
if depth > 0 {
depth--
if depth == 0 {
return strings.TrimSpace(s[openIdx+1 : i]), i + 1, true
}
}
}
}
return "", 0, false
}
func trimOuterBraces(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 && s[0] == '{' && s[len(s)-1] == '}' {
return strings.TrimSpace(s[1 : len(s)-1])
}
return s
}
func splitTopLevelCommaLua(s string) []string {
var out []string
depth := 0
inStr := byte(0)
esc := false
start := 0
for i := 0; i < len(s); i++ {
c := s[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 '}', ')':
if depth > 0 {
depth--
}
case ',':
if depth == 0 {
out = append(out, strings.TrimSpace(s[start:i]))
start = i + 1
}
}
}
out = append(out, strings.TrimSpace(s[start:]))
return out
}
func splitLuaKeyVal(seg string) (key, val string, ok bool) {
seg = strings.TrimSpace(seg)
if seg == "" {
return "", "", false
}
depth := 0
inStr := byte(0)
esc := false
for i := 0; i < len(seg); i++ {
c := seg[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 '}', ')':
if depth > 0 {
depth--
}
case '=':
if depth == 0 {
return strings.TrimSpace(seg[:i]), strings.TrimSpace(seg[i+1:]), true
}
}
}
return "", "", false
}
func luaStringValue(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 {
q0 := s[0]
q1 := s[len(s)-1]
if q0 == q1 && (q0 == '"' || q0 == '\'') {
if q0 == '"' {
if u, err := strconv.Unquote(s); err == nil {
return u
}
} else if len(s) >= 2 && q0 == '\'' {
v := strings.TrimSuffix(strings.TrimPrefix(s, "'"), "'")
v = strings.ReplaceAll(v, `\'`, `'`)
return v
}
}
}
return strings.Trim(strings.TrimSpace(s), `"'`)
}
func luaBoolLike(s string) (val bool, ok bool) {
s = strings.TrimSpace(strings.ToLower(s))
switch s {
case "true", "yes", "1":
return true, true
case "false", "no", "0":
return false, true
default:
return false, false
}
}
func parseMatchLua(val string, m *luaMatchFields) {
body := trimOuterBraces(val)
segs := splitTopLevelCommaLua(body)
for _, seg := range segs {
k, v, ok := splitLuaKeyVal(seg)
if !ok {
continue
}
switch strings.TrimSpace(strings.ToLower(k)) {
case "class":
m.class = luaStringValue(v)
case "title":
m.title = luaStringValue(v)
case "xwayland":
if b, okb := luaBoolLike(v); okb {
m.xwayland = boolRef(b)
}
case "floating":
if b, okb := luaBoolLike(v); okb {
m.floating = boolRef(b)
}
case "fullscreen":
if b, okb := luaBoolLike(v); okb {
m.fullscreen = boolRef(b)
}
case "pinned":
if b, okb := luaBoolLike(v); okb {
m.pinned = boolRef(b)
}
case "initialised", "initialized":
if b, okb := luaBoolLike(v); okb {
m.initialised = boolRef(b)
}
}
}
}
func boolRef(b bool) *bool { return &b }
func applyLuaActionKey(a *windowrules.Actions, key, raw string) bool {
k := strings.TrimSpace(strings.ToLower(key))
raw = strings.TrimSpace(raw)
switch k {
case "float":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.OpenFloating = &t
return true
}
case "tile":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.Tile = &t
return true
}
case "fullscreen":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.OpenFullscreen = &t
return true
}
case "maximize":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.OpenMaximized = &t
return true
}
case "nofocus", "no_focus", "no_initial_focus":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.NoFocus = &t
return true
}
case "noborder":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.NoBorder = &t
return true
}
case "noshadow", "no_shadow":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.NoShadow = &t
return true
}
case "nodim", "no_dim":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.NoDim = &t
return true
}
case "noblur", "no_blur":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.NoBlur = &t
return true
}
case "noanim", "no_anim":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.NoAnim = &t
return true
}
case "norounding":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.NoRounding = &t
return true
}
case "pin":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.Pin = &t
return true
}
case "opaque":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.Opaque = &t
return true
}
case "forcergbx", "force_rgbx":
if b, ok := luaBoolLike(raw); ok && b {
t := true
a.ForcergbX = &t
return true
}
case "opacity":
if f, err := strconv.ParseFloat(luaStringValue(raw), 64); err == nil {
a.Opacity = &f
return true
}
case "rounding":
if v := luaStringValue(raw); v != "" {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
a.CornerRadius = &n
return true
}
}
case "size":
a.Size = strings.TrimSpace(luaStringValue(raw))
return true
case "move":
a.Move = strings.TrimSpace(luaStringValue(raw))
return true
case "monitor":
a.Monitor = strings.TrimSpace(luaStringValue(raw))
return true
case "workspace":
a.Workspace = strings.TrimSpace(luaStringValue(raw))
return true
case "idleinhibit", "idle_inhibit":
a.Idleinhibit = strings.TrimSpace(luaStringValue(raw))
return true
default:
// Unsupported keys are left to Hyprland; DMS only round-trips managed fields.
}
return false
}
func parseHlWindowRuleLuaTable(inner string) (*windowrules.Actions, luaMatchFields, bool) {
body := trimOuterBraces(strings.TrimSpace(inner))
if body == "" {
return nil, luaMatchFields{}, false
}
segs := splitTopLevelCommaLua(body)
var match luaMatchFields
var a windowrules.Actions
matchParsed := false
haveActions := false
for _, seg := range segs {
k, v, ok := splitLuaKeyVal(seg)
if !ok {
continue
}
switch strings.TrimSpace(strings.ToLower(k)) {
case "match":
parseMatchLua(v, &match)
matchParsed = true
default:
if applyLuaActionKey(&a, k, v) {
haveActions = true
}
}
}
if !haveActions {
return nil, luaMatchFields{}, false
}
return &a, match, matchParsed || haveActions
}