Skip to content

Commit

Permalink
feat: color conversion utilities for (hsv, rgb, and hex)
Browse files Browse the repository at this point in the history
  • Loading branch information
meowgorithm committed Sep 21, 2024
1 parent a4978c8 commit bb9237f
Show file tree
Hide file tree
Showing 5 changed files with 214 additions and 2 deletions.
145 changes: 145 additions & 0 deletions exp/color/conversion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package color

import (
"fmt"
"image/color"
"math"
)

// Color is convenience type that wraps an RGBA color with additional methods
// for converion.
type Color struct {
v color.RGBA
}

// RGBA returns the RGBA values of the color.
func (c Color) RGBA() (r, g, b, a uint32) {
return c.v.RGBA()
}

// Hex returns the hex value of the color as a string.
func (c Color) Hex() string {
return ColorToHex(c)
}

// HSV returns the HSV values of the color.
func (c Color) HSV() (h, s, v float64) {
return ColorToHSV(c)
}

// FromHSV sets the color from HSV values.
func (c Color) FromHSV(h, s, v float64) {
c.v = HSVToRGBA(h, s, v)
}

// FromRGB sets the color from RGB values.
func (c Color) FromRGB(r, g, b uint8) {
c.v.R = r
c.v.G = g
c.v.B = b
c.v.A = 255
}

// ColorToHex converts a color to a hex string.
func ColorToHex(c color.Color) string {
r, g, b, _ := c.RGBA()
return fmt.Sprintf("#%02X%02X%02X", r, g, b)
}

// HSVToRGBA converts HSV values to an RGBA color. HSV values should be in the
// ranges [0, 360], [0, 1], and [0, 1] respectively.
func HSVToRGBA(h, s, v float64) color.RGBA {
h = math.Mod(h, 360) // Ensure h is in the range [0, 360]
s = math.Max(0, math.Min(1, s)) // Clamp s to [0, 1]
v = math.Max(0, math.Min(1, v)) // Clamp v to [0, 1]

c := v * s
x := c * (1 - math.Abs(math.Mod(h/60, 2)-1))
m := v - c

var r, g, b float64

switch {
case h < 60:
r, g, b = c, x, 0
case h < 120:
r, g, b = x, c, 0
case h < 180:
r, g, b = 0, c, x
case h < 240:
r, g, b = 0, x, c
case h < 300:
r, g, b = x, 0, c
default:
r, g, b = c, 0, x
}

r = math.Round((r + m) * 255)
g = math.Round((g + m) * 255)
b = math.Round((b + m) * 255)

return color.RGBA{
R: uint8(r),
G: uint8(g),
B: uint8(b),
A: 255, // Full opacity
}
}

// ColorToHSV converts a color to HSV.
//
// A few things to note:
//
// - Due to rounding errors, we mayget a slightly different color when
// converting back to RGB.
// - The hue will be rounded to the nearest degree, while the saturation and
// value will be rounded to two decimal places.
func ColorToHSV(c color.Color) (h, s, v float64) {
r, g, b, _ := c.RGBA()

// Convert from uint32 (0-MaxUint32) to float64 (0-1).
rf := float64(r) / float64(0xFFFF)
gf := float64(g) / float64(0xFFFF)
bf := float64(b) / float64(0xFFFF)

minimum := math.Min(rf, math.Min(gf, bf))
maximum := math.Max(rf, math.Max(gf, bf))

v = maximum
delta := maximum - minimum

if maximum == 0 {
// Black.
s = 0
h = 0
return
}

s = delta / maximum

if delta == 0 {
// Gray.
h = 0
return
}

switch maximum {
case rf:
h = (gf - bf) / delta
if gf < bf {
h += 6
}
case gf:
h = 2 + (bf-rf)/delta
case bf:
h = 4 + (rf-gf)/delta
}

h *= 60

h = math.Round(h) // Round to nearest degree.
s = math.Round(s*100) / 100 // Round to two decimal places.
v = math.Round(v*100) / 100 // Round to two decimal places.

return
}
65 changes: 65 additions & 0 deletions exp/color/conversion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package color

import (
"image/color"
"testing"
)

func TestColorToHex(t *testing.T) {
for i, test := range []struct {
c color.Color
want string
}{
{color.RGBA{0, 0, 0, 255}, "#000000"},
{color.RGBA{255, 255, 255, 255}, "#FFFFFF"},
{color.RGBA{255, 0, 0, 255}, "#FF0000"},
{color.RGBA{0, 255, 0, 255}, "#00FF00"},
{color.RGBA{0, 0, 255, 255}, "#0000FF"},
{color.RGBA{107, 80, 255, 255}, "#6B50FF"},
} {
got := ColorToHex(test.c)
if got != test.want {
t.Errorf("Test %d: ColorToHex(%v)\nGot: %v\nWant: %v", i, test.c, got, test.want)
}
}
}

func TestHSVToRGBA(t *testing.T) {
for i, test := range []struct {
h, s, v float64
want color.RGBA
}{
{0, 0, 0, color.RGBA{0, 0, 0, 255}},
{0, 0, 1, color.RGBA{255, 255, 255, 255}},
{0, 1, 1, color.RGBA{255, 0, 0, 255}},
{120, 1, 1, color.RGBA{0, 255, 0, 255}},
{240, 1, 1, color.RGBA{0, 0, 255, 255}},
{249, 0.69, 1, color.RGBA{105, 79, 255, 255}},
} {
got := HSVToRGBA(test.h, test.s, test.v)
if got != test.want {
t.Errorf("Test %d: HSVToRGBA(%v, %v, %v)\nGot: %v\nWant: %v", i, test.h, test.s, test.v, got, test.want)
}
}
}

func TestColorToHSV(t *testing.T) {
for i, test := range []struct {
c color.Color
want struct {
h, s, v float64
}
}{
{color.RGBA{0, 0, 0, 255}, struct{ h, s, v float64 }{0, 0, 0}},
{color.RGBA{255, 255, 255, 255}, struct{ h, s, v float64 }{0, 0, 1}},
{color.RGBA{255, 0, 0, 255}, struct{ h, s, v float64 }{0, 1, 1}},
{color.RGBA{0, 255, 0, 255}, struct{ h, s, v float64 }{120, 1, 1}},
{color.RGBA{0, 0, 255, 255}, struct{ h, s, v float64 }{240, 1, 1}},
{color.RGBA{105, 79, 255, 255}, struct{ h, s, v float64 }{249, 0.69, 1}},
} {
h, s, v := ColorToHSV(test.c)
if h != test.want.h || s != test.want.s || v != test.want.v {
t.Errorf("Test %d: ColorToHSV(%v)\nGot: %v\nWant: %v", i, test.c, struct{ h, s, v float64 }{h, s, v}, test.want)
}
}
}
3 changes: 3 additions & 0 deletions exp/color/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/charmbracelet/x/exp/color

go 1.18
1 change: 1 addition & 0 deletions go.work
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use (
./editor
./errors
./examples
./exp/color
./exp/golden
./exp/higherorder
./exp/maps
Expand Down
2 changes: 0 additions & 2 deletions go.work.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
Expand All @@ -11,6 +10,5 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=

0 comments on commit bb9237f

Please sign in to comment.