diff --git a/go.sum b/go.sum index 809fa9d..62f4163 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= diff --git a/inline/README.md b/inline/README.md index dbf7e5e..8ab10b5 100644 --- a/inline/README.md +++ b/inline/README.md @@ -7,7 +7,8 @@ tooling, build scripts) that want color and live status without a full-screen TUI. 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 @@ -29,13 +30,14 @@ active live block erases, prints, and redraws in one flush. | 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). | | `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. | +| `IsTerminal() bool` | Whether the output is a terminal. | ### Style @@ -44,7 +46,9 @@ Value type, zero value is unstyled, builder-composable: | Function | Description | |---|---| | `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) BgANSI(i uint8) Style` | Adds an ANSI 16 background. | | `(s Style) Bold() Style` | Adds bold attribute (also: `Dim`, `Italic`, `Underline`, `Blink`, `Reverse`). | ```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 -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 diff --git a/inline/inline.go b/inline/inline.go index 929476b..1b3ba85 100644 --- a/inline/inline.go +++ b/inline/inline.go @@ -19,6 +19,7 @@ import ( "fmt" "io" "os" + "strings" "sync" "github.com/lixenwraith/color" @@ -30,8 +31,17 @@ type colorMode uint8 const ( colorMode256 colorMode = iota 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. type Printer struct { mu sync.Mutex @@ -44,7 +54,7 @@ type Printer struct { } // 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 { p := &Printer{w: bufio.NewWriter(w)} if f, isFile := w.(*os.File); isFile { @@ -52,7 +62,7 @@ func New(w io.Writer) *Printer { 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() // Keep the LUT build out of the first Paint call if p.color && p.mode == colorMode256 { @@ -61,6 +71,9 @@ func New(w io.Writer) *Printer { 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 // updates remain terminal-gated. func (p *Printer) SetColor(on bool) { diff --git a/inline/style.go b/inline/style.go index 098ae48..259994e 100644 --- a/inline/style.go +++ b/inline/style.go @@ -24,16 +24,36 @@ const ( // Style describes text appearance; zero value is unstyled. // Composable: inline.Fg(color.Amber).Bold().Underline() type Style struct { - fg, bg color.RGB - hasFg, hasBg bool - attr Attribute + fg, bg paint + 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 -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 -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 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) } } - if s.hasFg { - if p.mode == 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", 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)) - } - } + p.writeColor(b, s.fg, 30) + p.writeColor(b, s.bg, 40) 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) --- // visibleLen counts runes excluding SGR sequences diff --git a/inline/style_test.go b/inline/style_test.go new file mode 100644 index 0000000..f3c0f8c --- /dev/null +++ b/inline/style_test.go @@ -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) + } + } +} diff --git a/inline/sys_unix.go b/inline/sys_unix.go index 41110f4..0e53a77 100644 --- a/inline/sys_unix.go +++ b/inline/sys_unix.go @@ -10,6 +10,9 @@ import ( ) func detectColorMode() colorMode { + if textConsole() { + return colorMode16 + } colorterm := os.Getenv("COLORTERM") if colorterm == "truecolor" || colorterm == "24bit" { return colorModeTrueColor diff --git a/inline/sys_unsupported.go b/inline/sys_unsupported.go index a7f094c..081389d 100644 --- a/inline/sys_unsupported.go +++ b/inline/sys_unsupported.go @@ -5,6 +5,9 @@ package inline import "os" func detectColorMode() colorMode { + if textConsole() { + return colorMode16 + } return colorMode256 } diff --git a/inline/sys_windows.go b/inline/sys_windows.go index b5e56f1..bbc97f5 100644 --- a/inline/sys_windows.go +++ b/inline/sys_windows.go @@ -9,6 +9,9 @@ import ( ) func detectColorMode() colorMode { + if textConsole() { + return colorMode16 + } if os.Getenv("WT_SESSION") != "" || os.Getenv("WT_PROFILE_ID") != "" { return colorModeTrueColor }