Rules-based browser launcher for TUI + GNOME. switchyard.aly.codes
tui gome bowser go
0

Configure Feed

Select the types of activity you want to include in your feed.

Add inverted condition logic (#25)

* add support for negate condition property

* add GUI for negative conditions

* update docs

authored by

Aly Raffauf and committed by
GitHub
(Jan 24, 2026, 1:16 AM EST) ee26c1b5 3bba86b9

+182 -35
+1
README.md
··· 19 19 - **Rule-based routing**: Automatically open URLs in specific browsers based on powerful patterns. 20 20 - **Multi-condition rules**: Combine multiple conditions with AND/OR logic for precise control. 21 21 - **Multiple pattern types**: Exact Domain, URL Contains, Wildcard, and Regex matching. 22 + - **Negative patterns**: Exclude specific URLs by inverting any condition (e.g., "all GitHub except Gist"). 22 23 - **Custom URI scheme**: Create links that specify browser preferences directly with `switchyard://` URLs. 23 24 - **Quick browser launcher**: When no rule matches, quickly select a browser with keyboard or mouse. 24 25 - **Keyboard shortcuts**: Press Ctrl+1-9 to instantly select a browser.
+16
docs/Configuration.md
··· 55 55 type = "domain" 56 56 pattern = "twitch.tv" 57 57 58 + # Rule with negated condition 59 + [[rules]] 60 + name = "GitHub (not Gist)" 61 + logic = "all" 62 + browser = "firefox.desktop" 63 + 64 + [[rules.conditions]] 65 + type = "glob" 66 + pattern = "*.github.com" 67 + 68 + [[rules.conditions]] 69 + type = "domain" 70 + pattern = "gist.github.com" 71 + negate = true # URL must NOT match this pattern 72 + 58 73 # Rule with always ask 59 74 [[rules]] 60 75 name = "Shopping Sites" ··· 87 102 88 103 - **type**: Match type (see below). 89 104 - **pattern**: The pattern to match against. 105 + - **negate**: If true, the condition is inverted (URL must NOT match). Default: false. 90 106 91 107 ## Condition Types 92 108
+3 -2
src/config.go
··· 25 25 type Condition struct { 26 26 Type string `toml:"type"` // "domain", "keyword", "glob", "regex" 27 27 Pattern string `toml:"pattern"` 28 + Negate bool `toml:"negate,omitempty"` 28 29 } 29 30 30 31 type Rule struct { ··· 109 110 if logic == "all" { 110 111 // AND: All conditions must match 111 112 for _, cond := range r.Conditions { 112 - if !matchesPattern(url, cond.Pattern, cond.Type) { 113 + if !matchesPattern(url, cond.Pattern, cond.Type, cond.Negate) { 113 114 return false 114 115 } 115 116 } ··· 117 118 } else { 118 119 // OR: Any condition must match 119 120 for _, cond := range r.Conditions { 120 - if matchesPattern(url, cond.Pattern, cond.Type) { 121 + if matchesPattern(url, cond.Pattern, cond.Type, cond.Negate) { 121 122 return true 122 123 } 123 124 }
+72
src/config_test.go
··· 94 94 url: "https://docs.github.com/api/reference", 95 95 want: true, 96 96 }, 97 + { 98 + name: "negated condition excludes match", 99 + rule: Rule{ 100 + Logic: "all", 101 + Conditions: []Condition{ 102 + {Type: "glob", Pattern: "*.github.com"}, 103 + {Type: "domain", Pattern: "gist.github.com", Negate: true}, 104 + }, 105 + }, 106 + url: "https://gist.github.com", 107 + want: false, // Glob matches, but negated domain also matches so rule fails 108 + }, 109 + { 110 + name: "negated condition allows non-match", 111 + rule: Rule{ 112 + Logic: "all", 113 + Conditions: []Condition{ 114 + {Type: "glob", Pattern: "*.github.com"}, 115 + {Type: "domain", Pattern: "gist.github.com", Negate: true}, 116 + }, 117 + }, 118 + url: "https://api.github.com", 119 + want: true, // Glob matches, negated domain doesn't match (negate returns true) 120 + }, 121 + { 122 + name: "all negated conditions", 123 + rule: Rule{ 124 + Logic: "all", 125 + Conditions: []Condition{ 126 + {Type: "domain", Pattern: "example.com", Negate: true}, 127 + {Type: "keyword", Pattern: "admin", Negate: true}, 128 + }, 129 + }, 130 + url: "https://other.com/user", 131 + want: true, // Neither condition matches, so both negations return true 132 + }, 133 + { 134 + name: "negated condition fails when pattern not matched", 135 + rule: Rule{ 136 + Logic: "all", 137 + Conditions: []Condition{ 138 + {Type: "domain", Pattern: "github.com"}, 139 + {Type: "keyword", Pattern: "secret", Negate: true}, 140 + }, 141 + }, 142 + url: "https://github.com/user/repo", 143 + want: true, // Domain matches, "secret" not in URL so negation returns true 144 + }, 97 145 } 98 146 99 147 for _, tt := range tests { ··· 187 235 }, 188 236 url: "https://github.com/user/repo", 189 237 want: true, 238 + }, 239 + { 240 + name: "OR with negated condition matches", 241 + rule: Rule{ 242 + Logic: "any", 243 + Conditions: []Condition{ 244 + {Type: "domain", Pattern: "github.com", Negate: true}, 245 + {Type: "domain", Pattern: "gitlab.com"}, 246 + }, 247 + }, 248 + url: "https://example.com", 249 + want: true, // First condition: github.com doesn't match, negated = true 250 + }, 251 + { 252 + name: "OR with all negated conditions none match", 253 + rule: Rule{ 254 + Logic: "any", 255 + Conditions: []Condition{ 256 + {Type: "domain", Pattern: "github.com", Negate: true}, 257 + {Type: "domain", Pattern: "gitlab.com", Negate: true}, 258 + }, 259 + }, 260 + url: "https://github.com", 261 + want: true, // github.com matches, negated = false; gitlab.com doesn't match, negated = true 190 262 }, 191 263 } 192 264
+8
src/dialog_condition_edit.go
··· 31 31 patternRow.SetText(cond.Pattern) 32 32 group.Add(patternRow) 33 33 34 + // Negate row 35 + negateRow := adw.NewSwitchRow() 36 + negateRow.SetTitle("Invert Match") 37 + negateRow.SetSubtitle("URL must NOT match this pattern") 38 + negateRow.SetActive(cond.Negate) 39 + group.Add(negateRow) 40 + 34 41 // Error label for validation 35 42 errorLabel := gtk.NewLabel("") 36 43 errorLabel.SetWrap(true) ··· 86 93 87 94 cond.Type = indexToConditionType(selectedType) 88 95 cond.Pattern = pattern 96 + cond.Negate = negateRow.Active() 89 97 90 98 onSave() 91 99 dialog.Close()
+16
src/dialog_rule_common.go
··· 187 187 typeDropdown.SetSizeRequest(150, -1) // Fixed width for consistent alignment 188 188 conditionContainer.Append(typeDropdown) 189 189 190 + // Negate dropdown (is / is not) 191 + negateDropdown := gtk.NewDropDown( 192 + gtk.NewStringList([]string{"is", "is not"}), 193 + nil, 194 + ) 195 + if (*conditions)[condIdx].Negate { 196 + negateDropdown.SetSelected(1) 197 + } else { 198 + negateDropdown.SetSelected(0) 199 + } 200 + negateDropdown.SetVAlign(gtk.AlignCenter) 201 + negateDropdown.Connect("notify::selected", func() { 202 + (*conditions)[condIdx].Negate = negateDropdown.Selected() == 1 203 + }) 204 + conditionContainer.Append(negateDropdown) 205 + 190 206 // Pattern entry 191 207 patternEntry := gtk.NewEntry() 192 208 patternEntry.SetText((*conditions)[condIdx].Pattern)
+11 -5
src/pattern.go
··· 21 21 return re, true 22 22 } 23 23 24 - func matchesPattern(url, pattern, patternType string) bool { 24 + func matchesPattern(url, pattern, patternType string, negate bool) bool { 25 + var result bool 25 26 switch patternType { 26 27 case "domain": 27 28 domain := extractDomain(url) 28 - return strings.EqualFold(domain, pattern) 29 + result = strings.EqualFold(domain, pattern) 29 30 case "keyword": 30 - return strings.Contains(strings.ToLower(url), strings.ToLower(pattern)) 31 + result = strings.Contains(strings.ToLower(url), strings.ToLower(pattern)) 31 32 case "regex": 32 33 re, ok := getCompiledRegex(pattern) 33 34 if !ok { 34 35 return false 35 36 } 36 - return re.MatchString(url) 37 + result = re.MatchString(url) 37 38 case "glob": 38 - return matchGlob(url, pattern) 39 + result = matchGlob(url, pattern) 39 40 default: 40 41 return false 41 42 } 43 + 44 + if negate { 45 + return !result 46 + } 47 + return result 42 48 } 43 49 44 50 // matchGlob performs glob-style pattern matching against a URL's domain or full URL
+32 -21
src/pattern_test.go
··· 12 12 url string 13 13 pattern string 14 14 patternType string 15 + negate bool 15 16 want bool 16 17 }{ 17 18 // Domain matching - exact match only 18 - {"domain match", "https://github.com/user/repo", "github.com", "domain", true}, 19 - {"domain case insensitive", "https://GitHub.COM/user", "github.com", "domain", true}, 20 - {"domain no match", "https://gitlab.com", "github.com", "domain", false}, 21 - {"domain subdomain no match", "https://api.github.com", "github.com", "domain", false}, 19 + {"domain match", "https://github.com/user/repo", "github.com", "domain", false, true}, 20 + {"domain case insensitive", "https://GitHub.COM/user", "github.com", "domain", false, true}, 21 + {"domain no match", "https://gitlab.com", "github.com", "domain", false, false}, 22 + {"domain subdomain no match", "https://api.github.com", "github.com", "domain", false, false}, 22 23 23 24 // Keyword matching - anywhere in URL 24 - {"keyword in domain", "https://github.com", "github", "keyword", true}, 25 - {"keyword in path", "https://example.com/github/repo", "github", "keyword", true}, 26 - {"keyword in query", "https://example.com?repo=github", "github", "keyword", true}, 27 - {"keyword case insensitive", "https://GITHUB.com", "github", "keyword", true}, 28 - {"keyword no match", "https://gitlab.com", "github", "keyword", false}, 25 + {"keyword in domain", "https://github.com", "github", "keyword", false, true}, 26 + {"keyword in path", "https://example.com/github/repo", "github", "keyword", false, true}, 27 + {"keyword in query", "https://example.com?repo=github", "github", "keyword", false, true}, 28 + {"keyword case insensitive", "https://GITHUB.com", "github", "keyword", false, true}, 29 + {"keyword no match", "https://gitlab.com", "github", "keyword", false, false}, 29 30 30 31 // Glob matching - wildcards for subdomains 31 - {"glob wildcard subdomain", "https://api.github.com", "*.github.com", "glob", true}, 32 - {"glob exact", "https://github.com", "github.com", "glob", true}, 33 - {"glob no match", "https://github.com", "*.gitlab.com", "glob", false}, 34 - {"glob multiple wildcards", "https://api.v2.example.com", "*.*.example.com", "glob", true}, 32 + {"glob wildcard subdomain", "https://api.github.com", "*.github.com", "glob", false, true}, 33 + {"glob exact", "https://github.com", "github.com", "glob", false, true}, 34 + {"glob no match", "https://github.com", "*.gitlab.com", "glob", false, false}, 35 + {"glob multiple wildcards", "https://api.v2.example.com", "*.*.example.com", "glob", false, true}, 35 36 36 37 // Regex matching - full control 37 - {"regex simple", "https://github.com/user/repo", `github\.com`, "regex", true}, 38 - {"regex path pattern", "https://github.com/user123/repo", `github\.com/user\d+`, "regex", true}, 39 - {"regex no match", "https://github.com", `gitlab\.com`, "regex", false}, 40 - {"regex invalid pattern", "https://example.com", "[invalid", "regex", false}, 38 + {"regex simple", "https://github.com/user/repo", `github\.com`, "regex", false, true}, 39 + {"regex path pattern", "https://github.com/user123/repo", `github\.com/user\d+`, "regex", false, true}, 40 + {"regex no match", "https://github.com", `gitlab\.com`, "regex", false, false}, 41 + {"regex invalid pattern", "https://example.com", "[invalid", "regex", false, false}, 41 42 42 43 // Unknown pattern type 43 - {"unknown type", "https://example.com", "example.com", "invalid", false}, 44 + {"unknown type", "https://example.com", "example.com", "invalid", false, false}, 45 + 46 + // Negation tests 47 + {"domain match negated", "https://github.com/user", "github.com", "domain", true, false}, 48 + {"domain no match negated", "https://gitlab.com", "github.com", "domain", true, true}, 49 + {"keyword match negated", "https://github.com", "github", "keyword", true, false}, 50 + {"keyword no match negated", "https://example.com", "github", "keyword", true, true}, 51 + {"glob match negated", "https://api.github.com", "*.github.com", "glob", true, false}, 52 + {"glob no match negated", "https://example.com", "*.github.com", "glob", true, true}, 53 + {"regex match negated", "https://github.com", `github\.com`, "regex", true, false}, 54 + {"regex no match negated", "https://example.com", `github\.com`, "regex", true, true}, 44 55 } 45 56 46 57 for _, tt := range tests { 47 58 t.Run(tt.name, func(t *testing.T) { 48 - result := matchesPattern(tt.url, tt.pattern, tt.patternType) 59 + result := matchesPattern(tt.url, tt.pattern, tt.patternType, tt.negate) 49 60 if result != tt.want { 50 - t.Errorf("matchesPattern(%q, %q, %q) = %v, want %v", 51 - tt.url, tt.pattern, tt.patternType, result, tt.want) 61 + t.Errorf("matchesPattern(%q, %q, %q, %v) = %v, want %v", 62 + tt.url, tt.pattern, tt.patternType, tt.negate, result, tt.want) 52 63 } 53 64 }) 54 65 }
+23 -7
src/settings_rules.go
··· 29 29 logicText = "All match" 30 30 } 31 31 32 + formatSingleCondition := func(cond *Condition) string { 33 + return fmt.Sprintf("%s %s", getConditionLabel(cond.Type, cond.Negate), cond.Pattern) 34 + } 35 + 32 36 if rule.AlwaysAsk { 33 37 if condCount == 1 && includePattern { 34 - return fmt.Sprintf("%s: %s · Always ask", getTypeLabel(rule.Conditions[0].Type), rule.Conditions[0].Pattern) 38 + return fmt.Sprintf("%s · Always ask", formatSingleCondition(&rule.Conditions[0])) 35 39 } 36 40 return fmt.Sprintf("%d conditions (%s) · Always ask", condCount, logicText) 37 41 } 38 42 if condCount == 1 && includePattern { 39 - return fmt.Sprintf("%s: %s · Opens in %s", getTypeLabel(rule.Conditions[0].Type), rule.Conditions[0].Pattern, browserName) 43 + return fmt.Sprintf("%s · Opens in %s", formatSingleCondition(&rule.Conditions[0]), browserName) 40 44 } 41 45 return fmt.Sprintf("%d conditions (%s) · Opens in %s", condCount, logicText, browserName) 42 46 } ··· 44 48 return "No conditions" 45 49 } 46 50 47 - func getTypeLabel(patternType string) string { 51 + func getConditionLabel(patternType string, negate bool) string { 48 52 switch patternType { 49 53 case "domain": 50 - return "Exact Domain" 54 + if negate { 55 + return "Domain is not" 56 + } 57 + return "Domain is" 51 58 case "keyword": 52 - return "URL Contains" 59 + if negate { 60 + return "URL does not contain" 61 + } 62 + return "URL contains" 53 63 case "glob": 54 - return "Wildcard" 64 + if negate { 65 + return "Wildcard is not" 66 + } 67 + return "Wildcard is" 55 68 case "regex": 56 - return "Regex" 69 + if negate { 70 + return "Regex does not match" 71 + } 72 + return "Regex matches" 57 73 default: 58 74 return patternType 59 75 }