- 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
243 lines
5.8 KiB
Go
243 lines
5.8 KiB
Go
package bonfire
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"strings"
|
||
|
||
"charm.land/lipgloss/v2"
|
||
"github.com/charmbracelet/colorprofile"
|
||
"golang.org/x/term"
|
||
)
|
||
|
||
const (
|
||
symCheck = "✓"
|
||
symCross = "✗"
|
||
symWarn = "⚠"
|
||
symInfo = "ℹ"
|
||
symArrow = "→"
|
||
symPrompt = "?"
|
||
)
|
||
|
||
// Output is the injected console used by commands. Tests supply streams.
|
||
type Output interface {
|
||
io.Writer
|
||
Println(a ...any)
|
||
Printf(format string, a ...any)
|
||
Info(message string)
|
||
Success(message string)
|
||
Error(message string)
|
||
Warning(message string)
|
||
Table(headers []string, rows [][]string)
|
||
Spinner(message string, fn func() error) error
|
||
Progress(total int, fn func(Progress) error) error
|
||
Ask(question, def string) (string, error)
|
||
Confirm(question string, def bool) (bool, error)
|
||
Choice(question string, choices []string, def int) (string, error)
|
||
Secret(question string) (string, error)
|
||
}
|
||
|
||
// Progress is a single progress bar advanced from a command.
|
||
type Progress interface {
|
||
Advance(step int)
|
||
}
|
||
|
||
type console struct {
|
||
in io.Reader
|
||
out io.Writer
|
||
err io.Writer
|
||
lookup func(string) string
|
||
// tty is true when stdout is a terminal; it picks animated widgets
|
||
// and boxed tables.
|
||
tty bool
|
||
// interactive is true when stdin and stdout are terminals.
|
||
interactive bool
|
||
// inTTY is true when stdin is a terminal; Secret then reads a hidden
|
||
// value instead of a line.
|
||
inTTY bool
|
||
// color is the environment's color policy (NO_COLOR, TERM=dumb,
|
||
// FORCE_COLOR, terminal) for ColorAuto.
|
||
color bool
|
||
// globals are the parsed global options of the running command, set
|
||
// by the root before the command runs.
|
||
globals Globals
|
||
palette Palette
|
||
}
|
||
|
||
// NewOutput builds Output from injected streams and the process color policy.
|
||
func NewOutput(in io.Reader, out, errW io.Writer) Output {
|
||
return newConsole(in, out, errW, os.Getenv, nil, nil)
|
||
}
|
||
|
||
func newConsole(in io.Reader, out, errW io.Writer, lookup func(string) string, tty, interactive *bool) *console {
|
||
if in == nil {
|
||
in = os.Stdin
|
||
}
|
||
if out == nil {
|
||
out = os.Stdout
|
||
}
|
||
if errW == nil {
|
||
errW = out
|
||
}
|
||
if lookup == nil {
|
||
lookup = os.Getenv
|
||
}
|
||
c := &console{in: in, out: out, err: errW, lookup: lookup, palette: DefaultPalette()}
|
||
if tty != nil {
|
||
c.tty = *tty
|
||
} else {
|
||
c.tty = isTerminal(out)
|
||
}
|
||
c.inTTY = isTerminal(in)
|
||
if interactive != nil {
|
||
c.interactive = *interactive
|
||
} else {
|
||
c.interactive = c.inTTY && c.tty
|
||
}
|
||
c.color = supportsColor(lookup, c.tty)
|
||
return c
|
||
}
|
||
|
||
func supportsColor(lookup func(string) string, tty bool) bool {
|
||
if lookup == nil {
|
||
lookup = os.Getenv
|
||
}
|
||
if lookup("NO_COLOR") != "" || lookup("TERM") == "dumb" {
|
||
return false
|
||
}
|
||
if lookup("FORCE_COLOR") != "" {
|
||
return true
|
||
}
|
||
return tty
|
||
}
|
||
|
||
func isTerminal(rw any) bool {
|
||
type fd interface{ Fd() uintptr }
|
||
f, ok := rw.(fd)
|
||
if !ok {
|
||
return false
|
||
}
|
||
return term.IsTerminal(int(f.Fd()))
|
||
}
|
||
|
||
// setGlobals records the parsed global options of the running command.
|
||
func (c *console) setGlobals(g Globals) {
|
||
c.globals = g
|
||
}
|
||
|
||
// live reports whether widgets animate: stdout is a terminal and none of
|
||
// -q, --no-ansi and -n is set (D-03). A live view that is already running
|
||
// makes nested widgets print plain lines.
|
||
func (c *console) live() bool {
|
||
g := c.globals
|
||
return c.tty && !g.Quiet && g.Color != ColorNever && !g.NoInteraction && !liveActive()
|
||
}
|
||
|
||
// canPrompt reports whether prompts may wait for input from a person:
|
||
// stdin and stdout are terminals and -n (or -q) is not set.
|
||
func (c *console) canPrompt() bool {
|
||
return c.interactive && !c.globals.NoInteraction
|
||
}
|
||
|
||
// profile is the color profile of styled output: none for --no-ansi or
|
||
// when the environment turns color off, at least 256 colors otherwise.
|
||
func (c *console) profile() colorprofile.Profile {
|
||
switch c.globals.Color {
|
||
case ColorNever:
|
||
return colorprofile.NoTTY
|
||
case ColorAuto:
|
||
if !c.color {
|
||
return colorprofile.NoTTY
|
||
}
|
||
}
|
||
return DetectProfile(c.out, ColorAlways, c.environ())
|
||
}
|
||
|
||
// environ rebuilds the color-related environment from lookup.
|
||
func (c *console) environ() []string {
|
||
var env []string
|
||
for _, k := range []string{"TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR", "CLICOLOR", "CLICOLOR_FORCE"} {
|
||
if v := c.lookup(k); v != "" {
|
||
env = append(env, k+"="+v)
|
||
}
|
||
}
|
||
return env
|
||
}
|
||
|
||
// styled wraps w so palette styles follow the color profile.
|
||
func (c *console) styled(w io.Writer) io.Writer {
|
||
return &colorprofile.Writer{Forward: w, Profile: c.profile()}
|
||
}
|
||
|
||
// emit writes s, a styled line or block, to w. While a live view runs on
|
||
// stdout, lines for stdout print above it instead of tearing it.
|
||
func (c *console) emit(w io.Writer, s string) {
|
||
if w == nil {
|
||
return
|
||
}
|
||
if w == c.out && printAboveLines(s) {
|
||
return
|
||
}
|
||
_, _ = io.WriteString(c.styled(w), s)
|
||
}
|
||
|
||
func (c *console) Write(p []byte) (int, error) {
|
||
if c.out == nil {
|
||
return 0, io.ErrClosedPipe
|
||
}
|
||
return c.out.Write(p)
|
||
}
|
||
|
||
func (c *console) Println(a ...any) {
|
||
if c.out == nil {
|
||
return
|
||
}
|
||
s := fmt.Sprintln(a...)
|
||
if printAboveLines(s) {
|
||
return
|
||
}
|
||
_, _ = io.WriteString(c.out, s)
|
||
}
|
||
|
||
func (c *console) Printf(format string, a ...any) {
|
||
if c.out == nil {
|
||
return
|
||
}
|
||
s := fmt.Sprintf(format, a...)
|
||
if strings.HasSuffix(s, "\n") && printAboveLines(s) {
|
||
return
|
||
}
|
||
_, _ = io.WriteString(c.out, s)
|
||
}
|
||
|
||
// status prints a status line unless -q is set.
|
||
func (c *console) status(style lipgloss.Style, symbol, message string) {
|
||
if c.globals.Quiet {
|
||
return
|
||
}
|
||
c.emit(c.out, style.Render(symbol)+" "+message+"\n")
|
||
}
|
||
|
||
func (c *console) Info(message string) {
|
||
c.status(c.palette.Current, symInfo, message)
|
||
}
|
||
|
||
func (c *console) Success(message string) {
|
||
c.status(c.palette.Success, symCheck, message)
|
||
}
|
||
|
||
func (c *console) Error(message string) {
|
||
w := c.err
|
||
if w == nil {
|
||
w = c.out
|
||
}
|
||
c.emit(w, c.palette.Error.Render(symCross)+" "+message+"\n")
|
||
}
|
||
|
||
func (c *console) Warning(message string) {
|
||
c.status(c.palette.Warning, symWarn, message)
|
||
}
|
||
|
||
func boolPtr(v bool) *bool { return &v }
|