diff options
Diffstat (limited to 'raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules')
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 +} |