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 url bar and stage-based layout

Aly Raffauf (Jul 5, 2026, 10:34 PM EDT) 00ffd102 eb02fdfe

+139 -14
+20 -2
cmd/sw/main.go
··· 7 7 "strings" 8 8 9 9 "charm.land/bubbles/v2/list" 10 + "charm.land/bubbles/v2/textinput" 10 11 tea "charm.land/bubbletea/v2" 11 12 "github.com/alyraffauf/switchyard/internal/browser" 12 13 "github.com/alyraffauf/switchyard/internal/browserscan" ··· 19 20 browserListWidth = 30 20 21 actionListWidth = 25 21 22 listHeight = 20 23 + 24 + // The actions column is a 2-row header stacked on its list; making the list 25 + // shorter keeps that column the same height as the browser list. 26 + actionListHeight = listHeight - 2 27 + 28 + // Everything is laid out inside this fixed-size box and then centered, so 29 + // opening actions never resizes the outer block. Width has slack for the 30 + // widest help line, which is wider than the browser+separator+actions columns. 31 + stageWidth = 70 32 + stageHeight = listHeight 22 33 ) 23 34 24 35 func initialModel(cfg *config.Config, url string) model { ··· 60 71 actionDelegate.ShowDescription = false 61 72 actionDelegate.Styles = newDelegateStyles(true) 62 73 63 - actionList := list.New([]list.Item{}, actionDelegate, actionListWidth, listHeight) 74 + actionList := list.New([]list.Item{}, actionDelegate, actionListWidth, actionListHeight) 64 75 actionList.SetShowTitle(false) 65 76 actionList.SetShowStatusBar(false) 66 77 actionList.SetShowPagination(false) 67 78 actionList.SetFilteringEnabled(false) 68 79 actionList.SetShowHelp(false) 69 80 81 + urlInput := textinput.New() 82 + urlInput.Prompt = "🔗 " 83 + urlInput.Placeholder = "https://…" 84 + urlInput.SetValue(url) 85 + urlInput.CharLimit = 0 86 + urlInput.SetWidth(browserListWidth) 87 + 70 88 m := model{ 71 89 browserList: browserList, 72 90 actionList: actionList, 73 91 delegate: delegate, 74 92 actionDelegate: actionDelegate, 75 - url: url, 93 + urlInput: urlInput, 76 94 } 77 95 m.updateStyles(true) 78 96 return m
+91 -11
cmd/sw/model.go
··· 6 6 "strings" 7 7 8 8 "charm.land/bubbles/v2/list" 9 + "charm.land/bubbles/v2/textinput" 9 10 tea "charm.land/bubbletea/v2" 10 11 "charm.land/lipgloss/v2" 11 12 "github.com/alyraffauf/switchyard/internal/browser" ··· 17 18 actionList list.Model 18 19 delegate list.DefaultDelegate 19 20 actionDelegate list.DefaultDelegate 20 - url string 21 + urlInput textinput.Model 22 + urlFocused bool 21 23 choice string 22 24 styles styles 23 25 width int ··· 40 42 m.actionList.Styles.PaginationStyle = m.styles.pagination 41 43 m.actionList.Styles.HelpStyle = m.styles.help 42 44 m.actionList.SetDelegate(m.actionDelegate) 45 + 46 + m.urlInput.SetStyles(newURLInputStyles(isDark)) 43 47 } 44 48 45 49 func (m model) Init() tea.Cmd { ··· 58 62 return m, nil 59 63 60 64 case tea.KeyPressMsg: 61 - if m.pickingAction { 62 - if next, cmd, handled := handleActionKey(msg, m); handled { 65 + if m.urlFocused { 66 + if next, cmd, handled := handleURLInputKey(msg, m); handled { 63 67 return next, cmd 64 68 } 65 69 } else { 66 - if next, cmd, handled := handleBrowserKey(msg, m); handled { 67 - return next, cmd 70 + switch msg.String() { 71 + case "/": 72 + m.urlFocused = true 73 + return m, m.urlInput.Focus() 74 + } 75 + } 76 + 77 + if !m.urlFocused { 78 + if m.pickingAction { 79 + if next, cmd, handled := handleActionKey(msg, m); handled { 80 + return next, cmd 81 + } 82 + } else { 83 + if next, cmd, handled := handleBrowserKey(msg, m); handled { 84 + return next, cmd 85 + } 68 86 } 69 87 } 70 88 } 71 89 72 90 var cmd tea.Cmd 91 + if m.urlFocused { 92 + m.urlInput, cmd = m.urlInput.Update(msg) 93 + return m, cmd 94 + } 73 95 if m.pickingAction { 74 96 m.actionList, cmd = m.actionList.Update(msg) 75 97 } else { ··· 80 102 81 103 func launchAndQuit(m model, name, exec string) (model, tea.Cmd, bool) { 82 104 m.choice = name 83 - if err := browser.Launch(exec, m.url, "", host.InFlatpak()); err != nil { 105 + url := m.urlInput.Value() 106 + if err := browser.Launch(exec, url, "", host.InFlatpak()); err != nil { 84 107 fmt.Fprintf(os.Stderr, "Error launching %s: %v\n", name, err) 85 108 } 86 109 m.quitting = true 87 110 return m, tea.Quit, true 111 + } 112 + 113 + func handleURLInputKey(msg tea.KeyPressMsg, m model) (model, tea.Cmd, bool) { 114 + switch msg.String() { 115 + case "esc", "tab", "enter": 116 + m.urlFocused = false 117 + m.urlInput.Blur() 118 + return m, nil, true 119 + } 120 + return m, nil, false 88 121 } 89 122 90 123 func handleBrowserKey(msg tea.KeyPressMsg, m model) (model, tea.Cmd, bool) { ··· 150 183 return launchingView(m) 151 184 } 152 185 153 - var content string 186 + var columns string 154 187 if m.pickingAction { 155 - content = sideBySideView(m) 188 + columns = sideBySideView(m) 156 189 } else { 157 - content = m.browserList.View() 190 + columns = m.browserList.View() 158 191 } 159 192 193 + content := lipgloss.JoinVertical(lipgloss.Left, 194 + browserStageRowView(m, stageHeight, columns), 195 + "", 196 + statusRowView(m), 197 + ) 198 + 160 199 if m.width > 0 && m.height > 0 { 161 200 content = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) 162 201 } ··· 167 206 return v 168 207 } 169 208 209 + func browserStageRowView(m model, height int, content string) string { 210 + align := lipgloss.Center 211 + if m.pickingAction { 212 + align = lipgloss.Left 213 + } 214 + 215 + positioned := lipgloss.PlaceHorizontal(stageWidth, align, content) 216 + return lipgloss.NewStyle(). 217 + Width(stageWidth). 218 + Height(height). 219 + Render(positioned) 220 + } 221 + 222 + func urlBarView(m model) string { 223 + urlInput := m.urlInput.View() 224 + return centeredStageRowView(lipgloss.Height(urlInput), m.styles.urlBar.Render(urlInput)) 225 + } 226 + 227 + func statusRowView(m model) string { 228 + if m.urlFocused { 229 + return urlBarView(m) 230 + } 231 + return helpView(m) 232 + } 233 + 234 + func helpView(m model) string { 235 + var hints string 236 + if m.pickingAction { 237 + hints = "↑↓ navigate • ↩ launch • ← back • / edit URL • q quit" 238 + } else { 239 + hints = "↑↓ navigate • ↩ launch • → actions • / edit URL • q quit" 240 + } 241 + return centeredStageRowView(lipgloss.Height(hints), m.styles.helpText.Render(hints)) 242 + } 243 + 244 + func centeredStageRowView(height int, content string) string { 245 + positioned := lipgloss.PlaceHorizontal(stageWidth, lipgloss.Center, content) 246 + return lipgloss.NewStyle(). 247 + Width(stageWidth). 248 + Height(height). 249 + Render(positioned) 250 + } 251 + 170 252 func sideBySideView(m model) string { 171 253 browserView := m.browserList.View() 172 254 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 255 header := lipgloss.NewStyle(). 176 256 Padding(0, 0, 1, 2). 177 257 Render(m.styles.title.Render("Actions"))
+28 -1
cmd/sw/styles.go
··· 2 2 3 3 import ( 4 4 "charm.land/bubbles/v2/list" 5 + "charm.land/bubbles/v2/textinput" 5 6 "charm.land/lipgloss/v2" 6 7 ) 7 8 ··· 16 17 title lipgloss.Style 17 18 pagination lipgloss.Style 18 19 help lipgloss.Style 20 + helpText lipgloss.Style 19 21 quitText lipgloss.Style 20 22 separator lipgloss.Style 23 + urlBar lipgloss.Style 21 24 } 22 25 23 26 func newStyles(darkBG bool) styles { ··· 29 32 s.title = lipgloss.NewStyle(). 30 33 Foreground(lipgloss.Color("#FFFDF5")). 31 34 Background(lipgloss.Color(green)). 32 - Padding(0, 1) 35 + Width(browserListWidth). 36 + Align(lipgloss.Center) 33 37 34 38 s.pagination = list.DefaultStyles(darkBG).PaginationStyle.PaddingLeft(4) 35 39 ··· 46 50 Foreground(ld(lipgloss.Color("#b0b0b0"), lipgloss.Color("240"))). 47 51 Padding(0, 2) 48 52 53 + s.urlBar = lipgloss.NewStyle(). 54 + MarginBottom(0) 55 + 56 + s.helpText = lipgloss.NewStyle(). 57 + MarginTop(0). 58 + Foreground(ld(lipgloss.Color("#999999"), lipgloss.Color("#666666"))) 59 + 49 60 return s 50 61 } 51 62 ··· 70 81 71 82 return s 72 83 } 84 + 85 + func newURLInputStyles(darkBG bool) textinput.Styles { 86 + s := textinput.DefaultStyles(darkBG) 87 + ld := lipgloss.LightDark(darkBG) 88 + 89 + s.Focused.Prompt = lipgloss.NewStyle(). 90 + Foreground(lipgloss.Color(greenBright)) 91 + s.Blurred.Prompt = lipgloss.NewStyle(). 92 + Foreground(lipgloss.Color(green)) 93 + 94 + s.Focused.Placeholder = lipgloss.NewStyle(). 95 + Foreground(ld(lipgloss.Color("#999999"), lipgloss.Color("#666666"))) 96 + s.Blurred.Placeholder = s.Focused.Placeholder 97 + 98 + return s 99 + }