148 lines
3.7 KiB
Go
148 lines
3.7 KiB
Go
// Package inline renders styled text and in-place progress in the normal
|
||
// terminal scrollback: no raw mode, no alternate screen, no input handling,
|
||
// no cursor hiding. Intended for CLI tools that want color and live status
|
||
// without owning the screen.
|
||
//
|
||
// Model: permanent lines scroll via Log; a live block of status lines is
|
||
// pinned below them and rewritten in place via Update. Done erases the
|
||
// block and optionally prints final permanent lines.
|
||
//
|
||
// Non-terminal output (pipes, CI): Update is a no-op, styling is stripped
|
||
// unless overridden with SetColor(true), Log and Done print plainly.
|
||
//
|
||
// Width is measured in runes (unicode/utf8); wide and combining characters
|
||
// are not width-aware.
|
||
package inline
|
||
|
||
import (
|
||
"bufio"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"sync"
|
||
|
||
"github.com/lixenwraith/color"
|
||
)
|
||
|
||
// Internal color capability representation
|
||
type colorMode uint8
|
||
|
||
const (
|
||
colorMode256 colorMode = iota
|
||
colorModeTrueColor
|
||
)
|
||
|
||
// Printer manages styled output and the live block. Safe for concurrent use.
|
||
type Printer struct {
|
||
mu sync.Mutex
|
||
w *bufio.Writer
|
||
tty *os.File // non-nil when output is a terminal
|
||
color bool
|
||
mode colorMode
|
||
live []string // desired live block content
|
||
drawn int // lines currently on screen (may be clamped below len(live))
|
||
}
|
||
|
||
// New creates a Printer for w. Terminal detection via size probe;
|
||
// styling defaults on for terminals with NO_COLOR unset.
|
||
func New(w io.Writer) *Printer {
|
||
p := &Printer{w: bufio.NewWriter(w)}
|
||
if f, isFile := w.(*os.File); isFile {
|
||
if _, _, ok := windowSize(f); ok {
|
||
p.tty = f
|
||
}
|
||
}
|
||
p.color = p.tty != nil && os.Getenv("NO_COLOR") == ""
|
||
p.mode = detectColorMode()
|
||
// Keep the LUT build out of the first Paint call
|
||
if p.color && p.mode == colorMode256 {
|
||
color.WarmXterm256()
|
||
}
|
||
return p
|
||
}
|
||
|
||
// SetColor overrides style detection. Affects Paint only; live-block
|
||
// updates remain terminal-gated.
|
||
func (p *Printer) SetColor(on bool) {
|
||
p.mu.Lock()
|
||
p.color = on
|
||
p.mu.Unlock()
|
||
}
|
||
|
||
// Size returns terminal dimensions, 80×24 when unknown
|
||
func (p *Printer) Size() (w, h int) {
|
||
if p.tty != nil {
|
||
if w, h, ok := windowSize(p.tty); ok {
|
||
return w, h
|
||
}
|
||
}
|
||
return 80, 24
|
||
}
|
||
|
||
// Log prints a permanent line above the live block
|
||
func (p *Printer) Log(format string, a ...any) {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
p.eraseLocked()
|
||
fmt.Fprintf(p.w, format, a...)
|
||
p.w.WriteByte('\n')
|
||
p.redrawLocked()
|
||
p.w.Flush()
|
||
}
|
||
|
||
// Update replaces the live block, rewriting in place. No-op on non-terminal output.
|
||
func (p *Printer) Update(lines ...string) {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if p.tty == nil {
|
||
return
|
||
}
|
||
p.eraseLocked()
|
||
p.live = append(p.live[:0], lines...)
|
||
p.redrawLocked()
|
||
p.w.Flush()
|
||
}
|
||
|
||
// Done erases the live block and prints final permanent lines
|
||
func (p *Printer) Done(final ...string) {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
p.eraseLocked()
|
||
p.live = p.live[:0]
|
||
for _, ln := range final {
|
||
p.w.WriteString(ln)
|
||
p.w.WriteByte('\n')
|
||
}
|
||
p.w.Flush()
|
||
}
|
||
|
||
// eraseLocked removes the drawn live block; cursor ends at block origin.
|
||
// Cursor sits one line below the block (redraw ends each line with '\n').
|
||
func (p *Printer) eraseLocked() {
|
||
if p.tty == nil || p.drawn == 0 {
|
||
return
|
||
}
|
||
fmt.Fprintf(p.w, "\x1b[%dA\r\x1b[J", p.drawn)
|
||
p.drawn = 0
|
||
}
|
||
|
||
// redrawLocked writes the live block; assumes screen below cursor is clear.
|
||
// Lines are truncated to terminal width so cursor-up arithmetic stays valid
|
||
// (relies on xterm deferred autowrap for exact-width lines). Block is
|
||
// clamped to height-1 rows, keeping newest lines.
|
||
func (p *Printer) redrawLocked() {
|
||
if p.tty == nil || len(p.live) == 0 {
|
||
return
|
||
}
|
||
w, h := p.Size()
|
||
lines := p.live
|
||
if h > 1 && len(lines) > h-1 {
|
||
lines = lines[len(lines)-(h-1):]
|
||
}
|
||
for _, ln := range lines {
|
||
p.w.WriteString(truncVisible(ln, w))
|
||
p.w.WriteByte('\n')
|
||
}
|
||
p.drawn = len(lines)
|
||
}
|