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.

cmd/sw: add tui browser picker

Aly Raffauf (Jul 5, 2026, 9:35 PM EDT) bcb1bb0e 2eb0567b

+442
+20
cmd/sw/browser.go
··· 1 + package main 2 + 3 + type actionItem struct { 4 + name string 5 + exec string 6 + } 7 + 8 + type browserItem struct { 9 + name string 10 + id string 11 + exec string 12 + } 13 + 14 + func (i actionItem) FilterValue() string { return i.name } 15 + func (i actionItem) Title() string { return i.name } 16 + func (i actionItem) Description() string { return "" } 17 + 18 + func (i browserItem) FilterValue() string { return i.name } 19 + func (i browserItem) Title() string { return i.name } 20 + func (i browserItem) Description() string { return i.id }
+154
cmd/sw/main.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "net/url" 6 + "os" 7 + "strings" 8 + 9 + "charm.land/bubbles/v2/list" 10 + tea "charm.land/bubbletea/v2" 11 + "github.com/alyraffauf/switchyard/internal/browser" 12 + "github.com/alyraffauf/switchyard/internal/browserscan" 13 + "github.com/alyraffauf/switchyard/internal/config" 14 + "github.com/alyraffauf/switchyard/internal/host" 15 + "github.com/alyraffauf/switchyard/internal/routing" 16 + ) 17 + 18 + const ( 19 + browserListWidth = 30 20 + actionListWidth = 25 21 + listHeight = 20 22 + ) 23 + 24 + func initialModel(cfg *config.Config, url string) model { 25 + hiddenSet := make(map[string]bool, len(cfg.HiddenBrowsers)) 26 + for _, id := range cfg.HiddenBrowsers { 27 + hiddenSet[id] = true 28 + } 29 + 30 + browsers := browserscan.Installed() 31 + items := make([]list.Item, 0, len(browsers)) 32 + for _, installed := range browsers { 33 + if hiddenSet[installed.ID] { 34 + continue 35 + } 36 + items = append(items, browserItem{name: installed.Name, id: installed.ID, exec: installed.Exec}) 37 + } 38 + 39 + if cfg.FavoriteBrowser != "" { 40 + for index, item := range items { 41 + candidate, ok := item.(browserItem) 42 + if ok && candidate.id == cfg.FavoriteBrowser { 43 + items[0], items[index] = items[index], items[0] 44 + break 45 + } 46 + } 47 + } 48 + 49 + delegate := list.NewDefaultDelegate() 50 + delegate.ShowDescription = true 51 + delegate.Styles = newDelegateStyles(true) 52 + 53 + browserList := list.New(items, delegate, browserListWidth, listHeight) 54 + browserList.Title = "Switchyard" 55 + browserList.SetShowStatusBar(false) 56 + browserList.SetFilteringEnabled(false) 57 + browserList.SetShowHelp(false) 58 + 59 + actionDelegate := list.NewDefaultDelegate() 60 + actionDelegate.ShowDescription = false 61 + actionDelegate.Styles = newDelegateStyles(true) 62 + 63 + actionList := list.New([]list.Item{}, actionDelegate, actionListWidth, listHeight) 64 + actionList.SetShowTitle(false) 65 + actionList.SetShowStatusBar(false) 66 + actionList.SetShowPagination(false) 67 + actionList.SetFilteringEnabled(false) 68 + actionList.SetShowHelp(false) 69 + 70 + m := model{ 71 + browserList: browserList, 72 + actionList: actionList, 73 + delegate: delegate, 74 + actionDelegate: actionDelegate, 75 + url: url, 76 + } 77 + m.updateStyles(true) 78 + return m 79 + } 80 + 81 + func findBrowser(browsers []browser.Browser, id string) *browser.Browser { 82 + for i := range browsers { 83 + if browsers[i].ID == id { 84 + return &browsers[i] 85 + } 86 + } 87 + return nil 88 + } 89 + 90 + func launchOrLog(browsers []browser.Browser, id, url string) bool { 91 + match := findBrowser(browsers, id) 92 + if match == nil { 93 + return false 94 + } 95 + if err := browser.Launch(match.Exec, url, "", host.InFlatpak()); err != nil { 96 + fmt.Fprintf(os.Stderr, "Error launching %s: %v\n", match.Name, err) 97 + } 98 + return true 99 + } 100 + 101 + func main() { 102 + rawURL := "" 103 + if len(os.Args) > 1 { 104 + rawURL = os.Args[1] 105 + } 106 + 107 + if u, err := url.Parse(rawURL); err == nil && u.Scheme == "switchyard" { 108 + targetURL, browserPrefs, err := routing.ParseSwitchyardURL(rawURL) 109 + if err != nil { 110 + fmt.Fprintf(os.Stderr, "Invalid switchyard URL: %v\n", err) 111 + os.Exit(1) 112 + } 113 + allBrowsers := browserscan.Installed() 114 + for _, pref := range browserPrefs { 115 + id := pref 116 + if !strings.HasSuffix(id, ".desktop") { 117 + id += ".desktop" 118 + } 119 + if launchOrLog(allBrowsers, id, targetURL) { 120 + return 121 + } 122 + } 123 + rawURL = targetURL 124 + } 125 + 126 + cfg, _ := config.Load(config.Path()) 127 + sanitized := routing.PrepareURLForRouting(rawURL, cfg.RemoveTrackingParameters, cfg.Redirections) 128 + 129 + if rawURL != "" && sanitized == "" { 130 + if err := host.HostCommand("xdg-open", rawURL).Start(); err != nil { 131 + fmt.Fprintf(os.Stderr, "Error opening %s: %v\n", rawURL, err) 132 + } 133 + os.Exit(0) 134 + } 135 + 136 + allBrowsers := browserscan.Installed() 137 + 138 + if sanitized != "" { 139 + if browserID, alwaysAsk, matched := cfg.MatchRule(sanitized); matched { 140 + if !alwaysAsk && launchOrLog(allBrowsers, browserID, sanitized) { 141 + return 142 + } 143 + } else if !cfg.PromptOnClick && cfg.FavoriteBrowser != "" { 144 + if launchOrLog(allBrowsers, cfg.FavoriteBrowser, sanitized) { 145 + return 146 + } 147 + } 148 + } 149 + 150 + if _, err := tea.NewProgram(initialModel(cfg, sanitized)).Run(); err != nil { 151 + fmt.Fprintf(os.Stderr, "Error: %v\n", err) 152 + os.Exit(1) 153 + } 154 + }
+196
cmd/sw/model.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "strings" 7 + 8 + "charm.land/bubbles/v2/list" 9 + tea "charm.land/bubbletea/v2" 10 + "charm.land/lipgloss/v2" 11 + "github.com/alyraffauf/switchyard/internal/browser" 12 + "github.com/alyraffauf/switchyard/internal/host" 13 + ) 14 + 15 + type model struct { 16 + browserList list.Model 17 + actionList list.Model 18 + delegate list.DefaultDelegate 19 + actionDelegate list.DefaultDelegate 20 + url string 21 + choice string 22 + styles styles 23 + width int 24 + height int 25 + quitting bool 26 + pickingAction bool 27 + } 28 + 29 + func (m *model) updateStyles(isDark bool) { 30 + m.styles = newStyles(isDark) 31 + m.delegate.Styles = newDelegateStyles(isDark) 32 + 33 + m.browserList.Styles.Title = m.styles.title 34 + m.browserList.Styles.PaginationStyle = m.styles.pagination 35 + m.browserList.Styles.HelpStyle = m.styles.help 36 + m.browserList.SetDelegate(m.delegate) 37 + 38 + m.actionDelegate.Styles = newDelegateStyles(isDark) 39 + m.actionList.Styles.Title = m.styles.title 40 + m.actionList.Styles.PaginationStyle = m.styles.pagination 41 + m.actionList.Styles.HelpStyle = m.styles.help 42 + m.actionList.SetDelegate(m.actionDelegate) 43 + } 44 + 45 + func (m model) Init() tea.Cmd { 46 + return tea.RequestBackgroundColor 47 + } 48 + 49 + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 50 + switch msg := msg.(type) { 51 + case tea.BackgroundColorMsg: 52 + m.updateStyles(msg.IsDark()) 53 + return m, nil 54 + 55 + case tea.WindowSizeMsg: 56 + m.width = msg.Width 57 + m.height = msg.Height 58 + return m, nil 59 + 60 + case tea.KeyPressMsg: 61 + if m.pickingAction { 62 + if next, cmd, handled := handleActionKey(msg, m); handled { 63 + return next, cmd 64 + } 65 + } else { 66 + if next, cmd, handled := handleBrowserKey(msg, m); handled { 67 + return next, cmd 68 + } 69 + } 70 + } 71 + 72 + var cmd tea.Cmd 73 + if m.pickingAction { 74 + m.actionList, cmd = m.actionList.Update(msg) 75 + } else { 76 + m.browserList, cmd = m.browserList.Update(msg) 77 + } 78 + return m, cmd 79 + } 80 + 81 + func launchAndQuit(m model, name, exec string) (model, tea.Cmd, bool) { 82 + m.choice = name 83 + if err := browser.Launch(exec, m.url, "", host.InFlatpak()); err != nil { 84 + fmt.Fprintf(os.Stderr, "Error launching %s: %v\n", name, err) 85 + } 86 + m.quitting = true 87 + return m, tea.Quit, true 88 + } 89 + 90 + func handleBrowserKey(msg tea.KeyPressMsg, m model) (model, tea.Cmd, bool) { 91 + switch msg.String() { 92 + case "q", "ctrl+c": 93 + m.quitting = true 94 + return m, tea.Quit, true 95 + 96 + case "enter": 97 + selected, ok := m.browserList.SelectedItem().(browserItem) 98 + if !ok { 99 + return m, nil, true 100 + } 101 + return launchAndQuit(m, selected.name, selected.exec) 102 + 103 + case "right": 104 + selected, ok := m.browserList.SelectedItem().(browserItem) 105 + if !ok { 106 + return m, nil, true 107 + } 108 + actions := browser.ListDesktopActions(selected.id) 109 + if len(actions) == 0 { 110 + return m, nil, true 111 + } 112 + m.pickingAction = true 113 + items := make([]list.Item, len(actions)) 114 + for index, action := range actions { 115 + items[index] = actionItem{name: action.Name, exec: action.Exec} 116 + } 117 + return m, m.actionList.SetItems(items), true 118 + } 119 + 120 + return m, nil, false 121 + } 122 + 123 + func handleActionKey(msg tea.KeyPressMsg, m model) (model, tea.Cmd, bool) { 124 + switch msg.String() { 125 + case "q", "ctrl+c": 126 + m.quitting = true 127 + return m, tea.Quit, true 128 + 129 + case "enter": 130 + action, ok := m.actionList.SelectedItem().(actionItem) 131 + if !ok { 132 + m.quitting = true 133 + return m, tea.Quit, true 134 + } 135 + return launchAndQuit(m, action.name, action.exec) 136 + 137 + case "left", "esc": 138 + m.pickingAction = false 139 + return m, nil, true 140 + } 141 + 142 + return m, nil, false 143 + } 144 + 145 + func (m model) View() tea.View { 146 + if m.quitting { 147 + return quitView(m) 148 + } 149 + if m.choice != "" { 150 + return launchingView(m) 151 + } 152 + 153 + var content string 154 + if m.pickingAction { 155 + content = sideBySideView(m) 156 + } else { 157 + content = m.browserList.View() 158 + } 159 + 160 + if m.width > 0 && m.height > 0 { 161 + content = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) 162 + } 163 + 164 + v := tea.NewView(m.styles.app.Render(content)) 165 + v.AltScreen = true 166 + v.WindowTitle = "Switchyard" 167 + return v 168 + } 169 + 170 + func sideBySideView(m model) string { 171 + browserView := m.browserList.View() 172 + 173 + // Mirror the list's title bar geometry (Padding(0, 0, 1, 2)) so the first 174 + // action row lines up with the first browser row. 175 + header := lipgloss.NewStyle(). 176 + Padding(0, 0, 1, 2). 177 + Render(m.styles.title.Render("Actions")) 178 + actionView := lipgloss.JoinVertical(lipgloss.Left, header, m.actionList.View()) 179 + 180 + height := max(lipgloss.Height(browserView), lipgloss.Height(actionView)) 181 + separator := m.styles.separator.Render(verticalRule(height)) 182 + 183 + return lipgloss.JoinHorizontal(lipgloss.Top, browserView, separator, actionView) 184 + } 185 + 186 + func verticalRule(height int) string { 187 + return strings.Repeat("│\n", height-1) + "│" 188 + } 189 + 190 + func quitView(m model) tea.View { 191 + return tea.NewView(m.styles.quitText.Render("Exiting...")) 192 + } 193 + 194 + func launchingView(m model) tea.View { 195 + return tea.NewView(m.styles.quitText.Render(fmt.Sprintf("Launching %s...", m.choice))) 196 + }
+72
cmd/sw/styles.go
··· 1 + package main 2 + 3 + import ( 4 + "charm.land/bubbles/v2/list" 5 + "charm.land/lipgloss/v2" 6 + ) 7 + 8 + const ( 9 + green = "#4f895d" 10 + greenBright = "#6dd791" 11 + greenDark = "#3d6b48" 12 + ) 13 + 14 + type styles struct { 15 + app lipgloss.Style 16 + title lipgloss.Style 17 + pagination lipgloss.Style 18 + help lipgloss.Style 19 + quitText lipgloss.Style 20 + separator lipgloss.Style 21 + } 22 + 23 + func newStyles(darkBG bool) styles { 24 + var s styles 25 + 26 + s.app = lipgloss.NewStyle(). 27 + Padding(1, 2) 28 + 29 + s.title = lipgloss.NewStyle(). 30 + Foreground(lipgloss.Color("#FFFDF5")). 31 + Background(lipgloss.Color(green)). 32 + Padding(0, 1) 33 + 34 + s.pagination = list.DefaultStyles(darkBG).PaginationStyle.PaddingLeft(4) 35 + 36 + s.help = list.DefaultStyles(darkBG).HelpStyle. 37 + PaddingLeft(4). 38 + PaddingBottom(1) 39 + 40 + s.quitText = lipgloss.NewStyle(). 41 + Margin(1, 0, 2, 4). 42 + Foreground(lipgloss.Color(green)) 43 + 44 + ld := lipgloss.LightDark(darkBG) 45 + s.separator = lipgloss.NewStyle(). 46 + Foreground(ld(lipgloss.Color("#b0b0b0"), lipgloss.Color("240"))). 47 + Padding(0, 2) 48 + 49 + return s 50 + } 51 + 52 + func newDelegateStyles(darkBG bool) list.DefaultItemStyles { 53 + s := list.NewDefaultItemStyles(darkBG) 54 + ld := lipgloss.LightDark(darkBG) 55 + 56 + s.NormalTitle = s.NormalTitle.Foreground(ld(lipgloss.Color("#1a1a1a"), lipgloss.Color("#dddddd"))) 57 + s.NormalDesc = s.NormalDesc.Foreground(ld(lipgloss.Color("#888888"), lipgloss.Color("241"))) 58 + 59 + s.SelectedTitle = s.SelectedTitle. 60 + Foreground(ld(lipgloss.Color(greenDark), lipgloss.Color(greenBright))). 61 + BorderForeground(ld(lipgloss.Color(greenDark), lipgloss.Color(greenBright))). 62 + Bold(true) 63 + 64 + s.SelectedDesc = s.SelectedDesc. 65 + Foreground(ld(lipgloss.Color("#888888"), lipgloss.Color("241"))). 66 + BorderForeground(ld(lipgloss.Color(greenDark), lipgloss.Color(greenBright))) 67 + 68 + s.DimmedTitle = s.DimmedTitle.Foreground(ld(lipgloss.Color("#A49FA5"), lipgloss.Color("#777777"))) 69 + s.DimmedDesc = s.DimmedDesc.Foreground(ld(lipgloss.Color("#C2B8C2"), lipgloss.Color("#4D4D4D"))) 70 + 71 + return s 72 + }