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.

split source files; add proper launch context for donateAction launched from picker window

Aly Raffauf (Jan 22, 2026, 1:14 AM EST) d7fab3c1 707a8831

+754 -877
+2 -2
justfile
··· 52 52 # Run unit tests 53 53 test: 54 54 @echo "Running unit tests..." 55 - go test -v ./src/config_test.go ./src/url_test.go ./src/pattern_test.go ./src/validation_test.go ./src/app.go ./src/config.go ./src/url.go ./src/validation.go 55 + go test -v ./src/config_test.go ./src/url_test.go ./src/pattern_test.go ./src/validation_test.go ./src/app.go ./src/config.go ./src/url.go ./src/pattern.go ./src/validation.go 56 56 57 57 # Run tests with coverage report 58 58 test-coverage: 59 59 @echo "Running tests with coverage..." 60 - go test -coverprofile=coverage.out ./src/config_test.go ./src/url_test.go ./src/pattern_test.go ./src/validation_test.go ./src/app.go ./src/config.go ./src/url.go ./src/validation.go 60 + go test -coverprofile=coverage.out ./src/config_test.go ./src/url_test.go ./src/pattern_test.go ./src/validation_test.go ./src/app.go ./src/config.go ./src/url.go ./src/pattern.go ./src/validation.go 61 61 go tool cover -func=coverage.out 62 62 @echo "" 63 63 @echo "To view HTML coverage report, run: go tool cover -html=coverage.out"
+3 -32
src/dialog_condition_edit.go
··· 11 11 12 12 // showEditConditionDialog displays a dialog for editing a single condition. 13 13 func showEditConditionDialog(parent *adw.Window, cond *Condition, onSave func()) { 14 - dialog := adw.NewDialog() 15 - dialog.SetTitle("Edit Condition") 16 - dialog.SetContentWidth(400) 17 - dialog.SetContentHeight(300) 18 - dialog.SetCanClose(true) 19 - 20 - toolbarView := adw.NewToolbarView() 21 - 22 - header := adw.NewHeaderBar() 23 - header.SetShowStartTitleButtons(false) 24 - header.SetShowEndTitleButtons(false) 25 - 26 - cancelBtn := gtk.NewButton() 27 - cancelBtn.SetLabel("Cancel") 28 - cancelBtn.ConnectClicked(func() { dialog.Close() }) 29 - header.PackStart(cancelBtn) 30 - 31 - saveBtn := gtk.NewButton() 32 - saveBtn.SetLabel("Save") 33 - saveBtn.AddCSSClass("suggested-action") 34 - header.PackEnd(saveBtn) 35 - 36 - toolbarView.AddTopBar(header) 37 - 38 - content := gtk.NewBox(gtk.OrientationVertical, 18) 39 - content.SetMarginStart(18) 40 - content.SetMarginEnd(18) 41 - content.SetMarginTop(18) 42 - content.SetMarginBottom(18) 14 + var dialog *adw.Dialog 15 + header, saveBtn := dialogHeader("Cancel", "Save", func() { dialog.Close() }, nil) 16 + dialog, content := simpleDialogWithToolbar("Edit Condition", 400, 300, header) 43 17 44 18 // Preferences group 45 19 group := adw.NewPreferencesGroup() ··· 92 66 93 67 content.Append(group) 94 68 content.Append(errorLabel) 95 - 96 - toolbarView.SetContent(content) 97 - dialog.SetChild(toolbarView) 98 69 99 70 // Initial validation check 100 71 updateValidation()
+6 -31
src/dialog_rule_add.go
··· 9 9 10 10 // showAddRuleDialog displays the add rule dialog. 11 11 func showAddRuleDialog(parent *adw.Window, cfg *Config, browsers []*Browser, rebuildRulesList func()) { 12 - dialog := adw.NewDialog() 13 - dialog.SetTitle("Add Rule") 14 - dialog.SetContentWidth(600) 15 - dialog.SetContentHeight(650) 16 - dialog.SetCanClose(true) 17 - 18 - toolbarView := adw.NewToolbarView() 19 - 20 - header := adw.NewHeaderBar() 21 - header.SetShowStartTitleButtons(false) 22 - header.SetShowEndTitleButtons(false) 23 - 24 - cancelBtn := gtk.NewButton() 25 - cancelBtn.SetLabel("Cancel") 26 - cancelBtn.ConnectClicked(func() { dialog.Close() }) 27 - header.PackStart(cancelBtn) 28 - 29 - addBtn := gtk.NewButton() 30 - addBtn.SetLabel("Add") 31 - addBtn.AddCSSClass("suggested-action") 12 + var dialog *adw.Dialog 13 + header, addBtn := dialogHeader("Cancel", "Add", func() { dialog.Close() }, nil) 32 14 addBtn.SetSensitive(false) // Insensitive until at least one valid condition is added 33 - header.PackEnd(addBtn) 34 15 35 - toolbarView.AddTopBar(header) 36 - 37 - scrolledWindow := gtk.NewScrolledWindow() 38 - scrolledWindow.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 39 - scrolledWindow.SetVExpand(true) 16 + var scrolledWindow *gtk.ScrolledWindow 17 + dialog, _, scrolledWindow = dialogWithToolbar("Add Rule", 600, 650, header) 40 18 41 - nameEntry, conditions, logicRow, alwaysAskRow, browserRow, content := buildRuleDialogContent(nil, browsers, addBtn) 42 - 43 - scrolledWindow.SetChild(content) 44 - toolbarView.SetContent(scrolledWindow) 45 - dialog.SetChild(toolbarView) 19 + nameEntry, conditions, logicRow, alwaysAskRow, browserRow, formContent := buildRuleDialogContent(nil, browsers, addBtn) 20 + scrolledWindow.SetChild(formContent) 46 21 47 22 addBtn.ConnectClicked(func() { 48 23 browserIdx := browserRow.Selected()
+6 -31
src/dialog_rule_edit.go
··· 18 18 rule.Logic = "all" 19 19 } 20 20 21 - dialog := adw.NewDialog() 22 - dialog.SetTitle("Edit Rule") 23 - dialog.SetContentWidth(600) 24 - dialog.SetContentHeight(650) 25 - dialog.SetCanClose(true) 21 + var dialog *adw.Dialog 22 + header, saveBtn := dialogHeader("Cancel", "Save", func() { dialog.Close() }, nil) 26 23 27 - toolbarView := adw.NewToolbarView() 24 + var scrolledWindow *gtk.ScrolledWindow 25 + dialog, _, scrolledWindow = dialogWithToolbar("Edit Rule", 600, 650, header) 28 26 29 - header := adw.NewHeaderBar() 30 - header.SetShowStartTitleButtons(false) 31 - header.SetShowEndTitleButtons(false) 32 - 33 - cancelBtn := gtk.NewButton() 34 - cancelBtn.SetLabel("Cancel") 35 - cancelBtn.ConnectClicked(func() { dialog.Close() }) 36 - header.PackStart(cancelBtn) 37 - 38 - saveBtn := gtk.NewButton() 39 - saveBtn.SetLabel("Save") 40 - saveBtn.AddCSSClass("suggested-action") 41 - header.PackEnd(saveBtn) 42 - 43 - toolbarView.AddTopBar(header) 44 - 45 - scrolledWindow := gtk.NewScrolledWindow() 46 - scrolledWindow.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 47 - scrolledWindow.SetVExpand(true) 48 - 49 - nameEntry, conditions, logicRow, alwaysAskRow, browserRow, content := buildRuleDialogContent(rule, browsers, saveBtn) 50 - 51 - scrolledWindow.SetChild(content) 52 - toolbarView.SetContent(scrolledWindow) 53 - dialog.SetChild(toolbarView) 27 + nameEntry, conditions, logicRow, alwaysAskRow, browserRow, formContent := buildRuleDialogContent(rule, browsers, saveBtn) 28 + scrolledWindow.SetChild(formContent) 54 29 55 30 saveBtn.ConnectClicked(func() { 56 31 browserIdx := browserRow.Selected()
-59
src/formatting.go
··· 1 - // SPDX-License-Identifier: GPL-3.0-or-later 2 - 3 - package main 4 - 5 - import "fmt" 6 - 7 - // formatRuleSubtitle formats a subtitle for a rule row with pattern included 8 - func formatRuleSubtitle(rule *Rule, browserName string) string { 9 - return formatRuleSubtitleInternal(rule, browserName, true) 10 - } 11 - 12 - // formatRuleSubtitleNoPattern formats a subtitle for a rule row without pattern 13 - func formatRuleSubtitleNoPattern(rule *Rule, browserName string) string { 14 - return formatRuleSubtitleInternal(rule, browserName, false) 15 - } 16 - 17 - // formatRuleSubtitleInternal is the internal implementation for formatting rule subtitles 18 - func formatRuleSubtitleInternal(rule *Rule, browserName string, includePattern bool) string { 19 - // Handle multi-condition format 20 - if len(rule.Conditions) > 0 { 21 - condCount := len(rule.Conditions) 22 - var logicText string 23 - if rule.Logic == "any" { 24 - logicText = "Any match" 25 - } else { 26 - logicText = "All match" 27 - } 28 - 29 - if rule.AlwaysAsk { 30 - if condCount == 1 && includePattern { 31 - return fmt.Sprintf("%s: %s · Always ask", getTypeLabel(rule.Conditions[0].Type), rule.Conditions[0].Pattern) 32 - } 33 - return fmt.Sprintf("%d conditions (%s) · Always ask", condCount, logicText) 34 - } 35 - if condCount == 1 && includePattern { 36 - return fmt.Sprintf("%s: %s · Opens in %s", getTypeLabel(rule.Conditions[0].Type), rule.Conditions[0].Pattern, browserName) 37 - } 38 - return fmt.Sprintf("%d conditions (%s) · Opens in %s", condCount, logicText, browserName) 39 - } 40 - 41 - // No conditions (should not happen in normal use) 42 - return "No conditions" 43 - } 44 - 45 - // getTypeLabel returns a human-readable label for a condition type 46 - func getTypeLabel(patternType string) string { 47 - switch patternType { 48 - case "domain": 49 - return "Exact Domain" 50 - case "keyword": 51 - return "URL Contains" 52 - case "glob": 53 - return "Wildcard" 54 - case "regex": 55 - return "Regex" 56 - default: 57 - return patternType 58 - } 59 - }
+46
src/pattern.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "regexp" 7 + "strings" 8 + ) 9 + 10 + // matchesPattern checks if a URL matches a given pattern based on the pattern type 11 + func matchesPattern(url, pattern, patternType string) bool { 12 + switch patternType { 13 + case "domain": 14 + domain := extractDomain(url) 15 + return strings.EqualFold(domain, pattern) 16 + case "keyword": 17 + return strings.Contains(strings.ToLower(url), strings.ToLower(pattern)) 18 + case "regex": 19 + re, err := regexp.Compile(pattern) 20 + if err != nil { 21 + return false 22 + } 23 + return re.MatchString(url) 24 + case "glob": 25 + return matchGlob(url, pattern) 26 + default: 27 + return false 28 + } 29 + } 30 + 31 + // matchGlob performs glob-style pattern matching against a URL's domain or full URL 32 + func matchGlob(url, pattern string) bool { 33 + domain := extractDomain(url) 34 + 35 + // Convert glob to regex: escape dots, convert * to .* 36 + pattern = strings.ReplaceAll(pattern, ".", "\\.") 37 + pattern = strings.ReplaceAll(pattern, "*", ".*") 38 + pattern = "^" + pattern + "$" 39 + 40 + re, err := regexp.Compile(pattern) 41 + if err != nil { 42 + return false 43 + } 44 + 45 + return re.MatchString(domain) || re.MatchString(url) 46 + }
+122
src/picker_actions.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "context" 7 + "fmt" 8 + "strings" 9 + 10 + "github.com/diamondburned/gotk4-adwaita/pkg/adw" 11 + "github.com/diamondburned/gotk4/pkg/gio/v2" 12 + "github.com/diamondburned/gotk4/pkg/glib/v2" 13 + "github.com/diamondburned/gotk4/pkg/gtk/v4" 14 + ) 15 + 16 + // showBrowserActionsMenu shows a context menu with desktop file actions 17 + func showBrowserActionsMenu(btn *gtk.Button, browser *Browser, url string) { 18 + actions := ListDesktopActions(browser.AppInfo) 19 + if len(actions) == 0 { 20 + return 21 + } 22 + 23 + menu := gio.NewMenu() 24 + for _, action := range actions { 25 + menu.Append(action.Name, fmt.Sprintf("win.launch-action::%s:%s", browser.ID, action.ID)) 26 + } 27 + 28 + popover := gtk.NewPopoverMenuFromModel(menu) 29 + popover.SetParent(btn) 30 + popover.Popup() 31 + } 32 + 33 + // showShortcutsDialog displays available keyboard shortcuts 34 + func showShortcutsDialog(parent *adw.Window) { 35 + dialog := adw.NewAlertDialog( 36 + "Keyboard Shortcuts", 37 + "Ctrl+1 through Ctrl+9: Select browser 1-9\nEsc: Close picker window", 38 + ) 39 + 40 + dialog.AddResponse("ok", "OK") 41 + dialog.SetDefaultResponse("ok") 42 + dialog.SetCloseResponse("ok") 43 + 44 + dialog.Present(parent) 45 + } 46 + 47 + // setupPickerActions sets up the action group for the picker window 48 + func setupPickerActions(win *adw.Window, app *adw.Application, browsers []*Browser, url string, onClose func()) *gio.SimpleActionGroup { 49 + actionGroup := gio.NewSimpleActionGroup() 50 + 51 + settingsAction := gio.NewSimpleAction("settings", nil) 52 + settingsAction.ConnectActivate(func(p *glib.Variant) { 53 + showSettingsWindow(app, loadConfig()) 54 + }) 55 + actionGroup.AddAction(settingsAction) 56 + 57 + aboutAction := gio.NewSimpleAction("about", nil) 58 + aboutAction.ConnectActivate(func(p *glib.Variant) { 59 + showAboutDialog(win) 60 + }) 61 + actionGroup.AddAction(aboutAction) 62 + 63 + donateAction := gio.NewSimpleAction("donate", nil) 64 + donateAction.ConnectActivate(func(p *glib.Variant) { 65 + launcher := gtk.NewURILauncher(DonateURL) 66 + launcher.Launch(context.Background(), &win.Window, nil) 67 + }) 68 + actionGroup.AddAction(donateAction) 69 + 70 + quitAction := gio.NewSimpleAction("quit", nil) 71 + quitAction.ConnectActivate(func(p *glib.Variant) { 72 + onClose() 73 + }) 74 + actionGroup.AddAction(quitAction) 75 + 76 + shortcutsAction := gio.NewSimpleAction("shortcuts", nil) 77 + shortcutsAction.ConnectActivate(func(p *glib.Variant) { 78 + showShortcutsDialog(win) 79 + }) 80 + actionGroup.AddAction(shortcutsAction) 81 + 82 + // Action to launch browser with a specific desktop action 83 + launchActionAction := gio.NewSimpleAction("launch-action", glib.NewVariantType("s")) 84 + launchActionAction.ConnectActivate(func(param *glib.Variant) { 85 + if param == nil { 86 + return 87 + } 88 + 89 + actionSpec := param.String() 90 + parts := strings.Split(actionSpec, ":") 91 + if len(parts) != 2 { 92 + return 93 + } 94 + 95 + browserID := parts[0] 96 + actionID := parts[1] 97 + 98 + var selectedBrowser *Browser 99 + for _, b := range browsers { 100 + if b.ID == browserID { 101 + selectedBrowser = b 102 + break 103 + } 104 + } 105 + 106 + if selectedBrowser == nil { 107 + return 108 + } 109 + 110 + actions := ListDesktopActions(selectedBrowser.AppInfo) 111 + for _, action := range actions { 112 + if action.ID == actionID { 113 + launchBrowserAction(selectedBrowser, action, url) 114 + onClose() 115 + return 116 + } 117 + } 118 + }) 119 + actionGroup.AddAction(launchActionAction) 120 + 121 + return actionGroup 122 + }
+110
src/settings_advanced.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "context" 7 + "fmt" 8 + "os" 9 + 10 + "github.com/diamondburned/gotk4-adwaita/pkg/adw" 11 + "github.com/diamondburned/gotk4/pkg/gio/v2" 12 + "github.com/diamondburned/gotk4/pkg/gtk/v4" 13 + ) 14 + 15 + // Note: gio/v2 is needed for gio.AsyncResulter in file dialog callbacks 16 + 17 + func createAdvancedPage(win *adw.Window, cfg *Config) gtk.Widgetter { 18 + toolbarView, content, _ := settingsPageLayout("Advanced") 19 + 20 + // Config file info 21 + configGroup := adw.NewPreferencesGroup() 22 + configGroup.SetTitle("Configuration") 23 + 24 + configRow := adw.NewActionRow() 25 + configRow.SetTitle("Configuration File") 26 + configRow.SetSubtitle(configPath()) 27 + configRow.SetActivatable(true) 28 + configRow.AddSuffix(gtk.NewImageFromIconName("document-edit-symbolic")) 29 + configRow.ConnectActivated(func() { 30 + saveConfig(cfg) 31 + cmd := hostCommand("xdg-open", configPath()) 32 + if err := cmd.Start(); err != nil { 33 + fmt.Printf("Failed to open config file: %v\n", err) 34 + } 35 + go cmd.Wait() 36 + }) 37 + configGroup.Add(configRow) 38 + 39 + // Export config 40 + exportRow := adw.NewActionRow() 41 + exportRow.SetTitle("Export Configuration") 42 + exportRow.SetSubtitle("Save configuration to a file") 43 + exportRow.SetActivatable(true) 44 + exportRow.AddSuffix(gtk.NewImageFromIconName("document-save-symbolic")) 45 + exportRow.ConnectActivated(func() { 46 + dialog := gtk.NewFileDialog() 47 + dialog.SetTitle("Export Configuration") 48 + dialog.SetInitialName("switchyard.toml") 49 + dialog.SetFilters(configFileFilters()) 50 + 51 + dialog.Save(context.Background(), &win.Window, func(res gio.AsyncResulter) { 52 + file, err := dialog.SaveFinish(res) 53 + if err != nil || file == nil { 54 + return 55 + } 56 + path := file.Path() 57 + if path == "" { 58 + return 59 + } 60 + if err := exportConfig(cfg, path); err != nil { 61 + fmt.Fprintf(os.Stderr, "Failed to export config: %v\n", err) 62 + } 63 + }) 64 + }) 65 + configGroup.Add(exportRow) 66 + 67 + // Import config 68 + importRow := adw.NewActionRow() 69 + importRow.SetTitle("Import Configuration") 70 + importRow.SetSubtitle("Load configuration from a file") 71 + importRow.SetActivatable(true) 72 + importRow.AddSuffix(gtk.NewImageFromIconName("document-open-symbolic")) 73 + importRow.ConnectActivated(func() { 74 + fileDialog := gtk.NewFileDialog() 75 + fileDialog.SetTitle("Import Configuration") 76 + fileDialog.SetFilters(configFileFilters()) 77 + 78 + fileDialog.Open(context.Background(), &win.Window, func(res gio.AsyncResulter) { 79 + file, err := fileDialog.OpenFinish(res) 80 + if err != nil || file == nil { 81 + return 82 + } 83 + path := file.Path() 84 + if path == "" { 85 + return 86 + } 87 + 88 + warnDialog := adw.NewAlertDialog("Import Configuration?", "This will replace all your current settings and rules with the imported configuration.") 89 + warnDialog.AddResponse("cancel", "Cancel") 90 + warnDialog.AddResponse("import", "Import") 91 + warnDialog.SetResponseAppearance("import", adw.ResponseDestructive) 92 + warnDialog.SetDefaultResponse("cancel") 93 + warnDialog.SetCloseResponse("cancel") 94 + 95 + warnDialog.ConnectResponse(func(response string) { 96 + if response == "import" { 97 + if err := importConfig(cfg, path); err != nil { 98 + fmt.Fprintf(os.Stderr, "Failed to import config: %v\n", err) 99 + } 100 + } 101 + }) 102 + warnDialog.Present(win) 103 + }) 104 + }) 105 + configGroup.Add(importRow) 106 + 107 + content.Append(configGroup) 108 + 109 + return toolbarView 110 + }
+65
src/settings_appearance.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "github.com/diamondburned/gotk4-adwaita/pkg/adw" 7 + "github.com/diamondburned/gotk4/pkg/gtk/v4" 8 + ) 9 + 10 + func createAppearancePage(win *adw.Window, cfg *Config) gtk.Widgetter { 11 + browsers := detectBrowsers() 12 + toolbarView, content, _ := settingsPageLayout("Appearance") 13 + 14 + // General section 15 + appearanceGroup := adw.NewPreferencesGroup() 16 + appearanceGroup.SetTitle("General") 17 + 18 + forceDarkRow := adw.NewSwitchRow() 19 + forceDarkRow.SetTitle("Force dark mode") 20 + forceDarkRow.SetSubtitle("Always use dark mode") 21 + forceDarkRow.SetActive(cfg.ForceDarkMode) 22 + appearanceGroup.Add(forceDarkRow) 23 + 24 + content.Append(appearanceGroup) 25 + 26 + // Picker Window section 27 + pickerGroup := adw.NewPreferencesGroup() 28 + pickerGroup.SetTitle("Picker Window") 29 + 30 + showNamesRow := adw.NewSwitchRow() 31 + showNamesRow.SetTitle("Show browser names") 32 + showNamesRow.SetSubtitle("Show browser names below icons") 33 + showNamesRow.SetActive(cfg.ShowAppNames) 34 + pickerGroup.Add(showNamesRow) 35 + 36 + // Hidden browsers row 37 + hiddenBrowsersRow := adw.NewActionRow() 38 + hiddenBrowsersRow.SetTitle("Hidden browsers") 39 + hiddenBrowsersRow.SetSubtitle("Choose which browsers to hide from the picker") 40 + hiddenBrowsersRow.SetActivatable(true) 41 + 42 + chevron := gtk.NewImageFromIconName("go-next-symbolic") 43 + hiddenBrowsersRow.AddSuffix(chevron) 44 + 45 + hiddenBrowsersRow.ConnectActivated(func() { 46 + showHiddenBrowsersDialog(win, cfg, browsers) 47 + }) 48 + 49 + pickerGroup.Add(hiddenBrowsersRow) 50 + 51 + content.Append(pickerGroup) 52 + 53 + // Connect change handlers 54 + forceDarkRow.Connect("notify::active", func() { 55 + cfg.ForceDarkMode = forceDarkRow.Active() 56 + saveConfigWithFlag(cfg) 57 + }) 58 + 59 + showNamesRow.Connect("notify::active", func() { 60 + cfg.ShowAppNames = showNamesRow.Active() 61 + saveConfigWithFlag(cfg) 62 + }) 63 + 64 + return toolbarView 65 + }
+77
src/settings_behavior.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "github.com/diamondburned/gotk4-adwaita/pkg/adw" 7 + "github.com/diamondburned/gotk4/pkg/gtk/v4" 8 + ) 9 + 10 + func createBehaviorPage(win *adw.Window, cfg *Config, browsers []*Browser) gtk.Widgetter { 11 + toolbarView, content, _ := settingsPageLayout("Behavior") 12 + 13 + // General Behavior section 14 + behaviorGroup := adw.NewPreferencesGroup() 15 + behaviorGroup.SetTitle("General") 16 + 17 + checkDefaultRow := adw.NewSwitchRow() 18 + checkDefaultRow.SetTitle("Prompt to set as default browser") 19 + checkDefaultRow.SetSubtitle("Show prompt on startup if Switchyard is not the default browser") 20 + checkDefaultRow.SetActive(cfg.CheckDefaultBrowser) 21 + behaviorGroup.Add(checkDefaultRow) 22 + 23 + promptRow := adw.NewSwitchRow() 24 + promptRow.SetTitle("Show picker when no rule matches") 25 + promptRow.SetSubtitle("Let you choose a browser for unmatched URLs") 26 + promptRow.SetActive(cfg.PromptOnClick) 27 + behaviorGroup.Add(promptRow) 28 + 29 + // Favorite browser dropdown 30 + browserNames := make([]string, len(browsers)+1) 31 + browserNames[0] = "None" 32 + for i, b := range browsers { 33 + browserNames[i+1] = b.Name 34 + } 35 + browserList := gtk.NewStringList(browserNames) 36 + 37 + defaultRow := adw.NewComboRow() 38 + defaultRow.SetTitle("Favorite browser") 39 + defaultRow.SetSubtitle("Appears first in picker and opens when picker is disabled") 40 + defaultRow.SetModel(browserList) 41 + 42 + // Set initial selection 43 + selectedIndex := uint(0) 44 + for i, b := range browsers { 45 + if b.ID == cfg.FavoriteBrowser { 46 + selectedIndex = uint(i + 1) 47 + break 48 + } 49 + } 50 + defaultRow.SetSelected(selectedIndex) 51 + 52 + behaviorGroup.Add(defaultRow) 53 + content.Append(behaviorGroup) 54 + 55 + // Connect change handlers 56 + checkDefaultRow.Connect("notify::active", func() { 57 + cfg.CheckDefaultBrowser = checkDefaultRow.Active() 58 + saveConfigWithFlag(cfg) 59 + }) 60 + 61 + promptRow.Connect("notify::active", func() { 62 + cfg.PromptOnClick = promptRow.Active() 63 + saveConfigWithFlag(cfg) 64 + }) 65 + 66 + defaultRow.Connect("notify::selected", func() { 67 + idx := defaultRow.Selected() 68 + if idx == 0 { 69 + cfg.FavoriteBrowser = "" 70 + } else if idx > 0 && int(idx) <= len(browsers) { 71 + cfg.FavoriteBrowser = browsers[idx-1].ID 72 + } 73 + saveConfigWithFlag(cfg) 74 + }) 75 + 76 + return toolbarView 77 + }
+256
src/settings_rules.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "fmt" 7 + 8 + "github.com/diamondburned/gotk4-adwaita/pkg/adw" 9 + "github.com/diamondburned/gotk4/pkg/gtk/v4" 10 + ) 11 + 12 + // formatRuleSubtitle formats a subtitle for a rule row with pattern included 13 + func formatRuleSubtitle(rule *Rule, browserName string) string { 14 + return formatRuleSubtitleInternal(rule, browserName, true) 15 + } 16 + 17 + // formatRuleSubtitleNoPattern formats a subtitle for a rule row without pattern 18 + func formatRuleSubtitleNoPattern(rule *Rule, browserName string) string { 19 + return formatRuleSubtitleInternal(rule, browserName, false) 20 + } 21 + 22 + func formatRuleSubtitleInternal(rule *Rule, browserName string, includePattern bool) string { 23 + if len(rule.Conditions) > 0 { 24 + condCount := len(rule.Conditions) 25 + var logicText string 26 + if rule.Logic == "any" { 27 + logicText = "Any match" 28 + } else { 29 + logicText = "All match" 30 + } 31 + 32 + if rule.AlwaysAsk { 33 + if condCount == 1 && includePattern { 34 + return fmt.Sprintf("%s: %s · Always ask", getTypeLabel(rule.Conditions[0].Type), rule.Conditions[0].Pattern) 35 + } 36 + return fmt.Sprintf("%d conditions (%s) · Always ask", condCount, logicText) 37 + } 38 + if condCount == 1 && includePattern { 39 + return fmt.Sprintf("%s: %s · Opens in %s", getTypeLabel(rule.Conditions[0].Type), rule.Conditions[0].Pattern, browserName) 40 + } 41 + return fmt.Sprintf("%d conditions (%s) · Opens in %s", condCount, logicText, browserName) 42 + } 43 + 44 + return "No conditions" 45 + } 46 + 47 + func getTypeLabel(patternType string) string { 48 + switch patternType { 49 + case "domain": 50 + return "Exact Domain" 51 + case "keyword": 52 + return "URL Contains" 53 + case "glob": 54 + return "Wildcard" 55 + case "regex": 56 + return "Regex" 57 + default: 58 + return patternType 59 + } 60 + } 61 + 62 + func createRulesPage(win *adw.Window, cfg *Config, browsers []*Browser) gtk.Widgetter { 63 + toolbarView := adw.NewToolbarView() 64 + 65 + header := adw.NewHeaderBar() 66 + header.SetShowEndTitleButtons(true) 67 + titleLabel := gtk.NewLabel("Rules") 68 + titleLabel.AddCSSClass("title") 69 + header.SetTitleWidget(titleLabel) 70 + 71 + // Add Rule button in header 72 + addButton := gtk.NewButton() 73 + addButton.SetIconName("list-add-symbolic") 74 + addButton.SetTooltipText("Add New Rule") 75 + addButton.SetHasFrame(false) 76 + header.PackEnd(addButton) 77 + 78 + toolbarView.AddTopBar(header) 79 + 80 + // Scrolled window for rules list 81 + scrolled := gtk.NewScrolledWindow() 82 + scrolled.SetVExpand(true) 83 + scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 84 + 85 + content := gtk.NewBox(gtk.OrientationVertical, 12) 86 + content.SetMarginStart(12) 87 + content.SetMarginEnd(12) 88 + content.SetMarginTop(12) 89 + content.SetMarginBottom(12) 90 + 91 + // Info banner 92 + infoLabel := gtk.NewLabel("Rules are evaluated in order. First match wins.") 93 + infoLabel.SetWrap(true) 94 + infoLabel.SetXAlign(0) 95 + infoLabel.AddCSSClass("dim-label") 96 + infoLabel.SetMarginStart(12) 97 + infoLabel.SetMarginEnd(12) 98 + infoLabel.SetMarginBottom(6) 99 + content.Append(infoLabel) 100 + 101 + // Rules list 102 + rulesListBox := gtk.NewListBox() 103 + rulesListBox.SetSelectionMode(gtk.SelectionNone) 104 + rulesListBox.AddCSSClass("boxed-list") 105 + 106 + // Empty state 107 + emptyState := adw.NewStatusPage() 108 + emptyState.SetIconName("list-add-symbolic") 109 + emptyState.SetTitle("No Rules") 110 + emptyState.SetDescription("Add rules to automatically route URLs to specific browsers") 111 + emptyState.SetVExpand(true) 112 + 113 + // Helper to get browser name from ID 114 + getBrowserName := func(id string) string { 115 + if browser := findBrowserByID(browsers, id); browser != nil { 116 + return browser.Name 117 + } 118 + return id 119 + } 120 + 121 + // Function to rebuild the rules list UI 122 + var rebuildRulesList func() 123 + 124 + // Function to create a rule row 125 + createRuleRow := func(ruleIndex int) *adw.ActionRow { 126 + rule := &cfg.Rules[ruleIndex] 127 + 128 + row := adw.NewActionRow() 129 + if rule.Name != "" { 130 + row.SetTitle(rule.Name) 131 + row.SetSubtitle(formatRuleSubtitle(rule, getBrowserName(rule.Browser))) 132 + } else { 133 + if len(rule.Conditions) > 0 { 134 + row.SetTitle(rule.Conditions[0].Pattern) 135 + } 136 + row.SetSubtitle(formatRuleSubtitleNoPattern(rule, getBrowserName(rule.Browser))) 137 + } 138 + row.SetActivatable(true) 139 + 140 + // Browser icon 141 + var icon *gtk.Image 142 + if rule.AlwaysAsk { 143 + appBrowser := &Browser{ 144 + ID: getAppID(), 145 + Icon: getAppID(), 146 + AppInfo: nil, 147 + } 148 + icon = loadBrowserIcon(appBrowser, 24) 149 + } else { 150 + browser := findBrowserByID(browsers, rule.Browser) 151 + if browser != nil { 152 + icon = loadBrowserIcon(browser, 24) 153 + } else { 154 + icon = gtk.NewImageFromIconName("web-browser-symbolic") 155 + icon.SetPixelSize(24) 156 + } 157 + } 158 + row.AddPrefix(icon) 159 + 160 + // Reorder buttons 161 + reorderBox := gtk.NewBox(gtk.OrientationHorizontal, 0) 162 + reorderBox.SetVAlign(gtk.AlignCenter) 163 + 164 + upBtn := gtk.NewButton() 165 + upBtn.SetIconName("go-up-symbolic") 166 + upBtn.AddCSSClass("flat") 167 + upBtn.SetSensitive(ruleIndex > 0) 168 + upBtn.SetTooltipText("Move rule up") 169 + upBtn.ConnectClicked(func() { 170 + if ruleIndex > 0 { 171 + cfg.Rules[ruleIndex], cfg.Rules[ruleIndex-1] = cfg.Rules[ruleIndex-1], cfg.Rules[ruleIndex] 172 + saveConfig(cfg) 173 + rebuildRulesList() 174 + } 175 + }) 176 + reorderBox.Append(upBtn) 177 + 178 + downBtn := gtk.NewButton() 179 + downBtn.SetIconName("go-down-symbolic") 180 + downBtn.AddCSSClass("flat") 181 + downBtn.SetSensitive(ruleIndex < len(cfg.Rules)-1) 182 + downBtn.SetTooltipText("Move rule down") 183 + downBtn.ConnectClicked(func() { 184 + if ruleIndex < len(cfg.Rules)-1 { 185 + cfg.Rules[ruleIndex], cfg.Rules[ruleIndex+1] = cfg.Rules[ruleIndex+1], cfg.Rules[ruleIndex] 186 + saveConfig(cfg) 187 + rebuildRulesList() 188 + } 189 + }) 190 + reorderBox.Append(downBtn) 191 + 192 + row.AddSuffix(reorderBox) 193 + 194 + // Delete button 195 + deleteBtn := gtk.NewButton() 196 + deleteBtn.SetIconName("edit-delete-symbolic") 197 + deleteBtn.AddCSSClass("flat") 198 + deleteBtn.AddCSSClass("destructive-action") 199 + deleteBtn.SetTooltipText("Delete rule") 200 + deleteBtn.ConnectClicked(func() { 201 + cfg.Rules = append(cfg.Rules[:ruleIndex], cfg.Rules[ruleIndex+1:]...) 202 + saveConfig(cfg) 203 + rebuildRulesList() 204 + }) 205 + row.AddSuffix(deleteBtn) 206 + 207 + // Edit on click 208 + row.ConnectActivated(func() { 209 + showEditRuleDialog(win, cfg, rule, browsers, rebuildRulesList) 210 + }) 211 + 212 + return row 213 + } 214 + 215 + rebuildRulesList = func() { 216 + // Remove all children 217 + for { 218 + child := rulesListBox.FirstChild() 219 + if child == nil { 220 + break 221 + } 222 + rulesListBox.Remove(child) 223 + } 224 + 225 + // Show/hide empty state vs rules list 226 + if len(cfg.Rules) == 0 { 227 + infoLabel.SetVisible(false) 228 + rulesListBox.SetVisible(false) 229 + emptyState.SetVisible(true) 230 + } else { 231 + infoLabel.SetVisible(true) 232 + rulesListBox.SetVisible(true) 233 + emptyState.SetVisible(false) 234 + 235 + for i := range cfg.Rules { 236 + row := createRuleRow(i) 237 + rulesListBox.Append(row) 238 + } 239 + } 240 + } 241 + 242 + // Initial build 243 + rebuildRulesList() 244 + 245 + content.Append(rulesListBox) 246 + content.Append(emptyState) 247 + scrolled.SetChild(content) 248 + toolbarView.SetContent(scrolled) 249 + 250 + // Connect Add Rule button handler 251 + addButton.ConnectClicked(func() { 252 + showAddRuleDialog(win, cfg, browsers, rebuildRulesList) 253 + }) 254 + 255 + return toolbarView 256 + }
src/ui_components.go src/dialog_helpers.go
+48
src/ui_helpers.go
··· 4 4 5 5 import ( 6 6 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 7 + "github.com/diamondburned/gotk4/pkg/gio/v2" 7 8 "github.com/diamondburned/gotk4/pkg/glib/v2" 8 9 "github.com/diamondburned/gotk4/pkg/gtk/v4" 9 10 ) ··· 63 64 } 64 65 return nil 65 66 } 67 + 68 + // settingsPageLayout creates the standard layout for settings pages. 69 + // Returns the toolbar view, content box for adding widgets, and the header bar. 70 + func settingsPageLayout(title string) (*adw.ToolbarView, *gtk.Box, *adw.HeaderBar) { 71 + toolbarView := adw.NewToolbarView() 72 + 73 + header := adw.NewHeaderBar() 74 + header.SetShowEndTitleButtons(true) 75 + titleLabel := gtk.NewLabel(title) 76 + titleLabel.AddCSSClass("title") 77 + header.SetTitleWidget(titleLabel) 78 + toolbarView.AddTopBar(header) 79 + 80 + scrolled := gtk.NewScrolledWindow() 81 + scrolled.SetVExpand(true) 82 + scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 83 + 84 + content := gtk.NewBox(gtk.OrientationVertical, 24) 85 + content.SetMarginStart(24) 86 + content.SetMarginEnd(24) 87 + content.SetMarginTop(24) 88 + content.SetMarginBottom(24) 89 + 90 + clamp := adw.NewClamp() 91 + clamp.SetMaximumSize(600) 92 + clamp.SetChild(content) 93 + scrolled.SetChild(clamp) 94 + 95 + toolbarView.SetContent(scrolled) 96 + return toolbarView, content, header 97 + } 98 + 99 + // configFileFilters creates the standard file filter list for config import/export. 100 + func configFileFilters() *gio.ListStore { 101 + tomlFilter := gtk.NewFileFilter() 102 + tomlFilter.SetName("TOML files") 103 + tomlFilter.AddPattern("*.toml") 104 + 105 + allFilter := gtk.NewFileFilter() 106 + allFilter.SetName("All files") 107 + allFilter.AddPattern("*") 108 + 109 + filters := gio.NewListStore(glib.TypeObject) 110 + filters.Append(tomlFilter.Object) 111 + filters.Append(allFilter.Object) 112 + return filters 113 + }
-42
src/url.go
··· 5 5 import ( 6 6 "fmt" 7 7 "net/url" 8 - "regexp" 9 8 "strings" 10 9 ) 11 10 ··· 50 49 default: 51 50 return "" 52 51 } 53 - } 54 - 55 - func matchesPattern(url, pattern, patternType string) bool { 56 - domain := extractDomain(url) 57 - 58 - switch patternType { 59 - case "domain": 60 - // Exact domain match 61 - return strings.EqualFold(domain, pattern) 62 - case "keyword": 63 - // URL contains text 64 - return strings.Contains(strings.ToLower(url), strings.ToLower(pattern)) 65 - case "regex": 66 - re, err := regexp.Compile(pattern) 67 - if err != nil { 68 - return false 69 - } 70 - return re.MatchString(url) 71 - case "glob": 72 - return matchGlob(url, pattern) 73 - default: 74 - return false 75 - } 76 - } 77 - 78 - func matchGlob(url, pattern string) bool { 79 - // Extract domain from URL for matching 80 - domain := extractDomain(url) 81 - 82 - // Simple glob matching: * matches any characters 83 - pattern = strings.ReplaceAll(pattern, ".", "\\.") 84 - pattern = strings.ReplaceAll(pattern, "*", ".*") 85 - pattern = "^" + pattern + "$" 86 - 87 - re, err := regexp.Compile(pattern) 88 - if err != nil { 89 - return false 90 - } 91 - 92 - // Match against domain or full URL 93 - return re.MatchString(domain) || re.MatchString(url) 94 52 } 95 53 96 54 func parseSwitchyardURL(rawURL string) (targetURL string, browserPrefs []string, err error) {
+2 -120
src/window_picker.go
··· 3 3 package main 4 4 5 5 import ( 6 - "context" 7 - "fmt" 8 - "strings" 9 - 10 6 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 11 7 coreglib "github.com/diamondburned/gotk4/pkg/core/glib" 12 8 "github.com/diamondburned/gotk4/pkg/gdk/v4" 13 9 "github.com/diamondburned/gotk4/pkg/gio/v2" 14 - "github.com/diamondburned/gotk4/pkg/glib/v2" 15 10 "github.com/diamondburned/gotk4/pkg/gtk/v4" 16 11 "github.com/diamondburned/gotk4/pkg/pango" 17 12 ) ··· 248 243 }) 249 244 win.AddController(keyController) 250 245 251 - // Set up action handlers for menu and desktop file actions 252 - actionGroup := gio.NewSimpleActionGroup() 253 - 254 - // Menu actions 255 - settingsAction := gio.NewSimpleAction("settings", nil) 256 - settingsAction.ConnectActivate(func(p *glib.Variant) { 257 - showSettingsWindow(app, loadConfig()) 258 - }) 259 - actionGroup.AddAction(settingsAction) 260 - 261 - aboutAction := gio.NewSimpleAction("about", nil) 262 - aboutAction.ConnectActivate(func(p *glib.Variant) { 263 - showAboutDialog(win) 264 - }) 265 - actionGroup.AddAction(aboutAction) 266 - 267 - donateAction := gio.NewSimpleAction("donate", nil) 268 - donateAction.ConnectActivate(func(p *glib.Variant) { 269 - launcher := gtk.NewURILauncher(DonateURL) 270 - launcher.Launch(context.Background(), &win.Window, nil) 271 - }) 272 - actionGroup.AddAction(donateAction) 273 - 274 - quitAction := gio.NewSimpleAction("quit", nil) 275 - quitAction.ConnectActivate(func(p *glib.Variant) { 276 - win.Close() 277 - }) 278 - actionGroup.AddAction(quitAction) 279 - 280 - // Keyboard shortcuts action 281 - shortcutsAction := gio.NewSimpleAction("shortcuts", nil) 282 - shortcutsAction.ConnectActivate(func(p *glib.Variant) { 283 - showShortcutsDialog(win) 284 - }) 285 - actionGroup.AddAction(shortcutsAction) 286 - 287 - // Action to launch browser with a specific action 288 - // Parameter format: "browserID:actionID" 289 - launchActionAction := gio.NewSimpleAction("launch-action", glib.NewVariantType("s")) 290 - launchActionAction.ConnectActivate(func(param *glib.Variant) { 291 - if param == nil { 292 - return 293 - } 294 - 295 - // Parse "browserID:actionID" from the parameter 296 - actionSpec := param.String() 297 - parts := strings.Split(actionSpec, ":") 298 - if len(parts) != 2 { 299 - return 300 - } 301 - 302 - browserID := parts[0] 303 - actionID := parts[1] 304 - 305 - // Find the browser 306 - var selectedBrowser *Browser 307 - for _, b := range filteredBrowsers { 308 - if b.ID == browserID { 309 - selectedBrowser = b 310 - break 311 - } 312 - } 313 - 314 - if selectedBrowser == nil { 315 - return 316 - } 317 - 318 - // Find the action and launch it 319 - actions := ListDesktopActions(selectedBrowser.AppInfo) 320 - for _, action := range actions { 321 - if action.ID == actionID { 322 - launchBrowserAction(selectedBrowser, action, url) 323 - win.Close() 324 - return 325 - } 326 - } 327 - }) 328 - actionGroup.AddAction(launchActionAction) 329 - 246 + // Set up action handlers 247 + actionGroup := setupPickerActions(win, app, filteredBrowsers, url, func() { win.Close() }) 330 248 win.InsertActionGroup("win", actionGroup) 331 249 332 250 win.Present() 333 251 } 334 - 335 - // showBrowserActionsMenu shows a context menu with desktop file actions 336 - func showBrowserActionsMenu(btn *gtk.Button, browser *Browser, url string) { 337 - actions := ListDesktopActions(browser.AppInfo) 338 - if len(actions) == 0 { 339 - return 340 - } 341 - 342 - // Build menu model 343 - menu := gio.NewMenu() 344 - 345 - // Add desktop file actions 346 - for _, action := range actions { 347 - // Use the action ID as a unique identifier for the action 348 - menu.Append(action.Name, fmt.Sprintf("win.launch-action::%s:%s", browser.ID, action.ID)) 349 - } 350 - 351 - // Create and show popover 352 - popover := gtk.NewPopoverMenuFromModel(menu) 353 - popover.SetParent(btn) 354 - popover.Popup() 355 - } 356 - 357 - // showShortcutsDialog displays available keyboard shortcuts 358 - func showShortcutsDialog(parent *adw.Window) { 359 - dialog := adw.NewAlertDialog( 360 - "Keyboard Shortcuts", 361 - "Ctrl+1 through Ctrl+9: Select browser 1-9\nEsc: Close picker window", 362 - ) 363 - 364 - dialog.AddResponse("ok", "OK") 365 - dialog.SetDefaultResponse("ok") 366 - dialog.SetCloseResponse("ok") 367 - 368 - dialog.Present(parent) 369 - }
+11 -560
src/window_settings.go
··· 4 4 5 5 import ( 6 6 "context" 7 - "fmt" 8 - "os" 9 7 10 8 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 11 9 "github.com/diamondburned/gotk4/pkg/gio/v2" ··· 19 17 win.SetApplication(&app.Application) 20 18 win.SetDefaultSize(900, 600) 21 19 win.SetResizable(true) 22 - 23 - // Set minimum size to prevent too-small window 24 20 win.SetSizeRequest(700, 500) 25 21 26 22 browsers := detectBrowsers() 27 23 28 - // Setup app-level actions 29 24 setupAppActions(app, win) 30 25 31 - // Navigation split view (sidebar + content) 26 + // Navigation split view 32 27 splitView := adw.NewNavigationSplitView() 33 28 splitView.SetShowContent(true) 34 29 splitView.SetMinSidebarWidth(200) ··· 38 33 sidebarPage := adw.NewNavigationPage(createSidebar(win, cfg, browsers, splitView), "Switchyard") 39 34 splitView.SetSidebar(sidebarPage) 40 35 41 - // Initial content - show Appearance page by default 36 + // Initial content 42 37 contentPage := adw.NewNavigationPage(createAppearancePage(win, cfg), "Appearance") 43 38 splitView.SetContent(contentPage) 44 39 45 40 win.SetContent(splitView) 46 41 47 - // Check if we should prompt to set as default browser 48 42 if cfg.CheckDefaultBrowser && !isDefaultBrowser() { 49 43 showDefaultBrowserPrompt(win, cfg, func() {}) 50 44 } 51 45 52 - // Watch config file for external changes 53 - watchConfigFile(cfg, func() { 54 - // Refresh current view 55 - // For now, we'll just note that external changes happened 56 - // A full implementation would reload the current page 57 - }) 46 + watchConfigFile(cfg, func() {}) 58 47 59 48 win.Present() 60 49 } ··· 79 68 }) 80 69 app.AddAction(quitAction) 81 70 82 - // Keyboard shortcuts 83 71 app.SetAccelsForAction("app.quit", []string{"<Ctrl>q"}) 84 72 } 85 73 86 74 func createSidebar(win *adw.Window, cfg *Config, browsers []*Browser, splitView *adw.NavigationSplitView) gtk.Widgetter { 87 - // Use AdwToolbarView for proper sidebar architecture 88 75 toolbarView := adw.NewToolbarView() 89 76 90 - // Sidebar header with menu 77 + // Header with menu 91 78 sidebarHeader := adw.NewHeaderBar() 92 79 sidebarHeader.SetShowEndTitleButtons(false) 93 80 ··· 95 82 titleLabel.AddCSSClass("title") 96 83 sidebarHeader.SetTitleWidget(titleLabel) 97 84 98 - // Hamburger menu 99 85 menuBtn := gtk.NewMenuButton() 100 86 menuBtn.SetIconName("open-menu-symbolic") 101 87 menuBtn.SetTooltipText("Main Menu") ··· 113 99 114 100 toolbarView.AddTopBar(sidebarHeader) 115 101 116 - // Scrolled window for sidebar content 102 + // Navigation list 117 103 scrolled := gtk.NewScrolledWindow() 118 104 scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 119 105 scrolled.SetVExpand(true) 120 106 121 - // List box for navigation items 122 107 listBox := gtk.NewListBox() 123 108 listBox.SetSelectionMode(gtk.SelectionSingle) 124 109 listBox.AddCSSClass("navigation-sidebar") 125 110 126 - // Appearance row 111 + // Navigation rows 127 112 appearanceRow := adw.NewActionRow() 128 113 appearanceRow.SetTitle("Appearance") 129 114 appearanceRow.AddPrefix(gtk.NewImageFromIconName("applications-graphics-symbolic")) 130 115 listBox.Append(appearanceRow) 131 116 132 - // Behavior row 133 117 behaviorRow := adw.NewActionRow() 134 118 behaviorRow.SetTitle("Behavior") 135 119 behaviorRow.AddPrefix(gtk.NewImageFromIconName("preferences-system-symbolic")) 136 120 listBox.Append(behaviorRow) 137 121 138 - // Rules row 139 122 rulesRow := adw.NewActionRow() 140 123 rulesRow.SetTitle("Rules") 141 124 rulesRow.AddPrefix(gtk.NewImageFromIconName("view-list-symbolic")) 142 125 listBox.Append(rulesRow) 143 126 144 - // Advanced row 145 127 advancedRow := adw.NewActionRow() 146 128 advancedRow.SetTitle("Advanced") 147 129 advancedRow.AddPrefix(gtk.NewImageFromIconName("preferences-other-symbolic")) ··· 150 132 scrolled.SetChild(listBox) 151 133 toolbarView.SetContent(scrolled) 152 134 153 - // Select first item by default 154 135 listBox.SelectRow(listBox.RowAtIndex(0)) 155 136 156 - // Handle navigation - need to connect to both activated and selected 137 + // Navigation handler 157 138 navigateToPage := func(index int) { 158 139 var page gtk.Widgetter 159 140 var title string 160 141 161 142 switch index { 162 - case 0: // Appearance 143 + case 0: 163 144 page = createAppearancePage(win, cfg) 164 145 title = "Appearance" 165 - case 1: // Behavior 146 + case 1: 166 147 page = createBehaviorPage(win, cfg, browsers) 167 148 title = "Behavior" 168 - case 2: // Rules 149 + case 2: 169 150 page = createRulesPage(win, cfg, browsers) 170 151 title = "Rules" 171 - case 3: // Advanced 152 + case 3: 172 153 page = createAdvancedPage(win, cfg) 173 154 title = "Advanced" 174 155 } ··· 192 173 return toolbarView 193 174 } 194 175 195 - func createAppearancePage(win *adw.Window, cfg *Config) gtk.Widgetter { 196 - browsers := detectBrowsers() 197 - 198 - // Use AdwToolbarView for proper page architecture 199 - toolbarView := adw.NewToolbarView() 200 - 201 - // Header for this page 202 - header := adw.NewHeaderBar() 203 - header.SetShowEndTitleButtons(true) 204 - titleLabel := gtk.NewLabel("Appearance") 205 - titleLabel.AddCSSClass("title") 206 - header.SetTitleWidget(titleLabel) 207 - toolbarView.AddTopBar(header) 208 - 209 - scrolled := gtk.NewScrolledWindow() 210 - scrolled.SetVExpand(true) 211 - scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 212 - 213 - content := gtk.NewBox(gtk.OrientationVertical, 24) 214 - content.SetMarginStart(24) 215 - content.SetMarginEnd(24) 216 - content.SetMarginTop(24) 217 - content.SetMarginBottom(24) 218 - 219 - clamp := adw.NewClamp() 220 - clamp.SetMaximumSize(600) 221 - clamp.SetChild(content) 222 - scrolled.SetChild(clamp) 223 - 224 - // App-wide appearance settings 225 - appearanceGroup := adw.NewPreferencesGroup() 226 - appearanceGroup.SetTitle("General") 227 - 228 - forceDarkRow := adw.NewSwitchRow() 229 - forceDarkRow.SetTitle("Force dark mode") 230 - forceDarkRow.SetSubtitle("Always use dark mode") 231 - forceDarkRow.SetActive(cfg.ForceDarkMode) 232 - appearanceGroup.Add(forceDarkRow) 233 - 234 - content.Append(appearanceGroup) 235 - 236 - // Picker Window section 237 - pickerGroup := adw.NewPreferencesGroup() 238 - pickerGroup.SetTitle("Picker Window") 239 - 240 - showNamesRow := adw.NewSwitchRow() 241 - showNamesRow.SetTitle("Show browser names") 242 - showNamesRow.SetSubtitle("Show browser names below icons") 243 - showNamesRow.SetActive(cfg.ShowAppNames) 244 - pickerGroup.Add(showNamesRow) 245 - 246 - // Hidden browsers row 247 - hiddenBrowsersRow := adw.NewActionRow() 248 - hiddenBrowsersRow.SetTitle("Hidden browsers") 249 - hiddenBrowsersRow.SetSubtitle("Choose which browsers to hide from the picker") 250 - hiddenBrowsersRow.SetActivatable(true) 251 - 252 - // Show arrow icon to indicate it's clickable 253 - chevron := gtk.NewImageFromIconName("go-next-symbolic") 254 - hiddenBrowsersRow.AddSuffix(chevron) 255 - 256 - hiddenBrowsersRow.ConnectActivated(func() { 257 - showHiddenBrowsersDialog(win, cfg, browsers) 258 - }) 259 - 260 - pickerGroup.Add(hiddenBrowsersRow) 261 - 262 - content.Append(pickerGroup) 263 - 264 - // Connect change handlers 265 - forceDarkRow.Connect("notify::active", func() { 266 - cfg.ForceDarkMode = forceDarkRow.Active() 267 - saveConfigWithFlag(cfg) 268 - }) 269 - 270 - showNamesRow.Connect("notify::active", func() { 271 - cfg.ShowAppNames = showNamesRow.Active() 272 - saveConfigWithFlag(cfg) 273 - }) 274 - 275 - toolbarView.SetContent(scrolled) 276 - return toolbarView 277 - } 278 - 279 - func createBehaviorPage(win *adw.Window, cfg *Config, browsers []*Browser) gtk.Widgetter { 280 - // Use AdwToolbarView for proper page architecture 281 - toolbarView := adw.NewToolbarView() 282 - 283 - // Header for this page 284 - header := adw.NewHeaderBar() 285 - header.SetShowEndTitleButtons(true) 286 - titleLabel := gtk.NewLabel("Behavior") 287 - titleLabel.AddCSSClass("title") 288 - header.SetTitleWidget(titleLabel) 289 - toolbarView.AddTopBar(header) 290 - 291 - scrolled := gtk.NewScrolledWindow() 292 - scrolled.SetVExpand(true) 293 - scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 294 - 295 - content := gtk.NewBox(gtk.OrientationVertical, 24) 296 - content.SetMarginStart(24) 297 - content.SetMarginEnd(24) 298 - content.SetMarginTop(24) 299 - content.SetMarginBottom(24) 300 - 301 - clamp := adw.NewClamp() 302 - clamp.SetMaximumSize(600) 303 - clamp.SetChild(content) 304 - scrolled.SetChild(clamp) 305 - 306 - // General Behavior section 307 - behaviorGroup := adw.NewPreferencesGroup() 308 - behaviorGroup.SetTitle("General") 309 - 310 - checkDefaultRow := adw.NewSwitchRow() 311 - checkDefaultRow.SetTitle("Prompt to set as default browser") 312 - checkDefaultRow.SetSubtitle("Show prompt on startup if Switchyard is not the default browser") 313 - checkDefaultRow.SetActive(cfg.CheckDefaultBrowser) 314 - behaviorGroup.Add(checkDefaultRow) 315 - 316 - promptRow := adw.NewSwitchRow() 317 - promptRow.SetTitle("Show picker when no rule matches") 318 - promptRow.SetSubtitle("Let you choose a browser for unmatched URLs") 319 - promptRow.SetActive(cfg.PromptOnClick) 320 - behaviorGroup.Add(promptRow) 321 - 322 - // Favorite browser dropdown 323 - browserNames := make([]string, len(browsers)+1) 324 - browserNames[0] = "None" 325 - for i, b := range browsers { 326 - browserNames[i+1] = b.Name 327 - } 328 - browserList := gtk.NewStringList(browserNames) 329 - 330 - defaultRow := adw.NewComboRow() 331 - defaultRow.SetTitle("Favorite browser") 332 - defaultRow.SetSubtitle("Appears first in picker and opens when picker is disabled") 333 - defaultRow.SetModel(browserList) 334 - 335 - // Set initial selection 336 - selectedIndex := uint(0) 337 - for i, b := range browsers { 338 - if b.ID == cfg.FavoriteBrowser { 339 - selectedIndex = uint(i + 1) 340 - break 341 - } 342 - } 343 - defaultRow.SetSelected(selectedIndex) 344 - 345 - behaviorGroup.Add(defaultRow) 346 - content.Append(behaviorGroup) 347 - 348 - // Connect change handlers 349 - checkDefaultRow.Connect("notify::active", func() { 350 - cfg.CheckDefaultBrowser = checkDefaultRow.Active() 351 - saveConfigWithFlag(cfg) 352 - }) 353 - 354 - promptRow.Connect("notify::active", func() { 355 - cfg.PromptOnClick = promptRow.Active() 356 - saveConfigWithFlag(cfg) 357 - }) 358 - 359 - defaultRow.Connect("notify::selected", func() { 360 - idx := defaultRow.Selected() 361 - if idx == 0 { 362 - cfg.FavoriteBrowser = "" 363 - } else if idx > 0 && int(idx) <= len(browsers) { 364 - cfg.FavoriteBrowser = browsers[idx-1].ID 365 - } 366 - saveConfigWithFlag(cfg) 367 - }) 368 - 369 - toolbarView.SetContent(scrolled) 370 - return toolbarView 371 - } 372 - 373 - func createRulesPage(win *adw.Window, cfg *Config, browsers []*Browser) gtk.Widgetter { 374 - // Use AdwToolbarView for proper page architecture 375 - toolbarView := adw.NewToolbarView() 376 - 377 - // Header for this page 378 - header := adw.NewHeaderBar() 379 - header.SetShowEndTitleButtons(true) 380 - titleLabel := gtk.NewLabel("Rules") 381 - titleLabel.AddCSSClass("title") 382 - header.SetTitleWidget(titleLabel) 383 - 384 - // Add Rule button in header 385 - addButton := gtk.NewButton() 386 - addButton.SetIconName("list-add-symbolic") 387 - addButton.SetTooltipText("Add New Rule") 388 - addButton.SetHasFrame(false) 389 - header.PackEnd(addButton) 390 - 391 - toolbarView.AddTopBar(header) 392 - 393 - // Scrolled window for rules list 394 - scrolled := gtk.NewScrolledWindow() 395 - scrolled.SetVExpand(true) 396 - scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 397 - 398 - content := gtk.NewBox(gtk.OrientationVertical, 12) 399 - content.SetMarginStart(12) 400 - content.SetMarginEnd(12) 401 - content.SetMarginTop(12) 402 - content.SetMarginBottom(12) 403 - 404 - // Info banner (shown when rules exist) 405 - infoLabel := gtk.NewLabel("Rules are evaluated in order. First match wins.") 406 - infoLabel.SetWrap(true) 407 - infoLabel.SetXAlign(0) 408 - infoLabel.AddCSSClass("dim-label") 409 - infoLabel.SetMarginStart(12) 410 - infoLabel.SetMarginEnd(12) 411 - infoLabel.SetMarginBottom(6) 412 - content.Append(infoLabel) 413 - 414 - // Rules list with drag-and-drop support 415 - rulesListBox := gtk.NewListBox() 416 - rulesListBox.SetSelectionMode(gtk.SelectionNone) 417 - rulesListBox.AddCSSClass("boxed-list") 418 - 419 - // Empty state (shown when no rules exist) 420 - emptyState := adw.NewStatusPage() 421 - emptyState.SetIconName("list-add-symbolic") 422 - emptyState.SetTitle("No Rules") 423 - emptyState.SetDescription("Add rules to automatically route URLs to specific browsers") 424 - emptyState.SetVExpand(true) 425 - 426 - // Helper to get browser name from ID 427 - getBrowserName := func(id string) string { 428 - if browser := findBrowserByID(browsers, id); browser != nil { 429 - return browser.Name 430 - } 431 - return id 432 - } 433 - 434 - // Function to rebuild the rules list UI 435 - var rebuildRulesList func() 436 - 437 - // Function to create a rule row 438 - createRuleRow := func(ruleIndex int) *adw.ActionRow { 439 - rule := &cfg.Rules[ruleIndex] 440 - 441 - row := adw.NewActionRow() 442 - // Show name as title if set, otherwise show first condition pattern 443 - if rule.Name != "" { 444 - row.SetTitle(rule.Name) 445 - row.SetSubtitle(formatRuleSubtitle(rule, getBrowserName(rule.Browser))) 446 - } else { 447 - // For rules without names, show first condition pattern 448 - if len(rule.Conditions) > 0 { 449 - row.SetTitle(rule.Conditions[0].Pattern) 450 - } 451 - row.SetSubtitle(formatRuleSubtitleNoPattern(rule, getBrowserName(rule.Browser))) 452 - } 453 - row.SetActivatable(true) 454 - 455 - // Browser icon - use Switchyard icon if AlwaysAsk is enabled 456 - var icon *gtk.Image 457 - if rule.AlwaysAsk { 458 - appBrowser := &Browser{ 459 - ID: getAppID(), 460 - Icon: getAppID(), 461 - AppInfo: nil, 462 - } 463 - icon = loadBrowserIcon(appBrowser, 24) 464 - } else { 465 - browser := findBrowserByID(browsers, rule.Browser) 466 - if browser != nil { 467 - icon = loadBrowserIcon(browser, 24) 468 - } else { 469 - icon = gtk.NewImageFromIconName("web-browser-symbolic") 470 - icon.SetPixelSize(24) 471 - } 472 - } 473 - row.AddPrefix(icon) 474 - 475 - // Reorder buttons box 476 - reorderBox := gtk.NewBox(gtk.OrientationHorizontal, 0) 477 - reorderBox.SetVAlign(gtk.AlignCenter) 478 - 479 - // Move up button 480 - upBtn := gtk.NewButton() 481 - upBtn.SetIconName("go-up-symbolic") 482 - upBtn.AddCSSClass("flat") 483 - upBtn.SetSensitive(ruleIndex > 0) 484 - upBtn.SetTooltipText("Move rule up") 485 - upBtn.ConnectClicked(func() { 486 - if ruleIndex > 0 { 487 - cfg.Rules[ruleIndex], cfg.Rules[ruleIndex-1] = cfg.Rules[ruleIndex-1], cfg.Rules[ruleIndex] 488 - saveConfig(cfg) 489 - rebuildRulesList() 490 - } 491 - }) 492 - reorderBox.Append(upBtn) 493 - 494 - // Move down button 495 - downBtn := gtk.NewButton() 496 - downBtn.SetIconName("go-down-symbolic") 497 - downBtn.AddCSSClass("flat") 498 - downBtn.SetSensitive(ruleIndex < len(cfg.Rules)-1) 499 - downBtn.SetTooltipText("Move rule down") 500 - downBtn.ConnectClicked(func() { 501 - if ruleIndex < len(cfg.Rules)-1 { 502 - cfg.Rules[ruleIndex], cfg.Rules[ruleIndex+1] = cfg.Rules[ruleIndex+1], cfg.Rules[ruleIndex] 503 - saveConfig(cfg) 504 - rebuildRulesList() 505 - } 506 - }) 507 - reorderBox.Append(downBtn) 508 - 509 - row.AddSuffix(reorderBox) 510 - 511 - // Delete button 512 - deleteBtn := gtk.NewButton() 513 - deleteBtn.SetIconName("edit-delete-symbolic") 514 - deleteBtn.AddCSSClass("flat") 515 - deleteBtn.AddCSSClass("destructive-action") 516 - deleteBtn.SetTooltipText("Delete rule") 517 - deleteBtn.ConnectClicked(func() { 518 - cfg.Rules = append(cfg.Rules[:ruleIndex], cfg.Rules[ruleIndex+1:]...) 519 - saveConfig(cfg) 520 - rebuildRulesList() 521 - }) 522 - row.AddSuffix(deleteBtn) 523 - 524 - // Edit on click 525 - row.ConnectActivated(func() { 526 - showEditRuleDialog(win, cfg, rule, browsers, rebuildRulesList) 527 - }) 528 - 529 - return row 530 - } 531 - 532 - rebuildRulesList = func() { 533 - // Remove all children 534 - for { 535 - child := rulesListBox.FirstChild() 536 - if child == nil { 537 - break 538 - } 539 - rulesListBox.Remove(child) 540 - } 541 - 542 - // Show/hide empty state vs rules list 543 - if len(cfg.Rules) == 0 { 544 - infoLabel.SetVisible(false) 545 - rulesListBox.SetVisible(false) 546 - emptyState.SetVisible(true) 547 - } else { 548 - infoLabel.SetVisible(true) 549 - rulesListBox.SetVisible(true) 550 - emptyState.SetVisible(false) 551 - 552 - // Add all rule rows 553 - for i := range cfg.Rules { 554 - row := createRuleRow(i) 555 - rulesListBox.Append(row) 556 - } 557 - } 558 - } 559 - 560 - // Initial build 561 - rebuildRulesList() 562 - 563 - content.Append(rulesListBox) 564 - content.Append(emptyState) 565 - scrolled.SetChild(content) 566 - toolbarView.SetContent(scrolled) 567 - 568 - // Connect Add Rule button handler 569 - addButton.ConnectClicked(func() { 570 - showAddRuleDialog(win, cfg, browsers, rebuildRulesList) 571 - }) 572 - 573 - return toolbarView 574 - } 575 - 576 - func createAdvancedPage(win *adw.Window, cfg *Config) gtk.Widgetter { 577 - // Use AdwToolbarView for proper page architecture 578 - toolbarView := adw.NewToolbarView() 579 - 580 - // Header for this page 581 - header := adw.NewHeaderBar() 582 - header.SetShowEndTitleButtons(true) 583 - titleLabel := gtk.NewLabel("Advanced") 584 - titleLabel.AddCSSClass("title") 585 - header.SetTitleWidget(titleLabel) 586 - toolbarView.AddTopBar(header) 587 - 588 - scrolled := gtk.NewScrolledWindow() 589 - scrolled.SetVExpand(true) 590 - scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 591 - 592 - content := gtk.NewBox(gtk.OrientationVertical, 24) 593 - content.SetMarginStart(24) 594 - content.SetMarginEnd(24) 595 - content.SetMarginTop(24) 596 - content.SetMarginBottom(24) 597 - 598 - clamp := adw.NewClamp() 599 - clamp.SetMaximumSize(600) 600 - clamp.SetChild(content) 601 - scrolled.SetChild(clamp) 602 - 603 - // Config file info 604 - configGroup := adw.NewPreferencesGroup() 605 - configGroup.SetTitle("Configuration") 606 - 607 - configRow := adw.NewActionRow() 608 - configRow.SetTitle("Configuration File") 609 - configRow.SetSubtitle(configPath()) 610 - configRow.SetActivatable(true) 611 - configRow.AddSuffix(gtk.NewImageFromIconName("document-edit-symbolic")) 612 - configRow.ConnectActivated(func() { 613 - // Ensure config file exists 614 - saveConfig(cfg) 615 - // Open with xdg-open via flatpak-spawn when in Flatpak 616 - cmd := hostCommand("xdg-open", configPath()) 617 - if err := cmd.Start(); err != nil { 618 - fmt.Printf("Failed to open config file: %v\n", err) 619 - } 620 - go cmd.Wait() 621 - }) 622 - configGroup.Add(configRow) 623 - 624 - // Export config 625 - exportRow := adw.NewActionRow() 626 - exportRow.SetTitle("Export Configuration") 627 - exportRow.SetSubtitle("Save configuration to a file") 628 - exportRow.SetActivatable(true) 629 - exportRow.AddSuffix(gtk.NewImageFromIconName("document-save-symbolic")) 630 - exportRow.ConnectActivated(func() { 631 - dialog := gtk.NewFileDialog() 632 - dialog.SetTitle("Export Configuration") 633 - dialog.SetInitialName("switchyard.toml") 634 - 635 - tomlFilter := gtk.NewFileFilter() 636 - tomlFilter.SetName("TOML files") 637 - tomlFilter.AddPattern("*.toml") 638 - 639 - allFilter := gtk.NewFileFilter() 640 - allFilter.SetName("All files") 641 - allFilter.AddPattern("*") 642 - 643 - filters := gio.NewListStore(glib.TypeObject) 644 - filters.Append(tomlFilter.Object) 645 - filters.Append(allFilter.Object) 646 - dialog.SetFilters(filters) 647 - 648 - dialog.Save(context.Background(), &win.Window, func(res gio.AsyncResulter) { 649 - file, err := dialog.SaveFinish(res) 650 - if err != nil || file == nil { 651 - return 652 - } 653 - path := file.Path() 654 - if path == "" { 655 - return 656 - } 657 - if err := exportConfig(cfg, path); err != nil { 658 - fmt.Fprintf(os.Stderr, "Failed to export config: %v\n", err) 659 - } 660 - }) 661 - }) 662 - configGroup.Add(exportRow) 663 - 664 - // Import config 665 - importRow := adw.NewActionRow() 666 - importRow.SetTitle("Import Configuration") 667 - importRow.SetSubtitle("Load configuration from a file") 668 - importRow.SetActivatable(true) 669 - importRow.AddSuffix(gtk.NewImageFromIconName("document-open-symbolic")) 670 - importRow.ConnectActivated(func() { 671 - fileDialog := gtk.NewFileDialog() 672 - fileDialog.SetTitle("Import Configuration") 673 - 674 - tomlFilter := gtk.NewFileFilter() 675 - tomlFilter.SetName("TOML files") 676 - tomlFilter.AddPattern("*.toml") 677 - 678 - allFilter := gtk.NewFileFilter() 679 - allFilter.SetName("All files") 680 - allFilter.AddPattern("*") 681 - 682 - filters := gio.NewListStore(glib.TypeObject) 683 - filters.Append(tomlFilter.Object) 684 - filters.Append(allFilter.Object) 685 - fileDialog.SetFilters(filters) 686 - 687 - fileDialog.Open(context.Background(), &win.Window, func(res gio.AsyncResulter) { 688 - file, err := fileDialog.OpenFinish(res) 689 - if err != nil || file == nil { 690 - return 691 - } 692 - path := file.Path() 693 - if path == "" { 694 - return 695 - } 696 - 697 - // Show warning dialog before replacing 698 - warnDialog := adw.NewAlertDialog("Import Configuration?", "This will replace all your current settings and rules with the imported configuration.") 699 - warnDialog.AddResponse("cancel", "Cancel") 700 - warnDialog.AddResponse("import", "Import") 701 - warnDialog.SetResponseAppearance("import", adw.ResponseDestructive) 702 - warnDialog.SetDefaultResponse("cancel") 703 - warnDialog.SetCloseResponse("cancel") 704 - 705 - warnDialog.ConnectResponse(func(response string) { 706 - if response == "import" { 707 - if err := importConfig(cfg, path); err != nil { 708 - fmt.Fprintf(os.Stderr, "Failed to import config: %v\n", err) 709 - } 710 - } 711 - }) 712 - warnDialog.Present(win) 713 - }) 714 - }) 715 - configGroup.Add(importRow) 716 - 717 - content.Append(configGroup) 718 - 719 - toolbarView.SetContent(scrolled) 720 - return toolbarView 721 - } 722 - 723 176 func watchConfigFile(cfg *Config, onChange func()) { 724 177 configFile := gio.NewFileForPath(configPath()) 725 178 monitorIface, err := configFile.Monitor(context.Background(), gio.FileMonitorNone) ··· 728 181 if monitor != nil { 729 182 monitor.ConnectChanged(func(file, otherFile gio.Filer, eventType gio.FileMonitorEvent) { 730 183 if eventType == gio.FileMonitorEventChanged || eventType == gio.FileMonitorEventCreated { 731 - // Ignore file changes while we're saving to avoid race conditions 732 184 savingMux.Lock() 733 185 saving := isSaving 734 186 savingMux.Unlock() ··· 736 188 return 737 189 } 738 190 739 - // Reload config from disk 740 191 newCfg := loadConfig() 741 192 cfg.PromptOnClick = newCfg.PromptOnClick 742 193 cfg.FavoriteBrowser = newCfg.FavoriteBrowser