diff --git a/core/internal/windowrules/providers/niri_parser.go b/core/internal/windowrules/providers/niri_parser.go index 8f949fbd8..6387e3a41 100644 --- a/core/internal/windowrules/providers/niri_parser.go +++ b/core/internal/windowrules/providers/niri_parser.go @@ -37,6 +37,7 @@ type NiriWindowRule struct { MatchIsUrgent *bool MatchAtStartup *bool Matches []NiriMatch + Excludes []NiriMatch Opacity *float64 OpenFloating *bool OpenMaximized *bool @@ -212,6 +213,8 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) { switch childName { case "match": rule.Matches = append(rule.Matches, p.parseMatchNode(child)) + case "exclude": + rule.Excludes = append(rule.Excludes, p.parseMatchNode(child)) case "opacity": if len(child.Arguments) > 0 { val := child.Arguments[0].ResolvedValue() @@ -597,7 +600,8 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win IsUrgent: nr.MatchIsUrgent, AtStartup: nr.MatchAtStartup, }, - Matches: convertNiriMatches(nr.Matches), + Matches: convertNiriMatches(nr.Matches), + Excludes: convertNiriMatches(nr.Excludes), Actions: windowrules.Actions{ Opacity: nr.Opacity, OpenFloating: nr.OpenFloating, @@ -810,7 +814,8 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) IsUrgent: nr.MatchIsUrgent, AtStartup: nr.MatchAtStartup, }, - Matches: convertNiriMatches(nr.Matches), + Matches: convertNiriMatches(nr.Matches), + Excludes: convertNiriMatches(nr.Excludes), Actions: windowrules.Actions{ Opacity: nr.Opacity, OpenFloating: nr.OpenFloating, @@ -874,7 +879,7 @@ func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) err return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644) } -func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) { +func formatNiriMatchLine(keyword string, m windowrules.MatchCriteria) (string, bool) { var matchProps []string if m.AppID != "" { matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID)) @@ -906,7 +911,7 @@ func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) { if len(matchProps) == 0 { return "", false } - return " match " + strings.Join(matchProps, " "), true + return " " + keyword + " " + strings.Join(matchProps, " "), true } func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string { @@ -919,7 +924,12 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string { matches = []windowrules.MatchCriteria{rule.MatchCriteria} } for _, m := range matches { - if line, ok := formatNiriMatchLine(m); ok { + if line, ok := formatNiriMatchLine("match", m); ok { + lines = append(lines, line) + } + } + for _, m := range rule.Excludes { + if line, ok := formatNiriMatchLine("exclude", m); ok { lines = append(lines, line) } } diff --git a/core/internal/windowrules/providers/niri_parser_test.go b/core/internal/windowrules/providers/niri_parser_test.go index f764b6a70..cf274b07e 100644 --- a/core/internal/windowrules/providers/niri_parser_test.go +++ b/core/internal/windowrules/providers/niri_parser_test.go @@ -333,3 +333,56 @@ window-rule { t.Error("DMSStatus.Exists should be false when dms rules file doesn't exist") } } + +func TestNiriExcludesSurviveEditOfOtherRule(t *testing.T) { + tmpDir := t.TempDir() + provider := NewNiriWritableProvider(tmpDir) + + dmsDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(dmsDir, 0755); err != nil { + t.Fatal(err) + } + existing := `// @id=thunderbird @name=Thunderbird +window-rule { + match app-id="^org.mozilla.Thunderbird$" + open-floating true + exclude title="^Mozilla Thunderbird$" + exclude title="^Verfassen:.*" +} + +// @id=firefox @name=Firefox +window-rule { + match app-id="^firefox$" + open-floating true +} +` + if err := os.WriteFile(filepath.Join(dmsDir, "windowrules.kdl"), []byte(existing), 0644); err != nil { + t.Fatal(err) + } + + edited := newTestWindowRule("firefox", "Firefox", "^firefox$") + edited.Actions.OpenFloating = boolPtr(false) + if err := provider.SetRule(edited); err != nil { + t.Fatalf("SetRule failed: %v", err) + } + + rules, err := provider.LoadDMSRules() + if err != nil { + t.Fatalf("LoadDMSRules failed: %v", err) + } + if len(rules) != 2 { + t.Fatalf("expected 2 rules, got %d", len(rules)) + } + if len(rules[0].Excludes) != 2 { + t.Fatalf("expected 2 excludes on untouched rule, got %d", len(rules[0].Excludes)) + } + if rules[0].Excludes[0].Title != "^Mozilla Thunderbird$" { + t.Errorf("Excludes[0].Title = %q", rules[0].Excludes[0].Title) + } + if rules[0].Excludes[1].Title != "^Verfassen:.*" { + t.Errorf("Excludes[1].Title = %q", rules[0].Excludes[1].Title) + } + if rules[1].Actions.OpenFloating == nil || *rules[1].Actions.OpenFloating { + t.Error("edited rule should have open-floating false") + } +} diff --git a/core/internal/windowrules/types.go b/core/internal/windowrules/types.go index 1b6b9c904..52257636b 100644 --- a/core/internal/windowrules/types.go +++ b/core/internal/windowrules/types.go @@ -77,6 +77,7 @@ type WindowRule struct { Enabled bool `json:"enabled"` MatchCriteria MatchCriteria `json:"matchCriteria"` Matches []MatchCriteria `json:"matches,omitempty"` + Excludes []MatchCriteria `json:"excludes,omitempty"` Actions Actions `json:"actions"` Source string `json:"source,omitempty"` } diff --git a/quickshell/Modals/WindowRuleModal.qml b/quickshell/Modals/WindowRuleModal.qml index 75716d89c..987bee1d4 100644 --- a/quickshell/Modals/WindowRuleModal.qml +++ b/quickshell/Modals/WindowRuleModal.qml @@ -457,6 +457,9 @@ FloatingWindow { }; if (isNiri && extraMatchModel.count > 0) ruleData.matches = matches; + // No exclude editor yet — carry existing excludes through so an edit doesn't delete them (#2996) + if (isEditMode && (editingRule.excludes?.length ?? 0) > 0) + ruleData.excludes = editingRule.excludes; submitting = true;