❌ is a thin black-and-white cross that gets lost against terminal backgrounds and the ANSI red text. 🛑 is a solid red octagon that reads unmistakably as 'stop/error' at a glance, even when the user isn't reading the line carefully.
205 lines
6.8 KiB
Go
205 lines
6.8 KiB
Go
// Package ui provides consistent user-facing output formatting for vaultik.
|
|
// All status updates, banners, errors, and warnings printed to the user
|
|
// should go through a *Writer from this package.
|
|
//
|
|
// Message classes (see Writer methods):
|
|
//
|
|
// - Begin — operation start, left-aligned, marker "》" (white)
|
|
// - Complete— operation completion, left-aligned, marker "》" (green)
|
|
// - Info — left-aligned neutral status, marker "》" (white)
|
|
// - Notice — left-aligned important note, marker "》" (cyan)
|
|
// - Warning — left-aligned warning, full word "Warning: " (orange/yellow)
|
|
// - Error — left-aligned error, full word "ERROR: " (red)
|
|
// - Progress— indented heartbeat / per-item update, marker " 》" (white)
|
|
// - Banner — application banner line, left-aligned, no marker
|
|
//
|
|
// Value formatters (Hex, Size, Duration, Time, Path, Snapshot, Speed,
|
|
// Count, Percent) return ANSI-colored strings the caller composes into
|
|
// the message body. When color is disabled (non-TTY output or NO_COLOR
|
|
// set) all formatters return plain text.
|
|
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// ANSI SGR escape sequences.
|
|
const (
|
|
ansiReset = "\033[0m"
|
|
ansiBold = "\033[1m"
|
|
ansiRed = "\033[31m"
|
|
ansiGreen = "\033[32m"
|
|
ansiYellow = "\033[33m" // used for orange "Warning:" and for durations
|
|
ansiBlue = "\033[34m"
|
|
ansiMagenta = "\033[35m"
|
|
ansiCyan = "\033[36m"
|
|
ansiWhite = "\033[37m"
|
|
)
|
|
|
|
// Marker is the chevron prefix used for all non-error/warning lines.
|
|
const Marker = "》"
|
|
|
|
// Writer formats and emits user-facing messages with optional ANSI color.
|
|
type Writer struct {
|
|
out io.Writer
|
|
color bool
|
|
}
|
|
|
|
// New returns a Writer that emits to out. Color is enabled when out is a
|
|
// TTY and the NO_COLOR environment variable is unset.
|
|
// https://no-color.org/
|
|
func New(out io.Writer) *Writer {
|
|
return &Writer{out: out, color: shouldColor(out)}
|
|
}
|
|
|
|
// NewWithColor returns a Writer with an explicit color setting, ignoring
|
|
// TTY detection. Useful for tests and for piped output that the caller
|
|
// wants to colorize anyway.
|
|
func NewWithColor(out io.Writer, color bool) *Writer {
|
|
return &Writer{out: out, color: color}
|
|
}
|
|
|
|
// Out returns the underlying writer.
|
|
func (w *Writer) Out() io.Writer { return w.out }
|
|
|
|
// Color reports whether color is enabled on this writer.
|
|
func (w *Writer) Color() bool { return w.color }
|
|
|
|
// shouldColor returns true when w is a real TTY and NO_COLOR is unset.
|
|
func shouldColor(w io.Writer) bool {
|
|
if os.Getenv("NO_COLOR") != "" {
|
|
return false
|
|
}
|
|
f, ok := w.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return term.IsTerminal(int(f.Fd()))
|
|
}
|
|
|
|
// paint wraps s in the given ANSI color when color is enabled.
|
|
func (w *Writer) paint(color, s string) string {
|
|
if !w.color {
|
|
return s
|
|
}
|
|
return color + s + ansiReset
|
|
}
|
|
|
|
// ───────────────────────── message methods ─────────────────────────
|
|
|
|
// Begin prints an operation-start line, left-aligned with a white marker.
|
|
func (w *Writer) Begin(format string, args ...any) {
|
|
w.emit(ansiWhite, Marker, "", format, args)
|
|
}
|
|
|
|
// Complete prints an operation-completion line in green, left-aligned.
|
|
func (w *Writer) Complete(format string, args ...any) {
|
|
w.emit(ansiGreen, Marker, ansiGreen, format, args)
|
|
}
|
|
|
|
// Info prints a neutral status line, left-aligned with a white marker.
|
|
func (w *Writer) Info(format string, args ...any) {
|
|
w.emit(ansiWhite, Marker, "", format, args)
|
|
}
|
|
|
|
// Notice prints an attention-worthy informational line, marker in cyan.
|
|
func (w *Writer) Notice(format string, args ...any) {
|
|
w.emit(ansiCyan, Marker, "", format, args)
|
|
}
|
|
|
|
// Warning prints "⚠️ Warning: " in orange/yellow followed by the message.
|
|
func (w *Writer) Warning(format string, args ...any) {
|
|
prefix := "⚠️ " + w.paint(ansiYellow+ansiBold, "Warning: ")
|
|
_, _ = fmt.Fprintln(w.out, prefix+fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// Error prints "🛑 ERROR: " in red followed by the message. Goes to the
|
|
// same writer as everything else; callers that want stderr should
|
|
// construct a separate Writer for it.
|
|
func (w *Writer) Error(format string, args ...any) {
|
|
prefix := "🛑 " + w.paint(ansiRed+ansiBold, "ERROR: ")
|
|
_, _ = fmt.Fprintln(w.out, prefix+fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// Progress prints an indented heartbeat / per-item update, marker in white.
|
|
func (w *Writer) Progress(format string, args ...any) {
|
|
w.emit(ansiWhite, " "+Marker, "", format, args)
|
|
}
|
|
|
|
// Banner prints a line with no marker, left-aligned. Used for the
|
|
// application startup banner only.
|
|
func (w *Writer) Banner(format string, args ...any) {
|
|
_, _ = fmt.Fprintln(w.out, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// emit writes "<prefix> <body>\n" with the prefix painted in prefixColor
|
|
// and the body optionally painted in bodyColor (empty = no body color).
|
|
func (w *Writer) emit(prefixColor, prefix, bodyColor, format string, args []any) {
|
|
body := fmt.Sprintf(format, args...)
|
|
if bodyColor != "" {
|
|
body = w.paint(bodyColor, body)
|
|
}
|
|
_, _ = fmt.Fprintln(w.out, w.paint(prefixColor, prefix)+" "+body)
|
|
}
|
|
|
|
// ───────────────────────── value formatters ─────────────────────────
|
|
//
|
|
// These return ANSI-colored strings the caller composes into a message
|
|
// body. When color is disabled they return plain text.
|
|
|
|
// Hex colorizes a hex identifier (blob hash, chunk hash, snapshot id).
|
|
// Long hashes are abbreviated to first 12 chars with "...".
|
|
func (w *Writer) Hex(s string) string {
|
|
short := s
|
|
if len(s) > 12 {
|
|
short = s[:12] + "..."
|
|
}
|
|
return w.paint(ansiCyan, short)
|
|
}
|
|
|
|
// Snapshot colorizes a snapshot ID (full, no abbreviation).
|
|
func (w *Writer) Snapshot(id string) string {
|
|
return w.paint(ansiCyan+ansiBold, id)
|
|
}
|
|
|
|
// Path colorizes a filesystem path.
|
|
func (w *Writer) Path(p string) string {
|
|
return w.paint(ansiBlue, p)
|
|
}
|
|
|
|
// Size colorizes a byte count using humanize.Bytes.
|
|
func (w *Writer) Size(bytes int64) string {
|
|
return w.paint(ansiMagenta, humanize.Bytes(uint64(bytes)))
|
|
}
|
|
|
|
// Speed colorizes a byte-per-second value as "<size>/sec".
|
|
func (w *Writer) Speed(bytesPerSec float64) string {
|
|
return w.paint(ansiMagenta, humanize.Bytes(uint64(bytesPerSec))+"/sec")
|
|
}
|
|
|
|
// Duration colorizes a time.Duration rounded to the nearest second.
|
|
func (w *Writer) Duration(d time.Duration) string {
|
|
return w.paint(ansiYellow, d.Round(time.Second).String())
|
|
}
|
|
|
|
// Time colorizes an absolute time (RFC3339, second precision).
|
|
func (w *Writer) Time(t time.Time) string {
|
|
return w.paint(ansiYellow, t.Format(time.RFC3339))
|
|
}
|
|
|
|
// Count colorizes an integer count with thousands separators.
|
|
func (w *Writer) Count(n int) string {
|
|
return w.paint(ansiMagenta, humanize.Comma(int64(n)))
|
|
}
|
|
|
|
// Percent colorizes a 0..100 percentage.
|
|
func (w *Writer) Percent(p float64) string {
|
|
return w.paint(ansiMagenta, fmt.Sprintf("%.1f%%", p))
|
|
}
|