From cb7802c5a0c11bb05d86f34f24cd02ccac223a8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:09:02 +0000 Subject: [PATCH 1/2] feat!: rewrite color engine with terminal detection and downsampling Replaces the Mode/Style API with a single Color value type, a Style value with attributes, per-writer level detection (TTY, FORCE_COLOR, NO_COLOR, COLORTERM, TERM, Windows VT), automatic downsampling to 256 and 16 colors, background color detection (COLORFGBG and OSC 11), Strip and Hyperlink. Drops the predefined styles. Requires Go 1.27. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011Dno7CsMx2fSzNx5ZTm7RL --- background.go | 89 +++++ background_other.go | 10 + background_unix.go | 74 +++++ color-ansi.go | 35 -- color-ansi256.go | 274 ---------------- color-ansi_windows.go | 18 - color-rgb.go | 64 ---- color.go | 257 ++++++++++++++- color_test.go | 744 +++++++++--------------------------------- console_other.go | 10 + console_windows.go | 70 ++++ detect.go | 184 +++++++++++ doc.go | 51 ++- downsample.go | 104 ++++++ examples_test.go | 73 +++-- go.mod | 7 +- go.sum | 6 +- level.go | 95 ++++++ mode.go | 28 -- modifier.go | 22 -- noColor.go | 10 - style.go | 246 ++++++++++---- style_test.go | 14 - styles.go | 34 -- 24 files changed, 1324 insertions(+), 1195 deletions(-) create mode 100644 background.go create mode 100644 background_other.go create mode 100644 background_unix.go delete mode 100644 color-ansi.go delete mode 100644 color-ansi256.go delete mode 100644 color-ansi_windows.go delete mode 100644 color-rgb.go create mode 100644 console_other.go create mode 100644 console_windows.go create mode 100644 detect.go create mode 100644 downsample.go create mode 100644 level.go delete mode 100644 mode.go delete mode 100644 modifier.go delete mode 100644 noColor.go delete mode 100644 style_test.go delete mode 100644 styles.go diff --git a/background.go b/background.go new file mode 100644 index 0000000..18481dc --- /dev/null +++ b/background.go @@ -0,0 +1,89 @@ +package color + +import ( + "os" + "strconv" + "strings" + "time" +) + +// Background returns the background color of the terminal, for example to +// choose between a dark and a light theme with Color.IsDark. +// +// It reads COLORFGBG first. When that is unset it asks the terminal with an +// OSC 11 query and waits at most timeout for the answer. The query needs a +// controlling terminal on Unix; on Windows only COLORFGBG is read. The bool +// is false when the background could not be found out. +// +// The query reads from the terminal, so call it before reading user input, +// not while another goroutine does. Keys pressed during the query are lost. +func Background(timeout time.Duration) (Color, bool) { + if c, ok := parseColorFgBg(os.Getenv("COLORFGBG")); ok { + return c, true + } + + return queryBackground(timeout) +} + +// parseColorFgBg reads the background from COLORFGBG, which rxvt, Konsole +// and others set to "fg;bg" or "fg;default;bg" with basic color indexes. +func parseColorFgBg(v string) (Color, bool) { + if v == "" { + return None, false + } + + parts := strings.Split(v, ";") + + n, err := strconv.ParseUint(parts[len(parts)-1], 10, 8) + if err != nil || n > 15 { + return None, false + } + + return Basic(uint8(n)), true +} + +// parseOSC11 extracts the color from a terminal's answer to an OSC 11 query, +// such as "\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\". Each channel has 1 to 4 hex +// digits. +func parseOSC11(resp string) (Color, bool) { + _, after, ok := strings.Cut(resp, "]11;") + if !ok { + return None, false + } + + end := strings.IndexAny(after, "\x07\x1b") + if end < 0 { + return None, false + } + + spec := after[:end] + if !strings.HasPrefix(spec, "rgb:") && !strings.HasPrefix(spec, "rgba:") { + return None, false + } + + _, spec, _ = strings.Cut(spec, ":") + + channels := strings.Split(spec, "/") + if len(channels) < 3 { + return None, false + } + + var rgb [3]uint8 + + for i := range 3 { + ch := channels[i] + if ch == "" || len(ch) > 4 { + return None, false + } + + v, err := strconv.ParseUint(ch, 16, 16) + if err != nil { + return None, false + } + + maxValue := uint64(1)<<(4*len(ch)) - 1 + rgb[i] = uint8(v * 255 / maxValue) //nolint:gosec // scaled into 0-255. + } + + return RGB(rgb[0], rgb[1], rgb[2]), true +} diff --git a/background_other.go b/background_other.go new file mode 100644 index 0000000..248dfe2 --- /dev/null +++ b/background_other.go @@ -0,0 +1,10 @@ +//go:build !unix + +package color + +import "time" + +// queryBackground has no terminal query on this platform. +func queryBackground(time.Duration) (Color, bool) { + return None, false +} diff --git a/background_unix.go b/background_unix.go new file mode 100644 index 0000000..8cf2981 --- /dev/null +++ b/background_unix.go @@ -0,0 +1,74 @@ +//go:build unix + +package color + +import ( + "os" + "strings" + "time" + + "golang.org/x/sys/unix" + "golang.org/x/term" +) + +// queryBackground asks the terminal for its background color with OSC 11. +// A primary device attributes query (DA1) follows it. Every terminal answers +// DA1, so when that answer arrives without an OSC 11 answer before it, the +// terminal does not support the query and there is no need to wait for the +// timeout. +func queryBackground(timeout time.Duration) (Color, bool) { + tty, err := os.OpenFile("/dev/tty", os.O_RDWR|unix.O_NOCTTY, 0) + if err != nil { + return None, false + } + defer tty.Close() //nolint:errcheck // read-only use, nothing to flush. + + fd := int(tty.Fd()) + + state, err := term.MakeRaw(fd) + if err != nil { + return None, false + } + defer term.Restore(fd, state) //nolint:errcheck // nothing to do if restoring fails. + + if _, err := tty.WriteString("\x1b]11;?\x1b\\\x1b[c"); err != nil { + return None, false + } + + deadline := time.Now().Add(timeout) + + var resp strings.Builder + + buf := make([]byte, 256) + + for { + left := time.Until(deadline) + if left <= 0 { + return None, false + } + + fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} //nolint:gosec // fd fits in int32. + + n, err := unix.Poll(fds, int(left.Milliseconds())+1) + if err != nil && err != unix.EINTR { + return None, false + } + + if n <= 0 { + continue + } + + read, err := unix.Read(fd, buf) + if err != nil || read <= 0 { + return None, false + } + + resp.Write(buf[:read]) + + // The DA1 answer ends the exchange: "\x1b[?...c". + s := resp.String() + if i := strings.LastIndex(s, "\x1b[?"); i >= 0 && strings.HasSuffix(s, "c") { + return parseOSC11(s[:i]) + } + } +} diff --git a/color-ansi.go b/color-ansi.go deleted file mode 100644 index ae0aa08..0000000 --- a/color-ansi.go +++ /dev/null @@ -1,35 +0,0 @@ -package color - -import "fmt" - -// ANSI color constants define the standard foreground color palette. -const ( - ANSIBlack ANSIColor = iota - ANSIRed - ANSIGreen - ANSIYellow - ANSIBlue - ANSIMagenta - ANSICyan - ANSIWhite - ANSIBrightBlack - ANSIBrightRed - ANSIBrightGreen - ANSIBrightYellow - ANSIBrightBlue - ANSIBrightMagenta - ANSIBrightCyan - ANSIBrightWhite -) - -// ANSIColor represents an ANSI color code. -type ANSIColor int - -// Sequence represents the ANSI escape sequence for the color. -func (c ANSIColor) Sequence(background bool) string { - if background { - return fmt.Sprintf("\033[48;5;%dm", c) - } - - return fmt.Sprintf("\033[38;5;%dm", c) -} diff --git a/color-ansi256.go b/color-ansi256.go deleted file mode 100644 index ced2e0e..0000000 --- a/color-ansi256.go +++ /dev/null @@ -1,274 +0,0 @@ -package color - -// ANSI256Color represents a color in the ANSI256 color palette. -type ANSI256Color uint8 - -// String returns the hex code of the color. -func (c ANSI256Color) String() string { - return ansi256Table[c] -} - -// Sequence returns the ANSI escape sequence for the color. -func (c ANSI256Color) Sequence(background bool) string { - return NewColorFromHex(c.String()).Sequence(background) -} - -// Hex codes for ANSI256 colors (0-255) -var ansi256Table = []string{ - "#000000", - "#800000", - "#008000", - "#808000", - "#000080", - "#800080", - "#008080", - "#c0c0c0", - "#808080", - "#ff0000", - "#00ff00", - "#ffff00", - "#0000ff", - "#ff00ff", - "#00ffff", - "#ffffff", - "#000000", - "#00005f", - "#000087", - "#0000af", - "#0000d7", - "#0000ff", - "#005f00", - "#005f5f", - "#005f87", - "#005faf", - "#005fd7", - "#005fff", - "#008700", - "#00875f", - "#008787", - "#0087af", - "#0087d7", - "#0087ff", - "#00af00", - "#00af5f", - "#00af87", - "#00afaf", - "#00afd7", - "#00afff", - "#00d700", - "#00d75f", - "#00d787", - "#00d7af", - "#00d7d7", - "#00d7ff", - "#00ff00", - "#00ff5f", - "#00ff87", - "#00ffaf", - "#00ffd7", - "#00ffff", - "#5f0000", - "#5f005f", - "#5f0087", - "#5f00af", - "#5f00d7", - "#5f00ff", - "#5f5f00", - "#5f5f5f", - "#5f5f87", - "#5f5faf", - "#5f5fd7", - "#5f5fff", - "#5f8700", - "#5f875f", - "#5f8787", - "#5f87af", - "#5f87d7", - "#5f87ff", - "#5faf00", - "#5faf5f", - "#5faf87", - "#5fafaf", - "#5fafd7", - "#5fafff", - "#5fd700", - "#5fd75f", - "#5fd787", - "#5fd7af", - "#5fd7d7", - "#5fd7ff", - "#5fff00", - "#5fff5f", - "#5fff87", - "#5fffaf", - "#5fffd7", - "#5fffff", - "#870000", - "#87005f", - "#870087", - "#8700af", - "#8700d7", - "#8700ff", - "#875f00", - "#875f5f", - "#875f87", - "#875faf", - "#875fd7", - "#875fff", - "#878700", - "#87875f", - "#878787", - "#8787af", - "#8787d7", - "#8787ff", - "#87af00", - "#87af5f", - "#87af87", - "#87afaf", - "#87afd7", - "#87afff", - "#87d700", - "#87d75f", - "#87d787", - "#87d7af", - "#87d7d7", - "#87d7ff", - "#87ff00", - "#87ff5f", - "#87ff87", - "#87ffaf", - "#87ffd7", - "#87ffff", - "#af0000", - "#af005f", - "#af0087", - "#af00af", - "#af00d7", - "#af00ff", - "#af5f00", - "#af5f5f", - "#af5f87", - "#af5faf", - "#af5fd7", - "#af5fff", - "#af8700", - "#af875f", - "#af8787", - "#af87af", - "#af87d7", - "#af87ff", - "#afaf00", - "#afaf5f", - "#afaf87", - "#afafaf", - "#afafd7", - "#afafff", - "#afd700", - "#afd75f", - "#afd787", - "#afd7af", - "#afd7d7", - "#afd7ff", - "#afff00", - "#afff5f", - "#afff87", - "#afffaf", - "#afffd7", - "#afffff", - "#d70000", - "#d7005f", - "#d70087", - "#d700af", - "#d700d7", - "#d700ff", - "#d75f00", - "#d75f5f", - "#d75f87", - "#d75faf", - "#d75fd7", - "#d75fff", - "#d78700", - "#d7875f", - "#d78787", - "#d787af", - "#d787d7", - "#d787ff", - "#d7af00", - "#d7af5f", - "#d7af87", - "#d7afaf", - "#d7afd7", - "#d7afff", - "#d7d700", - "#d7d75f", - "#d7d787", - "#d7d7af", - "#d7d7d7", - "#d7d7ff", - "#d7ff00", - "#d7ff5f", - "#d7ff87", - "#d7ffaf", - "#d7ffd7", - "#d7ffff", - "#ff0000", - "#ff005f", - "#ff0087", - "#ff00af", - "#ff00d7", - "#ff00ff", - "#ff5f00", - "#ff5f5f", - "#ff5f87", - "#ff5faf", - "#ff5fd7", - "#ff5fff", - "#ff8700", - "#ff875f", - "#ff8787", - "#ff87af", - "#ff87d7", - "#ff87ff", - "#ffaf00", - "#ffaf5f", - "#ffaf87", - "#ffafaf", - "#ffafd7", - "#ffafff", - "#ffd700", - "#ffd75f", - "#ffd787", - "#ffd7af", - "#ffd7d7", - "#ffd7ff", - "#ffff00", - "#ffff5f", - "#ffff87", - "#ffffaf", - "#ffffd7", - "#ffffff", - "#080808", - "#121212", - "#1c1c1c", - "#262626", - "#303030", - "#3a3a3a", - "#444444", - "#4e4e4e", - "#585858", - "#626262", - "#6c6c6c", - "#767676", - "#808080", - "#8a8a8a", - "#949494", - "#9e9e9e", - "#a8a8a8", - "#b2b2b2", - "#bcbcbc", - "#c6c6c6", - "#d0d0d0", - "#dadada", - "#e4e4e4", - "#eeeeee", -} diff --git a/color-ansi_windows.go b/color-ansi_windows.go deleted file mode 100644 index 2cc0951..0000000 --- a/color-ansi_windows.go +++ /dev/null @@ -1,18 +0,0 @@ -package color - -import ( - "golang.org/x/sys/windows" -) - -func init() { - handle, _ := windows.GetStdHandle(windows.STD_OUTPUT_HANDLE) - - var mode uint32 - windows.GetConsoleMode(handle, &mode) - - // Reference: https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences - if mode&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING { - vtpmode := mode | windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING - windows.SetConsoleMode(handle, vtpmode) - } -} diff --git a/color-rgb.go b/color-rgb.go deleted file mode 100644 index 32aae4b..0000000 --- a/color-rgb.go +++ /dev/null @@ -1,64 +0,0 @@ -package color - -import ( - "fmt" - "strconv" - "strings" -) - -// RGBColor represents a color in the RGB color space. -type RGBColor struct { - R, G, B uint8 -} - -// Hex returns the hex representation of the color. -func (c RGBColor) Hex() string { - return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) -} - -// NewColorFromRGB creates a new Color from RGB values. -func NewColorFromRGB(r, g, b uint8) Color { - return RGBColor{r, g, b} -} - -// NewColorFromHex creates a new Color from a hex string. -// Accepts both the 6-digit form ("#RRGGBB"/"RRGGBB") and the 3-digit -// shorthand ("#RGB"/"RGB"). If the hex string is invalid, NoColor is returned. -func NewColorFromHex(hex string) Color { - hex = strings.TrimPrefix(hex, "#") - - // Expand 3-digit shorthand (e.g. "f0a" -> "ff00aa"). - if len(hex) == 3 { - hex = string([]byte{hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]}) - } - - if len(hex) != 6 { - return NoColor - } - - r, err := strconv.ParseUint(hex[0:2], 16, 8) - if err != nil { - return NoColor - } - - g, err := strconv.ParseUint(hex[2:4], 16, 8) - if err != nil { - return NoColor - } - - b, err := strconv.ParseUint(hex[4:6], 16, 8) - if err != nil { - return NoColor - } - - return NewColorFromRGB(uint8(r), uint8(g), uint8(b)) -} - -// Sequence returns the ANSI escape sequence for the color. -func (c RGBColor) Sequence(background bool) string { - if background { - return fmt.Sprintf("\033[48;2;%d;%d;%dm", c.R, c.G, c.B) - } - - return fmt.Sprintf("\033[38;2;%d;%d;%dm", c.R, c.G, c.B) -} diff --git a/color.go b/color.go index c163d0f..f33537a 100644 --- a/color.go +++ b/color.go @@ -1,14 +1,257 @@ package color import ( - "io" - "os" + "errors" + "fmt" + "math" + "strconv" + "strings" ) -// Writer is the writer to write colorized output to. -var Writer io.Writer = os.Stdout +// kind tells which palette a Color comes from. +type kind uint8 -// Color is an interface for colors. -type Color interface { - Sequence(background bool) string +const ( + kindNone kind = iota + kindBasic + kind256 + kindRGB +) + +// Color is a single terminal color: one of the 16 basic ANSI colors, an +// entry of the xterm 256-color palette, or a 24-bit RGB value. +// +// The zero value is None, which renders nothing. Colors are comparable and +// safe to copy. +type Color struct { + kind kind + r, g, b uint8 // RGB value, or the palette index in r for basic and 256 +} + +// None is the absent color. Styles skip it, so a Style with a None +// background leaves the terminal's background alone. +var None = Color{} + +// The 16 basic ANSI colors. Terminals render them from their own palette, +// so they follow the user's terminal theme. +var ( + Black = Basic(0) + Red = Basic(1) + Green = Basic(2) + Yellow = Basic(3) + Blue = Basic(4) + Magenta = Basic(5) + Cyan = Basic(6) + White = Basic(7) + BrightBlack = Basic(8) + BrightRed = Basic(9) + BrightGreen = Basic(10) + BrightYellow = Basic(11) + BrightBlue = Basic(12) + BrightMagenta = Basic(13) + BrightCyan = Basic(14) + BrightWhite = Basic(15) +) + +var basicNames = [16]string{ + "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", + "brightblack", "brightred", "brightgreen", "brightyellow", + "brightblue", "brightmagenta", "brightcyan", "brightwhite", +} + +// ErrInvalidColor is returned by Parse and Hex for input that names no color. +var ErrInvalidColor = errors.New("color: invalid color") + +// Basic returns basic ANSI color n (0-15). Values above 15 wrap around. +func Basic(n uint8) Color { + return Color{kind: kindBasic, r: n % 16} +} + +// Index returns entry n of the xterm 256-color palette. +func Index(n uint8) Color { + return Color{kind: kind256, r: n} +} + +// RGB returns a 24-bit color. Terminals without true color support get the +// closest color they can show. +func RGB(r, g, b uint8) Color { + return Color{kind: kindRGB, r: r, g: g, b: b} +} + +// Hex parses "#rrggbb", "rrggbb", "#rgb" or "rgb" into an RGB color. +func Hex(s string) (Color, error) { + h := strings.TrimPrefix(strings.TrimSpace(s), "#") + if len(h) == 3 { + h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]}) + } + + if len(h) != 6 { + return None, fmt.Errorf("%w: %q", ErrInvalidColor, s) + } + + v, err := strconv.ParseUint(h, 16, 32) + if err != nil { + return None, fmt.Errorf("%w: %q", ErrInvalidColor, s) + } + + return RGB(uint8(v>>16), uint8(v>>8), uint8(v)), nil //nolint:gosec // v has 24 bits, each shift keeps one byte. +} + +// MustHex is like Hex but panics on invalid input. Use it for constants in +// your own code, never for user input. +func MustHex(s string) Color { + c, err := Hex(s) + if err != nil { + panic(err) + } + + return c +} + +// Parse reads a color the way a user would write it in a config file or an +// environment variable: +// +// - a hex value: "#b7bdf8", "b7bdf8", "#fff" +// - a basic color name: "red", "brightblue", "bright-blue", "bright_blue" +// - a 256-color palette index: "0" to "255" +// - "none" or "" for None +func Parse(s string) (Color, error) { + s = strings.ToLower(strings.TrimSpace(s)) + switch s { + case "", "none", "default": + return None, nil + } + + if isDigits(s) { + n, err := strconv.ParseUint(s, 10, 8) + if err != nil { + return None, fmt.Errorf("%w: %q is not a palette index (0-255)", ErrInvalidColor, s) + } + + return Index(uint8(n)), nil + } + + name := strings.NewReplacer("-", "", "_", "", " ", "").Replace(s) + if name == "gray" || name == "grey" { + name = "brightblack" + } + + for i, n := range basicNames { + if name == n { + return Basic(uint8(i)), nil + } + } + + return Hex(s) +} + +func isDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + + return s != "" +} + +// IsNone reports whether c is the absent color. +func (c Color) IsNone() bool { + return c.kind == kindNone +} + +// RGB returns the 24-bit value of c. Basic colors report the xterm default +// palette, because the real value depends on the user's terminal theme. +// None reports black. +func (c Color) RGB() (r, g, b uint8) { + switch c.kind { + case kindRGB: + return c.r, c.g, c.b + case kindBasic: + p := basicPalette[c.r] + return uint8(p[0]), uint8(p[1]), uint8(p[2]) //nolint:gosec // palette values are bytes. + case kind256: + return index256ToRGB(c.r) + default: + return 0, 0, 0 + } +} + +// Hex returns c as "#rrggbb". See Color.RGB for basic and palette colors. +func (c Color) Hex() string { + r, g, b := c.RGB() + return fmt.Sprintf("#%02x%02x%02x", r, g, b) +} + +// String describes c in the format Parse accepts. +func (c Color) String() string { + switch c.kind { + case kindBasic: + return basicNames[c.r] + case kind256: + return strconv.Itoa(int(c.r)) + case kindRGB: + return c.Hex() + default: + return "none" + } +} + +// Mix blends c toward other. t=0 returns c, t=1 returns other, and values in +// between interpolate in RGB space. Use it for gradients and for tints of a +// theme color. The result is always an RGB color. +func (c Color) Mix(other Color, t float64) Color { + t = min(max(t, 0), 1) + r1, g1, b1 := c.RGB() + r2, g2, b2 := other.RGB() + + lerp := func(a, b uint8) uint8 { + return uint8(float64(a) + (float64(b)-float64(a))*t + 0.5) + } + + return RGB(lerp(r1, r2), lerp(g1, g2), lerp(b1, b2)) +} + +// Luminance returns the relative luminance of c from 0 (black) to 1 (white), +// as defined by WCAG. +func (c Color) Luminance() float64 { + r, g, b := c.RGB() + + return 0.2126*linear(r) + 0.7152*linear(g) + 0.0722*linear(b) +} + +// IsDark reports whether c is a dark color. Use it to pick readable text on +// top of c, or to tell a dark terminal background from a light one. +func (c Color) IsDark() bool { + return c.Luminance() < 0.2 +} + +func linear(v uint8) float64 { + f := float64(v) / 255 + if f <= 0.04045 { + return f / 12.92 + } + + return math.Pow((f+0.055)/1.055, 2.4) +} + +// Fg returns a Style with c as its foreground. +func (c Color) Fg() Style { + return Style{Foreground: c} +} + +// Bg returns a Style with c as its background. +func (c Color) Bg() Style { + return Style{Background: c} +} + +// Sprint colors the text with c as foreground for standard output. +func (c Color) Sprint(a ...any) string { + return c.Fg().Sprint(a...) +} + +// Sprintf formats and colors the text with c as foreground for standard +// output. +func (c Color) Sprintf(format string, a ...any) string { + return c.Fg().Sprintf(format, a...) } diff --git a/color_test.go b/color_test.go index d7b1411..ca18c72 100644 --- a/color_test.go +++ b/color_test.go @@ -2,691 +2,247 @@ package color import ( "bytes" - "fmt" - "strings" + "errors" "testing" ) -func TestANSIColor_Sequence(t *testing.T) { +func TestParse(t *testing.T) { t.Parallel() tests := []struct { - name string - color ANSIColor - background bool - want string + in string + want Color }{ - {"black foreground", ANSIBlack, false, "\033[38;5;0m"}, - {"red foreground", ANSIRed, false, "\033[38;5;1m"}, - {"white foreground", ANSIWhite, false, "\033[38;5;7m"}, - {"bright black foreground", ANSIBrightBlack, false, "\033[38;5;8m"}, - {"bright white foreground", ANSIBrightWhite, false, "\033[38;5;15m"}, - {"black background", ANSIBlack, true, "\033[48;5;0m"}, - {"red background", ANSIRed, true, "\033[48;5;1m"}, - {"bright white background", ANSIBrightWhite, true, "\033[48;5;15m"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if got := tc.color.Sequence(tc.background); got != tc.want { - t.Errorf("Sequence(%v) = %q, want %q", tc.background, got, tc.want) - } - }) - } -} - -func TestANSIColor_ImplementsColor(t *testing.T) { - t.Parallel() - - var _ Color = ANSIRed -} - -func TestANSI256Color_String(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - color ANSI256Color - want string - }{ - {"first color", ANSI256Color(0), "#000000"}, - {"red", ANSI256Color(9), "#ff0000"}, - {"bright red in 256", ANSI256Color(196), "#ff0000"}, - {"last grayscale", ANSI256Color(255), "#eeeeee"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if got := tc.color.String(); got != tc.want { - t.Errorf("String() = %q, want %q", got, tc.want) - } - }) - } -} - -func TestANSI256Color_Sequence(t *testing.T) { - t.Parallel() + {"#b7bdf8", RGB(0xb7, 0xbd, 0xf8)}, + {"B7BDF8", RGB(0xb7, 0xbd, 0xf8)}, + {"#fff", RGB(255, 255, 255)}, + {"red", Red}, + {"Bright-Blue", BrightBlue}, + {"bright_white", BrightWhite}, + {"gray", BrightBlack}, + {"208", Index(208)}, + {"0", Index(0)}, + {"", None}, + {"none", None}, + } + + for _, tt := range tests { + got, err := Parse(tt.in) + if err != nil { + t.Errorf("Parse(%q) error: %v", tt.in, err) + continue + } - tests := []struct { - name string - color ANSI256Color - background bool - want string - }{ - {"red foreground", ANSI256Color(9), false, "\033[38;2;255;0;0m"}, - {"red background", ANSI256Color(9), true, "\033[48;2;255;0;0m"}, - {"black foreground", ANSI256Color(0), false, "\033[38;2;0;0;0m"}, + if got != tt.want { + t.Errorf("Parse(%q) = %v, want %v", tt.in, got, tt.want) + } } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if got := tc.color.Sequence(tc.background); got != tc.want { - t.Errorf("Sequence(%v) = %q, want %q", tc.background, got, tc.want) - } - }) + for _, bad := range []string{"#12", "purple-ish", "256", "#ggg"} { + if _, err := Parse(bad); !errors.Is(err, ErrInvalidColor) { + t.Errorf("Parse(%q) error = %v, want ErrInvalidColor", bad, err) + } } } -func TestANSI256Color_ImplementsColor(t *testing.T) { +func TestColorString(t *testing.T) { t.Parallel() - var _ Color = ANSI256Color(0) -} - -func TestANSI256Color_TableCoverage(t *testing.T) { - t.Parallel() - - // Every entry should be a valid 7-char hex string. This guards against - // accidental corruption of the lookup table. - for i, hex := range ansi256Table { - if len(hex) != 7 || hex[0] != '#' { - t.Errorf("ansi256Table[%d] = %q, want 7-char \"#RRGGBB\"", i, hex) + for _, c := range []Color{Red, BrightCyan, Index(42), RGB(1, 2, 3), None} { + back, err := Parse(c.String()) + if err != nil || back != c { + t.Errorf("Parse(%q) = %v, %v; want %v", c.String(), back, err, c) } } } -func TestRGBColor_Hex(t *testing.T) { +func TestSequenceDownsamples(t *testing.T) { t.Parallel() + lavender := RGB(0xb7, 0xbd, 0xf8) + tests := []struct { name string - color RGBColor + style Style + level Level want string }{ - {"black", RGBColor{0, 0, 0}, "#000000"}, - {"white", RGBColor{255, 255, 255}, "#FFFFFF"}, - {"red", RGBColor{255, 0, 0}, "#FF0000"}, - {"green", RGBColor{0, 255, 0}, "#00FF00"}, - {"blue", RGBColor{0, 0, 255}, "#0000FF"}, - {"mid gray", RGBColor{128, 128, 128}, "#808080"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if got := tc.color.Hex(); got != tc.want { - t.Errorf("Hex() = %q, want %q", got, tc.want) - } - }) - } -} - -func TestRGBColor_Sequence(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - color RGBColor - background bool - want string - }{ - {"red foreground", RGBColor{255, 0, 0}, false, "\033[38;2;255;0;0m"}, - {"red background", RGBColor{255, 0, 0}, true, "\033[48;2;255;0;0m"}, - {"black foreground", RGBColor{0, 0, 0}, false, "\033[38;2;0;0;0m"}, - {"white background", RGBColor{255, 255, 255}, true, "\033[48;2;255;255;255m"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if got := tc.color.Sequence(tc.background); got != tc.want { - t.Errorf("Sequence(%v) = %q, want %q", tc.background, got, tc.want) - } - }) - } -} - -func TestRGBColor_ImplementsColor(t *testing.T) { - t.Parallel() - - var _ Color = RGBColor{} -} - -func TestNewColorFromRGB(t *testing.T) { - t.Parallel() - - c := NewColorFromRGB(10, 20, 30) - - rgb, ok := c.(RGBColor) - if !ok { - t.Fatalf("NewColorFromRGB returned %T, want RGBColor", c) - } - - if rgb.R != 10 || rgb.G != 20 || rgb.B != 30 { - t.Errorf("NewColorFromRGB = {%d, %d, %d}, want {10, 20, 30}", rgb.R, rgb.G, rgb.B) + {"truecolor", lavender.Fg(), LevelTrueColor, "\x1b[38;2;183;189;248m"}, + {"256", lavender.Fg(), Level256, "\x1b[38;5;147m"}, + {"basic", lavender.Fg(), LevelBasic, "\x1b[37m"}, + {"none", lavender.Fg(), LevelNone, ""}, + {"basic color stays basic", Red.Fg(), LevelTrueColor, "\x1b[31m"}, + {"bright background", BrightBlue.Bg(), LevelBasic, "\x1b[104m"}, + {"index at truecolor", Index(208).Fg(), LevelTrueColor, "\x1b[38;5;208m"}, + {"index at basic", Index(196).Fg(), LevelBasic, "\x1b[91m"}, + {"attrs and colors", New(Bold, Underline).Fg(Green).Bg(Black), LevelBasic, "\x1b[1;4;32;40m"}, + {"zero", Style{}, LevelTrueColor, ""}, + } + + for _, tt := range tests { + if got := tt.style.Sequence(tt.level); got != tt.want { + t.Errorf("%s: Sequence = %q, want %q", tt.name, got, tt.want) + } } } -func TestNewColorFromHex_Valid(t *testing.T) { +func TestRenderNests(t *testing.T) { t.Parallel() - tests := []struct { - name string - hex string - want RGBColor - }{ - {"with hash", "#ff0000", RGBColor{255, 0, 0}}, - {"without hash", "00ff00", RGBColor{0, 255, 0}}, - {"mixed case", "#AaBbCc", RGBColor{0xAA, 0xBB, 0xCC}}, - {"black", "#000000", RGBColor{0, 0, 0}}, - {"white", "#ffffff", RGBColor{255, 255, 255}}, - {"shorthand 3-digit", "#f0a", RGBColor{0xFF, 0x00, 0xAA}}, - {"shorthand without hash", "abc", RGBColor{0xAA, 0xBB, 0xCC}}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - c := NewColorFromHex(tc.hex) + inner := Red.Fg().Render(LevelBasic, "inner") + got := New(Bold).Render(LevelBasic, "a "+inner+" b") + want := "\x1b[1ma \x1b[31minner\x1b[0m\x1b[1m b\x1b[0m" - rgb, ok := c.(RGBColor) - if !ok { - t.Fatalf("NewColorFromHex(%q) returned %T, want RGBColor", tc.hex, c) - } - - if rgb != tc.want { - t.Errorf("NewColorFromHex(%q) = %+v, want %+v", tc.hex, rgb, tc.want) - } - }) + if got != want { + t.Errorf("Render = %q, want %q", got, want) } } -func TestNewColorFromHex_Invalid(t *testing.T) { +func TestRenderPerLine(t *testing.T) { t.Parallel() - tests := []struct { - name string - hex string - }{ - {"empty string", ""}, - {"only hash", "#"}, - {"too short", "#12"}, - {"4 chars", "#1234"}, - {"5 chars", "#12345"}, - {"too long", "#1234567"}, - {"non-hex chars", "#gggggg"}, - {"non-hex in green", "#00zz00"}, - {"non-hex in blue", "#0000zz"}, - {"non-hex 3-digit", "#xyz"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() + got := Red.Bg().Render(LevelBasic, "a\n\nb") + want := "\x1b[41ma\x1b[0m\n\n\x1b[41mb\x1b[0m" - if got := NewColorFromHex(tc.hex); got != NoColor { - t.Errorf("NewColorFromHex(%q) = %v, want NoColor", tc.hex, got) - } - }) + if got != want { + t.Errorf("Render = %q, want %q", got, want) } -} -func TestNewColorFromHex_RoundTrip(t *testing.T) { - t.Parallel() - - original := RGBColor{R: 0x12, G: 0x34, B: 0x56} - - c := NewColorFromHex(original.Hex()) - - rgb, ok := c.(RGBColor) - if !ok { - t.Fatalf("round-trip returned %T, want RGBColor", c) - } - - if rgb != original { - t.Errorf("round-trip = %+v, want %+v", rgb, original) + if got := Red.Fg().Render(LevelNone, "a"); got != "a" { + t.Errorf("Render at LevelNone = %q, want plain text", got) } } -func TestNoColor_Sequence(t *testing.T) { +func TestStrip(t *testing.T) { t.Parallel() - if got := NoColor.Sequence(false); got != "" { - t.Errorf("NoColor.Sequence(false) = %q, want empty", got) - } - - if got := NoColor.Sequence(true); got != "" { - t.Errorf("NoColor.Sequence(true) = %q, want empty", got) + styled := New(Bold).Fg(Red).Render(LevelTrueColor, "hi") + Hyperlink(LevelBasic, "https://pterm.sh", "link") + "\x1b[2K" + if got := Strip(styled); got != "hilink" { + t.Errorf("Strip = %q, want %q", got, "hilink") } } -func TestModifier_Sequence(t *testing.T) { +func TestDetect(t *testing.T) { t.Parallel() - tests := []struct { - name string - modifier Modifier - want string - }{ - {"reset", Reset, "\033[0m"}, - {"bold", Bold, "\033[1m"}, - {"faint", Faint, "\033[2m"}, - {"italic", Italic, "\033[3m"}, - {"underline", Underline, "\033[4m"}, + env := func(vars map[string]string) func(string) string { + return func(k string) string { return vars[k] } } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if got := tc.modifier.Sequence(); got != tc.want { - t.Errorf("Sequence() = %q, want %q", got, tc.want) - } - }) - } -} - -func TestMode_String(t *testing.T) { - t.Parallel() - tests := []struct { name string - mode Mode - want string + vars map[string]string + tty bool + want Level }{ - {"TrueColor", TrueColor, "TrueColor"}, - {"ANSI256", ANSI256, "ANSI256"}, - {"ANSI", ANSI, "ANSI"}, - {"Disabled", Disabled, "Disabled"}, - {"unknown", Mode(99), "Unknown"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if got := tc.mode.String(); got != tc.want { - t.Errorf("String() = %q, want %q", got, tc.want) - } - }) - } -} - -func TestNewStyle_NilColorsBecomeNoColor(t *testing.T) { - t.Parallel() - - s := NewStyle(nil, nil) - - if s.Foreground != NoColor { - t.Errorf("Foreground = %v, want NoColor", s.Foreground) - } - - if s.Background != NoColor { - t.Errorf("Background = %v, want NoColor", s.Background) - } - - if len(s.Modifiers) != 0 { - t.Errorf("Modifiers = %v, want empty", s.Modifiers) - } -} - -func TestNewStyle_WithColorsAndModifiers(t *testing.T) { - t.Parallel() - - s := NewStyle(ANSIRed, ANSIBlue, Bold, Underline) - - if s.Foreground != ANSIRed { - t.Errorf("Foreground = %v, want ANSIRed", s.Foreground) - } - - if s.Background != ANSIBlue { - t.Errorf("Background = %v, want ANSIBlue", s.Background) - } - - if len(s.Modifiers) != 2 || s.Modifiers[0] != Bold || s.Modifiers[1] != Underline { - t.Errorf("Modifiers = %v, want [Bold, Underline]", s.Modifiers) - } -} - -func TestStyle_AddModifier_Deduplicates(t *testing.T) { - t.Parallel() - - s := NewStyle(nil, nil) - s.AddModifier(Bold) - s.AddModifier(Bold) - s.AddModifier(Italic) - s.AddModifier(Italic) - - if len(s.Modifiers) != 2 { - t.Errorf("Modifiers = %v, want [Bold, Italic]", s.Modifiers) - } -} - -func TestStyle_WithModifier(t *testing.T) { - t.Parallel() - - base := NewStyle(nil, nil) - extended := base.WithModifier(Bold) - - if len(base.Modifiers) != 0 { - t.Errorf("WithModifier mutated receiver: %v", base.Modifiers) - } - - if len(extended.Modifiers) != 1 || extended.Modifiers[0] != Bold { - t.Errorf("extended.Modifiers = %v, want [Bold]", extended.Modifiers) - } -} - -func TestStyle_Sequence(t *testing.T) { - t.Parallel() - - s := NewStyle(ANSIRed, ANSIBlue, Bold) - - want := ANSIRed.Sequence(false) + ANSIBlue.Sequence(true) + Bold.Sequence() - if got := s.Sequence(); got != want { - t.Errorf("Sequence() = %q, want %q", got, want) - } -} - -func TestStyle_Sequence_NoColors(t *testing.T) { - t.Parallel() - - s := NewStyle(nil, nil, Bold) - - // NoColor contributes no sequence, so only Bold is emitted. - if got, want := s.Sequence(), Bold.Sequence(); got != want { - t.Errorf("Sequence() = %q, want %q", got, want) - } -} - -func TestStyle_Sprint(t *testing.T) { - t.Parallel() - - s := NewStyle(ANSIRed, nil) - - got := s.Sprint("hello") - - if !strings.HasPrefix(got, s.Sequence()) { - t.Errorf("Sprint result %q missing style prefix", got) - } - - if !strings.HasSuffix(got, Reset.Sequence()) { - t.Errorf("Sprint result %q missing reset suffix", got) + {"pipe", map[string]string{"COLORTERM": "truecolor"}, false, LevelNone}, + {"pipe forced", map[string]string{"FORCE_COLOR": "1"}, false, LevelBasic}, + {"pipe forced 3", map[string]string{"FORCE_COLOR": "3"}, false, LevelTrueColor}, + {"force off wins", map[string]string{"FORCE_COLOR": "0", "COLORTERM": "truecolor"}, true, LevelNone}, + {"clicolor force", map[string]string{"CLICOLOR_FORCE": "1", "TERM": "xterm-256color"}, false, Level256}, + {"no color", map[string]string{"NO_COLOR": "1", "COLORTERM": "truecolor"}, true, LevelNone}, + {"truecolor", map[string]string{"COLORTERM": "truecolor"}, true, LevelTrueColor}, + {"256", map[string]string{"TERM": "xterm-256color"}, true, Level256}, + {"dumb", map[string]string{"TERM": "dumb"}, true, LevelNone}, + {"kitty", map[string]string{"TERM": "xterm-kitty"}, true, LevelTrueColor}, + {"windows terminal", map[string]string{"WT_SESSION": "x"}, true, LevelTrueColor}, + {"unknown terminal", map[string]string{}, true, LevelBasic}, + } + + for _, tt := range tests { + if got := detect(env(tt.vars), tt.tty, LevelNone, false); got != tt.want { + t.Errorf("%s: detect = %v, want %v", tt.name, got, tt.want) + } } - if !strings.Contains(got, "hello") { - t.Errorf("Sprint result %q missing payload", got) + // A legacy Windows console without ANSI support gets plain text. + if got := detect(env(nil), true, LevelNone, true); got != LevelNone { + t.Errorf("legacy console: detect = %v, want none", got) } } -func TestStyle_Sprintf(t *testing.T) { - t.Parallel() - - s := NewStyle(ANSIRed, nil) +func TestDetectBuffer(t *testing.T) { + t.Setenv("FORCE_COLOR", "") + t.Setenv("CLICOLOR_FORCE", "") - got := s.Sprintf("hello %s %d", "world", 42) - - if !strings.Contains(got, "hello world 42") { - t.Errorf("Sprintf result %q missing formatted payload", got) + if got := Detect(&bytes.Buffer{}); got != LevelNone { + t.Errorf("Detect(buffer) = %v, want none", got) } -} -func TestStyle_Fprint(t *testing.T) { - t.Parallel() - - s := NewStyle(ANSIRed, nil) + SetLevel(Level256) - var buf bytes.Buffer - - n, err := s.Fprint(&buf, "hello") - if err != nil { - t.Fatalf("Fprint returned error: %v", err) - } + defer ResetLevel() - if n != buf.Len() { - t.Errorf("Fprint returned n=%d, buffer has %d", n, buf.Len()) - } - - if !strings.Contains(buf.String(), "hello") { - t.Errorf("Fprint wrote %q, missing payload", buf.String()) + if got := Detect(&bytes.Buffer{}); got != Level256 { + t.Errorf("Detect with SetLevel = %v, want 256", got) } } -func TestStyle_Fprintf(t *testing.T) { +func TestOSC11(t *testing.T) { t.Parallel() - s := NewStyle(ANSIRed, nil) - - var buf bytes.Buffer - - if _, err := s.Fprintf(&buf, "hello %s", "world"); err != nil { - t.Fatalf("Fprintf returned error: %v", err) - } - - if !strings.Contains(buf.String(), "hello world") { - t.Errorf("Fprintf wrote %q, missing payload", buf.String()) - } -} - -func TestStyle_Fprintln(t *testing.T) { - t.Parallel() - - s := NewStyle(ANSIRed, nil) - - var buf bytes.Buffer - - if _, err := s.Fprintln(&buf, "hello"); err != nil { - t.Fatalf("Fprintln returned error: %v", err) - } - - if !strings.HasSuffix(buf.String(), "\n") { - t.Errorf("Fprintln result %q missing trailing newline", buf.String()) - } -} - -func TestStyle_Fprintfln(t *testing.T) { - t.Parallel() - - s := NewStyle(ANSIRed, nil) - - var buf bytes.Buffer - - if _, err := s.Fprintfln(&buf, "hello %s", "world"); err != nil { - t.Fatalf("Fprintfln returned error: %v", err) - } - - if !strings.Contains(buf.String(), "hello world") { - t.Errorf("Fprintfln wrote %q, missing payload", buf.String()) - } - - if !strings.HasSuffix(buf.String(), "\n") { - t.Errorf("Fprintfln result %q missing trailing newline", buf.String()) - } -} - -func TestStyle_Print_WritesToWriter(t *testing.T) { - // Not parallel: mutates the package-level Writer. - var buf bytes.Buffer - - oldWriter := Writer - Writer = &buf - - t.Cleanup(func() { Writer = oldWriter }) - - NewStyle(ANSIRed, nil).Print("hello") - - if !strings.Contains(buf.String(), "hello") { - t.Errorf("Print wrote %q, missing payload", buf.String()) + tests := []struct { + in string + want Color + ok bool + }{ + {"\x1b]11;rgb:2424/2727/3a3a\x1b\\", RGB(0x24, 0x27, 0x3a), true}, + {"\x1b]11;rgb:ff/ff/ff\x07", RGB(255, 255, 255), true}, + {"\x1b]11;rgba:0000/0000/0000/ffff\x1b\\", RGB(0, 0, 0), true}, + {"\x1b]11;rgb:f/0/0\x07", RGB(255, 0, 0), true}, + {"garbage", None, false}, + {"\x1b]11;rgb:zz/00/00\x07", None, false}, + } + + for _, tt := range tests { + got, ok := parseOSC11(tt.in) + if ok != tt.ok || got != tt.want { + t.Errorf("parseOSC11(%q) = %v, %v; want %v, %v", tt.in, got, ok, tt.want, tt.ok) + } } -} - -func TestStyle_Printf_WritesToWriter(t *testing.T) { - var buf bytes.Buffer - - oldWriter := Writer - Writer = &buf - t.Cleanup(func() { Writer = oldWriter }) - - NewStyle(ANSIRed, nil).Printf("hello %s", "world") - - if !strings.Contains(buf.String(), "hello world") { - t.Errorf("Printf wrote %q, missing payload", buf.String()) + if c, ok := parseColorFgBg("15;0"); !ok || c != Black || !c.IsDark() { + t.Errorf("COLORFGBG 15;0 = %v, %v; want dark black", c, ok) } -} - -func TestStyle_Println_WritesToWriter(t *testing.T) { - var buf bytes.Buffer - - oldWriter := Writer - Writer = &buf - - t.Cleanup(func() { Writer = oldWriter }) - - NewStyle(ANSIRed, nil).Println("hello") - if !strings.HasSuffix(buf.String(), "\n") { - t.Errorf("Println output %q missing trailing newline", buf.String()) + if c, ok := parseColorFgBg("0;default;15"); !ok || c.IsDark() { + t.Errorf("COLORFGBG 0;default;15 = %v, %v; want light", c, ok) } } -func TestStyle_Printfln_WritesToWriter(t *testing.T) { - var buf bytes.Buffer - - oldWriter := Writer - Writer = &buf - - t.Cleanup(func() { Writer = oldWriter }) - - NewStyle(ANSIRed, nil).Printfln("hello %s", "world") - - out := buf.String() - if !strings.Contains(out, "hello world") || !strings.HasSuffix(out, "\n") { - t.Errorf("Printfln wrote %q, want payload + newline", out) - } -} - -func TestPresetStyleFuncs(t *testing.T) { +func TestMixAndLuminance(t *testing.T) { t.Parallel() - // Each preset wraps the payload with its own escape sequence and a Reset. - tests := []struct { - name string - fn func(a ...any) string - }{ - {"Black", Black}, - {"BrightBlack", BrightBlack}, - {"Red", Red}, - {"BrightRed", BrightRed}, - {"Green", Green}, - {"BrightGreen", BrightGreen}, - {"Yellow", Yellow}, - {"BrightYellow", BrightYellow}, - {"Blue", Blue}, - {"BrightBlue", BrightBlue}, - {"Magenta", Magenta}, - {"BrightMagenta", BrightMagenta}, - {"Cyan", Cyan}, - {"BrightCyan", BrightCyan}, - {"White", White}, - {"BrightWhite", BrightWhite}, - {"Success", Success}, - {"Info", Info}, - {"Warning", Warning}, - {"Error", Error}, - {"Fatal", Fatal}, + black, white := RGB(0, 0, 0), RGB(255, 255, 255) + if got := black.Mix(white, 0.5); got != RGB(128, 128, 128) { + t.Errorf("Mix = %v", got) } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - out := tc.fn("hi") - if !strings.Contains(out, "hi") { - t.Errorf("%s(%q) = %q, missing payload", tc.name, "hi", out) - } - - if !strings.HasSuffix(out, Reset.Sequence()) { - t.Errorf("%s output %q missing Reset suffix", tc.name, out) - } - }) + if !RGB(0x24, 0x27, 0x3a).IsDark() || RGB(0xef, 0xf1, 0xf5).IsDark() { + t.Error("IsDark misjudges Macchiato base or Latte base") } -} - -func TestWriter_DefaultIsStdout(t *testing.T) { - t.Parallel() - // The package exposes Writer as a mutable var; it should default to a - // non-nil sink so zero-configuration use of Print* does not panic. - if Writer == nil { - t.Fatal("Writer is nil on package init") + if r, g, b := Index(196).RGB(); r != 255 || g != 0 || b != 0 { + t.Errorf("Index(196).RGB = %d,%d,%d", r, g, b) } -} - -// Sanity check that the ANSI256 -> RGB -> Sequence pipeline produces a -// truecolor escape that matches a direct construction from the same hex. -func TestANSI256_MatchesHex(t *testing.T) { - t.Parallel() - for i, hex := range ansi256Table { - fromTable := ANSI256Color(i).Sequence(false) - fromHex := NewColorFromHex(hex).Sequence(false) - - if fromTable != fromHex { - t.Fatalf("index %d: ANSI256 sequence %q != hex sequence %q", i, fromTable, fromHex) - } + if r, g, b := Index(232).RGB(); r != 8 || g != 8 || b != 8 { + t.Errorf("Index(232).RGB = %d,%d,%d", r, g, b) } } -// Double-check the escape character encoding used by all color sequences. -func TestSequencesUseEscChar(t *testing.T) { +func TestDownsampleRoundTrip(t *testing.T) { t.Parallel() - seqs := []string{ - ANSIRed.Sequence(false), - ANSI256Color(9).Sequence(false), - RGBColor{1, 2, 3}.Sequence(false), - Bold.Sequence(), - } - - for _, s := range seqs { - if !strings.HasPrefix(s, "\x1b[") { - t.Errorf("%q does not start with ESC[", s) - } - - if !strings.HasSuffix(s, "m") { - t.Errorf("%q does not end with 'm'", s) + for i := range 256 { + r, g, b := index256ToRGB(uint8(i)) + if got := rgbTo256(r, g, b); i >= 16 && index256ToRGB2(got) != [3]uint8{r, g, b} { + t.Errorf("rgbTo256(index %d) = %d", i, got) } } } -// Ensure RGBColor.Hex is compatible with fmt.Sprintf's %X verb format. -func TestRGBColor_Hex_MatchesSprintf(t *testing.T) { - t.Parallel() - - c := RGBColor{R: 1, G: 2, B: 3} - - want := fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) - if got := c.Hex(); got != want { - t.Errorf("Hex() = %q, want %q", got, want) - } +func index256ToRGB2(n uint8) [3]uint8 { + r, g, b := index256ToRGB(n) + return [3]uint8{r, g, b} } diff --git a/console_other.go b/console_other.go new file mode 100644 index 0000000..7bf51b9 --- /dev/null +++ b/console_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package color + +// consoleSupport reports the color support the platform console guarantees. +// Outside of Windows the console needs no setup and has no say, so the +// environment decides. +func consoleSupport() (Level, bool) { + return LevelNone, false +} diff --git a/console_windows.go b/console_windows.go new file mode 100644 index 0000000..47692bc --- /dev/null +++ b/console_windows.go @@ -0,0 +1,70 @@ +//go:build windows + +package color + +import ( + "os" + "sync" + + "golang.org/x/sys/windows" +) + +var ( + consoleOnce sync.Once + consoleVerdict Level + consoleDecided bool +) + +// consoleSupport reports the color support of the attached Windows console. +// The first call tries to enable virtual terminal processing on stdout and +// stderr, which makes classic consoles like cmd.exe and powershell interpret +// ANSI escape sequences. +// +// Verdicts: +// - (LevelTrueColor, true): a console is attached and interprets ANSI +// sequences. Every supported Windows version renders 24-bit color once +// virtual terminal processing is on. +// - (LevelNone, true): a console is attached but cannot interpret ANSI +// sequences (legacy consoles, e.g. on Windows 7/8). +// - (LevelNone, false): no console is attached (output is redirected), so +// the environment has to decide. +func consoleSupport() (Level, bool) { + consoleOnce.Do(func() { + sawConsole := false + ansiOK := true + + for _, f := range []*os.File{os.Stdout, os.Stderr} { + handle := windows.Handle(f.Fd()) + + var mode uint32 + if windows.GetConsoleMode(handle, &mode) != nil { + // Not a console (e.g. redirected to a file or pipe); escape + // sequences pass through unchanged, so there is nothing to + // enable and nothing to learn. + continue + } + + sawConsole = true + + if mode&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 { + continue + } + + if windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) != nil { + ansiOK = false + } + } + + if !sawConsole { + return + } + + consoleDecided = true + + if ansiOK { + consoleVerdict = LevelTrueColor + } + }) + + return consoleVerdict, consoleDecided +} diff --git a/detect.go b/detect.go new file mode 100644 index 0000000..d3139ae --- /dev/null +++ b/detect.go @@ -0,0 +1,184 @@ +package color + +import ( + "io" + "os" + "strings" + "sync" + "sync/atomic" + + "golang.org/x/term" +) + +// fder is implemented by *os.File and by writers that wrap a file descriptor. +type fder interface { + Fd() uintptr +} + +var ( + // override holds the level set by SetLevel, or nil. + override atomic.Pointer[Level] + + // ttyCache remembers which file descriptors are terminals, because the + // check costs a syscall and Detect runs for every styled print. + ttyCache sync.Map // map[uintptr]bool +) + +// SetLevel makes Detect return l for every writer, ignoring the environment +// and the terminal. Use it for a --color flag or in tests. ResetLevel undoes +// it. Safe for concurrent use. +func SetLevel(l Level) { + override.Store(&l) +} + +// ResetLevel undoes SetLevel, so Detect inspects the environment and the +// writer again. +func ResetLevel() { + override.Store(nil) +} + +// Detect reports how many colors w can show. Pass the writer you are about +// to write to: standard output can be a terminal while standard error is +// piped into a file, or the other way around. +// +// The checks, in order: +// +// 1. A level set with SetLevel wins. +// 2. FORCE_COLOR forces colors on, even for pipes and files: "1" or "true" +// for at least 16 colors, "2" for 256, "3" for true color. "0", "false" +// and "none" force colors off. CLICOLOR_FORCE=1 also forces colors on. +// 3. NO_COLOR (any value, see https://no-color.org) and CLICOLOR=0 turn +// colors off. +// 4. A writer that is not a terminal (a pipe, a file, a bytes.Buffer) gets +// LevelNone. +// 5. TERM=dumb gets LevelNone. COLORTERM, TERM, TERM_PROGRAM and a few +// terminal-specific variables decide between 16, 256 and true color. +// On Windows the first call turns on ANSI processing in the console, and +// consoles that cannot do it get LevelNone. +func Detect(w io.Writer) Level { + if l := override.Load(); l != nil { + return *l + } + + console, consoleKnown := consoleSupport() + + return detect(os.Getenv, isTerminal(w), console, consoleKnown) +} + +// DetectEnv is Detect for a terminal you know is there: it applies every +// check except the terminal check of step 4. +func DetectEnv() Level { + if l := override.Load(); l != nil { + return *l + } + + console, consoleKnown := consoleSupport() + + return detect(os.Getenv, true, console, consoleKnown) +} + +// IsTerminal reports whether w writes to a terminal. +func IsTerminal(w io.Writer) bool { + return isTerminal(w) +} + +func isTerminal(w io.Writer) bool { + f, ok := w.(fder) + if !ok || f == nil { + return false + } + + fd := f.Fd() + if v, ok := ttyCache.Load(fd); ok { + return v.(bool) //nolint:forcetypeassert // ttyCache only stores bools. + } + + tty := term.IsTerminal(int(fd)) + ttyCache.Store(fd, tty) + + return tty +} + +// detect is the testable core of Detect: every input is explicit. +func detect(getenv func(string) string, tty bool, console Level, consoleKnown bool) Level { + if force := getenv("FORCE_COLOR"); force != "" { + switch strings.ToLower(force) { + case "0", "false", "none": + return LevelNone + case "2": + return Level256 + case "3": + return LevelTrueColor + default: + return max(envLevel(getenv, console, consoleKnown), LevelBasic) + } + } + + if force := getenv("CLICOLOR_FORCE"); force != "" && force != "0" { + return max(envLevel(getenv, console, consoleKnown), LevelBasic) + } + + if getenv("NO_COLOR") != "" || getenv("CLICOLOR") == "0" { + return LevelNone + } + + if !tty { + return LevelNone + } + + return envLevel(getenv, console, consoleKnown) +} + +// envLevel reads the color level from variables that describe the terminal +// without forcing anything, plus the platform console verdict. +func envLevel(getenv func(string) string, console Level, consoleKnown bool) Level { + colorTerm := strings.ToLower(getenv("COLORTERM")) + term := strings.ToLower(getenv("TERM")) + + if strings.Contains(colorTerm, "truecolor") || strings.Contains(colorTerm, "24bit") { + return LevelTrueColor + } + + if strings.Contains(term, "truecolor") || strings.Contains(term, "24bit") || strings.Contains(term, "direct") { + return LevelTrueColor + } + + if term == "dumb" { + return LevelNone + } + + // These terminals show true color but do not always set COLORTERM. + if getenv("WT_SESSION") != "" || getenv("ConEmuANSI") == "ON" || getenv("TERMINAL_EMULATOR") == "JetBrains-JediTerm" { + return LevelTrueColor + } + + switch getenv("TERM_PROGRAM") { + case "iTerm.app", "Hyper", "vscode", "ghostty", "WezTerm", "rio", "Tabby": + return LevelTrueColor + case "Apple_Terminal": + // Older Terminal.app versions garble true color, and 256 colors + // look right on every version. + return Level256 + } + + if strings.Contains(term, "kitty") || strings.Contains(term, "alacritty") { + return LevelTrueColor + } + + if strings.Contains(term, "256color") { + return Level256 + } + + // ANSICON translates ANSI sequences for legacy Windows consoles. + if getenv("ANSICON") != "" { + return LevelBasic + } + + // On Windows the console decides: true color when it processes ANSI + // sequences, nothing when it cannot (cmd.exe before Windows 10). + if consoleKnown { + return console + } + + return LevelBasic +} diff --git a/doc.go b/doc.go index dc399e8..bb85da1 100644 --- a/doc.go +++ b/doc.go @@ -1,4 +1,53 @@ /* -Package color is a minimalistic package for coloring terminal output. +Package color colors terminal output. It handles ANSI, 256-color and true +color output, finds out what the terminal can show, and falls back to fewer +colors or plain text when it has to. + +It is only about color and text attributes (bold, italic, underline...). It +has no predefined styles such as "error" or "success"; build those in your +own code from a Style. + +# Colors + +A Color is one of the 16 basic ANSI colors (Red, BrightBlue...), an entry of +the 256-color palette (Index), or a 24-bit value (RGB, Hex, Parse): + + color.Red + color.Index(208) + color.RGB(183, 189, 248) + c, err := color.Hex("#b7bdf8") + c, err := color.Parse("brightblue") // also "#fff", "208", "none" + +# Styles + +A Style combines a foreground, a background and attributes. Styles are +values: every method returns a copy, so they are safe to share between +goroutines. + + title := color.New(color.Bold).Fg(color.MustHex("#b7bdf8")) + fmt.Println(title.Sprint("Hello")) + fmt.Println(color.Green.Sprint("done")) + +Sprint and Print style for standard output, Fprint for any writer. They pick +the color level with Detect, so output piped into a file or another program +stays plain. To control the level yourself, use Render: + + s := title.Render(color.LevelTrueColor, "Hello") + +# Detection + +Detect reports the level a writer can show: LevelNone, LevelBasic (16 +colors), Level256 or LevelTrueColor. It honors FORCE_COLOR, NO_COLOR, +CLICOLOR and CLICOLOR_FORCE, checks whether the writer is a terminal, and +reads COLORTERM, TERM and TERM_PROGRAM. On Windows it turns on ANSI +processing in the console. SetLevel overrides it, for example for a --color +flag. + +Colors above the detected level are converted to the closest color the +terminal can show. Basic colors are never converted, so they follow the +user's terminal palette. + +Background asks the terminal for its background color, so a program can pick +a light or a dark palette. */ package color diff --git a/downsample.go b/downsample.go new file mode 100644 index 0000000..4af635d --- /dev/null +++ b/downsample.go @@ -0,0 +1,104 @@ +package color + +// cubeSteps holds the color intensities of the xterm 6x6x6 color cube +// (palette indices 16-231). +var cubeSteps = [6]int{0, 95, 135, 175, 215, 255} + +// basicPalette holds the xterm default values of the 16 base colors, indexed +// 0-15 in SGR order (black, red, green, yellow, blue, magenta, cyan, white, +// then their bright variants). +var basicPalette = [16][3]int{ + {0, 0, 0}, // black + {205, 0, 0}, // red + {0, 205, 0}, // green + {205, 205, 0}, // yellow + {0, 0, 238}, // blue + {205, 0, 205}, // magenta + {0, 205, 205}, // cyan + {229, 229, 229}, // white + {127, 127, 127}, // bright black + {255, 0, 0}, // bright red + {0, 255, 0}, // bright green + {255, 255, 0}, // bright yellow + {92, 92, 255}, // bright blue + {255, 0, 255}, // bright magenta + {0, 255, 255}, // bright cyan + {255, 255, 255}, // bright white +} + +// rgbTo256 returns the index of the xterm 256-color palette entry closest to +// the given 24-bit color. It considers both the 6x6x6 color cube (indices +// 16-231) and the grayscale ramp (indices 232-255) and picks the closer one, +// the same way tmux does. +func rgbTo256(r, g, b uint8) uint8 { + ri, gi, bi := cubeIndex(r), cubeIndex(g), cubeIndex(b) + cubeColor := [3]int{cubeSteps[ri], cubeSteps[gi], cubeSteps[bi]} + cube := 16 + 36*ri + 6*gi + bi + + if cubeColor == [3]int{int(r), int(g), int(b)} { + return uint8(cube) //nolint:gosec // 16 + 36*5 + 6*5 + 5 = 231 always fits. + } + + // The grayscale ramp holds the grays 8, 18, ..., 238 at indices 232-255. + grayIndex := max(min((int(r)+int(g)+int(b))/3-3, 235), 0) / 10 + gray := 8 + 10*grayIndex + + if colorDistance([3]int{gray, gray, gray}, r, g, b) < colorDistance(cubeColor, r, g, b) { + return uint8(232 + grayIndex) + } + + return uint8(cube) //nolint:gosec // 16 + 36*5 + 6*5 + 5 = 231 always fits. +} + +// rgbToBasic returns the index (0-15) of the basic +// ANSI color closest to the given 24-bit color. +func rgbToBasic(r, g, b uint8) uint8 { + best, bestDistance := 0, colorDistance(basicPalette[0], r, g, b) + + for i := 1; i < len(basicPalette); i++ { + if d := colorDistance(basicPalette[i], r, g, b); d < bestDistance { + best, bestDistance = i, d + } + } + + return uint8(best) +} + +// cubeIndex returns the index of the cube step closest to the given +// intensity. +func cubeIndex(v uint8) int { + if v < 48 { + return 0 + } + + if v < 114 { + return 1 + } + + return int(v-35) / 40 +} + +// colorDistance returns the squared euclidean distance between a palette +// color and a 24-bit color. +func colorDistance(palette [3]int, r, g, b uint8) int { + dr := palette[0] - int(r) + dg := palette[1] - int(g) + db := palette[2] - int(b) + + return dr*dr + dg*dg + db*db +} + +// index256ToRGB returns the xterm default value of palette entry n. +func index256ToRGB(n uint8) (r, g, b uint8) { + switch { + case n < 16: + p := basicPalette[n] + return uint8(p[0]), uint8(p[1]), uint8(p[2]) //nolint:gosec // palette values are bytes. + case n < 232: + i := int(n) - 16 + return uint8(cubeSteps[i/36]), uint8(cubeSteps[i/6%6]), uint8(cubeSteps[i%6]) //nolint:gosec // cube steps are bytes. + default: + v := uint8(8 + 10*(int(n)-232)) //nolint:gosec // at most 238. + return v, v, v + } +} diff --git a/examples_test.go b/examples_test.go index afdd40a..8e641e3 100644 --- a/examples_test.go +++ b/examples_test.go @@ -7,33 +7,62 @@ import ( ) func Example_demo() { - // Simple coloring - fmt.Println("Hello, " + color.Green("World") + "!") + // Examples render at a fixed level so the output is predictable. + // Real programs call Sprint or Print and let Detect pick the level. + l := color.LevelBasic - fmt.Println() // blank line + fmt.Printf("%q\n", color.Green.Fg().Render(l, "done")) + fmt.Printf("%q\n", color.New(color.Bold).Fg(color.Red).Render(l, "failed")) + fmt.Printf("%q\n", color.Blue.Bg().Render(l, "info")) + // Output: + // "\x1b[32mdone\x1b[0m" + // "\x1b[1;31mfailed\x1b[0m" + // "\x1b[44minfo\x1b[0m" +} - // Theme colors - can be customized in init() function if needed - fmt.Println(color.Success("Success message")) - fmt.Println(color.Info("Info message")) - fmt.Println(color.Warning("Warning message")) - fmt.Println(color.Error("Error message")) - fmt.Println(color.Fatal("Fatal message")) +func ExampleParse() { + for _, s := range []string{"#b7bdf8", "brightblue", "208", "none"} { + c, err := color.Parse(s) + fmt.Println(c, err) + } + // Output: + // #b7bdf8 + // brightblue + // 208 + // none +} - fmt.Println() // blank line +func ExampleStyle_Render() { + lavender := color.MustHex("#b7bdf8") + title := color.New(color.Bold).Fg(lavender) - // Supports ANSI colors - ansiRed := color.NewStyle(color.ANSIRed, nil).Sprint - fmt.Println(ansiRed("This is printed red using an ANSI color code")) + // The same style at each level. Terminals with fewer colors get the + // closest color they can show. + for _, l := range []color.Level{color.LevelTrueColor, color.Level256, color.LevelBasic, color.LevelNone} { + fmt.Printf("%-9s %q\n", l, title.Render(l, "Hi")) + } + // Output: + // truecolor "\x1b[1;38;2;183;189;248mHi\x1b[0m" + // 256 "\x1b[1;38;5;147mHi\x1b[0m" + // 16 "\x1b[1;37mHi\x1b[0m" + // none "Hi" +} - // Supports ANSI256 colors - ansi256Red := color.NewStyle(color.ANSI256Color(196), nil).Sprint - fmt.Println(ansi256Red("This is printed red using an ANSI256 color code")) +func ExampleColor_Mix() { + from, to := color.MustHex("#b7bdf8"), color.MustHex("#8bd5ca") - // Supports RGB colors - redRGB := color.NewStyle(color.NewColorFromRGB(255, 0, 0), nil).Sprint - fmt.Println(redRGB("This is printed red using a RGB color code")) + // A three-step gradient from lavender to teal. + for i := range 3 { + fmt.Println(from.Mix(to, float64(i)/2)) + } + // Output: + // #b7bdf8 + // #a1c9e1 + // #8bd5ca +} - // Supports hex colors - redHex := color.NewStyle(color.NewColorFromHex("#ff0000"), nil).Sprint - fmt.Println(redHex("This is printed red using a hex color code")) +func ExampleStrip() { + s := color.Red.Fg().Render(color.LevelTrueColor, "error") + fmt.Println(color.Strip(s)) + // Output: error } diff --git a/go.mod b/go.mod index 86faca4..2ebde45 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,8 @@ module atomicgo.dev/color -go 1.25.0 +go 1.27.0 -require golang.org/x/sys v0.43.0 +require ( + golang.org/x/sys v0.49.0 + golang.org/x/term v0.47.0 +) diff --git a/go.sum b/go.sum index 71016e3..31af47c 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= diff --git a/level.go b/level.go new file mode 100644 index 0000000..e7bdebb --- /dev/null +++ b/level.go @@ -0,0 +1,95 @@ +package color + +import "strconv" + +// Level is how many colors a terminal can show. +type Level uint8 + +const ( + // LevelNone shows no colors and no text attributes. Rendering at this + // level returns the text unchanged. + LevelNone Level = iota + + // LevelBasic shows the 16 basic ANSI colors. + LevelBasic + + // Level256 shows the xterm 256-color palette. + Level256 + + // LevelTrueColor shows 24-bit RGB colors. + LevelTrueColor +) + +// String returns "none", "16", "256" or "truecolor". +func (l Level) String() string { + switch l { + case LevelNone: + return "none" + case LevelBasic: + return "16" + case Level256: + return "256" + case LevelTrueColor: + return "truecolor" + default: + return "unknown" + } +} + +// fgCode returns the SGR parameters that set c as foreground at level l, or +// "" when l cannot show colors or c is None. +func (l Level) fgCode(c Color) string { + return l.code(c, false) +} + +// bgCode is fgCode for the background. +func (l Level) bgCode(c Color) string { + return l.code(c, true) +} + +func (l Level) code(c Color, background bool) string { + if l == LevelNone || c.kind == kindNone { + return "" + } + + // A basic color keeps its basic code at every level, so it follows the + // user's terminal palette. Richer colors fall back to what l can show. + switch { + case c.kind == kindBasic: + return basicCode(c.r, background) + case c.kind == kind256 && l >= Level256: + return layer(background) + ";5;" + strconv.Itoa(int(c.r)) + case c.kind == kindRGB && l == LevelTrueColor: + return layer(background) + ";2;" + strconv.Itoa(int(c.r)) + ";" + strconv.Itoa(int(c.g)) + ";" + strconv.Itoa(int(c.b)) + case l == Level256: + r, g, b := c.RGB() + return layer(background) + ";5;" + strconv.Itoa(int(rgbTo256(r, g, b))) + default: + r, g, b := c.RGB() + return basicCode(rgbToBasic(r, g, b), background) + } +} + +func layer(background bool) string { + if background { + return "48" + } + + return "38" +} + +// basicCode returns the SGR code of basic color n: 30-37 and 90-97 for +// foregrounds, 40-47 and 100-107 for backgrounds. +func basicCode(n uint8, background bool) string { + base := 30 + if n >= 8 { + base = 90 + n -= 8 + } + + if background { + base += 10 + } + + return strconv.Itoa(base + int(n)) +} diff --git a/mode.go b/mode.go deleted file mode 100644 index 7ac615e..0000000 --- a/mode.go +++ /dev/null @@ -1,28 +0,0 @@ -package color - -// Mode represents the color mode used by the terminal. -type Mode int - -// Color modes describe the terminal color capabilities. -const ( - TrueColor Mode = iota - ANSI256 - ANSI - - Disabled -) - -func (m Mode) String() string { - switch m { - case TrueColor: - return "TrueColor" - case ANSI256: - return "ANSI256" - case ANSI: - return "ANSI" - case Disabled: - return "Disabled" - default: - return "Unknown" - } -} diff --git a/modifier.go b/modifier.go deleted file mode 100644 index f0d62e7..0000000 --- a/modifier.go +++ /dev/null @@ -1,22 +0,0 @@ -package color - -import "fmt" - -// Modifier type for text modifiers. -type Modifier int - -const modifierSequence = "\033[%dm" - -// Modifiers -const ( - Reset Modifier = iota - Bold - Faint - Italic - Underline -) - -// Sequence returns the ANSI escape sequence for the modifier. -func (m Modifier) Sequence() string { - return fmt.Sprintf(modifierSequence, m) -} diff --git a/noColor.go b/noColor.go deleted file mode 100644 index dc21941..0000000 --- a/noColor.go +++ /dev/null @@ -1,10 +0,0 @@ -package color - -type noColor struct{} - -func (noColor) Sequence(_ bool) string { - return "" -} - -// NoColor disables color output for a style component. -var NoColor Color = noColor{} diff --git a/style.go b/style.go index 5205e4d..b66fff1 100644 --- a/style.go +++ b/style.go @@ -3,122 +3,232 @@ package color import ( "fmt" "io" + "os" + "regexp" + "strconv" "strings" ) -// Style represents a text style with a foreground and background color and modifiers. +// Attr is a set of text attributes. Combine them with |, for example +// Bold|Underline. +type Attr uint16 + +// Text attributes. Terminals that do not support one ignore it. +const ( + Bold Attr = 1 << iota + Dim + Italic + Underline + Blink + Reverse + Hidden + Strikethrough + Overline +) + +// attrCodes lists the SGR code of each attribute in bit order. +var attrCodes = [...]string{"1", "2", "3", "4", "5", "7", "8", "9", "53"} + +const ( + esc = "\x1b" + + // Reset is the escape sequence that turns off every color and attribute. + Reset = esc + "[0m" +) + +// Style is a foreground color, a background color and text attributes. +// The zero value renders text unchanged. +// +// Styles are values: every method returns a new Style and leaves the +// receiver alone, so a Style can be shared between goroutines. type Style struct { Foreground Color Background Color - - Modifiers []Modifier + Attrs Attr } -// NewStyle creates a new Style with the given foreground and background colors and modifiers. -func NewStyle(foregroundColor, backgroundColor Color, modifiers ...Modifier) Style { - if foregroundColor == nil { - foregroundColor = NoColor +// New returns a Style with the given attributes and no colors. +func New(attrs ...Attr) Style { + var s Style + for _, a := range attrs { + s.Attrs |= a } - if backgroundColor == nil { - backgroundColor = NoColor - } + return s +} - return Style{ - Foreground: foregroundColor, - Background: backgroundColor, - Modifiers: modifiers, - } +// Fg returns a copy of s with c as foreground. +func (s Style) Fg(c Color) Style { + s.Foreground = c + return s } -// AddModifier adds a modifier to the style, if it's not already present. -func (s *Style) AddModifier(modifier Modifier) { - for _, mod := range s.Modifiers { - if mod == modifier { - return - } +// Bg returns a copy of s with c as background. +func (s Style) Bg(c Color) Style { + s.Background = c + return s +} + +// Add returns a copy of s with the given attributes turned on. +func (s Style) Add(attrs ...Attr) Style { + for _, a := range attrs { + s.Attrs |= a } - s.Modifiers = append(s.Modifiers, modifier) + return s } -// WithModifier returns a new Style with the given modifier added, if it's not already present. -func (s Style) WithModifier(modifier Modifier) Style { - s.AddModifier(modifier) - return s +// Bold returns a copy of s with Bold turned on. +func (s Style) Bold() Style { return s.Add(Bold) } + +// Dim returns a copy of s with Dim turned on. +func (s Style) Dim() Style { return s.Add(Dim) } + +// Italic returns a copy of s with Italic turned on. +func (s Style) Italic() Style { return s.Add(Italic) } + +// Underline returns a copy of s with Underline turned on. +func (s Style) Underline() Style { return s.Add(Underline) } + +// Strikethrough returns a copy of s with Strikethrough turned on. +func (s Style) Strikethrough() Style { return s.Add(Strikethrough) } + +// Reverse returns a copy of s with Reverse turned on. +func (s Style) Reverse() Style { return s.Add(Reverse) } + +// IsZero reports whether s has no colors and no attributes. +func (s Style) IsZero() bool { + return s == Style{} } -// Sequence returns the ANSI escape sequence for the style. -func (s Style) Sequence() string { - var sb strings.Builder +// Sequence returns the escape sequence that switches the terminal to s at +// level l. It returns "" when l is LevelNone or s is zero. +func (s Style) Sequence(l Level) string { + if l == LevelNone { + return "" + } + + codes := make([]string, 0, 4) + + for i, code := range attrCodes { + if s.Attrs&(1<]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`) + +// Strip removes every escape sequence from s: colors, attributes, cursor +// movement and hyperlinks. The text of a hyperlink stays. +func Strip(s string) string { + if !strings.Contains(s, esc) { + return s + } + + return ansiRegex.ReplaceAllString(s, "") } -// Printfln formats according to a format specifier and writes to standard output. -func (s Style) Printfln(format string, a ...any) { - _, _ = s.Fprintfln(Writer, format, a...) +// Hyperlink returns text as a clickable link to url (OSC 8). At LevelNone it +// returns text unchanged. Terminals without link support show the text and +// ignore the link. +func Hyperlink(l Level, url, text string) string { + if l == LevelNone || url == "" { + return text + } + + return esc + "]8;;" + url + esc + "\\" + text + esc + "]8;;" + esc + "\\" +} + +// SGR returns the escape sequence for raw SGR parameters, for the rare +// attribute this package has no name for, such as SGR(21) for a double +// underline. +func SGR(params ...int) string { + codes := make([]string, len(params)) + for i, p := range params { + codes[i] = strconv.Itoa(p) + } + + return esc + "[" + strings.Join(codes, ";") + "m" } diff --git a/style_test.go b/style_test.go deleted file mode 100644 index ab94734..0000000 --- a/style_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package color - -import "testing" - -func TestStyle_AddModifier(t *testing.T) { - t.Parallel() - - s := NewStyle(nil, nil) - s.AddModifier(Bold) - - if len(s.Modifiers) != 1 { - t.Error("expected 1 modifier") - } -} diff --git a/styles.go b/styles.go deleted file mode 100644 index 910280e..0000000 --- a/styles.go +++ /dev/null @@ -1,34 +0,0 @@ -package color - -// Color shortcut functions apply common ANSI styles to text. -var ( - Black = NewStyle(ANSIBlack, nil).Sprint - BrightBlack = NewStyle(ANSIBrightBlack, nil).Sprint - - Red = NewStyle(ANSIRed, nil).Sprint - BrightRed = NewStyle(ANSIBrightRed, nil).Sprint - - Green = NewStyle(ANSIGreen, nil).Sprint - BrightGreen = NewStyle(ANSIBrightGreen, nil).Sprint - - Yellow = NewStyle(ANSIYellow, nil).Sprint - BrightYellow = NewStyle(ANSIBrightYellow, nil).Sprint - - Blue = NewStyle(ANSIBlue, nil).Sprint - BrightBlue = NewStyle(ANSIBrightBlue, nil).Sprint - - Magenta = NewStyle(ANSIMagenta, nil).Sprint - BrightMagenta = NewStyle(ANSIBrightMagenta, nil).Sprint - - Cyan = NewStyle(ANSICyan, nil).Sprint - BrightCyan = NewStyle(ANSIBrightCyan, nil).Sprint - - White = NewStyle(ANSIWhite, nil).Sprint - BrightWhite = NewStyle(ANSIBrightWhite, nil).Sprint - - Success = NewStyle(ANSIBrightGreen, nil).Sprint - Info = NewStyle(ANSIBrightBlue, nil).Sprint - Warning = NewStyle(ANSIBrightYellow, nil).Sprint - Error = NewStyle(ANSIBrightRed, nil).Sprint - Fatal = NewStyle(ANSIBrightRed, nil, Bold).Sprint -) From 1cba28badc32b7f3c372513edb40765348096d19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:33:45 +0000 Subject: [PATCH 2/2] ci: build golangci-lint with the Go version from go.mod The v2.11.4 release binary is built with Go 1.26 and refuses to lint a module that targets Go 1.27. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011Dno7CsMx2fSzNx5ZTm7RL --- .github/workflows/go.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 1b04642..c719ee7 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -96,7 +96,10 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.11.4 + version: v2.14.0 + # Build golangci-lint with the Go version from go.mod. Release + # binaries lag behind new Go versions and refuse to lint newer code. + install-mode: goinstall args: --timeout=3m - name: Lint summary