Files
summercms/modules/bonfire/output.go

251 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// errTTY is true when the error writer is a terminal: a live view on
// stdout then shares the screen with it.
errTTY 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.errTTY = isTerminal(errW)
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: runLive then reports that it did
// not start.
func (c *console) live() bool {
g := c.globals
return c.tty && !g.Quiet && g.Color != ColorNever && !g.NoInteraction
}
// 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 this
// console started runs on stdout, lines for stdout print above it instead
// of tearing it, and so do lines for the error writer when stdout and the
// error writer are both terminals (they share the screen with the view).
func (c *console) emit(w io.Writer, s string) {
if w == nil {
return
}
above := w == c.out || (w == c.err && c.tty && c.errTTY)
if above && printAbove(c, 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 printAbove(c, 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") && printAbove(c, 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 }