Files
summercms/modules/bonfire/selector.go
Jakub Zych 3f38862b22 feat(bonfire): SSU widgets on charm v2 behind the Output interface
- Spinner, gradient progress bar and the multi-row process view ported
  from SSU to bubbletea v2 (input off, no signal handler, command
  context); boxed tables through lipgloss/table
- Prompts use huh fields and a single-select port of SSU's selector with
  its confirmation screen on an interactive terminal; line prompts off a
  terminal and under --no-ansi are unchanged (piped passwords still work)
- -q hides decoration but keeps data and errors; -n returns defaults and
  Secret fails with a hint on a terminal stdin; --no-ansi and non-TTY
  runs print today's plain lines
- Helpers: GlobalsOf, VerbosityOf, IsQuiet, Interactive, ConfirmProceed,
  Process, Current, Failed, PrintAbove, ItemProgress, ErrAborted
- diary's styled console lines print above a running live view
2026-10-21 12:30:53 +02:00

141 lines
3.8 KiB
Go

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())
}