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

177 lines
3.8 KiB
Go

package bonfire
import (
"context"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
)
// ItemProgress is the optional side of a Progress that knows about items:
// Current names the item being worked on, Fail records a failed item. The
// Progress handed to Output.Progress implements it; Current and Failed call
// it when present.
type ItemProgress interface {
Progress
Current(item string)
Fail(item string, err error)
}
func (c *console) Spinner(message string, fn func() error) error {
if fn == nil {
fn = func() error { return nil }
}
if c.globals.Quiet {
return fn()
}
if !c.live() {
c.Printf("[...] %s\n", message)
return fn()
}
return runLive(context.Background(), c.out, c, newSpinnerModel(c.palette, message), func(func(tea.Msg)) error {
return fn()
})
}
func (c *console) Progress(total int, fn func(Progress) error) error {
if total < 1 {
total = 1
}
if fn == nil {
return nil
}
if c.globals.Quiet {
return fn(&quietProgress{c: c})
}
if !c.live() {
return fn(&progressBar{c: c, total: total, lastPct: -1})
}
return runLive(context.Background(), c.out, c, newProgressModel(c.palette, total), func(send func(tea.Msg)) error {
return fn(&liveProgress{send: send, total: total})
})
}
// progressBar prints a line at every tenth of the work when widgets do not
// animate.
type progressBar struct {
c *console
total int
current int
lastPct int
}
func (p *progressBar) Advance(step int) {
if step < 1 {
step = 1
}
p.current += step
if p.current > p.total {
p.current = p.total
}
pct := 0
if p.total > 0 {
pct = p.current * 100 / p.total
}
if p.lastPct >= 0 && pct/10 == p.lastPct/10 && pct != 100 {
return
}
if pct == 0 && p.lastPct < 0 {
p.lastPct = 0
return
}
if pct%10 == 0 || pct == 100 {
p.c.Printf("[%d/%d] %d%%\n", p.current, p.total, pct)
p.lastPct = pct
}
}
func (p *progressBar) Current(string) {}
func (p *progressBar) Fail(item string, err error) {
p.c.Error(failureLine(item, err))
}
// quietProgress prints nothing but failures (-q).
type quietProgress struct {
c *console
}
func (p *quietProgress) Advance(int) {}
func (p *quietProgress) Current(string) {}
func (p *quietProgress) Fail(item string, err error) {
p.c.Error(failureLine(item, err))
}
// liveProgress drives the live progress model.
type liveProgress struct {
send func(tea.Msg)
total int
current int
item string
}
func (p *liveProgress) Advance(step int) {
if step < 1 {
step = 1
}
p.current = min(p.current+step, p.total)
p.send(itemMsg{done: p.current, total: p.total, item: p.item})
}
func (p *liveProgress) Current(item string) {
p.item = item
p.send(itemMsg{done: p.current, total: p.total, item: item})
}
func (p *liveProgress) Fail(item string, err error) {
p.send(itemMsg{done: p.current, total: p.total, item: item, err: err})
p.send(printAboveMsg(failureLine(item, err)))
}
func failureLine(item string, err error) string {
if err == nil {
return item
}
return item + ": " + err.Error()
}
func (c *console) Table(headers []string, rows [][]string) {
if !c.tty {
c.Println(strings.Join(headers, "\t"))
for _, row := range rows {
c.Println(strings.Join(padRow(row, len(headers)), "\t"))
}
return
}
padded := make([][]string, 0, len(rows))
for _, row := range rows {
padded = append(padded, padRow(row, len(headers)))
}
header := c.palette.Header
t := table.New().
Border(lipgloss.NormalBorder()).
Headers(headers...).
Rows(padded...).
StyleFunc(func(row, _ int) lipgloss.Style {
if row == table.HeaderRow {
return header.Padding(0, 1)
}
return lipgloss.NewStyle().Padding(0, 1)
})
c.emit(c.out, t.String()+"\n")
}
func padRow(row []string, n int) []string {
out := make([]string, n)
for i := 0; i < n; i++ {
if i < len(row) {
out[i] = row[i]
}
}
return out
}