Compare commits

...

10 commits

Author SHA1 Message Date
afc4092824
Added HSL and HSV colors
All checks were successful
/ test (push) Successful in 15s
2025-03-11 14:24:41 +01:00
b8b0fbb57e
Updated module path 2025-02-25 16:37:15 +01:00
cb8aacf9b8
Added FreeBSD support 2025-02-16 17:28:10 +01:00
77b109b517
Added Windows support 2025-02-11 12:19:55 +01:00
77e626de5d
Added ANSI colors for old terminals 2024-03-21 23:23:43 +01:00
e0bfa137d5
Added more tests 2024-03-12 15:23:08 +01:00
71c74c800b
Added terminal detection 2024-03-12 00:25:20 +01:00
929938dcd6
Added ANSI colors 2024-03-11 23:28:37 +01:00
55da31ffd6
Added missing test case 2024-03-11 22:09:45 +01:00
6db2852d9e
Improved interface 2024-03-11 22:08:28 +01:00
25 changed files with 424 additions and 131 deletions

View file

@ -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("")
}
}

View file

@ -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...))
}

View file

@ -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)
}

29
HSL.go Normal file
View file

@ -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)
}

26
HSL_test.go Normal file
View file

@ -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()
}
}

29
HSV.go Normal file
View file

@ -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)
}

26
HSV_test.go Normal file
View file

@ -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()
}
}

7
LCH.go
View file

@ -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.

View file

@ -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()

View file

@ -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

View file

@ -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)

29
RGB_test.go Normal file
View file

@ -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)
}
}

View file

@ -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"

4
Value.go Normal file
View file

@ -0,0 +1,4 @@
package color
// Value is a type definition for the data type of a single color component.
type Value = float64

40
ansi/Code.go Normal file
View file

@ -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...))
}

55
ansi/Code_test.go Normal file
View file

@ -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")
}

12
ansi/Color.go Normal file
View file

@ -0,0 +1,12 @@
package ansi
const (
Black Code = iota + 30
Red
Green
Yellow
Blue
Magenta
Cyan
White
)

14
ansi/Format.go Normal file
View file

@ -0,0 +1,14 @@
package ansi
const (
Reset Code = iota
Bold
Dim
Italic
Underline
Blink
BlinkFast
Reverse
Hidden
Strikethrough
)

6
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

11
tty/Terminal_bsd.go Normal file
View file

@ -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
}

11
tty/Terminal_linux.go Normal file
View file

@ -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
}

8
tty/Terminal_other.go Normal file
View file

@ -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
}

12
tty/Terminal_test.go Normal file
View file

@ -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())
}

12
tty/Terminal_windows.go Normal file
View file

@ -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
}