diff --git a/Benchmarks_test.go b/Benchmarks_test.go index 630f902..c811429 100644 --- a/Benchmarks_test.go +++ b/Benchmarks_test.go @@ -1,38 +1,37 @@ package color_test import ( - "io" "testing" - "git.akyoto.dev/go/color" + "git.urbach.dev/go/color" ) func BenchmarkRGB(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { color.RGB(1.0, 1.0, 1.0) } } func BenchmarkLCH(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { color.LCH(0.5, 0.5, 0.0) } } -func BenchmarkFprintColorized(b *testing.B) { +func BenchmarkPrint(b *testing.B) { color.Terminal = true c := color.RGB(1.0, 1.0, 1.0) - for i := 0; i < b.N; i++ { - c.Fprint(io.Discard, "") + for b.Loop() { + c.Print("") } } -func BenchmarkFprintRaw(b *testing.B) { +func BenchmarkPrintRaw(b *testing.B) { color.Terminal = false c := color.RGB(1.0, 1.0, 1.0) - for i := 0; i < b.N; i++ { - c.Fprint(io.Discard, "") + for b.Loop() { + c.Print("") } } diff --git a/Color.go b/Color.go index 3ffa1dc..5b007c9 100644 --- a/Color.go +++ b/Color.go @@ -2,50 +2,41 @@ package color import ( "fmt" - "io" ) -// Value is a type definition for the data type of a single color component. -type Value = float64 - -// Color represents an RGB color. +// Color represents an sRGB color. type Color struct { - R Value - G Value - B Value -} - -// RGB creates a new color with red, green and blue values in the range of 0.0 to 1.0. -func RGB(r Value, g Value, b Value) Color { - return Color{r, g, b} -} - -// Fprint writes the text in the given color to the writer. -func (c Color) Fprint(writer io.Writer, text string) { - if !Terminal { - fmt.Fprint(writer, text) - return - } - - fmt.Fprintf(writer, format, byte(c.R*255), byte(c.G*255), byte(c.B*255), text) + R byte + G byte + B byte } // Print writes the text in the given color to standard output. -func (c Color) Print(text string) { - if !Terminal { - fmt.Print(text) +func (c Color) Print(args ...any) { + if !Terminal || !TrueColor { + fmt.Print(args...) return } - fmt.Printf(format, byte(c.R*255), byte(c.G*255), byte(c.B*255), text) + fmt.Printf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", c.R, c.G, c.B, fmt.Sprint(args...)) +} + +// Printf formats according to a format specifier and writes the text in the given color to standard output. +func (c Color) Printf(format string, args ...any) { + if !Terminal || !TrueColor { + fmt.Printf(format, args...) + return + } + + fmt.Printf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", c.R, c.G, c.B, fmt.Sprintf(format, args...)) } // Println writes the text in the given color to standard output and appends a newline. -func (c Color) Println(text string) { - if !Terminal { - fmt.Println(text) +func (c Color) Println(args ...any) { + if !Terminal || !TrueColor { + fmt.Println(args...) return } - fmt.Printf(formatLine, byte(c.R*255), byte(c.G*255), byte(c.B*255), text) + fmt.Printf("\x1b[38;2;%d;%d;%dm%s\n\x1b[0m", c.R, c.G, c.B, fmt.Sprint(args...)) } diff --git a/Color_test.go b/Color_test.go index 87ad195..1abfa15 100644 --- a/Color_test.go +++ b/Color_test.go @@ -1,83 +1,55 @@ package color_test import ( - "io" "testing" - "git.akyoto.dev/go/assert" - "git.akyoto.dev/go/color" + "git.urbach.dev/go/color" ) -func TestFprint(t *testing.T) { - color.Terminal = true - - color.RGB(1, 0, 0).Fprint(io.Discard, "red") - color.RGB(0, 1, 0).Fprint(io.Discard, "green") - color.RGB(0, 0, 1).Fprint(io.Discard, "blue") - - color.Terminal = false - - color.RGB(1, 0, 0).Fprint(io.Discard, "red") - color.RGB(0, 1, 0).Fprint(io.Discard, "green") - color.RGB(0, 0, 1).Fprint(io.Discard, "blue") -} - func TestPrint(t *testing.T) { color.Terminal = true + color.TrueColor = true color.RGB(1, 0, 0).Print("red\n") color.RGB(0, 1, 0).Print("green\n") color.RGB(0, 0, 1).Print("blue\n") color.Terminal = false + color.TrueColor = false color.RGB(1, 0, 0).Print("red\n") color.RGB(0, 1, 0).Print("green\n") color.RGB(0, 0, 1).Print("blue\n") } +func TestPrintf(t *testing.T) { + color.Terminal = true + color.TrueColor = true + + color.RGB(1, 0, 0).Printf("%s\n", "red") + color.RGB(0, 1, 0).Printf("%s\n", "green") + color.RGB(0, 0, 1).Printf("%s\n", "blue") + + color.Terminal = false + color.TrueColor = false + + color.RGB(1, 0, 0).Printf("%s\n", "red") + color.RGB(0, 1, 0).Printf("%s\n", "green") + color.RGB(0, 0, 1).Printf("%s\n", "blue") +} + func TestPrintln(t *testing.T) { color.Terminal = true + color.TrueColor = true color.RGB(1, 0, 0).Println("red") color.RGB(0, 1, 0).Println("green") color.RGB(0, 0, 1).Println("blue") color.Terminal = false + color.TrueColor = false color.RGB(1, 0, 0).Println("red") color.RGB(0, 1, 0).Println("green") color.RGB(0, 0, 1).Println("blue") } - -func TestRGB(t *testing.T) { - color.Terminal = true - - rgbColors := map[string]color.Color{ - "black": color.RGB(0, 0, 0), - "white": color.RGB(1, 1, 1), - "gray": color.RGB(0.5, 0.5, 0.5), - "red": color.RGB(1, 0, 0), - "green": color.RGB(0, 1, 0), - "blue": color.RGB(0, 0, 1), - "cyan": color.RGB(0, 1, 1), - "yellow": color.RGB(1, 1, 0), - "orange": color.RGB(1, 0.5, 0), - "magenta": color.RGB(1, 0, 1), - } - - for name, c := range rgbColors { - testColorRange(t, c) - c.Println("█ " + name) - } -} - -func testColorRange(t *testing.T, c color.Color) { - assert.True(t, c.R >= 0.0) - assert.True(t, c.G >= 0.0) - assert.True(t, c.B >= 0.0) - - assert.True(t, c.R <= 1.0) - assert.True(t, c.G <= 1.0) - assert.True(t, c.B <= 1.0) -} diff --git a/HSL.go b/HSL.go new file mode 100644 index 0000000..f5cda9a --- /dev/null +++ b/HSL.go @@ -0,0 +1,29 @@ +package color + +import "math" + +// HSL represents a color using hue, saturation and lightness. +func HSL(hue Value, saturation Value, lightness Value) Color { + hue = math.Mod(hue, 360) + c := (1 - math.Abs(2*lightness-1)) * saturation + x := c * (1 - math.Abs(math.Mod(hue/60, 2)-1)) + var r, g, b Value + + switch { + case hue >= 0 && hue < 60: + r, g, b = c, x, 0 + case hue >= 60 && hue < 120: + r, g, b = x, c, 0 + case hue >= 120 && hue < 180: + r, g, b = 0, c, x + case hue >= 180 && hue < 240: + r, g, b = 0, x, c + case hue >= 240 && hue < 300: + r, g, b = x, 0, c + case hue >= 300 && hue < 360: + r, g, b = c, 0, x + } + + m := lightness - c/2 + return RGB(r+m, g+m, b+m) +} diff --git a/HSL_test.go b/HSL_test.go new file mode 100644 index 0000000..0c3e654 --- /dev/null +++ b/HSL_test.go @@ -0,0 +1,26 @@ +package color_test + +import ( + "fmt" + "testing" + + "git.urbach.dev/go/color" +) + +func TestHSLSpectrum(t *testing.T) { + color.Terminal = true + color.TrueColor = true + + for lightness := range 21 { + for hue := range 80 { + h := color.Value(hue) * 4.4 + s := color.Value(1.0) + l := color.Value(lightness) * 0.05 + + c := color.HSL(h, s, l) + c.Print("█") + } + + fmt.Println() + } +} diff --git a/HSV.go b/HSV.go new file mode 100644 index 0000000..2efa5fd --- /dev/null +++ b/HSV.go @@ -0,0 +1,29 @@ +package color + +import "math" + +// HSV represents a color using hue, saturation and value. +func HSV(hue Value, saturation Value, value Value) Color { + hue = math.Mod(hue, 360) + c := value * saturation + x := c * (1 - math.Abs(math.Mod(hue/60, 2)-1)) + var r, g, b Value + + switch { + case hue >= 0 && hue < 60: + r, g, b = c, x, 0 + case hue >= 60 && hue < 120: + r, g, b = x, c, 0 + case hue >= 120 && hue < 180: + r, g, b = 0, c, x + case hue >= 180 && hue < 240: + r, g, b = 0, x, c + case hue >= 240 && hue < 300: + r, g, b = x, 0, c + case hue >= 300 && hue < 360: + r, g, b = c, 0, x + } + + m := value - c + return RGB(r+m, g+m, b+m) +} diff --git a/HSV_test.go b/HSV_test.go new file mode 100644 index 0000000..c2b4bd3 --- /dev/null +++ b/HSV_test.go @@ -0,0 +1,26 @@ +package color_test + +import ( + "fmt" + "testing" + + "git.urbach.dev/go/color" +) + +func TestHSVSpectrum(t *testing.T) { + color.Terminal = true + color.TrueColor = true + + for value := range 21 { + for hue := range 80 { + h := color.Value(hue) * 4.4 + s := color.Value(1.0) + v := color.Value(value) * 0.05 + + c := color.HSV(h, s, v) + c.Print("█") + } + + fmt.Println() + } +} diff --git a/LCH.go b/LCH.go index fb2b662..c27d4df 100644 --- a/LCH.go +++ b/LCH.go @@ -19,12 +19,7 @@ func LCH(lightness Value, chroma Value, hue Value) Color { b *= chroma r, g, b := oklabToLinearRGB(lightness, a, b) - - r = sRGB(r) - g = sRGB(g) - b = sRGB(b) - - return Color{r, g, b} + return RGB(r, g, b) } // findChromaInSRGB tries to find the closest chroma that can be represented in sRGB color space. diff --git a/LCH_test.go b/LCH_test.go index 6dc33a2..59e9097 100644 --- a/LCH_test.go +++ b/LCH_test.go @@ -4,7 +4,7 @@ import ( "fmt" "testing" - "git.akyoto.dev/go/color" + "git.urbach.dev/go/color" ) func TestLCH(t *testing.T) { @@ -18,27 +18,29 @@ func TestLCH(t *testing.T) { "red": color.LCH(0.75, 1.0, 40), "orange": color.LCH(0.75, 1.0, 60), "yellow": color.LCH(0.9, 1.0, 100), - "green": color.LCH(0.75, 1.0, 150), + "green": color.LCH(0.75, 1.0, 135), "blue": color.LCH(0.75, 1.0, 260), "cyan": color.LCH(0.75, 1.0, 210), "magenta": color.LCH(0.75, 1.0, 320), } for name, c := range lchColors { - testColorRange(t, c) c.Println("█ " + name) } } func TestLCHSpectrum(t *testing.T) { color.Terminal = true + color.TrueColor = true for chroma := range 4 { for lightness := range 21 { for hue := range 80 { - c := color.LCH(color.Value(lightness)*0.05, color.Value(chroma)*0.05, color.Value(hue)*4.4) - testColorRange(t, c) - c.Print("█") + l := color.Value(lightness) * 0.05 + c := color.Value(chroma) * 0.05 + h := color.Value(hue) * 4.4 + col := color.LCH(l, c, h) + col.Print("█") } fmt.Println() diff --git a/README.md b/README.md index e7b485b..56fe49a 100644 --- a/README.md +++ b/README.md @@ -4,52 +4,60 @@ Adds color to your terminal output. ## Features -- RGB color space -- LCH color space (oklch) -- Truecolor terminal output -- Zero dependencies (excluding tests) +- ANSI colors +- HSL colors +- HSV colors +- LCH colors +- RGB colors ## Installation ```shell -go get git.akyoto.dev/go/color +go get git.urbach.dev/go/color ``` ## Usage ```go -red := color.RGB(1.0, 0.0, 0.0) -red.Println("red text") +// ANSI +ansi.Red.Println("red text") -orange := color.LCH(0.7, 1.0, 65) -orange.Println("orange text") +// LCH +green := color.LCH(0.5, 1.0, 135) +green.Println("green text") + +// RGB +blue := color.RGB(0, 0, 1) +blue.Println("blue text") ``` ## Tests ``` -PASS: TestFprint PASS: TestPrint +PASS: TestPrintf PASS: TestPrintln -PASS: TestRGB +PASS: TestHSLSpectrum +PASS: TestHSVSpectrum PASS: TestLCH PASS: TestLCHSpectrum +PASS: TestRGB coverage: 100.0% of statements ``` ## Benchmarks ``` -BenchmarkRGB-12 1000000000 0.3132 ns/op 0 B/op 0 allocs/op -BenchmarkLCH-12 4802006 249.8 ns/op 0 B/op 0 allocs/op -BenchmarkFprintColorized-12 6356535 188.4 ns/op 0 B/op 0 allocs/op -BenchmarkFprintRaw-12 27374659 43.76 ns/op 0 B/op 0 allocs/op +BenchmarkRGB-20 100000000 14.88 ns/op 0 B/op 0 allocs/op +BenchmarkLCH-20 5075756 227.8 ns/op 0 B/op 0 allocs/op +BenchmarkPrint-20 1587134 755.5 ns/op 0 B/op 0 allocs/op +BenchmarkPrintRaw-20 3166090 361.5 ns/op 0 B/op 0 allocs/op ``` ## License -Please see the [license documentation](https://akyoto.dev/license). +Please see the [license documentation](https://urbach.dev/license). ## Copyright -© 2024 Eduard Urbach +© 2024 Eduard Urbach \ No newline at end of file diff --git a/sRGB.go b/RGB.go similarity index 76% rename from sRGB.go rename to RGB.go index 1d522c8..3d94abc 100644 --- a/sRGB.go +++ b/RGB.go @@ -2,6 +2,15 @@ package color import "math" +// RGB creates a new sRGB color. +func RGB(r Value, g Value, b Value) Color { + return Color{ + byte(sRGB(r) * 255), + byte(sRGB(g) * 255), + byte(sRGB(b) * 255), + } +} + // inSRGB indicates whether the given color can be mapped to the sRGB color space. func inSRGB(l Value, a Value, b Value, chroma Value) bool { r, g, b := oklabToLinearRGB(l, a*chroma, b*chroma) diff --git a/RGB_test.go b/RGB_test.go new file mode 100644 index 0000000..a1f4481 --- /dev/null +++ b/RGB_test.go @@ -0,0 +1,29 @@ +package color_test + +import ( + "testing" + + "git.urbach.dev/go/color" +) + +func TestRGB(t *testing.T) { + color.Terminal = true + color.TrueColor = true + + rgbColors := map[string]color.Color{ + "black": color.RGB(0, 0, 0), + "white": color.RGB(1, 1, 1), + "gray": color.RGB(0.5, 0.5, 0.5), + "red": color.RGB(1, 0, 0), + "green": color.RGB(0, 1, 0), + "blue": color.RGB(0, 0, 1), + "cyan": color.RGB(0, 1, 1), + "yellow": color.RGB(1, 1, 0), + "orange": color.RGB(1, 0.5, 0), + "magenta": color.RGB(1, 0, 1), + } + + for name, c := range rgbColors { + c.Println("█ " + name) + } +} diff --git a/Terminal.go b/Terminal.go index e2a73d8..e47cf17 100644 --- a/Terminal.go +++ b/Terminal.go @@ -2,13 +2,12 @@ package color import ( "os" + + "git.urbach.dev/go/color/tty" ) -// These constants represent the escape codes needed to display color in terminals. -const ( - format = "\x1b[38;2;%d;%d;%dm%s\x1b[0m" - formatLine = "\x1b[38;2;%d;%d;%dm%s\n\x1b[0m" -) +// Terminal indicates if we're in a terminal with truecolor support. +var Terminal = tty.IsTerminal(os.Stdout.Fd()) -// Terminal is a boolean that indicates if we're in a terminal with truecolor support. -var Terminal = os.Getenv("COLORTERM") == "truecolor" +// TrueColor indicates if the terminal has 24-bit color support. +var TrueColor = os.Getenv("COLORTERM") == "truecolor" diff --git a/Value.go b/Value.go new file mode 100644 index 0000000..42635f1 --- /dev/null +++ b/Value.go @@ -0,0 +1,4 @@ +package color + +// Value is a type definition for the data type of a single color component. +type Value = float64 diff --git a/ansi/Code.go b/ansi/Code.go new file mode 100644 index 0000000..46c68bd --- /dev/null +++ b/ansi/Code.go @@ -0,0 +1,40 @@ +package ansi + +import ( + "fmt" + + "git.urbach.dev/go/color" +) + +// Code represents an ANSI escape code. +type Code int + +// Print writes the text in the given color to standard output. +func (code Code) Print(args ...any) { + if !color.Terminal { + fmt.Print(args...) + return + } + + fmt.Printf("\x1b[%dm%s\x1b[0m", code, fmt.Sprint(args...)) +} + +// Printf formats according to a format specifier and writes the text in the given color to standard output. +func (code Code) Printf(format string, args ...any) { + if !color.Terminal { + fmt.Printf(format, args...) + return + } + + fmt.Printf("\x1b[%dm%s\x1b[0m", code, fmt.Sprintf(format, args...)) +} + +// Println writes the text in the given color to standard output and appends a newline. +func (code Code) Println(args ...any) { + if !color.Terminal { + fmt.Println(args...) + return + } + + fmt.Printf("\x1b[%dm%s\n\x1b[0m", code, fmt.Sprint(args...)) +} diff --git a/ansi/Code_test.go b/ansi/Code_test.go new file mode 100644 index 0000000..64a1f47 --- /dev/null +++ b/ansi/Code_test.go @@ -0,0 +1,55 @@ +package ansi_test + +import ( + "testing" + + "git.urbach.dev/go/color" + "git.urbach.dev/go/color/ansi" +) + +func TestPrintRaw(t *testing.T) { + color.Terminal = false + testPrint() + testPrintf() + testPrintln() +} + +func TestPrint(t *testing.T) { + color.Terminal = true + testPrint() + testPrintf() + testPrintln() +} + +func testPrint() { + ansi.Black.Print("Black\n") + ansi.White.Print("White\n") + ansi.Red.Print("Red\n") + ansi.Green.Print("Green\n") + ansi.Blue.Print("Blue\n") + ansi.Yellow.Print("Yellow\n") + ansi.Magenta.Print("Magenta\n") + ansi.Cyan.Print("Cyan\n") +} + +func testPrintf() { + ansi.Black.Printf("%s\n", "Black") + ansi.White.Printf("%s\n", "White") + ansi.Red.Printf("%s\n", "Red") + ansi.Green.Printf("%s\n", "Green") + ansi.Blue.Printf("%s\n", "Blue") + ansi.Yellow.Printf("%s\n", "Yellow") + ansi.Magenta.Printf("%s\n", "Magenta") + ansi.Cyan.Printf("%s\n", "Cyan") +} + +func testPrintln() { + ansi.Black.Println("Black") + ansi.White.Println("White") + ansi.Red.Println("Red") + ansi.Green.Println("Green") + ansi.Blue.Println("Blue") + ansi.Yellow.Println("Yellow") + ansi.Magenta.Println("Magenta") + ansi.Cyan.Println("Cyan") +} diff --git a/ansi/Color.go b/ansi/Color.go new file mode 100644 index 0000000..65bacda --- /dev/null +++ b/ansi/Color.go @@ -0,0 +1,12 @@ +package ansi + +const ( + Black Code = iota + 30 + Red + Green + Yellow + Blue + Magenta + Cyan + White +) diff --git a/ansi/Format.go b/ansi/Format.go new file mode 100644 index 0000000..29bfcf6 --- /dev/null +++ b/ansi/Format.go @@ -0,0 +1,14 @@ +package ansi + +const ( + Reset Code = iota + Bold + Dim + Italic + Underline + Blink + BlinkFast + Reverse + Hidden + Strikethrough +) diff --git a/go.mod b/go.mod index 481b841..c3152ec 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ -module git.akyoto.dev/go/color +module git.urbach.dev/go/color -go 1.22.0 +go 1.24 -require git.akyoto.dev/go/assert v0.1.3 +require golang.org/x/sys v0.31.0 diff --git a/go.sum b/go.sum index 9fc2547..c55261f 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -git.akyoto.dev/go/assert v0.1.3 h1:QwCUbmG4aZYsNk/OuRBz1zWVKmGlDUHhOnnDBfn8Qw8= -git.akyoto.dev/go/assert v0.1.3/go.mod h1:0GzMaM0eURuDwtGkJJkCsI7r2aUKr+5GmWNTFPgDocM= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/tty/Terminal_bsd.go b/tty/Terminal_bsd.go new file mode 100644 index 0000000..15e06e0 --- /dev/null +++ b/tty/Terminal_bsd.go @@ -0,0 +1,11 @@ +//go:build darwin || freebsd + +package tty + +import "golang.org/x/sys/unix" + +// IsTerminal returns true if the file descriptor is a terminal. +func IsTerminal(fd uintptr) bool { + _, err := unix.IoctlGetTermios(int(fd), unix.TIOCGETA) + return err == nil +} diff --git a/tty/Terminal_linux.go b/tty/Terminal_linux.go new file mode 100644 index 0000000..023c564 --- /dev/null +++ b/tty/Terminal_linux.go @@ -0,0 +1,11 @@ +//go:build linux + +package tty + +import "golang.org/x/sys/unix" + +// IsTerminal returns true if the file descriptor is a terminal. +func IsTerminal(fd uintptr) bool { + _, err := unix.IoctlGetTermios(int(fd), unix.TCGETS) + return err == nil +} diff --git a/tty/Terminal_other.go b/tty/Terminal_other.go new file mode 100644 index 0000000..3ea5ee8 --- /dev/null +++ b/tty/Terminal_other.go @@ -0,0 +1,8 @@ +//go:build !darwin && !freebsd && !linux && !windows + +package tty + +// IsTerminal is always false on unsupported platforms. +func IsTerminal(fd uintptr) bool { + return false +} diff --git a/tty/Terminal_test.go b/tty/Terminal_test.go new file mode 100644 index 0000000..1f1a097 --- /dev/null +++ b/tty/Terminal_test.go @@ -0,0 +1,12 @@ +package tty_test + +import ( + "os" + "testing" + + "git.urbach.dev/go/color/tty" +) + +func TestIsTerminal(t *testing.T) { + tty.IsTerminal(os.Stdout.Fd()) +} diff --git a/tty/Terminal_windows.go b/tty/Terminal_windows.go new file mode 100644 index 0000000..0bf1927 --- /dev/null +++ b/tty/Terminal_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package tty + +import "golang.org/x/sys/windows" + +// IsTerminal returns true if the file descriptor is a terminal. +func IsTerminal(fd uintptr) bool { + var mode uint32 + err := windows.GetConsoleMode(windows.Handle(fd), &mode) + return err == nil +}