diff --git a/cmd/sw/main.go b/cmd/sw/main.go index bc2609a..5bc1a1c 100644 --- a/cmd/sw/main.go +++ b/cmd/sw/main.go @@ -7,6 +7,7 @@ import ( "strings" "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "github.com/alyraffauf/switchyard/internal/browser" "github.com/alyraffauf/switchyard/internal/browserscan" @@ -19,6 +20,16 @@ const ( browserListWidth = 30 actionListWidth = 25 listHeight = 20 + + // The actions column is a 2-row header stacked on its list; making the list + // shorter keeps that column the same height as the browser list. + actionListHeight = listHeight - 2 + + // Everything is laid out inside this fixed-size box and then centered, so + // opening actions never resizes the outer block. Width has slack for the + // widest help line, which is wider than the browser+separator+actions columns. + stageWidth = 70 + stageHeight = listHeight ) func initialModel(cfg *config.Config, url string) model { @@ -60,19 +71,26 @@ func initialModel(cfg *config.Config, url string) model { actionDelegate.ShowDescription = false actionDelegate.Styles = newDelegateStyles(true) - actionList := list.New([]list.Item{}, actionDelegate, actionListWidth, listHeight) + actionList := list.New([]list.Item{}, actionDelegate, actionListWidth, actionListHeight) actionList.SetShowTitle(false) actionList.SetShowStatusBar(false) actionList.SetShowPagination(false) actionList.SetFilteringEnabled(false) actionList.SetShowHelp(false) + urlInput := textinput.New() + urlInput.Prompt = "🔗 " + urlInput.Placeholder = "https://…" + urlInput.SetValue(url) + urlInput.CharLimit = 0 + urlInput.SetWidth(browserListWidth) + m := model{ browserList: browserList, actionList: actionList, delegate: delegate, actionDelegate: actionDelegate, - url: url, + urlInput: urlInput, } m.updateStyles(true) return m diff --git a/cmd/sw/model.go b/cmd/sw/model.go index 494aeeb..214efc6 100644 --- a/cmd/sw/model.go +++ b/cmd/sw/model.go @@ -6,6 +6,7 @@ import ( "strings" "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/alyraffauf/switchyard/internal/browser" @@ -17,7 +18,8 @@ type model struct { actionList list.Model delegate list.DefaultDelegate actionDelegate list.DefaultDelegate - url string + urlInput textinput.Model + urlFocused bool choice string styles styles width int @@ -40,6 +42,8 @@ func (m *model) updateStyles(isDark bool) { m.actionList.Styles.PaginationStyle = m.styles.pagination m.actionList.Styles.HelpStyle = m.styles.help m.actionList.SetDelegate(m.actionDelegate) + + m.urlInput.SetStyles(newURLInputStyles(isDark)) } func (m model) Init() tea.Cmd { @@ -58,18 +62,36 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyPressMsg: - if m.pickingAction { - if next, cmd, handled := handleActionKey(msg, m); handled { + if m.urlFocused { + if next, cmd, handled := handleURLInputKey(msg, m); handled { return next, cmd } } else { - if next, cmd, handled := handleBrowserKey(msg, m); handled { - return next, cmd + switch msg.String() { + case "/": + m.urlFocused = true + return m, m.urlInput.Focus() + } + } + + if !m.urlFocused { + if m.pickingAction { + if next, cmd, handled := handleActionKey(msg, m); handled { + return next, cmd + } + } else { + if next, cmd, handled := handleBrowserKey(msg, m); handled { + return next, cmd + } } } } var cmd tea.Cmd + if m.urlFocused { + m.urlInput, cmd = m.urlInput.Update(msg) + return m, cmd + } if m.pickingAction { m.actionList, cmd = m.actionList.Update(msg) } else { @@ -80,13 +102,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func launchAndQuit(m model, name, exec string) (model, tea.Cmd, bool) { m.choice = name - if err := browser.Launch(exec, m.url, "", host.InFlatpak()); err != nil { + url := m.urlInput.Value() + if err := browser.Launch(exec, url, "", host.InFlatpak()); err != nil { fmt.Fprintf(os.Stderr, "Error launching %s: %v\n", name, err) } m.quitting = true return m, tea.Quit, true } +func handleURLInputKey(msg tea.KeyPressMsg, m model) (model, tea.Cmd, bool) { + switch msg.String() { + case "esc", "tab", "enter": + m.urlFocused = false + m.urlInput.Blur() + return m, nil, true + } + return m, nil, false +} + func handleBrowserKey(msg tea.KeyPressMsg, m model) (model, tea.Cmd, bool) { switch msg.String() { case "q", "ctrl+c": @@ -150,13 +183,19 @@ func (m model) View() tea.View { return launchingView(m) } - var content string + var columns string if m.pickingAction { - content = sideBySideView(m) + columns = sideBySideView(m) } else { - content = m.browserList.View() + columns = m.browserList.View() } + content := lipgloss.JoinVertical(lipgloss.Left, + browserStageRowView(m, stageHeight, columns), + "", + statusRowView(m), + ) + if m.width > 0 && m.height > 0 { content = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) } @@ -167,11 +206,52 @@ func (m model) View() tea.View { return v } +func browserStageRowView(m model, height int, content string) string { + align := lipgloss.Center + if m.pickingAction { + align = lipgloss.Left + } + + positioned := lipgloss.PlaceHorizontal(stageWidth, align, content) + return lipgloss.NewStyle(). + Width(stageWidth). + Height(height). + Render(positioned) +} + +func urlBarView(m model) string { + urlInput := m.urlInput.View() + return centeredStageRowView(lipgloss.Height(urlInput), m.styles.urlBar.Render(urlInput)) +} + +func statusRowView(m model) string { + if m.urlFocused { + return urlBarView(m) + } + return helpView(m) +} + +func helpView(m model) string { + var hints string + if m.pickingAction { + hints = "↑↓ navigate • ↩ launch • ← back • / edit URL • q quit" + } else { + hints = "↑↓ navigate • ↩ launch • → actions • / edit URL • q quit" + } + return centeredStageRowView(lipgloss.Height(hints), m.styles.helpText.Render(hints)) +} + +func centeredStageRowView(height int, content string) string { + positioned := lipgloss.PlaceHorizontal(stageWidth, lipgloss.Center, content) + return lipgloss.NewStyle(). + Width(stageWidth). + Height(height). + Render(positioned) +} + func sideBySideView(m model) string { browserView := m.browserList.View() - // Mirror the list's title bar geometry (Padding(0, 0, 1, 2)) so the first - // action row lines up with the first browser row. header := lipgloss.NewStyle(). Padding(0, 0, 1, 2). Render(m.styles.title.Render("Actions")) diff --git a/cmd/sw/styles.go b/cmd/sw/styles.go index 2f24aea..5084a36 100644 --- a/cmd/sw/styles.go +++ b/cmd/sw/styles.go @@ -2,6 +2,7 @@ package main import ( "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/textinput" "charm.land/lipgloss/v2" ) @@ -16,8 +17,10 @@ type styles struct { title lipgloss.Style pagination lipgloss.Style help lipgloss.Style + helpText lipgloss.Style quitText lipgloss.Style separator lipgloss.Style + urlBar lipgloss.Style } func newStyles(darkBG bool) styles { @@ -29,7 +32,8 @@ func newStyles(darkBG bool) styles { s.title = lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFFDF5")). Background(lipgloss.Color(green)). - Padding(0, 1) + Width(browserListWidth). + Align(lipgloss.Center) s.pagination = list.DefaultStyles(darkBG).PaginationStyle.PaddingLeft(4) @@ -46,6 +50,13 @@ func newStyles(darkBG bool) styles { Foreground(ld(lipgloss.Color("#b0b0b0"), lipgloss.Color("240"))). Padding(0, 2) + s.urlBar = lipgloss.NewStyle(). + MarginBottom(0) + + s.helpText = lipgloss.NewStyle(). + MarginTop(0). + Foreground(ld(lipgloss.Color("#999999"), lipgloss.Color("#666666"))) + return s } @@ -70,3 +81,19 @@ func newDelegateStyles(darkBG bool) list.DefaultItemStyles { return s } + +func newURLInputStyles(darkBG bool) textinput.Styles { + s := textinput.DefaultStyles(darkBG) + ld := lipgloss.LightDark(darkBG) + + s.Focused.Prompt = lipgloss.NewStyle(). + Foreground(lipgloss.Color(greenBright)) + s.Blurred.Prompt = lipgloss.NewStyle(). + Foreground(lipgloss.Color(green)) + + s.Focused.Placeholder = lipgloss.NewStyle(). + Foreground(ld(lipgloss.Color("#999999"), lipgloss.Color("#666666"))) + s.Blurred.Placeholder = s.Focused.Placeholder + + return s +}