v0.1.8 inline: ANSI 16 styles, a 16-color mode for text consoles
This commit is contained in:
8 files changed
+137
-30
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ package inline
|
|||||||
import "os"
|
import "os"
|
||||||
|
|
||||||
func detectColorMode() colorMode {
|
func detectColorMode() colorMode {
|
||||||
|
if textConsole() {
|
||||||
|
return colorMode16
|
||||||
|
}
|
||||||
return colorMode256
|
return colorMode256
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user