summaryrefslogtreecommitdiff
path: root/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules
diff options
context:
space:
mode:
Diffstat (limited to 'raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules')
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser.go658
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser_test.go280
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser.go875
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser_test.go335
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/providers_test.go22
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/types.go103
6 files changed, 2273 insertions, 0 deletions
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser.go
new file mode 100644
index 0000000..918c4f9
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser.go
@@ -0,0 +1,658 @@
+package providers
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "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
+}
+
+type HyprlandRulesParser struct {
+ configDir string
+ processedFiles map[string]bool
+ rules []HyprlandWindowRule
+ currentSource string
+ dmsRulesExists bool
+ dmsRulesIncluded bool
+ includeCount int
+ dmsIncludePos int
+ rulesAfterDMS int
+ dmsProcessed bool
+}
+
+func NewHyprlandRulesParser(configDir string) *HyprlandRulesParser {
+ return &HyprlandRulesParser{
+ configDir: configDir,
+ processedFiles: make(map[string]bool),
+ rules: []HyprlandWindowRule{},
+ dmsIncludePos: -1,
+ }
+}
+
+func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) {
+ expandedDir, err := utils.ExpandPath(p.configDir)
+ if err != nil {
+ return nil, err
+ }
+
+ dmsRulesPath := filepath.Join(expandedDir, "dms", "windowrules.conf")
+ if _, err := os.Stat(dmsRulesPath); err == nil {
+ p.dmsRulesExists = true
+ }
+
+ mainConfig := filepath.Join(expandedDir, "hyprland.conf")
+ if err := p.parseFile(mainConfig); err != nil {
+ return nil, err
+ }
+
+ if p.dmsRulesExists && !p.dmsProcessed {
+ p.parseDMSRulesDirectly(dmsRulesPath)
+ }
+
+ return p.rules, nil
+}
+
+func (p *HyprlandRulesParser) parseDMSRulesDirectly(dmsRulesPath string) {
+ data, err := os.ReadFile(dmsRulesPath)
+ if err != nil {
+ return
+ }
+
+ prevSource := p.currentSource
+ p.currentSource = dmsRulesPath
+
+ 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
+ }
+
+ 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 := sourcePath == "dms/windowrules.conf" || strings.HasSuffix(sourcePath, "/dms/windowrules.conf")
+
+ 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/windowrules.conf does not exist"
+ case !p.dmsRulesIncluded:
+ status.Effective = false
+ status.StatusMessage = "dms/windowrules.conf is not sourced in config"
+ 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,
+ },
+ }
+ 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.conf")
+}
+
+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=(.*)$`)
+
+func (p *HyprlandWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) {
+ rulesPath := p.GetOverridePath()
+ data, err := os.ReadFile(rulesPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []windowrules.WindowRule{}, nil
+ }
+ return nil, err
+ }
+
+ 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) 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, p.formatRuleLines(rule)...)
+ }
+
+ return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
+}
+
+func (p *HyprlandWritableProvider) formatRuleLines(rule windowrules.WindowRule) []string {
+ var lines []string
+ lines = append(lines, fmt.Sprintf("# DMS-RULE: id=%s, name=%s", rule.ID, rule.Name))
+
+ var matchParts []string
+ if rule.MatchCriteria.AppID != "" {
+ matchParts = append(matchParts, fmt.Sprintf("class:%s", rule.MatchCriteria.AppID))
+ }
+ if rule.MatchCriteria.Title != "" {
+ matchParts = append(matchParts, fmt.Sprintf("title:%s", rule.MatchCriteria.Title))
+ }
+ if rule.MatchCriteria.XWayland != nil {
+ matchParts = append(matchParts, fmt.Sprintf("xwayland:%d", boolToInt(*rule.MatchCriteria.XWayland)))
+ }
+ if rule.MatchCriteria.IsFloating != nil {
+ matchParts = append(matchParts, fmt.Sprintf("floating:%d", boolToInt(*rule.MatchCriteria.IsFloating)))
+ }
+ if rule.MatchCriteria.Fullscreen != nil {
+ matchParts = append(matchParts, fmt.Sprintf("fullscreen:%d", boolToInt(*rule.MatchCriteria.Fullscreen)))
+ }
+ if rule.MatchCriteria.Pinned != nil {
+ matchParts = append(matchParts, fmt.Sprintf("pinned:%d", boolToInt(*rule.MatchCriteria.Pinned)))
+ }
+
+ matchStr := strings.Join(matchParts, ", ")
+ a := rule.Actions
+
+ if a.OpenFloating != nil && *a.OpenFloating {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = float, %s", matchStr))
+ }
+ if a.Tile != nil && *a.Tile {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = tile, %s", matchStr))
+ }
+ if a.OpenFullscreen != nil && *a.OpenFullscreen {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = fullscreen, %s", matchStr))
+ }
+ if a.OpenMaximized != nil && *a.OpenMaximized {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = maximize, %s", matchStr))
+ }
+ if a.NoFocus != nil && *a.NoFocus {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = nofocus, %s", matchStr))
+ }
+ if a.NoBorder != nil && *a.NoBorder {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = noborder, %s", matchStr))
+ }
+ if a.NoShadow != nil && *a.NoShadow {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = noshadow, %s", matchStr))
+ }
+ if a.NoDim != nil && *a.NoDim {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = nodim, %s", matchStr))
+ }
+ if a.NoBlur != nil && *a.NoBlur {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = noblur, %s", matchStr))
+ }
+ if a.NoAnim != nil && *a.NoAnim {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = noanim, %s", matchStr))
+ }
+ if a.NoRounding != nil && *a.NoRounding {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = norounding, %s", matchStr))
+ }
+ if a.Pin != nil && *a.Pin {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = pin, %s", matchStr))
+ }
+ if a.Opaque != nil && *a.Opaque {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = opaque, %s", matchStr))
+ }
+ if a.ForcergbX != nil && *a.ForcergbX {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = forcergbx, %s", matchStr))
+ }
+ if a.Opacity != nil {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = opacity %.2f, %s", *a.Opacity, matchStr))
+ }
+ if a.Size != "" {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = size %s, %s", a.Size, matchStr))
+ }
+ if a.Move != "" {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = move %s, %s", a.Move, matchStr))
+ }
+ if a.Monitor != "" {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = monitor %s, %s", a.Monitor, matchStr))
+ }
+ if a.Workspace != "" {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = workspace %s, %s", a.Workspace, matchStr))
+ }
+ if a.CornerRadius != nil {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = rounding %d, %s", *a.CornerRadius, matchStr))
+ }
+ if a.Idleinhibit != "" {
+ lines = append(lines, fmt.Sprintf("windowrulev2 = idleinhibit %s, %s", a.Idleinhibit, matchStr))
+ }
+
+ if len(lines) == 1 {
+ lines = append(lines, fmt.Sprintf("# (no actions defined for rule %s)", rule.ID))
+ }
+
+ lines = append(lines, "")
+ return lines
+}
+
+func boolToInt(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser_test.go
new file mode 100644
index 0000000..5341b4b
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser_test.go
@@ -0,0 +1,280 @@
+package providers
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestParseWindowRuleV1(t *testing.T) {
+ parser := NewHyprlandRulesParser("")
+
+ tests := []struct {
+ name string
+ line string
+ wantClass string
+ wantRule string
+ wantNil bool
+ }{
+ {
+ name: "basic float rule",
+ line: "windowrule = float, ^(firefox)$",
+ wantClass: "^(firefox)$",
+ wantRule: "float",
+ },
+ {
+ name: "tile rule",
+ line: "windowrule = tile, steam",
+ wantClass: "steam",
+ wantRule: "tile",
+ },
+ {
+ name: "no match returns empty class",
+ line: "windowrule = float",
+ wantClass: "",
+ wantRule: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := parser.parseWindowRuleLine(tt.line)
+ if tt.wantNil {
+ if result != nil {
+ t.Errorf("expected nil, got %+v", result)
+ }
+ return
+ }
+ if result == nil {
+ t.Fatal("expected non-nil result")
+ }
+ if result.MatchClass != tt.wantClass {
+ t.Errorf("MatchClass = %q, want %q", result.MatchClass, tt.wantClass)
+ }
+ if result.Rule != tt.wantRule {
+ t.Errorf("Rule = %q, want %q", result.Rule, tt.wantRule)
+ }
+ })
+ }
+}
+
+func TestParseWindowRuleV2(t *testing.T) {
+ parser := NewHyprlandRulesParser("")
+
+ tests := []struct {
+ name string
+ line string
+ wantClass string
+ wantTitle string
+ wantRule string
+ wantValue string
+ }{
+ {
+ name: "float with class",
+ line: "windowrulev2 = float, class:^(firefox)$",
+ wantClass: "^(firefox)$",
+ wantRule: "float",
+ },
+ {
+ name: "opacity with value",
+ line: "windowrulev2 = opacity 0.8, class:^(code)$",
+ wantClass: "^(code)$",
+ wantRule: "opacity",
+ wantValue: "0.8",
+ },
+ {
+ name: "size with value and title",
+ line: "windowrulev2 = size 800 600, class:^(steam)$, title:Settings",
+ wantClass: "^(steam)$",
+ wantTitle: "Settings",
+ wantRule: "size",
+ wantValue: "800 600",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := parser.parseWindowRuleLine(tt.line)
+ if result == nil {
+ t.Fatal("expected non-nil result")
+ }
+ if result.MatchClass != tt.wantClass {
+ t.Errorf("MatchClass = %q, want %q", result.MatchClass, tt.wantClass)
+ }
+ if result.MatchTitle != tt.wantTitle {
+ t.Errorf("MatchTitle = %q, want %q", result.MatchTitle, tt.wantTitle)
+ }
+ if result.Rule != tt.wantRule {
+ t.Errorf("Rule = %q, want %q", result.Rule, tt.wantRule)
+ }
+ if result.Value != tt.wantValue {
+ t.Errorf("Value = %q, want %q", result.Value, tt.wantValue)
+ }
+ })
+ }
+}
+
+func TestConvertHyprlandRulesToWindowRules(t *testing.T) {
+ hyprRules := []HyprlandWindowRule{
+ {MatchClass: "^(firefox)$", Rule: "float"},
+ {MatchClass: "^(code)$", Rule: "opacity", Value: "0.9"},
+ {MatchClass: "^(steam)$", Rule: "maximize"},
+ }
+
+ result := ConvertHyprlandRulesToWindowRules(hyprRules)
+
+ if len(result) != 3 {
+ t.Errorf("expected 3 rules, got %d", len(result))
+ }
+
+ if result[0].MatchCriteria.AppID != "^(firefox)$" {
+ t.Errorf("rule 0 AppID = %q, want ^(firefox)$", result[0].MatchCriteria.AppID)
+ }
+ if result[0].Actions.OpenFloating == nil || !*result[0].Actions.OpenFloating {
+ t.Error("rule 0 should have OpenFloating = true")
+ }
+
+ if result[1].Actions.Opacity == nil || *result[1].Actions.Opacity != 0.9 {
+ t.Errorf("rule 1 Opacity = %v, want 0.9", result[1].Actions.Opacity)
+ }
+
+ if result[2].Actions.OpenMaximized == nil || !*result[2].Actions.OpenMaximized {
+ t.Error("rule 2 should have OpenMaximized = true")
+ }
+}
+
+func TestHyprlandWritableProvider(t *testing.T) {
+ tmpDir := t.TempDir()
+ provider := NewHyprlandWritableProvider(tmpDir)
+
+ if provider.Name() != "hyprland" {
+ t.Errorf("Name() = %q, want hyprland", provider.Name())
+ }
+
+ expectedPath := filepath.Join(tmpDir, "dms", "windowrules.conf")
+ if provider.GetOverridePath() != expectedPath {
+ t.Errorf("GetOverridePath() = %q, want %q", provider.GetOverridePath(), expectedPath)
+ }
+}
+
+func TestHyprlandSetAndLoadDMSRules(t *testing.T) {
+ tmpDir := t.TempDir()
+ provider := NewHyprlandWritableProvider(tmpDir)
+
+ rule := newTestWindowRule("test_id", "Test Rule", "^(firefox)$")
+ rule.Actions.OpenFloating = boolPtr(true)
+
+ if err := provider.SetRule(rule); err != nil {
+ t.Fatalf("SetRule failed: %v", err)
+ }
+
+ rules, err := provider.LoadDMSRules()
+ if err != nil {
+ t.Fatalf("LoadDMSRules failed: %v", err)
+ }
+
+ if len(rules) != 1 {
+ t.Fatalf("expected 1 rule, got %d", len(rules))
+ }
+
+ if rules[0].ID != "test_id" {
+ t.Errorf("ID = %q, want test_id", rules[0].ID)
+ }
+ if rules[0].MatchCriteria.AppID != "^(firefox)$" {
+ t.Errorf("AppID = %q, want ^(firefox)$", rules[0].MatchCriteria.AppID)
+ }
+}
+
+func TestHyprlandRemoveRule(t *testing.T) {
+ tmpDir := t.TempDir()
+ provider := NewHyprlandWritableProvider(tmpDir)
+
+ rule1 := newTestWindowRule("rule1", "Rule 1", "^(app1)$")
+ rule1.Actions.OpenFloating = boolPtr(true)
+ rule2 := newTestWindowRule("rule2", "Rule 2", "^(app2)$")
+ rule2.Actions.OpenFloating = boolPtr(true)
+
+ _ = provider.SetRule(rule1)
+ _ = provider.SetRule(rule2)
+
+ if err := provider.RemoveRule("rule1"); err != nil {
+ t.Fatalf("RemoveRule failed: %v", err)
+ }
+
+ rules, _ := provider.LoadDMSRules()
+ if len(rules) != 1 {
+ t.Fatalf("expected 1 rule after removal, got %d", len(rules))
+ }
+ if rules[0].ID != "rule2" {
+ t.Errorf("remaining rule ID = %q, want rule2", rules[0].ID)
+ }
+}
+
+func TestHyprlandReorderRules(t *testing.T) {
+ tmpDir := t.TempDir()
+ provider := NewHyprlandWritableProvider(tmpDir)
+
+ rule1 := newTestWindowRule("rule1", "Rule 1", "^(app1)$")
+ rule1.Actions.OpenFloating = boolPtr(true)
+ rule2 := newTestWindowRule("rule2", "Rule 2", "^(app2)$")
+ rule2.Actions.OpenFloating = boolPtr(true)
+ rule3 := newTestWindowRule("rule3", "Rule 3", "^(app3)$")
+ rule3.Actions.OpenFloating = boolPtr(true)
+
+ _ = provider.SetRule(rule1)
+ _ = provider.SetRule(rule2)
+ _ = provider.SetRule(rule3)
+
+ if err := provider.ReorderRules([]string{"rule3", "rule1", "rule2"}); err != nil {
+ t.Fatalf("ReorderRules failed: %v", err)
+ }
+
+ rules, _ := provider.LoadDMSRules()
+ if len(rules) != 3 {
+ t.Fatalf("expected 3 rules, got %d", len(rules))
+ }
+ expectedOrder := []string{"rule3", "rule1", "rule2"}
+ for i, expectedID := range expectedOrder {
+ if rules[i].ID != expectedID {
+ t.Errorf("rule %d ID = %q, want %q", i, rules[i].ID, expectedID)
+ }
+ }
+}
+
+func TestHyprlandParseConfigWithSource(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ mainConfig := `
+windowrulev2 = float, class:^(mainapp)$
+source = ./extra.conf
+`
+ extraConfig := `
+windowrulev2 = tile, class:^(extraapp)$
+`
+
+ if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte(mainConfig), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(tmpDir, "extra.conf"), []byte(extraConfig), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ parser := NewHyprlandRulesParser(tmpDir)
+ rules, err := parser.Parse()
+ if err != nil {
+ t.Fatalf("Parse failed: %v", err)
+ }
+
+ if len(rules) != 2 {
+ t.Errorf("expected 2 rules, got %d", len(rules))
+ }
+}
+
+func TestBoolToInt(t *testing.T) {
+ if boolToInt(true) != 1 {
+ t.Error("boolToInt(true) should be 1")
+ }
+ if boolToInt(false) != 0 {
+ t.Error("boolToInt(false) should be 0")
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser.go
new file mode 100644
index 0000000..5bb5ec4
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser.go
@@ -0,0 +1,875 @@
+package providers
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/sblinch/kdl-go"
+ "github.com/sblinch/kdl-go/document"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
+)
+
+type NiriWindowRule struct {
+ MatchAppID string
+ MatchTitle string
+ MatchIsFloating *bool
+ MatchIsActive *bool
+ MatchIsFocused *bool
+ MatchIsActiveInColumn *bool
+ MatchIsWindowCastTarget *bool
+ MatchIsUrgent *bool
+ MatchAtStartup *bool
+ Opacity *float64
+ OpenFloating *bool
+ OpenMaximized *bool
+ OpenMaximizedToEdges *bool
+ OpenFullscreen *bool
+ OpenFocused *bool
+ OpenOnOutput string
+ OpenOnWorkspace string
+ DefaultColumnWidth string
+ DefaultWindowHeight string
+ VariableRefreshRate *bool
+ BlockOutFrom string
+ DefaultColumnDisplay string
+ ScrollFactor *float64
+ CornerRadius *int
+ ClipToGeometry *bool
+ TiledState *bool
+ MinWidth *int
+ MaxWidth *int
+ MinHeight *int
+ MaxHeight *int
+ BorderColor string
+ FocusRingColor string
+ FocusRingOff *bool
+ BorderOff *bool
+ DrawBorderWithBg *bool
+ Source string
+}
+
+type NiriRulesParser struct {
+ configDir string
+ processedFiles map[string]bool
+ rules []NiriWindowRule
+ currentSource string
+ dmsRulesIncluded bool
+ dmsRulesExists bool
+ includeCount int
+ dmsIncludePos int
+ rulesAfterDMS int
+ dmsProcessed bool
+}
+
+func NewNiriRulesParser(configDir string) *NiriRulesParser {
+ return &NiriRulesParser{
+ configDir: configDir,
+ processedFiles: make(map[string]bool),
+ rules: []NiriWindowRule{},
+ dmsIncludePos: -1,
+ }
+}
+
+func (p *NiriRulesParser) Parse() ([]NiriWindowRule, error) {
+ dmsRulesPath := filepath.Join(p.configDir, "dms", "windowrules.kdl")
+ if _, err := os.Stat(dmsRulesPath); err == nil {
+ p.dmsRulesExists = true
+ }
+
+ configPath := filepath.Join(p.configDir, "config.kdl")
+ if err := p.parseFile(configPath); err != nil {
+ return nil, err
+ }
+
+ if p.dmsRulesExists && !p.dmsProcessed {
+ p.parseDMSRulesDirectly(dmsRulesPath)
+ }
+
+ return p.rules, nil
+}
+
+func (p *NiriRulesParser) parseDMSRulesDirectly(dmsRulesPath string) {
+ data, err := os.ReadFile(dmsRulesPath)
+ if err != nil {
+ return
+ }
+
+ doc, err := kdl.Parse(strings.NewReader(string(data)))
+ if err != nil {
+ return
+ }
+
+ prevSource := p.currentSource
+ p.currentSource = dmsRulesPath
+ p.processNodes(doc.Nodes, filepath.Dir(dmsRulesPath))
+ p.currentSource = prevSource
+ p.dmsProcessed = true
+}
+
+func (p *NiriRulesParser) 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
+ }
+
+ doc, err := kdl.Parse(strings.NewReader(string(data)))
+ if err != nil {
+ return err
+ }
+
+ prevSource := p.currentSource
+ p.currentSource = absPath
+ baseDir := filepath.Dir(absPath)
+ p.processNodes(doc.Nodes, baseDir)
+ p.currentSource = prevSource
+
+ return nil
+}
+
+func (p *NiriRulesParser) processNodes(nodes []*document.Node, baseDir string) {
+ for _, node := range nodes {
+ name := node.Name.String()
+
+ switch name {
+ case "include":
+ p.handleInclude(node, baseDir)
+ case "window-rule":
+ p.parseWindowRuleNode(node)
+ }
+ }
+}
+
+func (p *NiriRulesParser) handleInclude(node *document.Node, baseDir string) {
+ if len(node.Arguments) == 0 {
+ return
+ }
+
+ includePath := strings.Trim(node.Arguments[0].String(), "\"")
+ isDMSInclude := includePath == "dms/windowrules.kdl" || strings.HasSuffix(includePath, "/dms/windowrules.kdl")
+
+ p.includeCount++
+ if isDMSInclude {
+ p.dmsRulesIncluded = true
+ p.dmsIncludePos = p.includeCount
+ p.dmsProcessed = true
+ }
+
+ fullPath := filepath.Join(baseDir, includePath)
+ if filepath.IsAbs(includePath) {
+ fullPath = includePath
+ }
+
+ _ = p.parseFile(fullPath)
+}
+
+func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
+ if node.Children == nil {
+ return
+ }
+
+ rule := NiriWindowRule{
+ Source: p.currentSource,
+ }
+
+ for _, child := range node.Children {
+ childName := child.Name.String()
+
+ switch childName {
+ case "match":
+ p.parseMatchNode(child, &rule)
+ case "opacity":
+ if len(child.Arguments) > 0 {
+ val := child.Arguments[0].ResolvedValue()
+ if f, ok := val.(float64); ok {
+ rule.Opacity = &f
+ }
+ }
+ case "open-floating":
+ b := p.parseBoolArg(child)
+ rule.OpenFloating = &b
+ case "open-maximized":
+ b := p.parseBoolArg(child)
+ rule.OpenMaximized = &b
+ case "open-maximized-to-edges":
+ b := p.parseBoolArg(child)
+ rule.OpenMaximizedToEdges = &b
+ case "open-fullscreen":
+ b := p.parseBoolArg(child)
+ rule.OpenFullscreen = &b
+ case "open-focused":
+ b := p.parseBoolArg(child)
+ rule.OpenFocused = &b
+ case "open-on-output":
+ if len(child.Arguments) > 0 {
+ rule.OpenOnOutput = child.Arguments[0].ValueString()
+ }
+ case "open-on-workspace":
+ if len(child.Arguments) > 0 {
+ rule.OpenOnWorkspace = child.Arguments[0].ValueString()
+ }
+ case "default-column-width":
+ rule.DefaultColumnWidth = p.parseSizeNode(child)
+ case "default-window-height":
+ rule.DefaultWindowHeight = p.parseSizeNode(child)
+ case "variable-refresh-rate":
+ b := p.parseBoolArg(child)
+ rule.VariableRefreshRate = &b
+ case "block-out-from":
+ if len(child.Arguments) > 0 {
+ rule.BlockOutFrom = child.Arguments[0].ValueString()
+ }
+ case "default-column-display":
+ if len(child.Arguments) > 0 {
+ rule.DefaultColumnDisplay = child.Arguments[0].ValueString()
+ }
+ case "scroll-factor":
+ if len(child.Arguments) > 0 {
+ val := child.Arguments[0].ResolvedValue()
+ if f, ok := val.(float64); ok {
+ rule.ScrollFactor = &f
+ }
+ }
+ case "geometry-corner-radius":
+ if len(child.Arguments) > 0 {
+ val := child.Arguments[0].ResolvedValue()
+ if i, ok := val.(int64); ok {
+ intVal := int(i)
+ rule.CornerRadius = &intVal
+ }
+ }
+ case "clip-to-geometry":
+ b := p.parseBoolArg(child)
+ rule.ClipToGeometry = &b
+ case "tiled-state":
+ b := p.parseBoolArg(child)
+ rule.TiledState = &b
+ case "min-width":
+ if len(child.Arguments) > 0 {
+ val := child.Arguments[0].ResolvedValue()
+ if i, ok := val.(int64); ok {
+ intVal := int(i)
+ rule.MinWidth = &intVal
+ }
+ }
+ case "max-width":
+ if len(child.Arguments) > 0 {
+ val := child.Arguments[0].ResolvedValue()
+ if i, ok := val.(int64); ok {
+ intVal := int(i)
+ rule.MaxWidth = &intVal
+ }
+ }
+ case "min-height":
+ if len(child.Arguments) > 0 {
+ val := child.Arguments[0].ResolvedValue()
+ if i, ok := val.(int64); ok {
+ intVal := int(i)
+ rule.MinHeight = &intVal
+ }
+ }
+ case "max-height":
+ if len(child.Arguments) > 0 {
+ val := child.Arguments[0].ResolvedValue()
+ if i, ok := val.(int64); ok {
+ intVal := int(i)
+ rule.MaxHeight = &intVal
+ }
+ }
+ case "border":
+ p.parseBorderNode(child, &rule)
+ case "focus-ring":
+ p.parseFocusRingNode(child, &rule)
+ case "draw-border-with-background":
+ b := p.parseBoolArg(child)
+ rule.DrawBorderWithBg = &b
+ }
+ }
+
+ p.rules = append(p.rules, rule)
+}
+
+func (p *NiriRulesParser) parseSizeNode(node *document.Node) string {
+ if node.Children == nil {
+ return ""
+ }
+ for _, child := range node.Children {
+ name := child.Name.String()
+ if len(child.Arguments) > 0 {
+ val := child.Arguments[0].ResolvedValue()
+ switch name {
+ case "fixed":
+ if i, ok := val.(int64); ok {
+ return "fixed " + strconv.FormatInt(i, 10)
+ }
+ case "proportion":
+ if f, ok := val.(float64); ok {
+ return "proportion " + strconv.FormatFloat(f, 'f', -1, 64)
+ }
+ }
+ }
+ }
+ return ""
+}
+
+func (p *NiriRulesParser) parseMatchNode(node *document.Node, rule *NiriWindowRule) {
+ if node.Properties == nil {
+ return
+ }
+
+ if val, ok := node.Properties.Get("app-id"); ok {
+ rule.MatchAppID = val.ValueString()
+ }
+ if val, ok := node.Properties.Get("title"); ok {
+ rule.MatchTitle = val.ValueString()
+ }
+ if val, ok := node.Properties.Get("is-floating"); ok {
+ b := val.ValueString() == "true"
+ rule.MatchIsFloating = &b
+ }
+ if val, ok := node.Properties.Get("is-active"); ok {
+ b := val.ValueString() == "true"
+ rule.MatchIsActive = &b
+ }
+ if val, ok := node.Properties.Get("is-focused"); ok {
+ b := val.ValueString() == "true"
+ rule.MatchIsFocused = &b
+ }
+ if val, ok := node.Properties.Get("is-active-in-column"); ok {
+ b := val.ValueString() == "true"
+ rule.MatchIsActiveInColumn = &b
+ }
+ if val, ok := node.Properties.Get("is-window-cast-target"); ok {
+ b := val.ValueString() == "true"
+ rule.MatchIsWindowCastTarget = &b
+ }
+ if val, ok := node.Properties.Get("is-urgent"); ok {
+ b := val.ValueString() == "true"
+ rule.MatchIsUrgent = &b
+ }
+ if val, ok := node.Properties.Get("at-startup"); ok {
+ b := val.ValueString() == "true"
+ rule.MatchAtStartup = &b
+ }
+}
+
+func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) {
+ if node.Children == nil {
+ return
+ }
+
+ for _, child := range node.Children {
+ switch child.Name.String() {
+ case "off":
+ b := true
+ rule.BorderOff = &b
+ case "active-color":
+ if len(child.Arguments) > 0 {
+ rule.BorderColor = child.Arguments[0].ValueString()
+ }
+ }
+ }
+}
+
+func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) {
+ if node.Children == nil {
+ return
+ }
+
+ for _, child := range node.Children {
+ switch child.Name.String() {
+ case "off":
+ b := true
+ rule.FocusRingOff = &b
+ case "active-color":
+ if len(child.Arguments) > 0 {
+ rule.FocusRingColor = child.Arguments[0].ValueString()
+ }
+ }
+ }
+}
+
+func (p *NiriRulesParser) parseBoolArg(node *document.Node) bool {
+ if len(node.Arguments) == 0 {
+ return true
+ }
+ return node.Arguments[0].ValueString() != "false"
+}
+
+func (p *NiriRulesParser) HasDMSRulesIncluded() bool {
+ return p.dmsRulesIncluded
+}
+
+func (p *NiriRulesParser) 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/windowrules.kdl does not exist"
+ case !p.dmsRulesIncluded:
+ status.Effective = false
+ status.StatusMessage = "dms/windowrules.kdl is not included in config.kdl"
+ 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 NiriRulesParseResult struct {
+ Rules []NiriWindowRule
+ DMSRulesIncluded bool
+ DMSStatus *windowrules.DMSRulesStatus
+}
+
+func ParseNiriWindowRules(configDir string) (*NiriRulesParseResult, error) {
+ parser := NewNiriRulesParser(configDir)
+ rules, err := parser.Parse()
+ if err != nil {
+ return nil, err
+ }
+ return &NiriRulesParseResult{
+ Rules: rules,
+ DMSRulesIncluded: parser.HasDMSRulesIncluded(),
+ DMSStatus: parser.buildDMSStatus(),
+ }, nil
+}
+
+func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule {
+ result := make([]windowrules.WindowRule, 0, len(niriRules))
+ for i, nr := range niriRules {
+ wr := windowrules.WindowRule{
+ ID: fmt.Sprintf("rule_%d", i),
+ Enabled: true,
+ Source: nr.Source,
+ MatchCriteria: windowrules.MatchCriteria{
+ AppID: nr.MatchAppID,
+ Title: nr.MatchTitle,
+ IsFloating: nr.MatchIsFloating,
+ IsActive: nr.MatchIsActive,
+ IsFocused: nr.MatchIsFocused,
+ IsActiveInColumn: nr.MatchIsActiveInColumn,
+ IsWindowCastTarget: nr.MatchIsWindowCastTarget,
+ IsUrgent: nr.MatchIsUrgent,
+ AtStartup: nr.MatchAtStartup,
+ },
+ Actions: windowrules.Actions{
+ Opacity: nr.Opacity,
+ OpenFloating: nr.OpenFloating,
+ OpenMaximized: nr.OpenMaximized,
+ OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
+ OpenFullscreen: nr.OpenFullscreen,
+ OpenFocused: nr.OpenFocused,
+ OpenOnOutput: nr.OpenOnOutput,
+ OpenOnWorkspace: nr.OpenOnWorkspace,
+ DefaultColumnWidth: nr.DefaultColumnWidth,
+ DefaultWindowHeight: nr.DefaultWindowHeight,
+ VariableRefreshRate: nr.VariableRefreshRate,
+ BlockOutFrom: nr.BlockOutFrom,
+ DefaultColumnDisplay: nr.DefaultColumnDisplay,
+ ScrollFactor: nr.ScrollFactor,
+ CornerRadius: nr.CornerRadius,
+ ClipToGeometry: nr.ClipToGeometry,
+ TiledState: nr.TiledState,
+ MinWidth: nr.MinWidth,
+ MaxWidth: nr.MaxWidth,
+ MinHeight: nr.MinHeight,
+ MaxHeight: nr.MaxHeight,
+ BorderColor: nr.BorderColor,
+ FocusRingColor: nr.FocusRingColor,
+ FocusRingOff: nr.FocusRingOff,
+ BorderOff: nr.BorderOff,
+ DrawBorderWithBg: nr.DrawBorderWithBg,
+ },
+ }
+ result = append(result, wr)
+ }
+ return result
+}
+
+type NiriWritableProvider struct {
+ configDir string
+}
+
+func NewNiriWritableProvider(configDir string) *NiriWritableProvider {
+ return &NiriWritableProvider{configDir: configDir}
+}
+
+func (p *NiriWritableProvider) Name() string {
+ return "niri"
+}
+
+func (p *NiriWritableProvider) GetOverridePath() string {
+ return filepath.Join(p.configDir, "dms", "windowrules.kdl")
+}
+
+func (p *NiriWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
+ result, err := ParseNiriWindowRules(p.configDir)
+ if err != nil {
+ return nil, err
+ }
+ return &windowrules.RuleSet{
+ Title: "Niri Window Rules",
+ Provider: "niri",
+ Rules: ConvertNiriRulesToWindowRules(result.Rules),
+ DMSRulesIncluded: result.DMSRulesIncluded,
+ DMSStatus: result.DMSStatus,
+ }, nil
+}
+
+func (p *NiriWritableProvider) 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 *NiriWritableProvider) 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 *NiriWritableProvider) 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 niriMetaCommentRegex = regexp.MustCompile(`^//\s*@id=(\S*)\s*@name=(.*)$`)
+
+func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) {
+ rulesPath := p.GetOverridePath()
+ data, err := os.ReadFile(rulesPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []windowrules.WindowRule{}, nil
+ }
+ return nil, err
+ }
+
+ content := string(data)
+ lines := strings.Split(content, "\n")
+
+ type ruleMeta struct {
+ id string
+ name string
+ }
+ var metas []ruleMeta
+ var currentID, currentName string
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if matches := niriMetaCommentRegex.FindStringSubmatch(trimmed); matches != nil {
+ currentID = matches[1]
+ currentName = strings.TrimSpace(matches[2])
+ continue
+ }
+ if strings.HasPrefix(trimmed, "window-rule") {
+ metas = append(metas, ruleMeta{id: currentID, name: currentName})
+ currentID = ""
+ currentName = ""
+ }
+ }
+
+ doc, err := kdl.Parse(strings.NewReader(content))
+ if err != nil {
+ return nil, err
+ }
+
+ parser := NewNiriRulesParser(p.configDir)
+ parser.currentSource = rulesPath
+
+ for _, node := range doc.Nodes {
+ if node.Name.String() == "window-rule" {
+ parser.parseWindowRuleNode(node)
+ }
+ }
+
+ var rules []windowrules.WindowRule
+ for i, nr := range parser.rules {
+ id := ""
+ name := ""
+ if i < len(metas) {
+ id = metas[i].id
+ name = metas[i].name
+ }
+ if id == "" {
+ id = fmt.Sprintf("dms_rule_%d", i)
+ }
+
+ wr := windowrules.WindowRule{
+ ID: id,
+ Name: name,
+ Enabled: true,
+ Source: rulesPath,
+ MatchCriteria: windowrules.MatchCriteria{
+ AppID: nr.MatchAppID,
+ Title: nr.MatchTitle,
+ IsFloating: nr.MatchIsFloating,
+ IsActive: nr.MatchIsActive,
+ IsFocused: nr.MatchIsFocused,
+ IsActiveInColumn: nr.MatchIsActiveInColumn,
+ IsWindowCastTarget: nr.MatchIsWindowCastTarget,
+ IsUrgent: nr.MatchIsUrgent,
+ AtStartup: nr.MatchAtStartup,
+ },
+ Actions: windowrules.Actions{
+ Opacity: nr.Opacity,
+ OpenFloating: nr.OpenFloating,
+ OpenMaximized: nr.OpenMaximized,
+ OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
+ OpenFullscreen: nr.OpenFullscreen,
+ OpenFocused: nr.OpenFocused,
+ OpenOnOutput: nr.OpenOnOutput,
+ OpenOnWorkspace: nr.OpenOnWorkspace,
+ DefaultColumnWidth: nr.DefaultColumnWidth,
+ DefaultWindowHeight: nr.DefaultWindowHeight,
+ VariableRefreshRate: nr.VariableRefreshRate,
+ BlockOutFrom: nr.BlockOutFrom,
+ DefaultColumnDisplay: nr.DefaultColumnDisplay,
+ ScrollFactor: nr.ScrollFactor,
+ CornerRadius: nr.CornerRadius,
+ ClipToGeometry: nr.ClipToGeometry,
+ TiledState: nr.TiledState,
+ MinWidth: nr.MinWidth,
+ MaxWidth: nr.MaxWidth,
+ MinHeight: nr.MinHeight,
+ MaxHeight: nr.MaxHeight,
+ BorderColor: nr.BorderColor,
+ FocusRingColor: nr.FocusRingColor,
+ FocusRingOff: nr.FocusRingOff,
+ BorderOff: nr.BorderOff,
+ DrawBorderWithBg: nr.DrawBorderWithBg,
+ },
+ }
+
+ rules = append(rules, wr)
+ }
+
+ return rules, nil
+}
+
+func (p *NiriWritableProvider) 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, p.formatRule(rule))
+ lines = append(lines, "")
+ }
+
+ return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
+}
+
+func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
+ var lines []string
+ lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name))
+ lines = append(lines, "window-rule {")
+
+ m := rule.MatchCriteria
+ if m.AppID != "" || m.Title != "" || m.IsFloating != nil || m.IsActive != nil ||
+ m.IsFocused != nil || m.IsActiveInColumn != nil || m.IsWindowCastTarget != nil ||
+ m.IsUrgent != nil || m.AtStartup != nil {
+ var matchProps []string
+ if m.AppID != "" {
+ matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
+ }
+ if m.Title != "" {
+ matchProps = append(matchProps, fmt.Sprintf("title=%q", m.Title))
+ }
+ if m.IsFloating != nil {
+ matchProps = append(matchProps, fmt.Sprintf("is-floating=%t", *m.IsFloating))
+ }
+ if m.IsActive != nil {
+ matchProps = append(matchProps, fmt.Sprintf("is-active=%t", *m.IsActive))
+ }
+ if m.IsFocused != nil {
+ matchProps = append(matchProps, fmt.Sprintf("is-focused=%t", *m.IsFocused))
+ }
+ if m.IsActiveInColumn != nil {
+ matchProps = append(matchProps, fmt.Sprintf("is-active-in-column=%t", *m.IsActiveInColumn))
+ }
+ if m.IsWindowCastTarget != nil {
+ matchProps = append(matchProps, fmt.Sprintf("is-window-cast-target=%t", *m.IsWindowCastTarget))
+ }
+ if m.IsUrgent != nil {
+ matchProps = append(matchProps, fmt.Sprintf("is-urgent=%t", *m.IsUrgent))
+ }
+ if m.AtStartup != nil {
+ matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup))
+ }
+ lines = append(lines, " match "+strings.Join(matchProps, " "))
+ }
+
+ a := rule.Actions
+ if a.Opacity != nil {
+ lines = append(lines, fmt.Sprintf(" opacity %.2f", *a.Opacity))
+ }
+ if a.OpenFloating != nil && *a.OpenFloating {
+ lines = append(lines, " open-floating true")
+ }
+ if a.OpenMaximized != nil && *a.OpenMaximized {
+ lines = append(lines, " open-maximized true")
+ }
+ if a.OpenMaximizedToEdges != nil && *a.OpenMaximizedToEdges {
+ lines = append(lines, " open-maximized-to-edges true")
+ }
+ if a.OpenFullscreen != nil && *a.OpenFullscreen {
+ lines = append(lines, " open-fullscreen true")
+ }
+ if a.OpenFocused != nil {
+ lines = append(lines, fmt.Sprintf(" open-focused %t", *a.OpenFocused))
+ }
+ if a.OpenOnOutput != "" {
+ lines = append(lines, fmt.Sprintf(" open-on-output %q", a.OpenOnOutput))
+ }
+ if a.OpenOnWorkspace != "" {
+ lines = append(lines, fmt.Sprintf(" open-on-workspace %q", a.OpenOnWorkspace))
+ }
+ if a.DefaultColumnWidth != "" {
+ lines = append(lines, formatSizeProperty("default-column-width", a.DefaultColumnWidth))
+ }
+ if a.DefaultWindowHeight != "" {
+ lines = append(lines, formatSizeProperty("default-window-height", a.DefaultWindowHeight))
+ }
+ if a.VariableRefreshRate != nil && *a.VariableRefreshRate {
+ lines = append(lines, " variable-refresh-rate true")
+ }
+ if a.BlockOutFrom != "" {
+ lines = append(lines, fmt.Sprintf(" block-out-from %q", a.BlockOutFrom))
+ }
+ if a.DefaultColumnDisplay != "" {
+ lines = append(lines, fmt.Sprintf(" default-column-display %q", a.DefaultColumnDisplay))
+ }
+ if a.ScrollFactor != nil {
+ lines = append(lines, fmt.Sprintf(" scroll-factor %.2f", *a.ScrollFactor))
+ }
+ if a.CornerRadius != nil {
+ lines = append(lines, fmt.Sprintf(" geometry-corner-radius %d", *a.CornerRadius))
+ }
+ if a.ClipToGeometry != nil && *a.ClipToGeometry {
+ lines = append(lines, " clip-to-geometry true")
+ }
+ if a.TiledState != nil && *a.TiledState {
+ lines = append(lines, " tiled-state true")
+ }
+ if a.MinWidth != nil {
+ lines = append(lines, fmt.Sprintf(" min-width %d", *a.MinWidth))
+ }
+ if a.MaxWidth != nil {
+ lines = append(lines, fmt.Sprintf(" max-width %d", *a.MaxWidth))
+ }
+ if a.MinHeight != nil {
+ lines = append(lines, fmt.Sprintf(" min-height %d", *a.MinHeight))
+ }
+ if a.MaxHeight != nil {
+ lines = append(lines, fmt.Sprintf(" max-height %d", *a.MaxHeight))
+ }
+ if a.BorderOff != nil && *a.BorderOff {
+ lines = append(lines, " border { off; }")
+ } else if a.BorderColor != "" {
+ lines = append(lines, fmt.Sprintf(" border { active-color %q; }", a.BorderColor))
+ }
+ if a.FocusRingOff != nil && *a.FocusRingOff {
+ lines = append(lines, " focus-ring { off; }")
+ } else if a.FocusRingColor != "" {
+ lines = append(lines, fmt.Sprintf(" focus-ring { active-color %q; }", a.FocusRingColor))
+ }
+ if a.DrawBorderWithBg != nil {
+ lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg))
+ }
+
+ lines = append(lines, "}")
+ return strings.Join(lines, "\n")
+}
+
+func formatSizeProperty(name, value string) string {
+ parts := strings.SplitN(value, " ", 2)
+ if len(parts) == 2 {
+ return fmt.Sprintf(" %s { %s %s; }", name, parts[0], parts[1])
+ }
+ // Bare number without type prefix — default to "fixed"
+ if _, err := strconv.Atoi(value); err == nil {
+ return fmt.Sprintf(" %s { fixed %s; }", name, value)
+ }
+ return fmt.Sprintf(" %s { }", name)
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser_test.go
new file mode 100644
index 0000000..f764b6a
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser_test.go
@@ -0,0 +1,335 @@
+package providers
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestNiriParseBasicWindowRule(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ config := `
+window-rule {
+ match app-id="^firefox$"
+ opacity 0.9
+ open-floating true
+}
+`
+ if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ parser := NewNiriRulesParser(tmpDir)
+ rules, err := parser.Parse()
+ if err != nil {
+ t.Fatalf("Parse failed: %v", err)
+ }
+
+ if len(rules) != 1 {
+ t.Fatalf("expected 1 rule, got %d", len(rules))
+ }
+
+ rule := rules[0]
+ if rule.MatchAppID != "^firefox$" {
+ t.Errorf("MatchAppID = %q, want ^firefox$", rule.MatchAppID)
+ }
+ if rule.Opacity == nil || *rule.Opacity != 0.9 {
+ t.Errorf("Opacity = %v, want 0.9", rule.Opacity)
+ }
+ if rule.OpenFloating == nil || !*rule.OpenFloating {
+ t.Error("OpenFloating should be true")
+ }
+}
+
+func TestNiriParseMultipleRules(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ config := `
+window-rule {
+ match app-id="app1"
+ open-maximized true
+}
+
+window-rule {
+ match app-id="app2"
+ open-fullscreen true
+}
+`
+ if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ parser := NewNiriRulesParser(tmpDir)
+ rules, err := parser.Parse()
+ if err != nil {
+ t.Fatalf("Parse failed: %v", err)
+ }
+
+ if len(rules) != 2 {
+ t.Fatalf("expected 2 rules, got %d", len(rules))
+ }
+
+ if rules[0].MatchAppID != "app1" {
+ t.Errorf("rule 0 MatchAppID = %q, want app1", rules[0].MatchAppID)
+ }
+ if rules[1].MatchAppID != "app2" {
+ t.Errorf("rule 1 MatchAppID = %q, want app2", rules[1].MatchAppID)
+ }
+}
+
+func TestConvertNiriRulesToWindowRules(t *testing.T) {
+ niriRules := []NiriWindowRule{
+ {MatchAppID: "^firefox$", Opacity: floatPtr(0.8)},
+ {MatchAppID: "^code$", OpenFloating: boolPtr(true)},
+ }
+
+ result := ConvertNiriRulesToWindowRules(niriRules)
+
+ if len(result) != 2 {
+ t.Errorf("expected 2 rules, got %d", len(result))
+ }
+
+ if result[0].MatchCriteria.AppID != "^firefox$" {
+ t.Errorf("rule 0 AppID = %q, want ^firefox$", result[0].MatchCriteria.AppID)
+ }
+ if result[0].Actions.Opacity == nil || *result[0].Actions.Opacity != 0.8 {
+ t.Errorf("rule 0 Opacity = %v, want 0.8", result[0].Actions.Opacity)
+ }
+
+ if result[1].Actions.OpenFloating == nil || !*result[1].Actions.OpenFloating {
+ t.Error("rule 1 should have OpenFloating = true")
+ }
+}
+
+func TestNiriWritableProvider(t *testing.T) {
+ tmpDir := t.TempDir()
+ provider := NewNiriWritableProvider(tmpDir)
+
+ if provider.Name() != "niri" {
+ t.Errorf("Name() = %q, want niri", provider.Name())
+ }
+
+ expectedPath := filepath.Join(tmpDir, "dms", "windowrules.kdl")
+ if provider.GetOverridePath() != expectedPath {
+ t.Errorf("GetOverridePath() = %q, want %q", provider.GetOverridePath(), expectedPath)
+ }
+}
+
+func TestNiriSetAndLoadDMSRules(t *testing.T) {
+ tmpDir := t.TempDir()
+ provider := NewNiriWritableProvider(tmpDir)
+
+ rule := newTestWindowRule("test_id", "Test Rule", "^firefox$")
+ rule.Actions.OpenFloating = boolPtr(true)
+ rule.Actions.Opacity = floatPtr(0.85)
+
+ if err := provider.SetRule(rule); err != nil {
+ t.Fatalf("SetRule failed: %v", err)
+ }
+
+ rules, err := provider.LoadDMSRules()
+ if err != nil {
+ t.Fatalf("LoadDMSRules failed: %v", err)
+ }
+
+ if len(rules) != 1 {
+ t.Fatalf("expected 1 rule, got %d", len(rules))
+ }
+
+ if rules[0].ID != "test_id" {
+ t.Errorf("ID = %q, want test_id", rules[0].ID)
+ }
+ if rules[0].MatchCriteria.AppID != "^firefox$" {
+ t.Errorf("AppID = %q, want ^firefox$", rules[0].MatchCriteria.AppID)
+ }
+}
+
+func TestNiriRemoveRule(t *testing.T) {
+ tmpDir := t.TempDir()
+ provider := NewNiriWritableProvider(tmpDir)
+
+ rule1 := newTestWindowRule("rule1", "Rule 1", "app1")
+ rule1.Actions.OpenFloating = boolPtr(true)
+ rule2 := newTestWindowRule("rule2", "Rule 2", "app2")
+ rule2.Actions.OpenFloating = boolPtr(true)
+
+ _ = provider.SetRule(rule1)
+ _ = provider.SetRule(rule2)
+
+ if err := provider.RemoveRule("rule1"); err != nil {
+ t.Fatalf("RemoveRule failed: %v", err)
+ }
+
+ rules, _ := provider.LoadDMSRules()
+ if len(rules) != 1 {
+ t.Fatalf("expected 1 rule after removal, got %d", len(rules))
+ }
+ if rules[0].ID != "rule2" {
+ t.Errorf("remaining rule ID = %q, want rule2", rules[0].ID)
+ }
+}
+
+func TestNiriReorderRules(t *testing.T) {
+ tmpDir := t.TempDir()
+ provider := NewNiriWritableProvider(tmpDir)
+
+ rule1 := newTestWindowRule("rule1", "Rule 1", "app1")
+ rule1.Actions.OpenFloating = boolPtr(true)
+ rule2 := newTestWindowRule("rule2", "Rule 2", "app2")
+ rule2.Actions.OpenFloating = boolPtr(true)
+ rule3 := newTestWindowRule("rule3", "Rule 3", "app3")
+ rule3.Actions.OpenFloating = boolPtr(true)
+
+ _ = provider.SetRule(rule1)
+ _ = provider.SetRule(rule2)
+ _ = provider.SetRule(rule3)
+
+ if err := provider.ReorderRules([]string{"rule3", "rule1", "rule2"}); err != nil {
+ t.Fatalf("ReorderRules failed: %v", err)
+ }
+
+ rules, _ := provider.LoadDMSRules()
+ if len(rules) != 3 {
+ t.Fatalf("expected 3 rules, got %d", len(rules))
+ }
+ expectedOrder := []string{"rule3", "rule1", "rule2"}
+ for i, expectedID := range expectedOrder {
+ if rules[i].ID != expectedID {
+ t.Errorf("rule %d ID = %q, want %q", i, rules[i].ID, expectedID)
+ }
+ }
+}
+
+func TestNiriParseConfigWithInclude(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ mainConfig := `
+window-rule {
+ match app-id="mainapp"
+ opacity 1.0
+}
+
+include "extra.kdl"
+`
+ extraConfig := `
+window-rule {
+ match app-id="extraapp"
+ open-maximized true
+}
+`
+
+ if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(mainConfig), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(tmpDir, "extra.kdl"), []byte(extraConfig), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ parser := NewNiriRulesParser(tmpDir)
+ rules, err := parser.Parse()
+ if err != nil {
+ t.Fatalf("Parse failed: %v", err)
+ }
+
+ if len(rules) != 2 {
+ t.Errorf("expected 2 rules, got %d", len(rules))
+ }
+}
+
+func TestNiriParseSizeNode(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ config := `
+window-rule {
+ match app-id="testapp"
+ default-column-width { fixed 800; }
+ default-window-height { proportion 0.5; }
+}
+`
+ if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ parser := NewNiriRulesParser(tmpDir)
+ rules, err := parser.Parse()
+ if err != nil {
+ t.Fatalf("Parse failed: %v", err)
+ }
+
+ if len(rules) != 1 {
+ t.Fatalf("expected 1 rule, got %d", len(rules))
+ }
+
+ if rules[0].DefaultColumnWidth != "fixed 800" {
+ t.Errorf("DefaultColumnWidth = %q, want 'fixed 800'", rules[0].DefaultColumnWidth)
+ }
+ if rules[0].DefaultWindowHeight != "proportion 0.5" {
+ t.Errorf("DefaultWindowHeight = %q, want 'proportion 0.5'", rules[0].DefaultWindowHeight)
+ }
+}
+
+func TestFormatSizeProperty(t *testing.T) {
+ tests := []struct {
+ name string
+ propName string
+ value string
+ want string
+ }{
+ {
+ name: "fixed size",
+ propName: "default-column-width",
+ value: "fixed 800",
+ want: " default-column-width { fixed 800; }",
+ },
+ {
+ name: "proportion",
+ propName: "default-window-height",
+ value: "proportion 0.5",
+ want: " default-window-height { proportion 0.5; }",
+ },
+ {
+ name: "invalid format",
+ propName: "default-column-width",
+ value: "invalid",
+ want: " default-column-width { }",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := formatSizeProperty(tt.propName, tt.value)
+ if result != tt.want {
+ t.Errorf("formatSizeProperty(%q, %q) = %q, want %q",
+ tt.propName, tt.value, result, tt.want)
+ }
+ })
+ }
+}
+
+func TestNiriDMSRulesStatus(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ config := `
+window-rule {
+ match app-id="testapp"
+ opacity 0.9
+}
+`
+ if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := ParseNiriWindowRules(tmpDir)
+ if err != nil {
+ t.Fatalf("ParseNiriWindowRules failed: %v", err)
+ }
+
+ if result.DMSStatus == nil {
+ t.Fatal("DMSStatus should not be nil")
+ }
+
+ if result.DMSStatus.Exists {
+ t.Error("DMSStatus.Exists should be false when dms rules file doesn't exist")
+ }
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/providers_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/providers_test.go
new file mode 100644
index 0000000..de84a38
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/providers_test.go
@@ -0,0 +1,22 @@
+package providers
+
+import "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
+
+func newTestWindowRule(id, name, appID string) windowrules.WindowRule {
+ return windowrules.WindowRule{
+ ID: id,
+ Name: name,
+ Enabled: true,
+ MatchCriteria: windowrules.MatchCriteria{
+ AppID: appID,
+ },
+ }
+}
+
+func boolPtr(b bool) *bool {
+ return &b
+}
+
+func floatPtr(f float64) *float64 {
+ return &f
+}
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/types.go
new file mode 100644
index 0000000..556acb7
--- /dev/null
+++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/types.go
@@ -0,0 +1,103 @@
+package windowrules
+
+type MatchCriteria struct {
+ AppID string `json:"appId,omitempty"`
+ Title string `json:"title,omitempty"`
+ IsFloating *bool `json:"isFloating,omitempty"`
+ IsActive *bool `json:"isActive,omitempty"`
+ IsFocused *bool `json:"isFocused,omitempty"`
+ IsActiveInColumn *bool `json:"isActiveInColumn,omitempty"`
+ IsWindowCastTarget *bool `json:"isWindowCastTarget,omitempty"`
+ IsUrgent *bool `json:"isUrgent,omitempty"`
+ AtStartup *bool `json:"atStartup,omitempty"`
+ XWayland *bool `json:"xwayland,omitempty"`
+ Fullscreen *bool `json:"fullscreen,omitempty"`
+ Pinned *bool `json:"pinned,omitempty"`
+ Initialised *bool `json:"initialised,omitempty"`
+}
+
+type Actions struct {
+ Opacity *float64 `json:"opacity,omitempty"`
+ OpenFloating *bool `json:"openFloating,omitempty"`
+ OpenMaximized *bool `json:"openMaximized,omitempty"`
+ OpenMaximizedToEdges *bool `json:"openMaximizedToEdges,omitempty"`
+ OpenFullscreen *bool `json:"openFullscreen,omitempty"`
+ OpenFocused *bool `json:"openFocused,omitempty"`
+ OpenOnOutput string `json:"openOnOutput,omitempty"`
+ OpenOnWorkspace string `json:"openOnWorkspace,omitempty"`
+ DefaultColumnWidth string `json:"defaultColumnWidth,omitempty"`
+ DefaultWindowHeight string `json:"defaultWindowHeight,omitempty"`
+ VariableRefreshRate *bool `json:"variableRefreshRate,omitempty"`
+ BlockOutFrom string `json:"blockOutFrom,omitempty"`
+ DefaultColumnDisplay string `json:"defaultColumnDisplay,omitempty"`
+ ScrollFactor *float64 `json:"scrollFactor,omitempty"`
+ CornerRadius *int `json:"cornerRadius,omitempty"`
+ ClipToGeometry *bool `json:"clipToGeometry,omitempty"`
+ TiledState *bool `json:"tiledState,omitempty"`
+ MinWidth *int `json:"minWidth,omitempty"`
+ MaxWidth *int `json:"maxWidth,omitempty"`
+ MinHeight *int `json:"minHeight,omitempty"`
+ MaxHeight *int `json:"maxHeight,omitempty"`
+ BorderColor string `json:"borderColor,omitempty"`
+ FocusRingColor string `json:"focusRingColor,omitempty"`
+ FocusRingOff *bool `json:"focusRingOff,omitempty"`
+ BorderOff *bool `json:"borderOff,omitempty"`
+ DrawBorderWithBg *bool `json:"drawBorderWithBackground,omitempty"`
+ Size string `json:"size,omitempty"`
+ Move string `json:"move,omitempty"`
+ Monitor string `json:"monitor,omitempty"`
+ Workspace string `json:"workspace,omitempty"`
+ Tile *bool `json:"tile,omitempty"`
+ NoFocus *bool `json:"nofocus,omitempty"`
+ NoBorder *bool `json:"noborder,omitempty"`
+ NoShadow *bool `json:"noshadow,omitempty"`
+ NoDim *bool `json:"nodim,omitempty"`
+ NoBlur *bool `json:"noblur,omitempty"`
+ NoAnim *bool `json:"noanim,omitempty"`
+ NoRounding *bool `json:"norounding,omitempty"`
+ Pin *bool `json:"pin,omitempty"`
+ Opaque *bool `json:"opaque,omitempty"`
+ ForcergbX *bool `json:"forcergbx,omitempty"`
+ Idleinhibit string `json:"idleinhibit,omitempty"`
+}
+
+type WindowRule struct {
+ ID string `json:"id"`
+ Name string `json:"name,omitempty"`
+ Enabled bool `json:"enabled"`
+ MatchCriteria MatchCriteria `json:"matchCriteria"`
+ Actions Actions `json:"actions"`
+ Source string `json:"source,omitempty"`
+}
+
+type DMSRulesStatus struct {
+ Exists bool `json:"exists"`
+ Included bool `json:"included"`
+ IncludePosition int `json:"includePosition"`
+ TotalIncludes int `json:"totalIncludes"`
+ RulesAfterDMS int `json:"rulesAfterDms"`
+ Effective bool `json:"effective"`
+ OverriddenBy int `json:"overriddenBy"`
+ StatusMessage string `json:"statusMessage"`
+}
+
+type RuleSet struct {
+ Title string `json:"title"`
+ Provider string `json:"provider"`
+ Rules []WindowRule `json:"rules"`
+ DMSRulesIncluded bool `json:"dmsRulesIncluded"`
+ DMSStatus *DMSRulesStatus `json:"dmsStatus,omitempty"`
+}
+
+type Provider interface {
+ Name() string
+ GetRuleSet() (*RuleSet, error)
+}
+
+type WritableProvider interface {
+ Provider
+ SetRule(rule WindowRule) error
+ RemoveRule(id string) error
+ ReorderRules(ids []string) error
+ GetOverridePath() string
+}