- Inject bonfire.Output from stdin/stdout/stderr with stdlib widgets and x/term - Degrade spinner, progress, table and prompts without a TTY or color - Reject plugin command names that are not namespace:verb
211 lines
4.3 KiB
Go
211 lines
4.3 KiB
Go
package bonfire
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
|
|
|
|
const (
|
|
boxTopLeft = "┌"
|
|
boxTopRight = "┐"
|
|
boxBottomLeft = "└"
|
|
boxBottomRight = "┘"
|
|
boxHorizontal = "─"
|
|
boxVertical = "│"
|
|
boxTeeRight = "├"
|
|
boxTeeLeft = "┤"
|
|
boxTeeDown = "┬"
|
|
boxTeeUp = "┴"
|
|
boxCross = "┼"
|
|
)
|
|
|
|
func (c *console) Spinner(message string, fn func() error) error {
|
|
if fn == nil {
|
|
fn = func() error { return nil }
|
|
}
|
|
if !c.tty {
|
|
c.Printf("[...] %s\n", message)
|
|
return fn()
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
i := 0
|
|
ticker := time.NewTicker(80 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
c.Printf("\r%s %s", c.style(ansiCyan, string(spinnerFrames[0])), message)
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-ticker.C:
|
|
i++
|
|
frame := spinnerFrames[i%len(spinnerFrames)]
|
|
c.Printf("\r%s %s", c.style(ansiCyan, string(frame)), message)
|
|
}
|
|
}
|
|
}()
|
|
|
|
err := fn()
|
|
close(done)
|
|
wg.Wait()
|
|
if err != nil {
|
|
c.Printf("\r%s %s\n", c.style(ansiRed, symCross), message)
|
|
return err
|
|
}
|
|
c.Printf("\r%s %s\n", c.style(ansiGreen, symCheck), message)
|
|
return nil
|
|
}
|
|
|
|
func (c *console) Progress(total int, fn func(Progress) error) error {
|
|
if total < 1 {
|
|
total = 1
|
|
}
|
|
bar := &progressBar{c: c, total: total, lastPct: -1}
|
|
if fn == nil {
|
|
return nil
|
|
}
|
|
err := fn(bar)
|
|
if c.tty && bar.current >= bar.total {
|
|
c.Println()
|
|
}
|
|
return err
|
|
}
|
|
|
|
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.c.tty {
|
|
p.renderTTY(pct)
|
|
return
|
|
}
|
|
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) renderTTY(pct int) {
|
|
width := 40
|
|
filled := 0
|
|
if p.total > 0 {
|
|
filled = p.current * width / p.total
|
|
}
|
|
if filled > width {
|
|
filled = width
|
|
}
|
|
bar := strings.Repeat("█", filled) + strings.Repeat(" ", width-filled)
|
|
color := progressColor(pct)
|
|
p.c.Printf("\r %s %5.1f%% %d/%d", p.c.style(color, bar), float64(pct), p.current, p.total)
|
|
}
|
|
|
|
func progressColor(pct int) string {
|
|
switch {
|
|
case pct >= 100:
|
|
return "\x1b[38;5;34m"
|
|
case pct >= 50:
|
|
return "\x1b[38;5;36m"
|
|
default:
|
|
return "\x1b[38;5;37m"
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
cols := len(headers)
|
|
all := make([][]string, 0, 1+len(rows))
|
|
all = append(all, headers)
|
|
for _, row := range rows {
|
|
all = append(all, padRow(row, cols))
|
|
}
|
|
widths := make([]int, cols)
|
|
for _, row := range all {
|
|
for i, cell := range row {
|
|
if n := utf8.RuneCountInString(cell); n > widths[i] {
|
|
widths[i] = n
|
|
}
|
|
}
|
|
}
|
|
c.Println(boxLine(widths, boxTopLeft, boxTeeDown, boxTopRight))
|
|
c.Println(boxRow(headers, widths, true, c))
|
|
c.Println(boxLine(widths, boxTeeRight, boxCross, boxTeeLeft))
|
|
for _, row := range rows {
|
|
c.Println(boxRow(padRow(row, cols), widths, false, c))
|
|
}
|
|
c.Println(boxLine(widths, boxBottomLeft, boxTeeUp, boxBottomRight))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func boxLine(widths []int, left, mid, right string) string {
|
|
var b strings.Builder
|
|
b.WriteString(left)
|
|
for i, w := range widths {
|
|
if i > 0 {
|
|
b.WriteString(mid)
|
|
}
|
|
b.WriteString(strings.Repeat(boxHorizontal, w+2))
|
|
}
|
|
b.WriteString(right)
|
|
return b.String()
|
|
}
|
|
|
|
func boxRow(cells []string, widths []int, header bool, c *console) string {
|
|
var b strings.Builder
|
|
b.WriteString(boxVertical)
|
|
for i, cell := range cells {
|
|
pad := widths[i] - utf8.RuneCountInString(cell)
|
|
text := cell
|
|
if header {
|
|
text = c.style("\x1b[1m", cell)
|
|
}
|
|
fmt.Fprintf(&b, " %s%s %s", text, strings.Repeat(" ", pad), boxVertical)
|
|
}
|
|
return b.String()
|
|
}
|