- 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
388 lines
9.6 KiB
Go
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
|
|
}
|