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.

routing: extract type mapping and formatting helpers from gtk

Aly Raffauf (Jul 5, 2026, 3:20 PM EDT) fb7381f9 9d2a6561

+240 -182
-56
gtk/dialog_helpers.go
··· 74 74 75 75 return dialog, content 76 76 } 77 - 78 - func conditionTypeToIndex(conditionType string) uint { 79 - switch conditionType { 80 - case "domain": 81 - return 0 82 - case "keyword": 83 - return 1 84 - case "glob": 85 - return 2 86 - case "regex": 87 - return 3 88 - default: 89 - return 0 90 - } 91 - } 92 - 93 - func indexToConditionType(index uint) string { 94 - switch index { 95 - case 0: 96 - return "domain" 97 - case 1: 98 - return "keyword" 99 - case 2: 100 - return "glob" 101 - case 3: 102 - return "regex" 103 - default: 104 - return "domain" 105 - } 106 - } 107 - 108 - func redirectionTypeToIndex(redirectionType string) uint { 109 - switch redirectionType { 110 - case "wildcard": 111 - return 1 112 - case "regex": 113 - return 2 114 - default: 115 - return 0 116 - } 117 - } 118 - 119 - func indexToRedirectionType(index uint) string { 120 - switch index { 121 - case 1: 122 - return "wildcard" 123 - case 2: 124 - return "regex" 125 - default: 126 - return "domain" 127 - } 128 - } 129 - 130 - func getRedirectionTypeLabels() []string { 131 - return []string{"Domain", "Wildcard", "Regex"} 132 - }
+4 -4
gtk/dialog_redirection.go
··· 50 50 51 51 typeRow := adw.NewComboRow() 52 52 typeRow.SetTitle("Type") 53 - typeRow.SetModel(gtk.NewStringList(getRedirectionTypeLabels())) 53 + typeRow.SetModel(gtk.NewStringList(routing.RedirectionTypeLabels())) 54 54 if !isNew { 55 - typeRow.SetSelected(redirectionTypeToIndex(redirection.Type)) 55 + typeRow.SetSelected(routing.RedirectionTypeToIndex(redirection.Type)) 56 56 } 57 57 group.Add(typeRow) 58 58 ··· 74 74 75 75 validateInputs := func() { 76 76 find := findRow.Text() 77 - redirectionType := indexToRedirectionType(typeRow.Selected()) 77 + redirectionType := routing.IndexToRedirectionType(typeRow.Selected()) 78 78 redirection := Redirection{Type: redirectionType, Find: find, Replace: replaceRow.Text()} 79 79 err := routing.ValidateRedirection(redirection) 80 80 ··· 95 95 saveBtn.ConnectClicked(func() { 96 96 name := nameRow.Text() 97 97 find := findRow.Text() 98 - redirectionType := indexToRedirectionType(typeRow.Selected()) 98 + redirectionType := routing.IndexToRedirectionType(typeRow.Selected()) 99 99 100 100 if isNew { 101 101 cfg.Redirections = append(cfg.Redirections, Redirection{
+4 -4
gtk/dialog_rule_common.go
··· 162 162 conditionContainer.SetMarginEnd(12) 163 163 164 164 typeDropdown := gtk.NewDropDown( 165 - gtk.NewStringList([]string{"Exact Domain", "URL Contains", "Wildcard", "Regex"}), 165 + gtk.NewStringList(routing.ConditionTypeLabels()), 166 166 nil, 167 167 ) 168 - typeDropdown.SetSelected(conditionTypeToIndex((*conditions)[conditionIndex].Type)) 168 + typeDropdown.SetSelected(routing.ConditionTypeToIndex((*conditions)[conditionIndex].Type)) 169 169 typeDropdown.SetVAlign(gtk.AlignCenter) 170 170 typeDropdown.SetSizeRequest(150, -1) 171 171 conditionContainer.Append(typeDropdown) ··· 192 192 conditionContainer.Append(patternEntry) 193 193 194 194 typeDropdown.Connect("notify::selected", func() { 195 - (*conditions)[conditionIndex].Type = indexToConditionType(typeDropdown.Selected()) 195 + (*conditions)[conditionIndex].Type = routing.IndexToConditionType(typeDropdown.Selected()) 196 196 validateConditionEntry(conditions, conditionIndex, typeDropdown, patternEntry, actionButton) 197 197 }) 198 198 ··· 229 229 actionButton *gtk.Button, 230 230 ) { 231 231 pattern := patternEntry.Text() 232 - conditionType := indexToConditionType(typeDropdown.Selected()) 232 + conditionType := routing.IndexToConditionType(typeDropdown.Selected()) 233 233 234 234 err := routing.ValidateConditionPattern(conditionType, pattern) 235 235 if err != nil {
+2 -19
gtk/run.go
··· 62 62 return 63 63 } 64 64 65 - sanitized := prepareURLForRouting(rawURL, cfg) 65 + sanitized := routing.PrepareURLForRouting(rawURL, cfg.RemoveTrackingParameters, cfg.Redirections) 66 66 if sanitized == "" { 67 67 // URL was rejected (mailto:, tel:, etc.) - pass to xdg-open 68 68 cmd := host.HostCommand("xdg-open", rawURL) ··· 105 105 return 106 106 } 107 107 108 - sanitized := prepareURLForRouting(targetURL, cfg) 108 + sanitized := routing.PrepareURLForRouting(targetURL, cfg.RemoveTrackingParameters, cfg.Redirections) 109 109 if sanitized == "" { 110 110 // Pass non-browser URLs to xdg-open 111 111 cmd := host.HostCommand("xdg-open", targetURL) ··· 133 133 134 134 // No browser specified - use standard routing 135 135 handleURL(app, browsers, cfg, sanitized) 136 - } 137 - 138 - func prepareURLForRouting(rawURL string, cfg *Config) string { 139 - sanitized := routing.SanitizeURL(rawURL) 140 - if sanitized == "" { 141 - return "" 142 - } 143 - 144 - if cfg.RemoveTrackingParameters { 145 - sanitized = routing.RemoveTrackingParameters(sanitized) 146 - } 147 - 148 - if len(cfg.Redirections) > 0 { 149 - sanitized = routing.ApplyRedirections(sanitized, cfg.Redirections) 150 - } 151 - 152 - return sanitized 153 136 } 154 137 155 138 // handleURL routes a URL to the appropriate browser based on rules
+3 -26
gtk/settings_redirections.go
··· 6 6 "fmt" 7 7 "html" 8 8 9 + "github.com/alyraffauf/switchyard/internal/routing" 9 10 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 10 11 "github.com/diamondburned/gotk4/pkg/gtk/v4" 11 12 ) 12 - 13 - func formatRedirectionSubtitle(redirection *Redirection) string { 14 - redirectionType := redirection.Type 15 - if redirectionType == "" { 16 - redirectionType = "domain" 17 - } 18 - 19 - var typeLabel string 20 - switch redirectionType { 21 - case "domain": 22 - typeLabel = "Domain" 23 - case "wildcard": 24 - typeLabel = "Wildcard" 25 - case "regex": 26 - typeLabel = "Regex" 27 - default: 28 - typeLabel = "Domain" 29 - } 30 - 31 - if redirection.Replace == "" { 32 - return fmt.Sprintf("%s · Removes match", typeLabel) 33 - } 34 - return fmt.Sprintf("%s · Replaces with %s", typeLabel, html.EscapeString(redirection.Replace)) 35 - } 36 13 37 14 func createRedirectionsPage(win *adw.Window, cfg *Config) gtk.Widgetter { 38 15 toolbarView := adw.NewToolbarView() ··· 79 56 row := adw.NewActionRow() 80 57 if redirection.Name != "" { 81 58 row.SetTitle(redirection.Name) 82 - row.SetSubtitle(fmt.Sprintf("%s · %s", html.EscapeString(redirection.Find), formatRedirectionSubtitle(redirection))) 59 + row.SetSubtitle(fmt.Sprintf("%s · %s", html.EscapeString(redirection.Find), html.EscapeString(routing.FormatRedirectionSubtitle(redirection)))) 83 60 } else { 84 61 row.SetTitle(redirection.Find) 85 - row.SetSubtitle(formatRedirectionSubtitle(redirection)) 62 + row.SetSubtitle(html.EscapeString(routing.FormatRedirectionSubtitle(redirection))) 86 63 } 87 64 row.SetActivatable(true) 88 65
+4 -69
gtk/settings_rules.go
··· 3 3 package gtk 4 4 5 5 import ( 6 - "fmt" 6 + "html" 7 7 8 8 appbrowser "github.com/alyraffauf/switchyard/internal/browser" 9 + "github.com/alyraffauf/switchyard/internal/routing" 9 10 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 10 11 "github.com/diamondburned/gotk4/pkg/gtk/v4" 11 12 ) 12 13 13 - // formatRuleSubtitle formats a subtitle for a rule row with pattern included 14 - func formatRuleSubtitle(rule *Rule, browserName string) string { 15 - return formatRuleSubtitleInternal(rule, browserName, true) 16 - } 17 - 18 - // formatRuleSubtitleNoPattern formats a subtitle for a rule row without pattern 19 - func formatRuleSubtitleNoPattern(rule *Rule, browserName string) string { 20 - return formatRuleSubtitleInternal(rule, browserName, false) 21 - } 22 - 23 - func formatRuleSubtitleInternal(rule *Rule, browserName string, includePattern bool) string { 24 - if len(rule.Conditions) > 0 { 25 - condCount := len(rule.Conditions) 26 - var logicText string 27 - if rule.Logic == "any" { 28 - logicText = "Any match" 29 - } else { 30 - logicText = "All match" 31 - } 32 - 33 - formatSingleCondition := func(cond *Condition) string { 34 - return fmt.Sprintf("%s %s", getConditionLabel(cond.Type, cond.Negate), cond.Pattern) 35 - } 36 - 37 - if rule.AlwaysAsk { 38 - if condCount == 1 && includePattern { 39 - return fmt.Sprintf("%s · Always ask", formatSingleCondition(&rule.Conditions[0])) 40 - } 41 - return fmt.Sprintf("%d conditions (%s) · Always ask", condCount, logicText) 42 - } 43 - if condCount == 1 && includePattern { 44 - return fmt.Sprintf("%s · Opens in %s", formatSingleCondition(&rule.Conditions[0]), browserName) 45 - } 46 - return fmt.Sprintf("%d conditions (%s) · Opens in %s", condCount, logicText, browserName) 47 - } 48 - 49 - return "No conditions" 50 - } 51 - 52 - func getConditionLabel(patternType string, negate bool) string { 53 - switch patternType { 54 - case "domain": 55 - if negate { 56 - return "Domain is not" 57 - } 58 - return "Domain is" 59 - case "keyword": 60 - if negate { 61 - return "URL does not contain" 62 - } 63 - return "URL contains" 64 - case "glob": 65 - if negate { 66 - return "Wildcard is not" 67 - } 68 - return "Wildcard is" 69 - case "regex": 70 - if negate { 71 - return "Regex does not match" 72 - } 73 - return "Regex matches" 74 - default: 75 - return patternType 76 - } 77 - } 78 - 79 14 func createRulesPage(win *adw.Window, browsers []*Browser, cfg *Config) gtk.Widgetter { 80 15 toolbarView := adw.NewToolbarView() 81 16 ··· 128 63 row := adw.NewActionRow() 129 64 if rule.Name != "" { 130 65 row.SetTitle(rule.Name) 131 - row.SetSubtitle(formatRuleSubtitle(rule, getBrowserName(rule.Browser))) 66 + row.SetSubtitle(html.EscapeString(routing.FormatRuleSubtitle(rule, getBrowserName(rule.Browser)))) 132 67 } else { 133 68 if len(rule.Conditions) > 0 { 134 69 row.SetTitle(rule.Conditions[0].Pattern) 135 70 } 136 - row.SetSubtitle(formatRuleSubtitleNoPattern(rule, getBrowserName(rule.Browser))) 71 + row.SetSubtitle(html.EscapeString(routing.FormatRuleSubtitleNoPattern(rule, getBrowserName(rule.Browser)))) 137 72 } 138 73 row.SetActivatable(true) 139 74
+187
internal/routing/format.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package routing 4 + 5 + import "fmt" 6 + 7 + // --- Condition types: single source of truth --- 8 + 9 + // conditionTypeDef is the single source of truth for a condition type: 10 + // its combo-box label, and the labels used in rule subtitles. 11 + type conditionTypeDef struct { 12 + Type string 13 + Label string // combo-box dropdown label 14 + MatchLabel string // e.g. "Domain is" 15 + NegateLabel string // e.g. "Domain is not" 16 + } 17 + 18 + // conditionTypes is the ordered list of supported condition types. 19 + // Index order is the combo-box order; adding a type here is sufficient 20 + // for all mappers, dropdown models, and condition labels. 21 + var conditionTypes = []conditionTypeDef{ 22 + {Type: "domain", Label: "Exact Domain", MatchLabel: "Domain is", NegateLabel: "Domain is not"}, 23 + {Type: "keyword", Label: "URL Contains", MatchLabel: "URL contains", NegateLabel: "URL does not contain"}, 24 + {Type: "glob", Label: "Wildcard", MatchLabel: "Wildcard is", NegateLabel: "Wildcard is not"}, 25 + {Type: "regex", Label: "Regex", MatchLabel: "Regex matches", NegateLabel: "Regex does not match"}, 26 + } 27 + 28 + // ConditionTypeToIndex maps a condition type string to its combo-box index. 29 + func ConditionTypeToIndex(conditionType string) uint { 30 + for i, def := range conditionTypes { 31 + if def.Type == conditionType { 32 + return uint(i) 33 + } 34 + } 35 + return 0 36 + } 37 + 38 + // IndexToConditionType maps a combo-box index to its condition type string. 39 + func IndexToConditionType(index uint) string { 40 + if int(index) < len(conditionTypes) { 41 + return conditionTypes[index].Type 42 + } 43 + return conditionTypes[0].Type 44 + } 45 + 46 + // ConditionTypeLabels returns the human-readable labels for condition types. 47 + func ConditionTypeLabels() []string { 48 + labels := make([]string, len(conditionTypes)) 49 + for i, def := range conditionTypes { 50 + labels[i] = def.Label 51 + } 52 + return labels 53 + } 54 + 55 + // --- Redirection types --- 56 + 57 + // redirectionTypeDef describes a redirection type for UI dropdowns and mappers. 58 + type redirectionTypeDef struct { 59 + Type string 60 + Label string 61 + } 62 + 63 + // redirectionTypes is the ordered list of supported redirection types. 64 + var redirectionTypes = []redirectionTypeDef{ 65 + {Type: "domain", Label: "Domain"}, 66 + {Type: "wildcard", Label: "Wildcard"}, 67 + {Type: "regex", Label: "Regex"}, 68 + } 69 + 70 + // RedirectionTypeToIndex maps a redirection type string to its combo-box index. 71 + func RedirectionTypeToIndex(redirectionType string) uint { 72 + for i, def := range redirectionTypes { 73 + if def.Type == redirectionType { 74 + return uint(i) 75 + } 76 + } 77 + return 0 78 + } 79 + 80 + // IndexToRedirectionType maps a combo-box index to its redirection type string. 81 + func IndexToRedirectionType(index uint) string { 82 + if int(index) < len(redirectionTypes) { 83 + return redirectionTypes[index].Type 84 + } 85 + return redirectionTypes[0].Type 86 + } 87 + 88 + // RedirectionTypeLabels returns the human-readable labels for redirection types. 89 + func RedirectionTypeLabels() []string { 90 + labels := make([]string, len(redirectionTypes)) 91 + for i, def := range redirectionTypes { 92 + labels[i] = def.Label 93 + } 94 + return labels 95 + } 96 + 97 + // NormalizeRedirectionType returns the canonical redirection type string, 98 + // defaulting empty to "domain". Unknown non-empty types are returned as-is 99 + // so callers can detect and handle them. 100 + func NormalizeRedirectionType(typ string) string { 101 + if typ == "" { 102 + return "domain" 103 + } 104 + return typ 105 + } 106 + 107 + // RedirectionTypeLabel returns the human-readable label for a redirection type. 108 + // Falls back to the raw type string for unknown types. 109 + func RedirectionTypeLabel(typ string) string { 110 + typ = NormalizeRedirectionType(typ) 111 + for _, def := range redirectionTypes { 112 + if def.Type == typ { 113 + return def.Label 114 + } 115 + } 116 + return typ 117 + } 118 + 119 + // --- Rule / condition formatting (plain text, no markup) --- 120 + 121 + // FormatRuleSubtitle returns a plain-text subtitle for a rule row with the 122 + // condition pattern included. Callers must escape the result before passing 123 + // it to a widget that parses markup. 124 + func FormatRuleSubtitle(rule *Rule, browserName string) string { 125 + return formatRuleSubtitle(rule, browserName, true) 126 + } 127 + 128 + // FormatRuleSubtitleNoPattern is like FormatRuleSubtitle but omits the 129 + // condition pattern from the result. 130 + func FormatRuleSubtitleNoPattern(rule *Rule, browserName string) string { 131 + return formatRuleSubtitle(rule, browserName, false) 132 + } 133 + 134 + func formatRuleSubtitle(rule *Rule, browserName string, includePattern bool) string { 135 + if len(rule.Conditions) == 0 { 136 + return "No conditions" 137 + } 138 + 139 + conditionCount := len(rule.Conditions) 140 + var logicText string 141 + if rule.Logic == "any" { 142 + logicText = "Any match" 143 + } else { 144 + logicText = "All match" 145 + } 146 + 147 + formatSingleCondition := func(condition *Condition) string { 148 + return fmt.Sprintf("%s %s", ConditionLabel(condition.Type, condition.Negate), condition.Pattern) 149 + } 150 + 151 + if rule.AlwaysAsk { 152 + if conditionCount == 1 && includePattern { 153 + return fmt.Sprintf("%s · Always ask", formatSingleCondition(&rule.Conditions[0])) 154 + } 155 + return fmt.Sprintf("%d conditions (%s) · Always ask", conditionCount, logicText) 156 + } 157 + if conditionCount == 1 && includePattern { 158 + return fmt.Sprintf("%s · Opens in %s", formatSingleCondition(&rule.Conditions[0]), browserName) 159 + } 160 + return fmt.Sprintf("%d conditions (%s) · Opens in %s", conditionCount, logicText, browserName) 161 + } 162 + 163 + // ConditionLabel returns a human-readable label for a condition type, 164 + // derived from the conditionTypes table. 165 + func ConditionLabel(patternType string, negate bool) string { 166 + for _, def := range conditionTypes { 167 + if def.Type == patternType { 168 + if negate { 169 + return def.NegateLabel 170 + } 171 + return def.MatchLabel 172 + } 173 + } 174 + return patternType 175 + } 176 + 177 + // FormatRedirectionSubtitle returns a plain-text subtitle describing a 178 + // redirection's type and action. Callers must escape the result before 179 + // passing it to a widget that parses markup. 180 + func FormatRedirectionSubtitle(redirection *Redirection) string { 181 + typeLabel := RedirectionTypeLabel(redirection.Type) 182 + 183 + if redirection.Replace == "" { 184 + return fmt.Sprintf("%s · Removes match", typeLabel) 185 + } 186 + return fmt.Sprintf("%s · Replaces with %s", typeLabel, redirection.Replace) 187 + }
+23
internal/routing/pipeline.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package routing 4 + 5 + // PrepareURLForRouting runs the full URL processing pipeline: sanitize, 6 + // optionally strip tracking parameters, and apply redirections. 7 + // Returns "" if the URL was rejected (e.g. mailto:, tel:). 8 + func PrepareURLForRouting(rawURL string, removeTracking bool, redirections []Redirection) string { 9 + sanitized := SanitizeURL(rawURL) 10 + if sanitized == "" { 11 + return "" 12 + } 13 + 14 + if removeTracking { 15 + sanitized = RemoveTrackingParameters(sanitized) 16 + } 17 + 18 + if len(redirections) > 0 { 19 + sanitized = ApplyRedirections(sanitized, redirections) 20 + } 21 + 22 + return sanitized 23 + }
+1 -4
internal/routing/redirection.go
··· 16 16 } 17 17 18 18 func applyRedirection(rawURL string, redirection Redirection) string { 19 - redirectionType := redirection.Type 20 - if redirectionType == "" { 21 - redirectionType = "domain" 22 - } 19 + redirectionType := NormalizeRedirectionType(redirection.Type) 23 20 24 21 switch redirectionType { 25 22 case "domain":
+12
internal/routing/validation.go
··· 119 119 redirectionType = "domain" 120 120 } 121 121 122 + // Reject unknown types before validating the pattern. 123 + valid := false 124 + for _, def := range redirectionTypes { 125 + if def.Type == redirectionType { 126 + valid = true 127 + break 128 + } 129 + } 130 + if !valid { 131 + return fmt.Errorf("Invalid redirection type: %s", redirectionType) 132 + } 133 + 122 134 switch redirectionType { 123 135 case "domain": 124 136 return validateDomainPattern(redirection.Find)