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

243 lines
5.8 KiB
Go
Raw Permalink 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
// 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 }