From 9cee35ac783e3304b26df9d84faad3c6467a8cb4 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Fri, 6 Jun 2025 13:51:43 -0500 Subject: [PATCH] rewrite color lib --- README.md | 21 +++++-- color.go | 132 ++++++++++++++++++++++++++------------------ color_unix.go | 33 +++++++++++ color_windows.go | 29 ++++++++++ tests/color_test.go | 119 ++++++++++++++++++++++++++++++--------- 5 files changed, 249 insertions(+), 85 deletions(-) create mode 100644 color_unix.go create mode 100644 color_windows.go diff --git a/README.md b/README.md index f976e8e..edfcb5f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,21 @@ # Color -Supported colors: black, white, red, green, blue, yellow, magenta, cyan +Supported colors: red, green, yellow, blue, purple, cyan, white, gray ```go -color.Blue.Print("Blue") -color.Blue.Printf("%s", "Blue") -color.Blue.Println("Blue") -blue := color.Blue.String("Blue") +fmt.Print(color.Blue("Blue text")) +fmt.Printf("Status: %s\n", color.Green("OK")) +fmt.Println(color.Red("Error occurred")) + +blue := color.Blue("Blue text") +warning := color.Yellow("Warning message") + +color.SetColors(false) +color.SetColors(true) + +if color.ColorsEnabled() { + // ... +} ``` + +Colors are automatically detected based on terminal capabilities and environment variables (`NO_COLOR`, `FORCE_COLOR`, `TERM`). diff --git a/color.go b/color.go index 740572a..dc2bf2f 100644 --- a/color.go +++ b/color.go @@ -1,75 +1,101 @@ package color import ( - "fmt" "os" "strings" + "sync" ) -// Color type -type Color int - -// Color codes +// ANSI color codes const ( - Black Color = iota + 30 - Red - Green - Yellow - Blue - Magenta - Cyan - White + resetCode = "\033[0m" + redCode = "\033[31m" + greenCode = "\033[32m" + yellowCode = "\033[33m" + blueCode = "\033[34m" + purpleCode = "\033[35m" + cyanCode = "\033[36m" + whiteCode = "\033[37m" + grayCode = "\033[90m" ) -func (c Color) String(args ...any) string { - return fmt.Sprintf("\x1b[%dm%s\x1b[0m", c, fmt.Sprint(args...)) +var ( + useColors bool + colorMu sync.RWMutex +) + +// Color function; makes a call to makeColorFunc with the associated color code +var ( + Reset = makeColorFunc(resetCode) + Red = makeColorFunc(redCode) + Green = makeColorFunc(greenCode) + Yellow = makeColorFunc(yellowCode) + Blue = makeColorFunc(blueCode) + Purple = makeColorFunc(purpleCode) + Cyan = makeColorFunc(cyanCode) + White = makeColorFunc(whiteCode) + Gray = makeColorFunc(grayCode) +) + +func init() { + useColors = DetectShellColors() } -func (c Color) Print(args ...any) { - if !supportsColors() { - fmt.Print(args...) - return +func makeColorFunc(code string) func(string) string { + return func(text string) string { + colorMu.RLock() + enabled := useColors + colorMu.RUnlock() + + if enabled { + return code + text + resetCode + } + return text + } +} + +// DetectShellColors checks if the current shell supports colors +func DetectShellColors() bool { + // Check NO_COLOR environment variable (standard) + if os.Getenv("NO_COLOR") != "" { + return false } - fmt.Printf("\x1b[%dm%s\x1b[0m", c, fmt.Sprint(args...)) -} - -func (c Color) Printf(format string, args ...any) { - if !supportsColors() { - fmt.Printf(format, args...) - return + // Check FORCE_COLOR environment variable + if os.Getenv("FORCE_COLOR") != "" { + return true } - fmt.Printf("\x1b[%dm%s\x1b[0m", c, fmt.Sprintf(format, args...)) -} - -func (c Color) Println(args ...any) { - if !supportsColors() { - fmt.Println(args...) - return + // Check if stdout is a terminal + if !isTerminal(os.Stdout) { + return false } - fmt.Printf("\x1b[%dm%s\x1b[0m\n", c, fmt.Sprint(args...)) -} - -func supportsColors() bool { - colorterm := os.Getenv("COLORTERM") + // Check TERM environment variable term := os.Getenv("TERM") - - // Check for true color (24-bit support) - if strings.Contains(colorterm, "truecolor") || strings.Contains(colorterm, "24bit") { - return true + if term == "" || term == "dumb" { + return false } - // Check for 256 colors support - if strings.Contains(term, "256color") { - return true - } - - // Check for basic color support - if term == "xterm" || term == "screen" || term == "vt100" || strings.Contains(term, "color") { - return true - } - - return false + // Common color-supporting terminals + return strings.Contains(term, "color") || + strings.Contains(term, "xterm") || + strings.Contains(term, "screen") || + strings.Contains(term, "tmux") || + term == "linux" || + isWindowsTerminal() +} + +// SetColors enables or disables colors globally +func SetColors(enabled bool) { + colorMu.Lock() + useColors = enabled + colorMu.Unlock() +} + +// ColorsEnabled returns current global color setting +func ColorsEnabled() bool { + colorMu.RLock() + defer colorMu.RUnlock() + return useColors } diff --git a/color_unix.go b/color_unix.go new file mode 100644 index 0000000..3f63471 --- /dev/null +++ b/color_unix.go @@ -0,0 +1,33 @@ +//go:build !windows +// +build !windows + +package color + +import ( + "os" + "syscall" + "unsafe" +) + +const ioctlReadTermios = 0x5401 + +// isTerminal checks if the file is a terminal +func isTerminal(f *os.File) bool { + fd := f.Fd() + var termios syscall.Termios + + r1, _, errno := syscall.Syscall6( + syscall.SYS_IOCTL, + fd, + ioctlReadTermios, + uintptr(unsafe.Pointer(&termios)), + 0, 0, 0, + ) + + return r1 == 0 && errno == 0 +} + +// isWindowsTerminal always returns false on Unix +func isWindowsTerminal() bool { + return false +} diff --git a/color_windows.go b/color_windows.go new file mode 100644 index 0000000..059ed9f --- /dev/null +++ b/color_windows.go @@ -0,0 +1,29 @@ +//go:build windows +// +build windows + +package color + +import ( + "os" + "syscall" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetConsoleMode = kernel32.NewProc("GetConsoleMode") +) + +// isTerminal checks if the file is a terminal on Windows +func isTerminal(f *os.File) bool { + handle := syscall.Handle(f.Fd()) + var mode uint32 + + r1, _, _ := procGetConsoleMode.Call(uintptr(handle), uintptr(unsafe.Pointer(&mode))) + return r1 != 0 +} + +// isWindowsTerminal checks for Windows Terminal +func isWindowsTerminal() bool { + return os.Getenv("WT_SESSION") != "" +} diff --git a/tests/color_test.go b/tests/color_test.go index dbb9bc8..18d6ffb 100644 --- a/tests/color_test.go +++ b/tests/color_test.go @@ -6,35 +6,100 @@ import ( color "git.sharkk.net/Go/Color" ) -func TestPrint(t *testing.T) { - color.Black.Print("Black\n") - color.White.Print("White\n") - color.Red.Print("Red\n") - color.Green.Print("Green\n") - color.Blue.Print("Blue\n") - color.Yellow.Print("Yellow\n") - color.Magenta.Print("Magenta\n") - color.Cyan.Print("Cyan\n") +func TestColorFunctions(t *testing.T) { + // Enable colors for testing + color.SetColors(true) + + tests := []struct { + name string + colorFunc func(string) string + expected string + }{ + {"Red", color.Red, "\033[31mtest\033[0m"}, + {"Green", color.Green, "\033[32mtest\033[0m"}, + {"Yellow", color.Yellow, "\033[33mtest\033[0m"}, + {"Blue", color.Blue, "\033[34mtest\033[0m"}, + {"Purple", color.Purple, "\033[35mtest\033[0m"}, + {"Cyan", color.Cyan, "\033[36mtest\033[0m"}, + {"White", color.White, "\033[37mtest\033[0m"}, + {"Gray", color.Gray, "\033[90mtest\033[0m"}, + {"Reset", color.Reset, "\033[0mtest\033[0m"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.colorFunc("test") + if result != tt.expected { + t.Errorf("got %q, want %q", result, tt.expected) + } + }) + } } -func TestPrintf(t *testing.T) { - color.Black.Printf("%s\n", "Black") - color.White.Printf("%s\n", "White") - color.Red.Printf("%s\n", "Red") - color.Green.Printf("%s\n", "Green") - color.Blue.Printf("%s\n", "Blue") - color.Yellow.Printf("%s\n", "Yellow") - color.Magenta.Printf("%s\n", "Magenta") - color.Cyan.Printf("%s\n", "Cyan") +func TestColorsDisabled(t *testing.T) { + // Disable colors + color.SetColors(false) + + input := "test" + expected := "test" + + colorFuncs := []func(string) string{ + color.Red, color.Green, color.Yellow, color.Blue, color.Purple, color.Cyan, color.White, color.Gray, color.Reset, + } + + for _, colorFunc := range colorFuncs { + result := colorFunc(input) + if result != expected { + t.Errorf("expected plain text when colors disabled, got %q", result) + } + } + + // Re-enable for other tests + color.SetColors(true) } -func TestPrintln(t *testing.T) { - color.Black.Println("Black") - color.White.Println("White") - color.Red.Println("Red") - color.Green.Println("Green") - color.Blue.Println("Blue") - color.Yellow.Println("Yellow") - color.Magenta.Println("Magenta") - color.Cyan.Println("Cyan") +func TestSetColorsAndColorsEnabled(t *testing.T) { + // Test enabling colors + color.SetColors(true) + if !color.ColorsEnabled() { + t.Error("expected colors to be enabled") + } + + // Test disabling colors + color.SetColors(false) + if color.ColorsEnabled() { + t.Error("expected colors to be disabled") + } + + // Restore default state + color.SetColors(color.DetectShellColors()) +} + +func TestEmptyString(t *testing.T) { + color.SetColors(true) + result := color.Red("") + expected := "\033[31m\033[0m" + if result != expected { + t.Errorf("got %q, want %q", result, expected) + } +} + +func BenchmarkColorFunction(b *testing.B) { + color.SetColors(true) + input := "benchmark test" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = color.Red(input) + } +} + +func BenchmarkColorFunctionDisabled(b *testing.B) { + color.SetColors(false) + input := "benchmark test" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = color.Red(input) + } }