v0.1.0 initial commit
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
# inline
|
||||
|
||||
Styled text and in-place progress in the normal terminal scrollback. No raw
|
||||
mode, no alternate screen, no input handling, no cursor hiding — the shell
|
||||
keeps owning the terminal. Intended for CLI tools (package managers, service
|
||||
tooling, build scripts) that want color and live status without a full-screen
|
||||
TUI.
|
||||
|
||||
Unix only (`//go:build unix`). Depends on the parent `terminal` package for
|
||||
color types, capability detection, and RGB → 256 mapping.
|
||||
|
||||
## Model
|
||||
|
||||
Output is split into two zones:
|
||||
|
||||
installed openssl ← permanent lines (Log) — scroll normally
|
||||
installed zlib
|
||||
⠹ installing curl ← live block (Update) — rewritten in place
|
||||
[██████░░░░░░░░] 2/5
|
||||
|
||||
`Log` prints permanent lines above the live block; `Update` replaces the live
|
||||
block by cursor-up + clear + rewrite; `Done` erases the block and optionally
|
||||
prints final lines. Interleaving is handled internally — `Log` during an
|
||||
active live block erases, prints, and redraws in one flush.
|
||||
|
||||
## API
|
||||
|
||||
### Printer
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `New(w io.Writer) *Printer` | Creates a printer. Terminal detection via size probe; color defaults on for terminals with `NO_COLOR` unset. Safe for concurrent use. |
|
||||
| `Log(format string, a ...any)` | Prints one permanent line above the live block (`Printf` semantics, newline appended). |
|
||||
| `Update(lines ...string)` | Replaces the live block, rewriting in place. No-op on non-terminal output. |
|
||||
| `Done(final ...string)` | Erases the live block and prints final permanent lines. Call before exit. |
|
||||
| `Paint(s string, st Style) string` | Returns `s` wrapped in SGR codes for the detected color mode, or unchanged when color is off. |
|
||||
| `SetColor(on bool)` | Overrides color detection (e.g. force styling into a pipe for `less -R`). Affects `Paint` only; `Update` remains terminal-gated. |
|
||||
| `Size() (w, h int)` | Current terminal dimensions, 80×24 when unknown. |
|
||||
|
||||
### Style
|
||||
|
||||
Value type, zero value is unstyled, builder-composable:
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `Fg(c terminal.RGB) Style` | Starts a style with foreground color. |
|
||||
| `(s Style) Bg(c terminal.RGB) Style` | Adds background color. |
|
||||
| `(s Style) Attr(a terminal.Attr) Style` | Adds attribute bits (`AttrBold`, `AttrDim`, ...). |
|
||||
|
||||
```go
|
||||
warn := inline.Fg(terminal.Amber).Attr(terminal.AttrBold)
|
||||
p.Log("%s low disk space", p.Paint("warning:", warn))
|
||||
```
|
||||
|
||||
True color terminals get `38;2;R;G;B`; 256-color terminals get `38;5;N` via
|
||||
Redmean mapping — same degradation path as the parent package.
|
||||
|
||||
### Progress helpers
|
||||
|
||||
Pure string builders, no Printer required:
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `Bar(width int, pct float64, chars [3]rune) string` | Progress bar of `width` cells, `pct` clamped to [0,1], half-cell resolution via the partial rune. |
|
||||
| `BarBlock` | Default character set `[3]rune{'█', '▌', '░'}`. |
|
||||
| `Spinner(frame int) string` | Braille spinner frame for a monotonic counter. |
|
||||
|
||||
Compose with `Paint` for colored bars:
|
||||
|
||||
```go
|
||||
line := "[" + p.Paint(inline.Bar(30, pct, inline.BarBlock), barStyle) + "]"
|
||||
```
|
||||
|
||||
## Non-terminal output
|
||||
|
||||
When output is a pipe or file (CI, redirection): `Update` is a no-op, `Paint`
|
||||
returns input unchanged, `Log` and `Done` print plain sequential text. A tool
|
||||
using inline degrades to ordinary log output with no code changes.
|
||||
|
||||
## Example
|
||||
|
||||
Simulated package installation — spinner, overall progress bar, permanent
|
||||
completion lines:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/terminal"
|
||||
"github.com/lixenwraith/terminal/inline"
|
||||
)
|
||||
|
||||
func main() {
|
||||
p := inline.New(os.Stdout)
|
||||
|
||||
name := inline.Fg(terminal.LightSkyBlue).Attr(terminal.AttrBold)
|
||||
okSt := inline.Fg(terminal.LimeGreen).Attr(terminal.AttrBold)
|
||||
dim := inline.Fg(terminal.IronGray)
|
||||
|
||||
pkgs := []string{"openssl", "zlib", "curl", "git", "go"}
|
||||
frame := 0
|
||||
|
||||
for i, pkg := range pkgs {
|
||||
const steps = 25
|
||||
for s := range steps {
|
||||
pct := (float64(i) + float64(s)/steps) / float64(len(pkgs))
|
||||
p.Update(
|
||||
inline.Spinner(frame)+" installing "+p.Paint(pkg, name),
|
||||
"["+inline.Bar(32, pct, inline.BarBlock)+"] "+
|
||||
p.Paint(fmt.Sprintf("%d/%d", i+1, len(pkgs)), dim),
|
||||
)
|
||||
frame++
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
}
|
||||
p.Log("%s %s", p.Paint("✓", okSt), pkg)
|
||||
}
|
||||
|
||||
p.Done(p.Paint("✓ 5 packages installed", okSt))
|
||||
}
|
||||
```
|
||||
|
||||
Run in a terminal: the two-line status block animates in place while
|
||||
completion lines accumulate above it. Piped (`go run . | cat`): only the
|
||||
completion lines and the final summary appear, unstyled.
|
||||
|
||||
## Notes
|
||||
|
||||
- Width is measured in runes (`unicode/utf8`); East Asian wide characters and
|
||||
combining marks are not width-aware — same limitation as `tui`.
|
||||
- Live block lines must occupy one visual row each: no `\n`, tabs, or control
|
||||
characters. Lines are truncated to terminal width automatically; embedded
|
||||
SGR from `Paint` is preserved through truncation.
|
||||
- The live block is clamped to terminal height − 1 rows (newest lines kept).
|
||||
- Pass external strings (package names, paths) as `Log` arguments, never as
|
||||
the format string.
|
||||
- Ctrl-C mid-update leaves the live block on screen but the terminal in a
|
||||
normal state — no raw mode or screen buffer to restore.
|
||||
@@ -0,0 +1,137 @@
|
||||
//go:build unix
|
||||
|
||||
// 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 — same documented limitation as tui.
|
||||
package inline
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/lixenwraith/terminal"
|
||||
)
|
||||
|
||||
// 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 terminal.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 WindowSize 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 := terminal.WindowSize(f); ok {
|
||||
p.tty = f
|
||||
}
|
||||
}
|
||||
p.color = p.tty != nil && os.Getenv("NO_COLOR") == ""
|
||||
p.mode = terminal.DetectColorMode()
|
||||
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 := terminal.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)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//go:build unix
|
||||
|
||||
package inline
|
||||
|
||||
import "strings"
|
||||
|
||||
// BarBlock is the default bar character set [filled, partial, empty]
|
||||
var BarBlock = [3]rune{'█', '▌', '░'}
|
||||
|
||||
// Bar renders an unstyled progress bar of width cells, pct in [0,1]
|
||||
func Bar(width int, pct float64, chars [3]rune) string {
|
||||
if width < 1 {
|
||||
return ""
|
||||
}
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
}
|
||||
if pct > 1 {
|
||||
pct = 1
|
||||
}
|
||||
filled := int(float64(width) * pct)
|
||||
rem := float64(width)*pct - float64(filled)
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(width * 3) // Worst-case UTF-8
|
||||
for i := range width {
|
||||
switch {
|
||||
case i < filled:
|
||||
b.WriteRune(chars[0])
|
||||
case i == filled && rem >= 0.5 && filled < width:
|
||||
b.WriteRune(chars[1])
|
||||
default:
|
||||
b.WriteRune(chars[2])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Braille frames; intentionally duplicated from tui (no tui dependency)
|
||||
var spinnerFrames = [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
// Spinner returns the frame for a monotonic counter
|
||||
func Spinner(frame int) string {
|
||||
i := frame % len(spinnerFrames)
|
||||
if i < 0 {
|
||||
i = -i
|
||||
}
|
||||
return spinnerFrames[i]
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
//go:build unix
|
||||
|
||||
package inline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/lixenwraith/terminal"
|
||||
)
|
||||
|
||||
// Style describes text appearance; zero value is unstyled.
|
||||
// Composable: inline.Fg(terminal.Amber).Attr(terminal.AttrBold)
|
||||
type Style struct {
|
||||
fg, bg terminal.RGB
|
||||
hasFg, hasBg bool
|
||||
attr terminal.Attr
|
||||
}
|
||||
|
||||
// Fg starts a style with foreground color
|
||||
func Fg(c terminal.RGB) Style { return Style{fg: c, hasFg: true} }
|
||||
|
||||
// Bg sets background color
|
||||
func (s Style) Bg(c terminal.RGB) Style { s.bg, s.hasBg = c, true; return s }
|
||||
|
||||
// Attr adds attribute bits
|
||||
func (s Style) Attr(a terminal.Attr) Style { s.attr |= a; return s }
|
||||
|
||||
// Paint returns s styled for the detected terminal, unchanged when color
|
||||
// is disabled. Composes with Log: p.Log("%s %s", p.Paint("ok", st), name)
|
||||
func (p *Printer) Paint(s string, st Style) string {
|
||||
if !p.color {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
p.writeSGR(&b, st)
|
||||
b.WriteString(s)
|
||||
b.WriteString("\x1b[0m")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (p *Printer) writeSGR(b *strings.Builder, s Style) {
|
||||
b.WriteString("\x1b[0")
|
||||
for _, m := range [...]struct {
|
||||
bit terminal.Attr
|
||||
code string
|
||||
}{
|
||||
{terminal.AttrBold, ";1"}, {terminal.AttrDim, ";2"},
|
||||
{terminal.AttrItalic, ";3"}, {terminal.AttrUnderline, ";4"},
|
||||
{terminal.AttrBlink, ";5"}, {terminal.AttrReverse, ";7"},
|
||||
} {
|
||||
if s.attr&m.bit != 0 {
|
||||
b.WriteString(m.code)
|
||||
}
|
||||
}
|
||||
if s.hasFg {
|
||||
if p.mode == terminal.ColorModeTrueColor {
|
||||
fmt.Fprintf(b, ";38;2;%d;%d;%d", s.fg.R, s.fg.G, s.fg.B)
|
||||
} else {
|
||||
fmt.Fprintf(b, ";38;5;%d", terminal.RGBTo256(s.fg))
|
||||
}
|
||||
}
|
||||
if s.hasBg {
|
||||
if p.mode == terminal.ColorModeTrueColor {
|
||||
fmt.Fprintf(b, ";48;2;%d;%d;%d", s.bg.R, s.bg.G, s.bg.B)
|
||||
} else {
|
||||
fmt.Fprintf(b, ";48;5;%d", terminal.RGBTo256(s.bg))
|
||||
}
|
||||
}
|
||||
b.WriteByte('m')
|
||||
}
|
||||
|
||||
// --- Width handling (internal, rune-count semantics) ---
|
||||
// Handles only 'm'-terminated escapes — this package's own SGR output.
|
||||
|
||||
// visibleLen counts runes excluding SGR sequences
|
||||
func visibleLen(s string) int {
|
||||
n := 0
|
||||
for {
|
||||
i := strings.IndexByte(s, 0x1b)
|
||||
if i < 0 {
|
||||
return n + utf8.RuneCountInString(s)
|
||||
}
|
||||
n += utf8.RuneCountInString(s[:i])
|
||||
m := strings.IndexByte(s[i:], 'm')
|
||||
if m < 0 {
|
||||
return n // Unterminated escape, remainder not visible
|
||||
}
|
||||
s = s[i+m+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// runePrefix returns up to k leading runes of s and the count taken
|
||||
func runePrefix(s string, k int) (string, int) {
|
||||
if k <= 0 {
|
||||
return "", 0
|
||||
}
|
||||
n := 0
|
||||
for i := range s {
|
||||
if n == k {
|
||||
return s[:i], n
|
||||
}
|
||||
n++
|
||||
}
|
||||
return s, n
|
||||
}
|
||||
|
||||
// truncVisible truncates to max visible runes, preserving embedded SGR
|
||||
// sequences and appending a reset when cut
|
||||
func truncVisible(s string, max int) string {
|
||||
if visibleLen(s) <= max {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
n := 0
|
||||
for len(s) > 0 {
|
||||
i := strings.IndexByte(s, 0x1b)
|
||||
if i != 0 {
|
||||
seg := s
|
||||
if i > 0 {
|
||||
seg = s[:i]
|
||||
}
|
||||
pre, taken := runePrefix(seg, max-n)
|
||||
b.WriteString(pre)
|
||||
n += taken
|
||||
if n >= max {
|
||||
break
|
||||
}
|
||||
s = s[len(seg):]
|
||||
continue
|
||||
}
|
||||
m := strings.IndexByte(s, 'm')
|
||||
if m < 0 {
|
||||
break // Unterminated escape, drop remainder
|
||||
}
|
||||
b.WriteString(s[:m+1])
|
||||
s = s[m+1:]
|
||||
}
|
||||
b.WriteString("\x1b[0m")
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user