Files
summercms/modules/bonfire/live.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

388 lines
9.6 KiB
Go

package bonfire
// Live views ported from SSU's terminal UI (internal/cli/tui spinner.go,
// process.go and progress.go) to bubbletea v2, without SSU's git and
// engine types. Each runs as a bubbletea program with input off (stdin
// stays in cooked mode, so Ctrl+C reaches the command's own signal
// handling), no signal handler of its own and the command's context.
import (
"context"
"fmt"
"io"
"strings"
"sync"
"charm.land/bubbles/v2/progress"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// Progress bar colors: the cyan to green gradient of the earlier bonfire
// bar (256-color 37 and 34).
var (
barFrom = lipgloss.Color("#00afaf")
barTo = lipgloss.Color("#00af00")
)
const barWidth = 40
// Messages sent to the live models from the command's goroutine.
type (
// itemMsg reports progress: done of total, the current item, and a
// failure of the current item when err is set.
itemMsg struct {
done, total int
item string
err error
}
// stepMsg reports one item of the process view changing state.
stepMsg struct {
item string
state itemState
err error
done int
}
// finishMsg ends a live view; err is the result of the work.
finishMsg struct{ err error }
// printAboveMsg is a line to print above the view.
printAboveMsg string
)
// itemState is the display state of one item in the process view.
type itemState int
const (
itemWaiting itemState = iota
itemRunning
itemDone
itemFailed
)
const symWaiting = "○"
// newSpinner is SSU's MiniDot spinner in the current (cyan) style.
func newSpinner(p Palette) spinner.Model {
return spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(p.Current))
}
// newBar is the gradient progress bar without its own percentage.
func newBar() progress.Model {
bar := progress.New(progress.WithColors(barFrom, barTo), progress.WithoutPercentage())
bar.SetWidth(barWidth)
return bar
}
// spinnerModel shows a spinner, the label and, once known, a done/total
// counter with the current item; it ends with ✓ or ✗ and the label.
type spinnerModel struct {
palette Palette
spin spinner.Model
label string
done int
total int
current string
failed int
finished bool
err error
}
func newSpinnerModel(p Palette, label string) spinnerModel {
return spinnerModel{palette: p, spin: newSpinner(p), label: label}
}
func (m spinnerModel) Init() tea.Cmd { return m.spin.Tick }
func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case itemMsg:
m.done, m.total, m.current = msg.done, msg.total, msg.item
if msg.err != nil {
m.failed++
}
return m, nil
case finishMsg:
m.finished, m.err = true, msg.err
return m, tea.Quit
case spinner.TickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
}
return m, nil
}
func (m spinnerModel) View() tea.View {
p := m.palette
if m.finished {
if m.err != nil {
return tea.NewView(p.Error.Render(symCross) + " " + m.label + "\n")
}
return tea.NewView(p.Success.Render(symCheck) + " " + m.label + "\n")
}
line := m.spin.View() + " " + m.label
if m.total > 0 {
line += fmt.Sprintf(" %d/%d", m.done, m.total)
}
if m.current != "" {
line += " " + p.Muted.Render(m.current+"…")
}
if m.failed > 0 {
line += " " + p.ErrorBold.Render(fmt.Sprintf("! %d failed", m.failed))
}
return tea.NewView(line)
}
// progressModel shows the gradient bar, done/total and the current item.
type progressModel struct {
palette Palette
bar progress.Model
done int
total int
current string
failed int
finished bool
}
func newProgressModel(p Palette, total int) progressModel {
return progressModel{palette: p, bar: newBar(), total: total}
}
func (m progressModel) Init() tea.Cmd { return nil }
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.bar.SetWidth(min(max(msg.Width-30, 10), barWidth))
return m, nil
case itemMsg:
m.done, m.total = msg.done, msg.total
if msg.item != "" {
m.current = msg.item
}
if msg.err != nil {
m.failed++
}
return m, nil
case finishMsg:
m.finished = true
return m, tea.Quit
}
return m, nil
}
func (m progressModel) View() tea.View {
pct := 0.0
if m.total > 0 {
pct = float64(m.done) / float64(m.total)
}
line := " " + m.bar.ViewAs(pct) + m.palette.Muted.Render(fmt.Sprintf(" %d/%d", m.done, m.total))
if m.current != "" && !m.finished {
line += " " + m.palette.Muted.Render(m.current)
}
if m.failed > 0 {
line += " " + m.palette.ErrorBold.Render(fmt.Sprintf("! %d failed", m.failed))
}
if m.finished {
line += "\n"
}
return tea.NewView(line)
}
// processItem is one row of the process view.
type processItem struct {
name string
state itemState
err error
}
// processModel is SSU's multi-row view: one row per item (waiting, running
// with a spinner, done or failed with its error) and the bar below.
type processModel struct {
palette Palette
items []processItem
spin spinner.Model
bar progress.Model
operation string
done int
height int
finished bool
}
func newProcessModel(p Palette, operation string, items []string) processModel {
rows := make([]processItem, len(items))
for i, name := range items {
rows[i] = processItem{name: name}
}
return processModel{palette: p, items: rows, spin: newSpinner(p), bar: newBar(), operation: operation}
}
func (m processModel) Init() tea.Cmd { return m.spin.Tick }
func (m processModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.height = msg.Height
return m, nil
case stepMsg:
m.done = msg.done
for i := range m.items {
if m.items[i].name == msg.item && m.items[i].state < msg.state {
m.items[i].state, m.items[i].err = msg.state, msg.err
break
}
}
return m, nil
case finishMsg:
m.finished = true
return m, tea.Quit
case spinner.TickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
}
return m, nil
}
// window is the range of rows that fits the terminal: around the running
// item, or the last finished ones.
func (m processModel) window() (int, int) {
visible := len(m.items)
if m.height > 0 && visible > m.height-3 {
visible = max(m.height-3, 3)
}
start := 0
if visible < len(m.items) {
active := -1
for i, it := range m.items {
if it.state == itemRunning {
active = i
break
}
}
if active >= 0 {
start = min(max(active-visible/2, 0), len(m.items)-visible)
} else {
start = max(m.done-visible, 0)
}
}
return start, min(start+visible, len(m.items))
}
func (m processModel) View() tea.View {
p := m.palette
var b strings.Builder
start, end := m.window()
for _, it := range m.items[start:end] {
switch it.state {
case itemWaiting:
b.WriteString(p.Muted.Render(" "+symWaiting) + " " + p.Muted.Render(it.name))
case itemRunning:
b.WriteString(" " + m.spin.View() + " " + it.name)
case itemDone:
b.WriteString(p.Success.Render(" "+symCheck) + " " + it.name)
case itemFailed:
b.WriteString(p.Error.Render(" "+symCross) + " " + it.name)
if it.err != nil {
b.WriteString(" " + p.Error.Render(it.err.Error()))
}
}
b.WriteString("\n")
}
total := len(m.items)
pct := 0.0
if total > 0 {
pct = float64(m.done) / float64(total)
}
op := m.operation
if op == "" {
op = "Processing"
}
b.WriteString(fmt.Sprintf("\n %s %d/%d %s %s", op, m.done, total, m.bar.ViewAs(pct), p.Muted.Render(fmt.Sprintf("%d%%", int(pct*100)))))
if m.finished {
b.WriteString("\n")
}
return tea.NewView(b.String())
}
// liveModel wraps a view so lines from PrintAbove print above it.
type liveModel struct {
inner tea.Model
}
func (m liveModel) Init() tea.Cmd { return m.inner.Init() }
func (m liveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if line, ok := msg.(printAboveMsg); ok {
return m, tea.Println(string(line))
}
inner, cmd := m.inner.Update(msg)
m.inner = inner
return m, cmd
}
func (m liveModel) View() tea.View { return m.inner.View() }
// The running live program, for PrintAbove. Only one live view runs at a
// time; widgets started while one runs print plain lines.
var (
liveMu sync.Mutex
activeProg *tea.Program
)
// liveActive reports whether a live view is running.
func liveActive() bool {
liveMu.Lock()
defer liveMu.Unlock()
return activeProg != nil
}
// runLive runs model as a bubbletea program on out while work runs on the
// calling goroutine. work gets a send function for progress messages. When
// work returns, the program is unregistered (so PrintAbove stops sending to
// it), told to finish and waited for; it never receives lines after that.
func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, work func(send func(tea.Msg)) error) error {
if ctx == nil {
ctx = context.Background()
}
prog := tea.NewProgram(liveModel{inner: model},
tea.WithInput(nil),
tea.WithOutput(out),
tea.WithoutSignalHandler(),
tea.WithContext(ctx),
tea.WithColorProfile(c.profile()),
)
finished := make(chan struct{})
go func() {
defer close(finished)
_, _ = prog.Run()
}()
liveMu.Lock()
activeProg = prog
liveMu.Unlock()
err := work(prog.Send)
liveMu.Lock()
activeProg = nil
liveMu.Unlock()
prog.Send(finishMsg{err: err})
<-finished
return err
}
// printAboveLines prints s, one or more lines, above the running live view.
// It reports false when no view runs.
func printAboveLines(s string) bool {
liveMu.Lock()
defer liveMu.Unlock()
if activeProg == nil {
return false
}
for _, line := range strings.Split(strings.TrimSuffix(s, "\n"), "\n") {
activeProg.Send(printAboveMsg(line))
}
return true
}