From d7fab3c1378e0bc06af7fb0032774e1faed60a23 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Thu, 22 Jan 2026 01:14:58 -0500 Subject: [PATCH] split source files; add proper launch context for donateAction launched from picker window --- justfile | 4 +- src/dialog_condition_edit.go | 35 +- src/{ui_components.go => dialog_helpers.go} | 0 src/dialog_rule_add.go | 37 +- src/dialog_rule_edit.go | 37 +- src/formatting.go | 59 -- src/pattern.go | 46 ++ src/picker_actions.go | 122 +++++ src/settings_advanced.go | 110 ++++ src/settings_appearance.go | 65 +++ src/settings_behavior.go | 77 +++ src/settings_rules.go | 256 +++++++++ src/ui_helpers.go | 48 ++ src/url.go | 42 -- src/window_picker.go | 122 +---- src/window_settings.go | 571 +------------------- 16 files changed, 754 insertions(+), 877 deletions(-) rename src/{ui_components.go => dialog_helpers.go} (100%) delete mode 100644 src/formatting.go create mode 100644 src/pattern.go create mode 100644 src/picker_actions.go create mode 100644 src/settings_advanced.go create mode 100644 src/settings_appearance.go create mode 100644 src/settings_behavior.go create mode 100644 src/settings_rules.go diff --git a/justfile b/justfile index ecfea63..a6a21c0 100644 --- a/justfile +++ b/justfile @@ -52,12 +52,12 @@ vendor: # Run unit tests test: @echo "Running unit tests..." - 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 + 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 # Run tests with coverage report test-coverage: @echo "Running tests with coverage..." - 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 + 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 go tool cover -func=coverage.out @echo "" @echo "To view HTML coverage report, run: go tool cover -html=coverage.out" diff --git a/src/dialog_condition_edit.go b/src/dialog_condition_edit.go index 409b2fd..b456df8 100644 --- a/src/dialog_condition_edit.go +++ b/src/dialog_condition_edit.go @@ -11,35 +11,9 @@ import ( // showEditConditionDialog displays a dialog for editing a single condition. func showEditConditionDialog(parent *adw.Window, cond *Condition, onSave func()) { - dialog := adw.NewDialog() - dialog.SetTitle("Edit Condition") - dialog.SetContentWidth(400) - dialog.SetContentHeight(300) - dialog.SetCanClose(true) - - toolbarView := adw.NewToolbarView() - - header := adw.NewHeaderBar() - header.SetShowStartTitleButtons(false) - header.SetShowEndTitleButtons(false) - - cancelBtn := gtk.NewButton() - cancelBtn.SetLabel("Cancel") - cancelBtn.ConnectClicked(func() { dialog.Close() }) - header.PackStart(cancelBtn) - - saveBtn := gtk.NewButton() - saveBtn.SetLabel("Save") - saveBtn.AddCSSClass("suggested-action") - header.PackEnd(saveBtn) - - toolbarView.AddTopBar(header) - - content := gtk.NewBox(gtk.OrientationVertical, 18) - content.SetMarginStart(18) - content.SetMarginEnd(18) - content.SetMarginTop(18) - content.SetMarginBottom(18) + var dialog *adw.Dialog + header, saveBtn := dialogHeader("Cancel", "Save", func() { dialog.Close() }, nil) + dialog, content := simpleDialogWithToolbar("Edit Condition", 400, 300, header) // Preferences group group := adw.NewPreferencesGroup() @@ -93,9 +67,6 @@ func showEditConditionDialog(parent *adw.Window, cond *Condition, onSave func()) content.Append(group) content.Append(errorLabel) - toolbarView.SetContent(content) - dialog.SetChild(toolbarView) - // Initial validation check updateValidation() diff --git a/src/ui_components.go b/src/dialog_helpers.go similarity index 100% rename from src/ui_components.go rename to src/dialog_helpers.go diff --git a/src/dialog_rule_add.go b/src/dialog_rule_add.go index d5501bd..113b7c6 100644 --- a/src/dialog_rule_add.go +++ b/src/dialog_rule_add.go @@ -9,40 +9,15 @@ import ( // showAddRuleDialog displays the add rule dialog. func showAddRuleDialog(parent *adw.Window, cfg *Config, browsers []*Browser, rebuildRulesList func()) { - dialog := adw.NewDialog() - dialog.SetTitle("Add Rule") - dialog.SetContentWidth(600) - dialog.SetContentHeight(650) - dialog.SetCanClose(true) - - toolbarView := adw.NewToolbarView() - - header := adw.NewHeaderBar() - header.SetShowStartTitleButtons(false) - header.SetShowEndTitleButtons(false) - - cancelBtn := gtk.NewButton() - cancelBtn.SetLabel("Cancel") - cancelBtn.ConnectClicked(func() { dialog.Close() }) - header.PackStart(cancelBtn) - - addBtn := gtk.NewButton() - addBtn.SetLabel("Add") - addBtn.AddCSSClass("suggested-action") + var dialog *adw.Dialog + header, addBtn := dialogHeader("Cancel", "Add", func() { dialog.Close() }, nil) addBtn.SetSensitive(false) // Insensitive until at least one valid condition is added - header.PackEnd(addBtn) - - toolbarView.AddTopBar(header) - - scrolledWindow := gtk.NewScrolledWindow() - scrolledWindow.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) - scrolledWindow.SetVExpand(true) - nameEntry, conditions, logicRow, alwaysAskRow, browserRow, content := buildRuleDialogContent(nil, browsers, addBtn) + var scrolledWindow *gtk.ScrolledWindow + dialog, _, scrolledWindow = dialogWithToolbar("Add Rule", 600, 650, header) - scrolledWindow.SetChild(content) - toolbarView.SetContent(scrolledWindow) - dialog.SetChild(toolbarView) + nameEntry, conditions, logicRow, alwaysAskRow, browserRow, formContent := buildRuleDialogContent(nil, browsers, addBtn) + scrolledWindow.SetChild(formContent) addBtn.ConnectClicked(func() { browserIdx := browserRow.Selected() diff --git a/src/dialog_rule_edit.go b/src/dialog_rule_edit.go index 48719b4..0833171 100644 --- a/src/dialog_rule_edit.go +++ b/src/dialog_rule_edit.go @@ -18,39 +18,14 @@ func showEditRuleDialog(parent *adw.Window, cfg *Config, rule *Rule, browsers [] rule.Logic = "all" } - dialog := adw.NewDialog() - dialog.SetTitle("Edit Rule") - dialog.SetContentWidth(600) - dialog.SetContentHeight(650) - dialog.SetCanClose(true) + var dialog *adw.Dialog + header, saveBtn := dialogHeader("Cancel", "Save", func() { dialog.Close() }, nil) - toolbarView := adw.NewToolbarView() + var scrolledWindow *gtk.ScrolledWindow + dialog, _, scrolledWindow = dialogWithToolbar("Edit Rule", 600, 650, header) - header := adw.NewHeaderBar() - header.SetShowStartTitleButtons(false) - header.SetShowEndTitleButtons(false) - - cancelBtn := gtk.NewButton() - cancelBtn.SetLabel("Cancel") - cancelBtn.ConnectClicked(func() { dialog.Close() }) - header.PackStart(cancelBtn) - - saveBtn := gtk.NewButton() - saveBtn.SetLabel("Save") - saveBtn.AddCSSClass("suggested-action") - header.PackEnd(saveBtn) - - toolbarView.AddTopBar(header) - - scrolledWindow := gtk.NewScrolledWindow() - scrolledWindow.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) - scrolledWindow.SetVExpand(true) - - nameEntry, conditions, logicRow, alwaysAskRow, browserRow, content := buildRuleDialogContent(rule, browsers, saveBtn) - - scrolledWindow.SetChild(content) - toolbarView.SetContent(scrolledWindow) - dialog.SetChild(toolbarView) + nameEntry, conditions, logicRow, alwaysAskRow, browserRow, formContent := buildRuleDialogContent(rule, browsers, saveBtn) + scrolledWindow.SetChild(formContent) saveBtn.ConnectClicked(func() { browserIdx := browserRow.Selected() diff --git a/src/formatting.go b/src/formatting.go deleted file mode 100644 index cf478af..0000000 --- a/src/formatting.go +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package main - -import "fmt" - -// formatRuleSubtitle formats a subtitle for a rule row with pattern included -func formatRuleSubtitle(rule *Rule, browserName string) string { - return formatRuleSubtitleInternal(rule, browserName, true) -} - -// formatRuleSubtitleNoPattern formats a subtitle for a rule row without pattern -func formatRuleSubtitleNoPattern(rule *Rule, browserName string) string { - return formatRuleSubtitleInternal(rule, browserName, false) -} - -// formatRuleSubtitleInternal is the internal implementation for formatting rule subtitles -func formatRuleSubtitleInternal(rule *Rule, browserName string, includePattern bool) string { - // Handle multi-condition format - if len(rule.Conditions) > 0 { - condCount := len(rule.Conditions) - var logicText string - if rule.Logic == "any" { - logicText = "Any match" - } else { - logicText = "All match" - } - - if rule.AlwaysAsk { - if condCount == 1 && includePattern { - return fmt.Sprintf("%s: %s · Always ask", getTypeLabel(rule.Conditions[0].Type), rule.Conditions[0].Pattern) - } - return fmt.Sprintf("%d conditions (%s) · Always ask", condCount, logicText) - } - if condCount == 1 && includePattern { - return fmt.Sprintf("%s: %s · Opens in %s", getTypeLabel(rule.Conditions[0].Type), rule.Conditions[0].Pattern, browserName) - } - return fmt.Sprintf("%d conditions (%s) · Opens in %s", condCount, logicText, browserName) - } - - // No conditions (should not happen in normal use) - return "No conditions" -} - -// getTypeLabel returns a human-readable label for a condition type -func getTypeLabel(patternType string) string { - switch patternType { - case "domain": - return "Exact Domain" - case "keyword": - return "URL Contains" - case "glob": - return "Wildcard" - case "regex": - return "Regex" - default: - return patternType - } -} diff --git a/src/pattern.go b/src/pattern.go new file mode 100644 index 0000000..2fd5f72 --- /dev/null +++ b/src/pattern.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "regexp" + "strings" +) + +// matchesPattern checks if a URL matches a given pattern based on the pattern type +func matchesPattern(url, pattern, patternType string) bool { + switch patternType { + case "domain": + domain := extractDomain(url) + return strings.EqualFold(domain, pattern) + case "keyword": + return strings.Contains(strings.ToLower(url), strings.ToLower(pattern)) + case "regex": + re, err := regexp.Compile(pattern) + if err != nil { + return false + } + return re.MatchString(url) + case "glob": + return matchGlob(url, pattern) + default: + return false + } +} + +// matchGlob performs glob-style pattern matching against a URL's domain or full URL +func matchGlob(url, pattern string) bool { + domain := extractDomain(url) + + // Convert glob to regex: escape dots, convert * to .* + pattern = strings.ReplaceAll(pattern, ".", "\\.") + pattern = strings.ReplaceAll(pattern, "*", ".*") + pattern = "^" + pattern + "$" + + re, err := regexp.Compile(pattern) + if err != nil { + return false + } + + return re.MatchString(domain) || re.MatchString(url) +} diff --git a/src/picker_actions.go b/src/picker_actions.go new file mode 100644 index 0000000..0e4b20b --- /dev/null +++ b/src/picker_actions.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/diamondburned/gotk4-adwaita/pkg/adw" + "github.com/diamondburned/gotk4/pkg/gio/v2" + "github.com/diamondburned/gotk4/pkg/glib/v2" + "github.com/diamondburned/gotk4/pkg/gtk/v4" +) + +// showBrowserActionsMenu shows a context menu with desktop file actions +func showBrowserActionsMenu(btn *gtk.Button, browser *Browser, url string) { + actions := ListDesktopActions(browser.AppInfo) + if len(actions) == 0 { + return + } + + menu := gio.NewMenu() + for _, action := range actions { + menu.Append(action.Name, fmt.Sprintf("win.launch-action::%s:%s", browser.ID, action.ID)) + } + + popover := gtk.NewPopoverMenuFromModel(menu) + popover.SetParent(btn) + popover.Popup() +} + +// showShortcutsDialog displays available keyboard shortcuts +func showShortcutsDialog(parent *adw.Window) { + dialog := adw.NewAlertDialog( + "Keyboard Shortcuts", + "Ctrl+1 through Ctrl+9: Select browser 1-9\nEsc: Close picker window", + ) + + dialog.AddResponse("ok", "OK") + dialog.SetDefaultResponse("ok") + dialog.SetCloseResponse("ok") + + dialog.Present(parent) +} + +// setupPickerActions sets up the action group for the picker window +func setupPickerActions(win *adw.Window, app *adw.Application, browsers []*Browser, url string, onClose func()) *gio.SimpleActionGroup { + actionGroup := gio.NewSimpleActionGroup() + + settingsAction := gio.NewSimpleAction("settings", nil) + settingsAction.ConnectActivate(func(p *glib.Variant) { + showSettingsWindow(app, loadConfig()) + }) + actionGroup.AddAction(settingsAction) + + aboutAction := gio.NewSimpleAction("about", nil) + aboutAction.ConnectActivate(func(p *glib.Variant) { + showAboutDialog(win) + }) + actionGroup.AddAction(aboutAction) + + donateAction := gio.NewSimpleAction("donate", nil) + donateAction.ConnectActivate(func(p *glib.Variant) { + launcher := gtk.NewURILauncher(DonateURL) + launcher.Launch(context.Background(), &win.Window, nil) + }) + actionGroup.AddAction(donateAction) + + quitAction := gio.NewSimpleAction("quit", nil) + quitAction.ConnectActivate(func(p *glib.Variant) { + onClose() + }) + actionGroup.AddAction(quitAction) + + shortcutsAction := gio.NewSimpleAction("shortcuts", nil) + shortcutsAction.ConnectActivate(func(p *glib.Variant) { + showShortcutsDialog(win) + }) + actionGroup.AddAction(shortcutsAction) + + // Action to launch browser with a specific desktop action + launchActionAction := gio.NewSimpleAction("launch-action", glib.NewVariantType("s")) + launchActionAction.ConnectActivate(func(param *glib.Variant) { + if param == nil { + return + } + + actionSpec := param.String() + parts := strings.Split(actionSpec, ":") + if len(parts) != 2 { + return + } + + browserID := parts[0] + actionID := parts[1] + + var selectedBrowser *Browser + for _, b := range browsers { + if b.ID == browserID { + selectedBrowser = b + break + } + } + + if selectedBrowser == nil { + return + } + + actions := ListDesktopActions(selectedBrowser.AppInfo) + for _, action := range actions { + if action.ID == actionID { + launchBrowserAction(selectedBrowser, action, url) + onClose() + return + } + } + }) + actionGroup.AddAction(launchActionAction) + + return actionGroup +} diff --git a/src/settings_advanced.go b/src/settings_advanced.go new file mode 100644 index 0000000..2666900 --- /dev/null +++ b/src/settings_advanced.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "context" + "fmt" + "os" + + "github.com/diamondburned/gotk4-adwaita/pkg/adw" + "github.com/diamondburned/gotk4/pkg/gio/v2" + "github.com/diamondburned/gotk4/pkg/gtk/v4" +) + +// Note: gio/v2 is needed for gio.AsyncResulter in file dialog callbacks + +func createAdvancedPage(win *adw.Window, cfg *Config) gtk.Widgetter { + toolbarView, content, _ := settingsPageLayout("Advanced") + + // Config file info + configGroup := adw.NewPreferencesGroup() + configGroup.SetTitle("Configuration") + + configRow := adw.NewActionRow() + configRow.SetTitle("Configuration File") + configRow.SetSubtitle(configPath()) + configRow.SetActivatable(true) + configRow.AddSuffix(gtk.NewImageFromIconName("document-edit-symbolic")) + configRow.ConnectActivated(func() { + saveConfig(cfg) + cmd := hostCommand("xdg-open", configPath()) + if err := cmd.Start(); err != nil { + fmt.Printf("Failed to open config file: %v\n", err) + } + go cmd.Wait() + }) + configGroup.Add(configRow) + + // Export config + exportRow := adw.NewActionRow() + exportRow.SetTitle("Export Configuration") + exportRow.SetSubtitle("Save configuration to a file") + exportRow.SetActivatable(true) + exportRow.AddSuffix(gtk.NewImageFromIconName("document-save-symbolic")) + exportRow.ConnectActivated(func() { + dialog := gtk.NewFileDialog() + dialog.SetTitle("Export Configuration") + dialog.SetInitialName("switchyard.toml") + dialog.SetFilters(configFileFilters()) + + dialog.Save(context.Background(), &win.Window, func(res gio.AsyncResulter) { + file, err := dialog.SaveFinish(res) + if err != nil || file == nil { + return + } + path := file.Path() + if path == "" { + return + } + if err := exportConfig(cfg, path); err != nil { + fmt.Fprintf(os.Stderr, "Failed to export config: %v\n", err) + } + }) + }) + configGroup.Add(exportRow) + + // Import config + importRow := adw.NewActionRow() + importRow.SetTitle("Import Configuration") + importRow.SetSubtitle("Load configuration from a file") + importRow.SetActivatable(true) + importRow.AddSuffix(gtk.NewImageFromIconName("document-open-symbolic")) + importRow.ConnectActivated(func() { + fileDialog := gtk.NewFileDialog() + fileDialog.SetTitle("Import Configuration") + fileDialog.SetFilters(configFileFilters()) + + fileDialog.Open(context.Background(), &win.Window, func(res gio.AsyncResulter) { + file, err := fileDialog.OpenFinish(res) + if err != nil || file == nil { + return + } + path := file.Path() + if path == "" { + return + } + + warnDialog := adw.NewAlertDialog("Import Configuration?", "This will replace all your current settings and rules with the imported configuration.") + warnDialog.AddResponse("cancel", "Cancel") + warnDialog.AddResponse("import", "Import") + warnDialog.SetResponseAppearance("import", adw.ResponseDestructive) + warnDialog.SetDefaultResponse("cancel") + warnDialog.SetCloseResponse("cancel") + + warnDialog.ConnectResponse(func(response string) { + if response == "import" { + if err := importConfig(cfg, path); err != nil { + fmt.Fprintf(os.Stderr, "Failed to import config: %v\n", err) + } + } + }) + warnDialog.Present(win) + }) + }) + configGroup.Add(importRow) + + content.Append(configGroup) + + return toolbarView +} diff --git a/src/settings_appearance.go b/src/settings_appearance.go new file mode 100644 index 0000000..3fc0af2 --- /dev/null +++ b/src/settings_appearance.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "github.com/diamondburned/gotk4-adwaita/pkg/adw" + "github.com/diamondburned/gotk4/pkg/gtk/v4" +) + +func createAppearancePage(win *adw.Window, cfg *Config) gtk.Widgetter { + browsers := detectBrowsers() + toolbarView, content, _ := settingsPageLayout("Appearance") + + // General section + appearanceGroup := adw.NewPreferencesGroup() + appearanceGroup.SetTitle("General") + + forceDarkRow := adw.NewSwitchRow() + forceDarkRow.SetTitle("Force dark mode") + forceDarkRow.SetSubtitle("Always use dark mode") + forceDarkRow.SetActive(cfg.ForceDarkMode) + appearanceGroup.Add(forceDarkRow) + + content.Append(appearanceGroup) + + // Picker Window section + pickerGroup := adw.NewPreferencesGroup() + pickerGroup.SetTitle("Picker Window") + + showNamesRow := adw.NewSwitchRow() + showNamesRow.SetTitle("Show browser names") + showNamesRow.SetSubtitle("Show browser names below icons") + showNamesRow.SetActive(cfg.ShowAppNames) + pickerGroup.Add(showNamesRow) + + // Hidden browsers row + hiddenBrowsersRow := adw.NewActionRow() + hiddenBrowsersRow.SetTitle("Hidden browsers") + hiddenBrowsersRow.SetSubtitle("Choose which browsers to hide from the picker") + hiddenBrowsersRow.SetActivatable(true) + + chevron := gtk.NewImageFromIconName("go-next-symbolic") + hiddenBrowsersRow.AddSuffix(chevron) + + hiddenBrowsersRow.ConnectActivated(func() { + showHiddenBrowsersDialog(win, cfg, browsers) + }) + + pickerGroup.Add(hiddenBrowsersRow) + + content.Append(pickerGroup) + + // Connect change handlers + forceDarkRow.Connect("notify::active", func() { + cfg.ForceDarkMode = forceDarkRow.Active() + saveConfigWithFlag(cfg) + }) + + showNamesRow.Connect("notify::active", func() { + cfg.ShowAppNames = showNamesRow.Active() + saveConfigWithFlag(cfg) + }) + + return toolbarView +} diff --git a/src/settings_behavior.go b/src/settings_behavior.go new file mode 100644 index 0000000..d706d0f --- /dev/null +++ b/src/settings_behavior.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "github.com/diamondburned/gotk4-adwaita/pkg/adw" + "github.com/diamondburned/gotk4/pkg/gtk/v4" +) + +func createBehaviorPage(win *adw.Window, cfg *Config, browsers []*Browser) gtk.Widgetter { + toolbarView, content, _ := settingsPageLayout("Behavior") + + // General Behavior section + behaviorGroup := adw.NewPreferencesGroup() + behaviorGroup.SetTitle("General") + + checkDefaultRow := adw.NewSwitchRow() + checkDefaultRow.SetTitle("Prompt to set as default browser") + checkDefaultRow.SetSubtitle("Show prompt on startup if Switchyard is not the default browser") + checkDefaultRow.SetActive(cfg.CheckDefaultBrowser) + behaviorGroup.Add(checkDefaultRow) + + promptRow := adw.NewSwitchRow() + promptRow.SetTitle("Show picker when no rule matches") + promptRow.SetSubtitle("Let you choose a browser for unmatched URLs") + promptRow.SetActive(cfg.PromptOnClick) + behaviorGroup.Add(promptRow) + + // Favorite browser dropdown + browserNames := make([]string, len(browsers)+1) + browserNames[0] = "None" + for i, b := range browsers { + browserNames[i+1] = b.Name + } + browserList := gtk.NewStringList(browserNames) + + defaultRow := adw.NewComboRow() + defaultRow.SetTitle("Favorite browser") + defaultRow.SetSubtitle("Appears first in picker and opens when picker is disabled") + defaultRow.SetModel(browserList) + + // Set initial selection + selectedIndex := uint(0) + for i, b := range browsers { + if b.ID == cfg.FavoriteBrowser { + selectedIndex = uint(i + 1) + break + } + } + defaultRow.SetSelected(selectedIndex) + + behaviorGroup.Add(defaultRow) + content.Append(behaviorGroup) + + // Connect change handlers + checkDefaultRow.Connect("notify::active", func() { + cfg.CheckDefaultBrowser = checkDefaultRow.Active() + saveConfigWithFlag(cfg) + }) + + promptRow.Connect("notify::active", func() { + cfg.PromptOnClick = promptRow.Active() + saveConfigWithFlag(cfg) + }) + + defaultRow.Connect("notify::selected", func() { + idx := defaultRow.Selected() + if idx == 0 { + cfg.FavoriteBrowser = "" + } else if idx > 0 && int(idx) <= len(browsers) { + cfg.FavoriteBrowser = browsers[idx-1].ID + } + saveConfigWithFlag(cfg) + }) + + return toolbarView +} diff --git a/src/settings_rules.go b/src/settings_rules.go new file mode 100644 index 0000000..3d29c0a --- /dev/null +++ b/src/settings_rules.go @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "fmt" + + "github.com/diamondburned/gotk4-adwaita/pkg/adw" + "github.com/diamondburned/gotk4/pkg/gtk/v4" +) + +// formatRuleSubtitle formats a subtitle for a rule row with pattern included +func formatRuleSubtitle(rule *Rule, browserName string) string { + return formatRuleSubtitleInternal(rule, browserName, true) +} + +// formatRuleSubtitleNoPattern formats a subtitle for a rule row without pattern +func formatRuleSubtitleNoPattern(rule *Rule, browserName string) string { + return formatRuleSubtitleInternal(rule, browserName, false) +} + +func formatRuleSubtitleInternal(rule *Rule, browserName string, includePattern bool) string { + if len(rule.Conditions) > 0 { + condCount := len(rule.Conditions) + var logicText string + if rule.Logic == "any" { + logicText = "Any match" + } else { + logicText = "All match" + } + + if rule.AlwaysAsk { + if condCount == 1 && includePattern { + return fmt.Sprintf("%s: %s · Always ask", getTypeLabel(rule.Conditions[0].Type), rule.Conditions[0].Pattern) + } + return fmt.Sprintf("%d conditions (%s) · Always ask", condCount, logicText) + } + if condCount == 1 && includePattern { + return fmt.Sprintf("%s: %s · Opens in %s", getTypeLabel(rule.Conditions[0].Type), rule.Conditions[0].Pattern, browserName) + } + return fmt.Sprintf("%d conditions (%s) · Opens in %s", condCount, logicText, browserName) + } + + return "No conditions" +} + +func getTypeLabel(patternType string) string { + switch patternType { + case "domain": + return "Exact Domain" + case "keyword": + return "URL Contains" + case "glob": + return "Wildcard" + case "regex": + return "Regex" + default: + return patternType + } +} + +func createRulesPage(win *adw.Window, cfg *Config, browsers []*Browser) gtk.Widgetter { + toolbarView := adw.NewToolbarView() + + header := adw.NewHeaderBar() + header.SetShowEndTitleButtons(true) + titleLabel := gtk.NewLabel("Rules") + titleLabel.AddCSSClass("title") + header.SetTitleWidget(titleLabel) + + // Add Rule button in header + addButton := gtk.NewButton() + addButton.SetIconName("list-add-symbolic") + addButton.SetTooltipText("Add New Rule") + addButton.SetHasFrame(false) + header.PackEnd(addButton) + + toolbarView.AddTopBar(header) + + // Scrolled window for rules list + scrolled := gtk.NewScrolledWindow() + scrolled.SetVExpand(true) + scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) + + content := gtk.NewBox(gtk.OrientationVertical, 12) + content.SetMarginStart(12) + content.SetMarginEnd(12) + content.SetMarginTop(12) + content.SetMarginBottom(12) + + // Info banner + infoLabel := gtk.NewLabel("Rules are evaluated in order. First match wins.") + infoLabel.SetWrap(true) + infoLabel.SetXAlign(0) + infoLabel.AddCSSClass("dim-label") + infoLabel.SetMarginStart(12) + infoLabel.SetMarginEnd(12) + infoLabel.SetMarginBottom(6) + content.Append(infoLabel) + + // Rules list + rulesListBox := gtk.NewListBox() + rulesListBox.SetSelectionMode(gtk.SelectionNone) + rulesListBox.AddCSSClass("boxed-list") + + // Empty state + emptyState := adw.NewStatusPage() + emptyState.SetIconName("list-add-symbolic") + emptyState.SetTitle("No Rules") + emptyState.SetDescription("Add rules to automatically route URLs to specific browsers") + emptyState.SetVExpand(true) + + // Helper to get browser name from ID + getBrowserName := func(id string) string { + if browser := findBrowserByID(browsers, id); browser != nil { + return browser.Name + } + return id + } + + // Function to rebuild the rules list UI + var rebuildRulesList func() + + // Function to create a rule row + createRuleRow := func(ruleIndex int) *adw.ActionRow { + rule := &cfg.Rules[ruleIndex] + + row := adw.NewActionRow() + if rule.Name != "" { + row.SetTitle(rule.Name) + row.SetSubtitle(formatRuleSubtitle(rule, getBrowserName(rule.Browser))) + } else { + if len(rule.Conditions) > 0 { + row.SetTitle(rule.Conditions[0].Pattern) + } + row.SetSubtitle(formatRuleSubtitleNoPattern(rule, getBrowserName(rule.Browser))) + } + row.SetActivatable(true) + + // Browser icon + var icon *gtk.Image + if rule.AlwaysAsk { + appBrowser := &Browser{ + ID: getAppID(), + Icon: getAppID(), + AppInfo: nil, + } + icon = loadBrowserIcon(appBrowser, 24) + } else { + browser := findBrowserByID(browsers, rule.Browser) + if browser != nil { + icon = loadBrowserIcon(browser, 24) + } else { + icon = gtk.NewImageFromIconName("web-browser-symbolic") + icon.SetPixelSize(24) + } + } + row.AddPrefix(icon) + + // Reorder buttons + reorderBox := gtk.NewBox(gtk.OrientationHorizontal, 0) + reorderBox.SetVAlign(gtk.AlignCenter) + + upBtn := gtk.NewButton() + upBtn.SetIconName("go-up-symbolic") + upBtn.AddCSSClass("flat") + upBtn.SetSensitive(ruleIndex > 0) + upBtn.SetTooltipText("Move rule up") + upBtn.ConnectClicked(func() { + if ruleIndex > 0 { + cfg.Rules[ruleIndex], cfg.Rules[ruleIndex-1] = cfg.Rules[ruleIndex-1], cfg.Rules[ruleIndex] + saveConfig(cfg) + rebuildRulesList() + } + }) + reorderBox.Append(upBtn) + + downBtn := gtk.NewButton() + downBtn.SetIconName("go-down-symbolic") + downBtn.AddCSSClass("flat") + downBtn.SetSensitive(ruleIndex < len(cfg.Rules)-1) + downBtn.SetTooltipText("Move rule down") + downBtn.ConnectClicked(func() { + if ruleIndex < len(cfg.Rules)-1 { + cfg.Rules[ruleIndex], cfg.Rules[ruleIndex+1] = cfg.Rules[ruleIndex+1], cfg.Rules[ruleIndex] + saveConfig(cfg) + rebuildRulesList() + } + }) + reorderBox.Append(downBtn) + + row.AddSuffix(reorderBox) + + // Delete button + deleteBtn := gtk.NewButton() + deleteBtn.SetIconName("edit-delete-symbolic") + deleteBtn.AddCSSClass("flat") + deleteBtn.AddCSSClass("destructive-action") + deleteBtn.SetTooltipText("Delete rule") + deleteBtn.ConnectClicked(func() { + cfg.Rules = append(cfg.Rules[:ruleIndex], cfg.Rules[ruleIndex+1:]...) + saveConfig(cfg) + rebuildRulesList() + }) + row.AddSuffix(deleteBtn) + + // Edit on click + row.ConnectActivated(func() { + showEditRuleDialog(win, cfg, rule, browsers, rebuildRulesList) + }) + + return row + } + + rebuildRulesList = func() { + // Remove all children + for { + child := rulesListBox.FirstChild() + if child == nil { + break + } + rulesListBox.Remove(child) + } + + // Show/hide empty state vs rules list + if len(cfg.Rules) == 0 { + infoLabel.SetVisible(false) + rulesListBox.SetVisible(false) + emptyState.SetVisible(true) + } else { + infoLabel.SetVisible(true) + rulesListBox.SetVisible(true) + emptyState.SetVisible(false) + + for i := range cfg.Rules { + row := createRuleRow(i) + rulesListBox.Append(row) + } + } + } + + // Initial build + rebuildRulesList() + + content.Append(rulesListBox) + content.Append(emptyState) + scrolled.SetChild(content) + toolbarView.SetContent(scrolled) + + // Connect Add Rule button handler + addButton.ConnectClicked(func() { + showAddRuleDialog(win, cfg, browsers, rebuildRulesList) + }) + + return toolbarView +} diff --git a/src/ui_helpers.go b/src/ui_helpers.go index 4a6de8b..851d0b5 100644 --- a/src/ui_helpers.go +++ b/src/ui_helpers.go @@ -4,6 +4,7 @@ package main import ( "github.com/diamondburned/gotk4-adwaita/pkg/adw" + "github.com/diamondburned/gotk4/pkg/gio/v2" "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" ) @@ -63,3 +64,50 @@ func findBrowserByID(browsers []*Browser, id string) *Browser { } return nil } + +// settingsPageLayout creates the standard layout for settings pages. +// Returns the toolbar view, content box for adding widgets, and the header bar. +func settingsPageLayout(title string) (*adw.ToolbarView, *gtk.Box, *adw.HeaderBar) { + toolbarView := adw.NewToolbarView() + + header := adw.NewHeaderBar() + header.SetShowEndTitleButtons(true) + titleLabel := gtk.NewLabel(title) + titleLabel.AddCSSClass("title") + header.SetTitleWidget(titleLabel) + toolbarView.AddTopBar(header) + + scrolled := gtk.NewScrolledWindow() + scrolled.SetVExpand(true) + scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) + + content := gtk.NewBox(gtk.OrientationVertical, 24) + content.SetMarginStart(24) + content.SetMarginEnd(24) + content.SetMarginTop(24) + content.SetMarginBottom(24) + + clamp := adw.NewClamp() + clamp.SetMaximumSize(600) + clamp.SetChild(content) + scrolled.SetChild(clamp) + + toolbarView.SetContent(scrolled) + return toolbarView, content, header +} + +// configFileFilters creates the standard file filter list for config import/export. +func configFileFilters() *gio.ListStore { + tomlFilter := gtk.NewFileFilter() + tomlFilter.SetName("TOML files") + tomlFilter.AddPattern("*.toml") + + allFilter := gtk.NewFileFilter() + allFilter.SetName("All files") + allFilter.AddPattern("*") + + filters := gio.NewListStore(glib.TypeObject) + filters.Append(tomlFilter.Object) + filters.Append(allFilter.Object) + return filters +} diff --git a/src/url.go b/src/url.go index 8d06727..b5ff7d3 100644 --- a/src/url.go +++ b/src/url.go @@ -5,7 +5,6 @@ package main import ( "fmt" "net/url" - "regexp" "strings" ) @@ -52,47 +51,6 @@ func sanitizeURL(rawURL string) string { } } -func matchesPattern(url, pattern, patternType string) bool { - domain := extractDomain(url) - - switch patternType { - case "domain": - // Exact domain match - return strings.EqualFold(domain, pattern) - case "keyword": - // URL contains text - return strings.Contains(strings.ToLower(url), strings.ToLower(pattern)) - case "regex": - re, err := regexp.Compile(pattern) - if err != nil { - return false - } - return re.MatchString(url) - case "glob": - return matchGlob(url, pattern) - default: - return false - } -} - -func matchGlob(url, pattern string) bool { - // Extract domain from URL for matching - domain := extractDomain(url) - - // Simple glob matching: * matches any characters - pattern = strings.ReplaceAll(pattern, ".", "\\.") - pattern = strings.ReplaceAll(pattern, "*", ".*") - pattern = "^" + pattern + "$" - - re, err := regexp.Compile(pattern) - if err != nil { - return false - } - - // Match against domain or full URL - return re.MatchString(domain) || re.MatchString(url) -} - func parseSwitchyardURL(rawURL string) (targetURL string, browserPrefs []string, err error) { u, err := url.Parse(rawURL) if err != nil { diff --git a/src/window_picker.go b/src/window_picker.go index 24dc511..2ba3da1 100644 --- a/src/window_picker.go +++ b/src/window_picker.go @@ -3,15 +3,10 @@ package main import ( - "context" - "fmt" - "strings" - "github.com/diamondburned/gotk4-adwaita/pkg/adw" coreglib "github.com/diamondburned/gotk4/pkg/core/glib" "github.com/diamondburned/gotk4/pkg/gdk/v4" "github.com/diamondburned/gotk4/pkg/gio/v2" - "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/diamondburned/gotk4/pkg/pango" ) @@ -248,122 +243,9 @@ func showPickerWindow(app *adw.Application, url string, browsers []*Browser, cfg }) win.AddController(keyController) - // Set up action handlers for menu and desktop file actions - actionGroup := gio.NewSimpleActionGroup() - - // Menu actions - settingsAction := gio.NewSimpleAction("settings", nil) - settingsAction.ConnectActivate(func(p *glib.Variant) { - showSettingsWindow(app, loadConfig()) - }) - actionGroup.AddAction(settingsAction) - - aboutAction := gio.NewSimpleAction("about", nil) - aboutAction.ConnectActivate(func(p *glib.Variant) { - showAboutDialog(win) - }) - actionGroup.AddAction(aboutAction) - - donateAction := gio.NewSimpleAction("donate", nil) - donateAction.ConnectActivate(func(p *glib.Variant) { - launcher := gtk.NewURILauncher(DonateURL) - launcher.Launch(context.Background(), &win.Window, nil) - }) - actionGroup.AddAction(donateAction) - - quitAction := gio.NewSimpleAction("quit", nil) - quitAction.ConnectActivate(func(p *glib.Variant) { - win.Close() - }) - actionGroup.AddAction(quitAction) - - // Keyboard shortcuts action - shortcutsAction := gio.NewSimpleAction("shortcuts", nil) - shortcutsAction.ConnectActivate(func(p *glib.Variant) { - showShortcutsDialog(win) - }) - actionGroup.AddAction(shortcutsAction) - - // Action to launch browser with a specific action - // Parameter format: "browserID:actionID" - launchActionAction := gio.NewSimpleAction("launch-action", glib.NewVariantType("s")) - launchActionAction.ConnectActivate(func(param *glib.Variant) { - if param == nil { - return - } - - // Parse "browserID:actionID" from the parameter - actionSpec := param.String() - parts := strings.Split(actionSpec, ":") - if len(parts) != 2 { - return - } - - browserID := parts[0] - actionID := parts[1] - - // Find the browser - var selectedBrowser *Browser - for _, b := range filteredBrowsers { - if b.ID == browserID { - selectedBrowser = b - break - } - } - - if selectedBrowser == nil { - return - } - - // Find the action and launch it - actions := ListDesktopActions(selectedBrowser.AppInfo) - for _, action := range actions { - if action.ID == actionID { - launchBrowserAction(selectedBrowser, action, url) - win.Close() - return - } - } - }) - actionGroup.AddAction(launchActionAction) - + // Set up action handlers + actionGroup := setupPickerActions(win, app, filteredBrowsers, url, func() { win.Close() }) win.InsertActionGroup("win", actionGroup) win.Present() } - -// showBrowserActionsMenu shows a context menu with desktop file actions -func showBrowserActionsMenu(btn *gtk.Button, browser *Browser, url string) { - actions := ListDesktopActions(browser.AppInfo) - if len(actions) == 0 { - return - } - - // Build menu model - menu := gio.NewMenu() - - // Add desktop file actions - for _, action := range actions { - // Use the action ID as a unique identifier for the action - menu.Append(action.Name, fmt.Sprintf("win.launch-action::%s:%s", browser.ID, action.ID)) - } - - // Create and show popover - popover := gtk.NewPopoverMenuFromModel(menu) - popover.SetParent(btn) - popover.Popup() -} - -// showShortcutsDialog displays available keyboard shortcuts -func showShortcutsDialog(parent *adw.Window) { - dialog := adw.NewAlertDialog( - "Keyboard Shortcuts", - "Ctrl+1 through Ctrl+9: Select browser 1-9\nEsc: Close picker window", - ) - - dialog.AddResponse("ok", "OK") - dialog.SetDefaultResponse("ok") - dialog.SetCloseResponse("ok") - - dialog.Present(parent) -} diff --git a/src/window_settings.go b/src/window_settings.go index 9b81596..13030f0 100644 --- a/src/window_settings.go +++ b/src/window_settings.go @@ -4,8 +4,6 @@ package main import ( "context" - "fmt" - "os" "github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4/pkg/gio/v2" @@ -19,16 +17,13 @@ func showSettingsWindow(app *adw.Application, cfg *Config) { win.SetApplication(&app.Application) win.SetDefaultSize(900, 600) win.SetResizable(true) - - // Set minimum size to prevent too-small window win.SetSizeRequest(700, 500) browsers := detectBrowsers() - // Setup app-level actions setupAppActions(app, win) - // Navigation split view (sidebar + content) + // Navigation split view splitView := adw.NewNavigationSplitView() splitView.SetShowContent(true) splitView.SetMinSidebarWidth(200) @@ -38,23 +33,17 @@ func showSettingsWindow(app *adw.Application, cfg *Config) { sidebarPage := adw.NewNavigationPage(createSidebar(win, cfg, browsers, splitView), "Switchyard") splitView.SetSidebar(sidebarPage) - // Initial content - show Appearance page by default + // Initial content contentPage := adw.NewNavigationPage(createAppearancePage(win, cfg), "Appearance") splitView.SetContent(contentPage) win.SetContent(splitView) - // Check if we should prompt to set as default browser if cfg.CheckDefaultBrowser && !isDefaultBrowser() { showDefaultBrowserPrompt(win, cfg, func() {}) } - // Watch config file for external changes - watchConfigFile(cfg, func() { - // Refresh current view - // For now, we'll just note that external changes happened - // A full implementation would reload the current page - }) + watchConfigFile(cfg, func() {}) win.Present() } @@ -79,15 +68,13 @@ func setupAppActions(app *adw.Application, win *adw.Window) { }) app.AddAction(quitAction) - // Keyboard shortcuts app.SetAccelsForAction("app.quit", []string{"q"}) } func createSidebar(win *adw.Window, cfg *Config, browsers []*Browser, splitView *adw.NavigationSplitView) gtk.Widgetter { - // Use AdwToolbarView for proper sidebar architecture toolbarView := adw.NewToolbarView() - // Sidebar header with menu + // Header with menu sidebarHeader := adw.NewHeaderBar() sidebarHeader.SetShowEndTitleButtons(false) @@ -95,7 +82,6 @@ func createSidebar(win *adw.Window, cfg *Config, browsers []*Browser, splitView titleLabel.AddCSSClass("title") sidebarHeader.SetTitleWidget(titleLabel) - // Hamburger menu menuBtn := gtk.NewMenuButton() menuBtn.SetIconName("open-menu-symbolic") menuBtn.SetTooltipText("Main Menu") @@ -113,35 +99,31 @@ func createSidebar(win *adw.Window, cfg *Config, browsers []*Browser, splitView toolbarView.AddTopBar(sidebarHeader) - // Scrolled window for sidebar content + // Navigation list scrolled := gtk.NewScrolledWindow() scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) scrolled.SetVExpand(true) - // List box for navigation items listBox := gtk.NewListBox() listBox.SetSelectionMode(gtk.SelectionSingle) listBox.AddCSSClass("navigation-sidebar") - // Appearance row + // Navigation rows appearanceRow := adw.NewActionRow() appearanceRow.SetTitle("Appearance") appearanceRow.AddPrefix(gtk.NewImageFromIconName("applications-graphics-symbolic")) listBox.Append(appearanceRow) - // Behavior row behaviorRow := adw.NewActionRow() behaviorRow.SetTitle("Behavior") behaviorRow.AddPrefix(gtk.NewImageFromIconName("preferences-system-symbolic")) listBox.Append(behaviorRow) - // Rules row rulesRow := adw.NewActionRow() rulesRow.SetTitle("Rules") rulesRow.AddPrefix(gtk.NewImageFromIconName("view-list-symbolic")) listBox.Append(rulesRow) - // Advanced row advancedRow := adw.NewActionRow() advancedRow.SetTitle("Advanced") advancedRow.AddPrefix(gtk.NewImageFromIconName("preferences-other-symbolic")) @@ -150,25 +132,24 @@ func createSidebar(win *adw.Window, cfg *Config, browsers []*Browser, splitView scrolled.SetChild(listBox) toolbarView.SetContent(scrolled) - // Select first item by default listBox.SelectRow(listBox.RowAtIndex(0)) - // Handle navigation - need to connect to both activated and selected + // Navigation handler navigateToPage := func(index int) { var page gtk.Widgetter var title string switch index { - case 0: // Appearance + case 0: page = createAppearancePage(win, cfg) title = "Appearance" - case 1: // Behavior + case 1: page = createBehaviorPage(win, cfg, browsers) title = "Behavior" - case 2: // Rules + case 2: page = createRulesPage(win, cfg, browsers) title = "Rules" - case 3: // Advanced + case 3: page = createAdvancedPage(win, cfg) title = "Advanced" } @@ -192,534 +173,6 @@ func createSidebar(win *adw.Window, cfg *Config, browsers []*Browser, splitView return toolbarView } -func createAppearancePage(win *adw.Window, cfg *Config) gtk.Widgetter { - browsers := detectBrowsers() - - // Use AdwToolbarView for proper page architecture - toolbarView := adw.NewToolbarView() - - // Header for this page - header := adw.NewHeaderBar() - header.SetShowEndTitleButtons(true) - titleLabel := gtk.NewLabel("Appearance") - titleLabel.AddCSSClass("title") - header.SetTitleWidget(titleLabel) - toolbarView.AddTopBar(header) - - scrolled := gtk.NewScrolledWindow() - scrolled.SetVExpand(true) - scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) - - content := gtk.NewBox(gtk.OrientationVertical, 24) - content.SetMarginStart(24) - content.SetMarginEnd(24) - content.SetMarginTop(24) - content.SetMarginBottom(24) - - clamp := adw.NewClamp() - clamp.SetMaximumSize(600) - clamp.SetChild(content) - scrolled.SetChild(clamp) - - // App-wide appearance settings - appearanceGroup := adw.NewPreferencesGroup() - appearanceGroup.SetTitle("General") - - forceDarkRow := adw.NewSwitchRow() - forceDarkRow.SetTitle("Force dark mode") - forceDarkRow.SetSubtitle("Always use dark mode") - forceDarkRow.SetActive(cfg.ForceDarkMode) - appearanceGroup.Add(forceDarkRow) - - content.Append(appearanceGroup) - - // Picker Window section - pickerGroup := adw.NewPreferencesGroup() - pickerGroup.SetTitle("Picker Window") - - showNamesRow := adw.NewSwitchRow() - showNamesRow.SetTitle("Show browser names") - showNamesRow.SetSubtitle("Show browser names below icons") - showNamesRow.SetActive(cfg.ShowAppNames) - pickerGroup.Add(showNamesRow) - - // Hidden browsers row - hiddenBrowsersRow := adw.NewActionRow() - hiddenBrowsersRow.SetTitle("Hidden browsers") - hiddenBrowsersRow.SetSubtitle("Choose which browsers to hide from the picker") - hiddenBrowsersRow.SetActivatable(true) - - // Show arrow icon to indicate it's clickable - chevron := gtk.NewImageFromIconName("go-next-symbolic") - hiddenBrowsersRow.AddSuffix(chevron) - - hiddenBrowsersRow.ConnectActivated(func() { - showHiddenBrowsersDialog(win, cfg, browsers) - }) - - pickerGroup.Add(hiddenBrowsersRow) - - content.Append(pickerGroup) - - // Connect change handlers - forceDarkRow.Connect("notify::active", func() { - cfg.ForceDarkMode = forceDarkRow.Active() - saveConfigWithFlag(cfg) - }) - - showNamesRow.Connect("notify::active", func() { - cfg.ShowAppNames = showNamesRow.Active() - saveConfigWithFlag(cfg) - }) - - toolbarView.SetContent(scrolled) - return toolbarView -} - -func createBehaviorPage(win *adw.Window, cfg *Config, browsers []*Browser) gtk.Widgetter { - // Use AdwToolbarView for proper page architecture - toolbarView := adw.NewToolbarView() - - // Header for this page - header := adw.NewHeaderBar() - header.SetShowEndTitleButtons(true) - titleLabel := gtk.NewLabel("Behavior") - titleLabel.AddCSSClass("title") - header.SetTitleWidget(titleLabel) - toolbarView.AddTopBar(header) - - scrolled := gtk.NewScrolledWindow() - scrolled.SetVExpand(true) - scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) - - content := gtk.NewBox(gtk.OrientationVertical, 24) - content.SetMarginStart(24) - content.SetMarginEnd(24) - content.SetMarginTop(24) - content.SetMarginBottom(24) - - clamp := adw.NewClamp() - clamp.SetMaximumSize(600) - clamp.SetChild(content) - scrolled.SetChild(clamp) - - // General Behavior section - behaviorGroup := adw.NewPreferencesGroup() - behaviorGroup.SetTitle("General") - - checkDefaultRow := adw.NewSwitchRow() - checkDefaultRow.SetTitle("Prompt to set as default browser") - checkDefaultRow.SetSubtitle("Show prompt on startup if Switchyard is not the default browser") - checkDefaultRow.SetActive(cfg.CheckDefaultBrowser) - behaviorGroup.Add(checkDefaultRow) - - promptRow := adw.NewSwitchRow() - promptRow.SetTitle("Show picker when no rule matches") - promptRow.SetSubtitle("Let you choose a browser for unmatched URLs") - promptRow.SetActive(cfg.PromptOnClick) - behaviorGroup.Add(promptRow) - - // Favorite browser dropdown - browserNames := make([]string, len(browsers)+1) - browserNames[0] = "None" - for i, b := range browsers { - browserNames[i+1] = b.Name - } - browserList := gtk.NewStringList(browserNames) - - defaultRow := adw.NewComboRow() - defaultRow.SetTitle("Favorite browser") - defaultRow.SetSubtitle("Appears first in picker and opens when picker is disabled") - defaultRow.SetModel(browserList) - - // Set initial selection - selectedIndex := uint(0) - for i, b := range browsers { - if b.ID == cfg.FavoriteBrowser { - selectedIndex = uint(i + 1) - break - } - } - defaultRow.SetSelected(selectedIndex) - - behaviorGroup.Add(defaultRow) - content.Append(behaviorGroup) - - // Connect change handlers - checkDefaultRow.Connect("notify::active", func() { - cfg.CheckDefaultBrowser = checkDefaultRow.Active() - saveConfigWithFlag(cfg) - }) - - promptRow.Connect("notify::active", func() { - cfg.PromptOnClick = promptRow.Active() - saveConfigWithFlag(cfg) - }) - - defaultRow.Connect("notify::selected", func() { - idx := defaultRow.Selected() - if idx == 0 { - cfg.FavoriteBrowser = "" - } else if idx > 0 && int(idx) <= len(browsers) { - cfg.FavoriteBrowser = browsers[idx-1].ID - } - saveConfigWithFlag(cfg) - }) - - toolbarView.SetContent(scrolled) - return toolbarView -} - -func createRulesPage(win *adw.Window, cfg *Config, browsers []*Browser) gtk.Widgetter { - // Use AdwToolbarView for proper page architecture - toolbarView := adw.NewToolbarView() - - // Header for this page - header := adw.NewHeaderBar() - header.SetShowEndTitleButtons(true) - titleLabel := gtk.NewLabel("Rules") - titleLabel.AddCSSClass("title") - header.SetTitleWidget(titleLabel) - - // Add Rule button in header - addButton := gtk.NewButton() - addButton.SetIconName("list-add-symbolic") - addButton.SetTooltipText("Add New Rule") - addButton.SetHasFrame(false) - header.PackEnd(addButton) - - toolbarView.AddTopBar(header) - - // Scrolled window for rules list - scrolled := gtk.NewScrolledWindow() - scrolled.SetVExpand(true) - scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) - - content := gtk.NewBox(gtk.OrientationVertical, 12) - content.SetMarginStart(12) - content.SetMarginEnd(12) - content.SetMarginTop(12) - content.SetMarginBottom(12) - - // Info banner (shown when rules exist) - infoLabel := gtk.NewLabel("Rules are evaluated in order. First match wins.") - infoLabel.SetWrap(true) - infoLabel.SetXAlign(0) - infoLabel.AddCSSClass("dim-label") - infoLabel.SetMarginStart(12) - infoLabel.SetMarginEnd(12) - infoLabel.SetMarginBottom(6) - content.Append(infoLabel) - - // Rules list with drag-and-drop support - rulesListBox := gtk.NewListBox() - rulesListBox.SetSelectionMode(gtk.SelectionNone) - rulesListBox.AddCSSClass("boxed-list") - - // Empty state (shown when no rules exist) - emptyState := adw.NewStatusPage() - emptyState.SetIconName("list-add-symbolic") - emptyState.SetTitle("No Rules") - emptyState.SetDescription("Add rules to automatically route URLs to specific browsers") - emptyState.SetVExpand(true) - - // Helper to get browser name from ID - getBrowserName := func(id string) string { - if browser := findBrowserByID(browsers, id); browser != nil { - return browser.Name - } - return id - } - - // Function to rebuild the rules list UI - var rebuildRulesList func() - - // Function to create a rule row - createRuleRow := func(ruleIndex int) *adw.ActionRow { - rule := &cfg.Rules[ruleIndex] - - row := adw.NewActionRow() - // Show name as title if set, otherwise show first condition pattern - if rule.Name != "" { - row.SetTitle(rule.Name) - row.SetSubtitle(formatRuleSubtitle(rule, getBrowserName(rule.Browser))) - } else { - // For rules without names, show first condition pattern - if len(rule.Conditions) > 0 { - row.SetTitle(rule.Conditions[0].Pattern) - } - row.SetSubtitle(formatRuleSubtitleNoPattern(rule, getBrowserName(rule.Browser))) - } - row.SetActivatable(true) - - // Browser icon - use Switchyard icon if AlwaysAsk is enabled - var icon *gtk.Image - if rule.AlwaysAsk { - appBrowser := &Browser{ - ID: getAppID(), - Icon: getAppID(), - AppInfo: nil, - } - icon = loadBrowserIcon(appBrowser, 24) - } else { - browser := findBrowserByID(browsers, rule.Browser) - if browser != nil { - icon = loadBrowserIcon(browser, 24) - } else { - icon = gtk.NewImageFromIconName("web-browser-symbolic") - icon.SetPixelSize(24) - } - } - row.AddPrefix(icon) - - // Reorder buttons box - reorderBox := gtk.NewBox(gtk.OrientationHorizontal, 0) - reorderBox.SetVAlign(gtk.AlignCenter) - - // Move up button - upBtn := gtk.NewButton() - upBtn.SetIconName("go-up-symbolic") - upBtn.AddCSSClass("flat") - upBtn.SetSensitive(ruleIndex > 0) - upBtn.SetTooltipText("Move rule up") - upBtn.ConnectClicked(func() { - if ruleIndex > 0 { - cfg.Rules[ruleIndex], cfg.Rules[ruleIndex-1] = cfg.Rules[ruleIndex-1], cfg.Rules[ruleIndex] - saveConfig(cfg) - rebuildRulesList() - } - }) - reorderBox.Append(upBtn) - - // Move down button - downBtn := gtk.NewButton() - downBtn.SetIconName("go-down-symbolic") - downBtn.AddCSSClass("flat") - downBtn.SetSensitive(ruleIndex < len(cfg.Rules)-1) - downBtn.SetTooltipText("Move rule down") - downBtn.ConnectClicked(func() { - if ruleIndex < len(cfg.Rules)-1 { - cfg.Rules[ruleIndex], cfg.Rules[ruleIndex+1] = cfg.Rules[ruleIndex+1], cfg.Rules[ruleIndex] - saveConfig(cfg) - rebuildRulesList() - } - }) - reorderBox.Append(downBtn) - - row.AddSuffix(reorderBox) - - // Delete button - deleteBtn := gtk.NewButton() - deleteBtn.SetIconName("edit-delete-symbolic") - deleteBtn.AddCSSClass("flat") - deleteBtn.AddCSSClass("destructive-action") - deleteBtn.SetTooltipText("Delete rule") - deleteBtn.ConnectClicked(func() { - cfg.Rules = append(cfg.Rules[:ruleIndex], cfg.Rules[ruleIndex+1:]...) - saveConfig(cfg) - rebuildRulesList() - }) - row.AddSuffix(deleteBtn) - - // Edit on click - row.ConnectActivated(func() { - showEditRuleDialog(win, cfg, rule, browsers, rebuildRulesList) - }) - - return row - } - - rebuildRulesList = func() { - // Remove all children - for { - child := rulesListBox.FirstChild() - if child == nil { - break - } - rulesListBox.Remove(child) - } - - // Show/hide empty state vs rules list - if len(cfg.Rules) == 0 { - infoLabel.SetVisible(false) - rulesListBox.SetVisible(false) - emptyState.SetVisible(true) - } else { - infoLabel.SetVisible(true) - rulesListBox.SetVisible(true) - emptyState.SetVisible(false) - - // Add all rule rows - for i := range cfg.Rules { - row := createRuleRow(i) - rulesListBox.Append(row) - } - } - } - - // Initial build - rebuildRulesList() - - content.Append(rulesListBox) - content.Append(emptyState) - scrolled.SetChild(content) - toolbarView.SetContent(scrolled) - - // Connect Add Rule button handler - addButton.ConnectClicked(func() { - showAddRuleDialog(win, cfg, browsers, rebuildRulesList) - }) - - return toolbarView -} - -func createAdvancedPage(win *adw.Window, cfg *Config) gtk.Widgetter { - // Use AdwToolbarView for proper page architecture - toolbarView := adw.NewToolbarView() - - // Header for this page - header := adw.NewHeaderBar() - header.SetShowEndTitleButtons(true) - titleLabel := gtk.NewLabel("Advanced") - titleLabel.AddCSSClass("title") - header.SetTitleWidget(titleLabel) - toolbarView.AddTopBar(header) - - scrolled := gtk.NewScrolledWindow() - scrolled.SetVExpand(true) - scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) - - content := gtk.NewBox(gtk.OrientationVertical, 24) - content.SetMarginStart(24) - content.SetMarginEnd(24) - content.SetMarginTop(24) - content.SetMarginBottom(24) - - clamp := adw.NewClamp() - clamp.SetMaximumSize(600) - clamp.SetChild(content) - scrolled.SetChild(clamp) - - // Config file info - configGroup := adw.NewPreferencesGroup() - configGroup.SetTitle("Configuration") - - configRow := adw.NewActionRow() - configRow.SetTitle("Configuration File") - configRow.SetSubtitle(configPath()) - configRow.SetActivatable(true) - configRow.AddSuffix(gtk.NewImageFromIconName("document-edit-symbolic")) - configRow.ConnectActivated(func() { - // Ensure config file exists - saveConfig(cfg) - // Open with xdg-open via flatpak-spawn when in Flatpak - cmd := hostCommand("xdg-open", configPath()) - if err := cmd.Start(); err != nil { - fmt.Printf("Failed to open config file: %v\n", err) - } - go cmd.Wait() - }) - configGroup.Add(configRow) - - // Export config - exportRow := adw.NewActionRow() - exportRow.SetTitle("Export Configuration") - exportRow.SetSubtitle("Save configuration to a file") - exportRow.SetActivatable(true) - exportRow.AddSuffix(gtk.NewImageFromIconName("document-save-symbolic")) - exportRow.ConnectActivated(func() { - dialog := gtk.NewFileDialog() - dialog.SetTitle("Export Configuration") - dialog.SetInitialName("switchyard.toml") - - tomlFilter := gtk.NewFileFilter() - tomlFilter.SetName("TOML files") - tomlFilter.AddPattern("*.toml") - - allFilter := gtk.NewFileFilter() - allFilter.SetName("All files") - allFilter.AddPattern("*") - - filters := gio.NewListStore(glib.TypeObject) - filters.Append(tomlFilter.Object) - filters.Append(allFilter.Object) - dialog.SetFilters(filters) - - dialog.Save(context.Background(), &win.Window, func(res gio.AsyncResulter) { - file, err := dialog.SaveFinish(res) - if err != nil || file == nil { - return - } - path := file.Path() - if path == "" { - return - } - if err := exportConfig(cfg, path); err != nil { - fmt.Fprintf(os.Stderr, "Failed to export config: %v\n", err) - } - }) - }) - configGroup.Add(exportRow) - - // Import config - importRow := adw.NewActionRow() - importRow.SetTitle("Import Configuration") - importRow.SetSubtitle("Load configuration from a file") - importRow.SetActivatable(true) - importRow.AddSuffix(gtk.NewImageFromIconName("document-open-symbolic")) - importRow.ConnectActivated(func() { - fileDialog := gtk.NewFileDialog() - fileDialog.SetTitle("Import Configuration") - - tomlFilter := gtk.NewFileFilter() - tomlFilter.SetName("TOML files") - tomlFilter.AddPattern("*.toml") - - allFilter := gtk.NewFileFilter() - allFilter.SetName("All files") - allFilter.AddPattern("*") - - filters := gio.NewListStore(glib.TypeObject) - filters.Append(tomlFilter.Object) - filters.Append(allFilter.Object) - fileDialog.SetFilters(filters) - - fileDialog.Open(context.Background(), &win.Window, func(res gio.AsyncResulter) { - file, err := fileDialog.OpenFinish(res) - if err != nil || file == nil { - return - } - path := file.Path() - if path == "" { - return - } - - // Show warning dialog before replacing - warnDialog := adw.NewAlertDialog("Import Configuration?", "This will replace all your current settings and rules with the imported configuration.") - warnDialog.AddResponse("cancel", "Cancel") - warnDialog.AddResponse("import", "Import") - warnDialog.SetResponseAppearance("import", adw.ResponseDestructive) - warnDialog.SetDefaultResponse("cancel") - warnDialog.SetCloseResponse("cancel") - - warnDialog.ConnectResponse(func(response string) { - if response == "import" { - if err := importConfig(cfg, path); err != nil { - fmt.Fprintf(os.Stderr, "Failed to import config: %v\n", err) - } - } - }) - warnDialog.Present(win) - }) - }) - configGroup.Add(importRow) - - content.Append(configGroup) - - toolbarView.SetContent(scrolled) - return toolbarView -} - func watchConfigFile(cfg *Config, onChange func()) { configFile := gio.NewFileForPath(configPath()) monitorIface, err := configFile.Monitor(context.Background(), gio.FileMonitorNone) @@ -728,7 +181,6 @@ func watchConfigFile(cfg *Config, onChange func()) { if monitor != nil { monitor.ConnectChanged(func(file, otherFile gio.Filer, eventType gio.FileMonitorEvent) { if eventType == gio.FileMonitorEventChanged || eventType == gio.FileMonitorEventCreated { - // Ignore file changes while we're saving to avoid race conditions savingMux.Lock() saving := isSaving savingMux.Unlock() @@ -736,7 +188,6 @@ func watchConfigFile(cfg *Config, onChange func()) { return } - // Reload config from disk newCfg := loadConfig() cfg.PromptOnClick = newCfg.PromptOnClick cfg.FavoriteBrowser = newCfg.FavoriteBrowser -- 2.51.2