mirror of
https://github.com/taigrr/colorhash.git
synced 2026-04-01 18:58:45 -07:00
feat: add OKLCH-aware palette generation (#5)
* chore: update deps, fix escape codes, expand tests, add README - Bump Go 1.18 → 1.26, simplecolorpalettes v0.9.5 → v0.9.7 - Fix swapped fg/bg ANSI escape code values in trueColorString - Expand test suite from 2 to 13 tests (determinism, positivity, color assignment, background contrast, stringer palette) - Add README with usage examples * feat: add OKLCH-aware palette generation Add GenerateOKLCHPalette() which creates n evenly-spaced colors in the OKLCH color space at a given lightness and chroma. This produces perceptually uniform palettes where all colors appear equally bright. Uses the new OKLCH support from simplecolorpalettes. Note: go.mod contains a replace directive for local development that must be removed before merge (after simplecolorpalettes is published). * build: update simplecolorpalettes to v0.9.8 (charmtone palette)
This commit is contained in:
57
README.md
57
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
|
## Features
|
||||||
- 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)
|
|
||||||
|
|
||||||
- Offer to return Hex codes (6 digits or 3)
|
- **Deterministic** — same input always produces the same color
|
||||||
- Offer to return ascii escape codes
|
- **Pluggable palettes** — bring your own `ColorSet` or use [simplecolorpalettes](https://github.com/taigrr/simplecolorpalettes)
|
||||||
- If the input is text, offer to wrap the input text and return the output as a string
|
- **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
|
```bash
|
||||||
1. hash the input
|
go get github.com/taigrr/colorhash
|
||||||
1. use modulo against the sum to choose the color to return from the subset of colors selected.
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
@@ -101,9 +101,8 @@ func createStringerPalette(backgroundFillMode, disableSmartMode bool, c ...Color
|
|||||||
return palette
|
return palette
|
||||||
}
|
}
|
||||||
|
|
||||||
// TBD not yet complete
|
|
||||||
func trueColorString(color color.Color, backgroundFillMode, disableSmartMode bool) ColorStringer {
|
func trueColorString(color color.Color, backgroundFillMode, disableSmartMode bool) ColorStringer {
|
||||||
fgEsc, bgEsc := 48, 38
|
fgEsc, bgEsc := 38, 48
|
||||||
sprint := func(args ...interface{}) string {
|
sprint := func(args ...interface{}) string {
|
||||||
r, g, b, _ := color.RGBA()
|
r, g, b, _ := color.RGBA()
|
||||||
if !disableSmartMode {
|
if !disableSmartMode {
|
||||||
|
|||||||
6
go.mod
6
go.mod
@@ -1,5 +1,7 @@
|
|||||||
module github.com/taigrr/colorhash
|
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
|
||||||
|
|||||||
2
go.sum
2
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=
|
|
||||||
|
|||||||
144
hash_test.go
144
hash_test.go
@@ -1,12 +1,31 @@
|
|||||||
package colorhash
|
package colorhash
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image/color"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/taigrr/simplecolorpalettes/simplecolor"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHashBytes(t *testing.T) {
|
// testPalette wraps a slice of colors to satisfy ColorSet.
|
||||||
testBytes := []struct{}{}
|
type testPalette []color.Color
|
||||||
_ = testBytes
|
|
||||||
|
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) {
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
21
oklch.go
Normal file
21
oklch.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
68
oklch_test.go
Normal file
68
oklch_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user