package bonfire // A single-select port of SSU's selector (internal/cli/tui selector.go, // selector_keys.go and confirm.go) on bubbletea v2: arrow keys or k/j move, // enter selects and opens SSU's confirmation screen, enter there confirms // and esc goes back; esc or ctrl+c in the list cancels. Multi-select, the // filter and the detail pane are not ported (D-30). import ( "strings" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" ) // selectorKeys are the selector's key bindings. type selectorKeys struct { Up key.Binding Down key.Binding Confirm key.Binding Back key.Binding Quit key.Binding } func defaultSelectorKeys() selectorKeys { return selectorKeys{ Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), Quit: key.NewBinding(key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc", "cancel")), } } // selectorModel picks one item from a list and asks for confirmation. type selectorModel struct { palette Palette keys selectorKeys title string items []string cursor int height int confirming bool chosen int cancelled bool } func newSelectorModel(p Palette, title string, items []string, def int) selectorModel { if def < 0 || def >= len(items) { def = 0 } return selectorModel{palette: p, keys: defaultSelectorKeys(), title: title, items: items, cursor: def, chosen: -1} } func (m selectorModel) Init() tea.Cmd { return nil } func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.height = msg.Height return m, nil case tea.KeyPressMsg: if msg.String() == "ctrl+c" { m.cancelled = true return m, tea.Quit } if m.confirming { switch { case key.Matches(msg, m.keys.Confirm): m.chosen = m.cursor return m, tea.Quit case key.Matches(msg, m.keys.Back): m.confirming = false } return m, nil } switch { case key.Matches(msg, m.keys.Up): if m.cursor > 0 { m.cursor-- } case key.Matches(msg, m.keys.Down): if m.cursor < len(m.items)-1 { m.cursor++ } case key.Matches(msg, m.keys.Confirm): if len(m.items) > 0 { m.confirming = true } case key.Matches(msg, m.keys.Quit): m.cancelled = true return m, tea.Quit } } return m, nil } // visible is the range of items that fits the terminal around the cursor. func (m selectorModel) visible() (int, int) { rows := len(m.items) if m.height > 0 { rows = min(rows, max(m.height-4, 3)) } start := 0 if m.cursor >= rows { start = m.cursor - rows + 1 } return start, min(start+rows, len(m.items)) } func (m selectorModel) View() tea.View { p := m.palette var b strings.Builder if m.chosen >= 0 || m.cancelled { // Leave one summary line once the program ends. b.WriteString(p.Current.Render(symPrompt) + " " + m.title) if m.chosen >= 0 { b.WriteString(" " + p.Success.Render(m.items[m.chosen])) } b.WriteString("\n") return tea.NewView(b.String()) } if m.confirming { b.WriteString(p.Header.Render("Selected:") + "\n\n") b.WriteString(" " + m.items[m.cursor] + "\n\n") b.WriteString(p.Muted.Render("enter confirm • esc back")) return tea.NewView(b.String()) } b.WriteString(p.Current.Render(symPrompt) + " " + p.Header.Render(m.title) + "\n") start, end := m.visible() for i := start; i < end; i++ { if i == m.cursor { b.WriteString(p.Current.Render("> ") + m.items[i] + "\n") continue } b.WriteString(" " + m.items[i] + "\n") } b.WriteString(p.Muted.Render("↑/k up • ↓/j down • enter select • esc cancel")) return tea.NewView(b.String()) }