diff --git a/README.md b/README.md index 9cc3cbb..a44582c 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,50 @@ +# colorhash +Deterministic color assignment from arbitrary input. Feed it a string or byte stream and get back a consistent color every time. - - Take in arbitrary input and return a deterministic color - - Color chosen can be limited in several ways: - - only visually / noticibly distinct colors to choose from - - Color exclusions - - dynamic color exclusions (optional terminal context) - - colors within different terminal support classes (i.e. term-256) +## Features - - Offer to return Hex codes (6 digits or 3) - - Offer to return ascii escape codes - - If the input is text, offer to wrap the input text and return the output as a string +- **Deterministic** — same input always produces the same color +- **Pluggable palettes** — bring your own `ColorSet` or use [simplecolorpalettes](https://github.com/taigrr/simplecolorpalettes) +- **Multiple output formats** — `color.Color`, ANSI escape codes, true-color terminal strings +- **String and byte input** — hash strings directly or stream bytes via `io.Reader` +## Install -1. take input as bytes -1. hash the input -1. use modulo against the sum to choose the color to return from the subset of colors selected. +```bash +go get github.com/taigrr/colorhash +``` + +## Usage + +### Hash a string to a color + +```go +import ( + "github.com/taigrr/colorhash" + "github.com/taigrr/simplecolorpalettes/palettes/html" +) + +palette := html.GetPalette() // or any ColorSet +c := colorhash.StringToColor(palette, "username") +// c is a deterministic color.Color +``` + +### ANSI terminal colors + +```go +fmt.Println(colorhash.Red("error message")) +fmt.Println(colorhash.Green("success")) +fmt.Println(colorhash.BIYellow("bold high-intensity yellow")) +``` + +### Automatic color from a palette + +```go +sp := colorhash.CreateStringerPalette(false, false, palette) +colored := sp.GetString("username") // wraps "username" in its assigned color +``` + +## License + +0BSD diff --git a/colors.go b/colors.go index 14e6fd6..c50b685 100644 --- a/colors.go +++ b/colors.go @@ -101,9 +101,8 @@ func createStringerPalette(backgroundFillMode, disableSmartMode bool, c ...Color return palette } -// TBD not yet complete func trueColorString(color color.Color, backgroundFillMode, disableSmartMode bool) ColorStringer { - fgEsc, bgEsc := 48, 38 + fgEsc, bgEsc := 38, 48 sprint := func(args ...interface{}) string { r, g, b, _ := color.RGBA() if !disableSmartMode { diff --git a/go.mod b/go.mod index 7c2b734..6c19357 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,7 @@ module github.com/taigrr/colorhash -go 1.18 +go 1.26 -require github.com/taigrr/simplecolorpalettes v0.9.5 +require github.com/taigrr/simplecolorpalettes v0.9.8 + +replace github.com/taigrr/simplecolorpalettes => ../simplecolorpallette diff --git a/go.sum b/go.sum index 90ea533..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +0,0 @@ -github.com/taigrr/simplecolorpalettes v0.9.5 h1:XPyRYwCHh+0ra/7Qw5c9yQf/O4yeLkuqx2X1tVuBE2U= -github.com/taigrr/simplecolorpalettes v0.9.5/go.mod h1:MFLQqI3JOfSc+8GiO3amYfzBiozxITaQi+F1iEV8XpQ= diff --git a/hash_test.go b/hash_test.go index 92a6d76..cc269d0 100644 --- a/hash_test.go +++ b/hash_test.go @@ -1,12 +1,31 @@ package colorhash import ( + "bytes" + "image/color" "testing" + + "github.com/taigrr/simplecolorpalettes/simplecolor" ) -func TestHashBytes(t *testing.T) { - testBytes := []struct{}{} - _ = testBytes +// testPalette wraps a slice of colors to satisfy ColorSet. +type testPalette []color.Color + +func (p testPalette) ToPalette() color.Palette { return color.Palette(p) } +func (p testPalette) Get(i int) color.Color { return p[i] } +func (p testPalette) Len() int { return len(p) } + +func newTestPalette() testPalette { + return testPalette{ + simplecolor.FromRGBA(255, 0, 0, 255), + simplecolor.FromRGBA(0, 255, 0, 255), + simplecolor.FromRGBA(0, 0, 255, 255), + simplecolor.FromRGBA(255, 255, 0, 255), + simplecolor.FromRGBA(255, 0, 255, 255), + simplecolor.FromRGBA(0, 255, 255, 255), + simplecolor.FromRGBA(128, 128, 128, 255), + simplecolor.FromRGBA(255, 128, 0, 255), + } } func TestHashString(t *testing.T) { @@ -29,3 +48,122 @@ func TestHashString(t *testing.T) { }) } } + +func TestHashStringDeterministic(t *testing.T) { + input := "deterministic-test" + h1 := HashString(input) + h2 := HashString(input) + if h1 != h2 { + t.Errorf("HashString is not deterministic: %d != %d", h1, h2) + } +} + +func TestHashStringPositive(t *testing.T) { + inputs := []string{"", "a", "test", "negative?", "🎨"} + for _, s := range inputs { + h := HashString(s) + if h < 0 { + t.Errorf("HashString(%q) returned negative value: %d", s, h) + } + } +} + +func TestHashBytes(t *testing.T) { + input := []byte("hello colorhash") + h := HashBytes(bytes.NewReader(input)) + expected := HashString("hello colorhash") + if h != expected { + t.Errorf("HashBytes and HashString diverged for same input: %d != %d", h, expected) + } +} + +func TestHashBytesDeterministic(t *testing.T) { + input := []byte("deterministic") + h1 := HashBytes(bytes.NewReader(input)) + h2 := HashBytes(bytes.NewReader(input)) + if h1 != h2 { + t.Errorf("HashBytes is not deterministic: %d != %d", h1, h2) + } +} + +func TestStringToColor(t *testing.T) { + palette := newTestPalette() + c := StringToColor(palette, "test") + if c == nil { + t.Fatal("StringToColor returned nil") + } +} + +func TestBytesToColor(t *testing.T) { + palette := newTestPalette() + c := BytesToColor(palette, bytes.NewReader([]byte("test"))) + if c == nil { + t.Fatal("BytesToColor returned nil") + } +} + +func TestStringToColorDeterministic(t *testing.T) { + palette := newTestPalette() + c1 := StringToColor(palette, "consistent") + c2 := StringToColor(palette, "consistent") + r1, g1, b1, a1 := c1.RGBA() + r2, g2, b2, a2 := c2.RGBA() + if r1 != r2 || g1 != g2 || b1 != b2 || a1 != a2 { + t.Error("StringToColor is not deterministic") + } +} + +func TestColorString(t *testing.T) { + result := Red("error") + if result == "" { + t.Fatal("ColorString returned empty string") + } + if result == "error" { + t.Fatal("ColorString did not wrap with escape codes") + } +} + +func TestGetBackgroundColor(t *testing.T) { + // White text should get black background + white := simplecolor.FromRGBA(255, 255, 255, 0) + bg := GetBackgroundColor(white) + r, g, b, _ := bg.RGBA() + if r != 0 || g != 0 || b != 0 { + t.Errorf("expected black background for white, got (%d,%d,%d)", r, g, b) + } + + // Black text should get white background + black := simplecolor.FromRGBA(0, 0, 0, 0) + bg2 := GetBackgroundColor(black) + r2, g2, b2, _ := bg2.RGBA() + if r2 == 0 && g2 == 0 && b2 == 0 { + t.Error("expected white background for black, got black") + } +} + +func TestStringerPalette(t *testing.T) { + palette := newTestPalette() + sp := createStringerPalette(false, false, palette) + if len(sp) == 0 { + t.Fatal("createStringerPalette returned empty palette") + } + result := sp.GetString("test") + if result == "" { + t.Fatal("GetString returned empty string") + } +} + +func TestDifferentInputsDifferentColors(t *testing.T) { + palette := newTestPalette() + type rgba struct{ r, g, b, a uint32 } + colors := make(map[rgba]bool) + inputs := []string{"alice", "bob", "charlie", "dave", "eve", "frank", "grace", "heidi"} + for _, s := range inputs { + c := StringToColor(palette, s) + r, g, b, a := c.RGBA() + colors[rgba{r, g, b, a}] = true + } + if len(colors) < 2 { + t.Errorf("expected multiple distinct colors, got %d", len(colors)) + } +} diff --git a/oklch.go b/oklch.go new file mode 100644 index 0000000..4af8c24 --- /dev/null +++ b/oklch.go @@ -0,0 +1,21 @@ +package colorhash + +import ( + "github.com/taigrr/simplecolorpalettes/simplecolor" +) + +// GenerateOKLCHPalette generates n evenly-spaced colors in the OKLCH color space +// at the given lightness and chroma. This produces a perceptually uniform palette +// where all colors appear equally bright and saturated. +func GenerateOKLCHPalette(n int, l, c float64) simplecolor.SimplePalette { + if n <= 0 { + return simplecolor.SimplePalette{} + } + palette := make(simplecolor.SimplePalette, n) + step := 360.0 / float64(n) + for i := 0; i < n; i++ { + h := float64(i) * step + palette[i] = simplecolor.FromOKLCH(l, c, h) + } + return palette +} diff --git a/oklch_test.go b/oklch_test.go new file mode 100644 index 0000000..1162031 --- /dev/null +++ b/oklch_test.go @@ -0,0 +1,68 @@ +package colorhash + +import ( + "math" + "testing" +) + +func TestGenerateOKLCHPalette(t *testing.T) { + palette := GenerateOKLCHPalette(8, 0.7, 0.15) + + if len(palette) != 8 { + t.Fatalf("expected 8 colors, got %d", len(palette)) + } + + // All colors should have approximately the same lightness and chroma + for i, c := range palette { + oklch := c.ToOKLCH() + if math.Abs(oklch.L-0.7) > 0.05 { + t.Errorf("color %d: L = %f, expected ~0.7", i, oklch.L) + } + if math.Abs(oklch.C-0.15) > 0.03 { + t.Errorf("color %d: C = %f, expected ~0.15", i, oklch.C) + } + } + + // Hues should be evenly spaced (45° apart for 8 colors) + for i, c := range palette { + oklch := c.ToOKLCH() + expectedH := float64(i) * 45.0 + diff := math.Abs(oklch.H - expectedH) + if diff > 180 { + diff = 360 - diff + } + if diff > 10.0 { + t.Errorf("color %d: H = %f, expected ~%f", i, oklch.H, expectedH) + } + } +} + +func TestGenerateOKLCHPaletteEmpty(t *testing.T) { + palette := GenerateOKLCHPalette(0, 0.7, 0.15) + if len(palette) != 0 { + t.Errorf("expected empty palette, got %d colors", len(palette)) + } +} + +func TestGenerateOKLCHPaletteSingle(t *testing.T) { + palette := GenerateOKLCHPalette(1, 0.6, 0.1) + if len(palette) != 1 { + t.Fatalf("expected 1 color, got %d", len(palette)) + } + oklch := palette[0].ToOKLCH() + if oklch.H > 5.0 && oklch.H < 355.0 { + t.Errorf("single color H = %f, expected ~0", oklch.H) + } +} + +func TestGenerateOKLCHPaletteDistinct(t *testing.T) { + palette := GenerateOKLCHPalette(6, 0.7, 0.15) + // All colors should be distinct + seen := make(map[int]bool) + for _, c := range palette { + if seen[int(c)] { + t.Errorf("duplicate color: %s", c.ToHex()) + } + seen[int(c)] = true + } +}