v0.1.8 inline: ANSI 16 styles, a 16-color mode for text consoles

This commit is contained in:
lixen committed 2026-10-04 00:16:59 -04:00
1 parent ea9a20c5de
commit aa004f3aba
8 files changed
+137 -30

No files matched your search

-6
View File
@@ -1,12 +1,6 @@
github.com/lixenwraith/color v0.0.0-20260715115131-49124ee97342 h1:KhjVSKhsHrngqeQ6sIcXX7HMmocixQ7ZZCumycbEKBo=
github.com/lixenwraith/color v0.0.0-20260715115131-49124ee97342/go.mod h1:p02MsAGqlmZu3sc6BPYCKmaedF+lqBoijnuu5cOrYeo=
github.com/lixenwraith/color v0.0.0-20260719094342-615e11bc7897 h1:YkTK1vIzG6sqKRKaeUdkPyMT2laithY1d8tCqyId77U= github.com/lixenwraith/color v0.0.0-20260719094342-615e11bc7897 h1:YkTK1vIzG6sqKRKaeUdkPyMT2laithY1d8tCqyId77U=
github.com/lixenwraith/color v0.0.0-20260719094342-615e11bc7897/go.mod h1:p02MsAGqlmZu3sc6BPYCKmaedF+lqBoijnuu5cOrYeo= github.com/lixenwraith/color v0.0.0-20260719094342-615e11bc7897/go.mod h1:p02MsAGqlmZu3sc6BPYCKmaedF+lqBoijnuu5cOrYeo=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
+18 -3
View File
@@ -7,7 +7,8 @@ tooling, build scripts) that want color and live status without a full-screen
TUI. TUI.
Cross-platform (Unix, Windows). Depends only on the `color` package for Cross-platform (Unix, Windows). Depends only on the `color` package for
24-bit RGB inputs and 256-color automatic degradation. 24-bit RGB inputs, the ANSI 16 indices, and automatic degradation to 256 or 16
colors.
## Model ## Model
@@ -29,13 +30,14 @@ active live block erases, prints, and redraws in one flush.
| Method | Description | | 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. | | `New(w io.Writer) *Printer` | Creates a printer. Terminal detection via size probe; color defaults on for terminals with `NO_COLOR` unset and `TERM` not `dumb`. Safe for concurrent use. |
| `Log(format string, a ...any)` | Prints one permanent line above the live block (`Printf` semantics, newline appended). | | `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. | | `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. | | `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. | | `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. | | `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. | | `Size() (w, h int)` | Current terminal dimensions, 80×24 when unknown. |
| `IsTerminal() bool` | Whether the output is a terminal. |
### Style ### Style
@@ -44,7 +46,9 @@ Value type, zero value is unstyled, builder-composable:
| Function | Description | | Function | Description |
|---|---| |---|---|
| `Fg(c color.RGB) Style` | Starts a style with foreground color. | | `Fg(c color.RGB) Style` | Starts a style with foreground color. |
| `FgANSI(i uint8) Style` | Starts a style with an ANSI 16 foreground (`color.ANSIRed`..). |
| `(s Style) Bg(c color.RGB) Style` | Adds background color. | | `(s Style) Bg(c color.RGB) Style` | Adds background color. |
| `(s Style) BgANSI(i uint8) Style` | Adds an ANSI 16 background. |
| `(s Style) Bold() Style` | Adds bold attribute (also: `Dim`, `Italic`, `Underline`, `Blink`, `Reverse`). | | `(s Style) Bold() Style` | Adds bold attribute (also: `Dim`, `Italic`, `Underline`, `Blink`, `Reverse`). |
```go ```go
@@ -53,7 +57,18 @@ 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 True color terminals get `38;2;R;G;B`; 256-color terminals get `38;5;N` via
Redmean mapping dynamically handled by the `color` package. Redmean mapping dynamically handled by the `color` package; 16-color terminals
(`TERM` of `linux`, `cons25*`, `vt*`, `ansi` or `*-16color`, whatever
`COLORTERM` says) get the nearest ANSI index.
ANSI 16 colors (`FgANSI`, `BgANSI`) are sent as `30-37`/`90-97` in every mode:
the terminal's theme picks the shade, and a text console renders them as
faithfully as an emulator. Prefer them for semantic colors such as log levels.
```go
errSt := inline.FgANSI(color.ANSIRed).Bold()
p.Log("%s disk full", p.Paint("ERROR", errSt))
```
### Progress helpers ### Progress helpers
+15 -2
View File
@@ -19,6 +19,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"strings"
"sync" "sync"
"github.com/lixenwraith/color" "github.com/lixenwraith/color"
@@ -30,8 +31,17 @@ type colorMode uint8
const ( const (
colorMode256 colorMode = iota colorMode256 colorMode = iota
colorModeTrueColor colorModeTrueColor
colorMode16
) )
// textConsole reports a TERM limited to 16 colors (Linux VT, FreeBSD syscons,
// VT100-class). It wins over COLORTERM, which profiles export everywhere.
func textConsole() bool {
term := os.Getenv("TERM")
return term == "linux" || term == "ansi" || strings.HasPrefix(term, "cons25") ||
strings.HasPrefix(term, "vt") || strings.HasSuffix(term, "-16color")
}
// Printer manages styled output and the live block. Safe for concurrent use. // Printer manages styled output and the live block. Safe for concurrent use.
type Printer struct { type Printer struct {
mu sync.Mutex mu sync.Mutex
@@ -44,7 +54,7 @@ type Printer struct {
} }
// New creates a Printer for w. Terminal detection via size probe; // New creates a Printer for w. Terminal detection via size probe;
// styling defaults on for terminals with NO_COLOR unset. // styling defaults on for terminals with NO_COLOR unset and TERM not dumb.
func New(w io.Writer) *Printer { func New(w io.Writer) *Printer {
p := &Printer{w: bufio.NewWriter(w)} p := &Printer{w: bufio.NewWriter(w)}
if f, isFile := w.(*os.File); isFile { if f, isFile := w.(*os.File); isFile {
@@ -52,7 +62,7 @@ func New(w io.Writer) *Printer {
p.tty = f p.tty = f
} }
} }
p.color = p.tty != nil && os.Getenv("NO_COLOR") == "" p.color = p.tty != nil && os.Getenv("NO_COLOR") == "" && os.Getenv("TERM") != "dumb"
p.mode = detectColorMode() p.mode = detectColorMode()
// Keep the LUT build out of the first Paint call // Keep the LUT build out of the first Paint call
if p.color && p.mode == colorMode256 { if p.color && p.mode == colorMode256 {
@@ -61,6 +71,9 @@ func New(w io.Writer) *Printer {
return p return p
} }
// IsTerminal reports whether the output is a terminal
func (p *Printer) IsTerminal() bool { return p.tty != nil }
// SetColor overrides style detection. Affects Paint only; live-block // SetColor overrides style detection. Affects Paint only; live-block
// updates remain terminal-gated. // updates remain terminal-gated.
func (p *Printer) SetColor(on bool) { func (p *Printer) SetColor(on bool) {
+51 -19
View File
@@ -24,16 +24,36 @@ const (
// Style describes text appearance; zero value is unstyled. // Style describes text appearance; zero value is unstyled.
// Composable: inline.Fg(color.Amber).Bold().Underline() // Composable: inline.Fg(color.Amber).Bold().Underline()
type Style struct { type Style struct {
fg, bg color.RGB fg, bg paint
hasFg, hasBg bool attr Attribute
attr Attribute
} }
// paint is one color: none, an RGB value, or an ANSI 16 index
type paint struct {
rgb color.RGB
ansi uint8
kind uint8 // paintNone, paintRGB, paintANSI
}
const (
paintNone uint8 = iota
paintRGB
paintANSI
)
// Fg starts a style with foreground color // Fg starts a style with foreground color
func Fg(c color.RGB) Style { return Style{fg: c, hasFg: true} } func Fg(c color.RGB) Style { return Style{fg: paint{rgb: c, kind: paintRGB}} }
// FgANSI starts a style with an ANSI 16 foreground (color.ANSIRed..). The
// terminal's theme picks the shade, and every terminal, a text console
// included, renders it.
func FgANSI(i uint8) Style { return Style{fg: paint{ansi: i & 15, kind: paintANSI}} }
// Bg sets background color // Bg sets background color
func (s Style) Bg(c color.RGB) Style { s.bg, s.hasBg = c, true; return s } func (s Style) Bg(c color.RGB) Style { s.bg = paint{rgb: c, kind: paintRGB}; return s }
// BgANSI sets an ANSI 16 background
func (s Style) BgANSI(i uint8) Style { s.bg = paint{ansi: i & 15, kind: paintANSI}; return s }
// Bold applies the bold attribute // Bold applies the bold attribute
func (s Style) Bold() Style { s.attr |= Bold; return s } func (s Style) Bold() Style { s.attr |= Bold; return s }
@@ -80,23 +100,35 @@ func (p *Printer) writeSGR(b *strings.Builder, s Style) {
b.WriteString(m.code) b.WriteString(m.code)
} }
} }
if s.hasFg { p.writeColor(b, s.fg, 30)
if p.mode == colorModeTrueColor { p.writeColor(b, s.bg, 40)
fmt.Fprintf(b, ";38;2;%d;%d;%d", s.fg.R, s.fg.G, s.fg.B)
} else {
fmt.Fprintf(b, ";38;5;%d", color.RGBTo256(s.fg))
}
}
if s.hasBg {
if p.mode == 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", color.RGBTo256(s.bg))
}
}
b.WriteByte('m') b.WriteByte('m')
} }
// writeColor emits one color for base 30 (foreground) or 40 (background).
// ANSI indices go out as 30-37/90-97 in every mode; RGB as the mode allows,
// degraded to the nearest ANSI index on a 16-color terminal.
func (p *Printer) writeColor(b *strings.Builder, c paint, base int) {
if c.kind == paintRGB {
switch p.mode {
case colorModeTrueColor:
fmt.Fprintf(b, ";%d;2;%d;%d;%d", base+8, c.rgb.R, c.rgb.G, c.rgb.B)
return
case colorMode256:
fmt.Fprintf(b, ";%d;5;%d", base+8, color.RGBTo256(c.rgb))
return
}
c = paint{ansi: color.RGBTo16(c.rgb), kind: paintANSI}
}
if c.kind == paintANSI {
if c.ansi < 8 {
fmt.Fprintf(b, ";%d", base+int(c.ansi))
} else {
fmt.Fprintf(b, ";%d", base+60+int(c.ansi)-8)
}
}
}
// --- Width handling (internal, rune-count semantics) --- // --- Width handling (internal, rune-count semantics) ---
// visibleLen counts runes excluding SGR sequences // visibleLen counts runes excluding SGR sequences
+44
View File
@@ -0,0 +1,44 @@
package inline
import (
"testing"
"github.com/lixenwraith/color"
)
// ANSI indices go out as 30-37/90-97 in every mode, so a text console and a
// themed emulator both render them; RGB degrades to them only in 16 colors.
func TestPaintEmitsANSIIndicesInEveryMode(t *testing.T) {
cases := []struct {
mode colorMode
st Style
want string
}{
{colorModeTrueColor, FgANSI(color.ANSIRed), "\x1b[0;31mx\x1b[0m"},
{colorMode256, FgANSI(color.ANSIBrightCyan).BgANSI(color.ANSIBlue), "\x1b[0;96;44mx\x1b[0m"},
{colorMode16, FgANSI(color.ANSIBrightBlack).Bold(), "\x1b[0;1;90mx\x1b[0m"},
{colorMode16, Fg(color.Red).Bg(color.Black), "\x1b[0;91;40mx\x1b[0m"},
{colorMode256, Fg(color.Red), "\x1b[0;38;5;196mx\x1b[0m"},
{colorModeTrueColor, Fg(color.RGB{R: 1, G: 2, B: 3}), "\x1b[0;38;2;1;2;3mx\x1b[0m"},
}
for _, c := range cases {
p := &Printer{color: true, mode: c.mode}
if got := p.Paint("x", c.st); got != c.want {
t.Errorf("mode %d: %q, want %q", c.mode, got, c.want)
}
}
}
// A text console gets 16 colors even when a profile exported COLORTERM
func TestTextConsoleDetectsSixteenColors(t *testing.T) {
t.Setenv("COLORTERM", "truecolor")
for term, want := range map[string]colorMode{
"linux": colorMode16, "cons25": colorMode16, "vt220": colorMode16,
"xterm-16color": colorMode16, "xterm-256color": colorModeTrueColor,
} {
t.Setenv("TERM", term)
if got := detectColorMode(); got != want {
t.Errorf("TERM=%s: mode %d, want %d", term, got, want)
}
}
}
+3
View File
@@ -10,6 +10,9 @@ import (
) )
func detectColorMode() colorMode { func detectColorMode() colorMode {
if textConsole() {
return colorMode16
}
colorterm := os.Getenv("COLORTERM") colorterm := os.Getenv("COLORTERM")
if colorterm == "truecolor" || colorterm == "24bit" { if colorterm == "truecolor" || colorterm == "24bit" {
return colorModeTrueColor return colorModeTrueColor
+3
View File
@@ -5,6 +5,9 @@ package inline
import "os" import "os"
func detectColorMode() colorMode { func detectColorMode() colorMode {
if textConsole() {
return colorMode16
}
return colorMode256 return colorMode256
} }
+3
View File
@@ -9,6 +9,9 @@ import (
) )
func detectColorMode() colorMode { func detectColorMode() colorMode {
if textConsole() {
return colorMode16
}
if os.Getenv("WT_SESSION") != "" || os.Getenv("WT_PROFILE_ID") != "" { if os.Getenv("WT_SESSION") != "" || os.Getenv("WT_PROFILE_ID") != "" {
return colorModeTrueColor return colorModeTrueColor
} }