diff options
author | Benjamin Chausse <benjamin@chausse.xyz> | 2024-11-23 21:05:11 -0500 |
---|---|---|
committer | Benjamin Chausse <benjamin@chausse.xyz> | 2024-11-23 21:05:11 -0500 |
commit | 4d25e4ece0b72d240bb2565f8abb7389e650990a (patch) | |
tree | 55af982b45d9ed576871c6f3ccf5f800cddc9b56 /internal | |
parent | b42ab480dd4c4eec83d79bba9400232ddb79f6b1 (diff) |
Preview + Unit-tests for color conversions
Diffstat (limited to 'internal')
-rw-r--r-- | internal/colors/cmyk.go | 9 | ||||
-rw-r--r-- | internal/colors/colors_test.go | 142 | ||||
-rw-r--r-- | internal/colors/hsl.go | 108 | ||||
-rw-r--r-- | internal/colors/precise.go | 6 | ||||
-rw-r--r-- | internal/colors/rgb.go | 9 | ||||
-rw-r--r-- | internal/picker/picker.go | 7 | ||||
-rw-r--r-- | internal/preview/preview.go | 42 | ||||
-rw-r--r-- | internal/switcher/switcher.go | 14 |
8 files changed, 332 insertions, 5 deletions
diff --git a/internal/colors/cmyk.go b/internal/colors/cmyk.go index 3460c4e..b5ef223 100644 --- a/internal/colors/cmyk.go +++ b/internal/colors/cmyk.go @@ -1,6 +1,9 @@ package colors -import "math" +import ( + "fmt" + "math" +) type CMYK struct { C int // 0-100 @@ -9,6 +12,10 @@ type CMYK struct { K int // 0-100 } +func (c CMYK) String() string { + return fmt.Sprintf("cmyk(%d, %d, %d, %d)", c.C, c.M, c.Y, c.K) +} + func (c CMYK) ToPrecise() PreciseColor { return PreciseColor{ R: (1 - float64(c.C)/100) * (1 - float64(c.K)/100), diff --git a/internal/colors/colors_test.go b/internal/colors/colors_test.go new file mode 100644 index 0000000..569d7e2 --- /dev/null +++ b/internal/colors/colors_test.go @@ -0,0 +1,142 @@ +package colors + +import ( + "math" + "testing" +) + +const ( + PCmaxDelta = 1e-8 + AssertTemplate = "Testing '%v'. Expected %v to become: %v Got: %v" +) + +type equivalentColors struct { + name string + pc PreciseColor + rgb RGB + cmyk CMYK + hsl HSL +} + +func pcDeltaOk(a, b PreciseColor) bool { + return math.Abs(a.R-b.R) < PCmaxDelta && + math.Abs(a.G-b.G) < PCmaxDelta && + math.Abs(a.B-b.B) < PCmaxDelta +} + +func getEquivalents() []equivalentColors { + return []equivalentColors{ + // Black & White {{{ + { + "Pure White", + PreciseColor{1, 1, 1}, + RGB{255, 255, 255}, + CMYK{0, 0, 0, 0}, + HSL{0, 0, 100}, + }, + { + "Pure Black", + PreciseColor{0, 0, 0}, + RGB{0, 0, 0}, + CMYK{0, 0, 0, 100}, + HSL{0, 0, 0}, + }, + // }}} + // Pure RGB {{{ + { + "Red", + PreciseColor{1, 0, 0}, + RGB{255, 0, 0}, + CMYK{0, 100, 100, 0}, + HSL{0, 100, 50}, + }, + { + "Green", + PreciseColor{0, 1, 0}, + RGB{0, 255, 0}, + CMYK{100, 0, 100, 0}, + HSL{120, 100, 50}, + }, + { + "Blue", + PreciseColor{0, 0, 1}, + RGB{0, 0, 255}, + CMYK{100, 100, 0, 0}, + HSL{240, 100, 50}, + }, + // }}} + // Pure CMYK {{{ + { + "Cyan", + PreciseColor{0, 1, 1}, + RGB{0, 255, 255}, + CMYK{100, 0, 0, 0}, + HSL{180, 100, 50}, + }, + { + "Magenta", + PreciseColor{1, 0, 1}, + RGB{255, 0, 255}, + CMYK{0, 100, 0, 0}, + HSL{300, 100, 50}, + }, + { + "Yellow", + PreciseColor{1, 1, 0}, + RGB{255, 255, 0}, + CMYK{0, 0, 100, 0}, + HSL{60, 100, 50}, + }, + // note: Black is already tested + // }}} + // TODO: add less pure colors to test luminance and saturation better + } +} + +func TestToPreciseColor(t *testing.T) { + for _, ce := range getEquivalents() { + target := ce.pc + for _, cs := range []ColorSpace{ce.rgb, ce.cmyk, ce.hsl} { + pc := cs.ToPrecise() + if !pcDeltaOk(pc, target) { + t.Errorf(AssertTemplate, ce.name, cs, target, pc) + } + } + } +} + +func TestToRgb(t *testing.T) { + for _, ce := range getEquivalents() { + target := ce.rgb + for _, cs := range []ColorSpace{ce.pc, ce.cmyk, ce.hsl} { + rgb := RGB{}.FromPrecise(cs.ToPrecise()).(RGB) + if rgb != target { + t.Errorf(AssertTemplate, ce.name, cs, target, rgb) + } + } + } +} + +func TestToCmyk(t *testing.T) { + for _, ce := range getEquivalents() { + target := ce.cmyk + for _, cs := range []ColorSpace{ce.pc, ce.rgb, ce.hsl} { + cmyk := CMYK{}.FromPrecise(cs.ToPrecise()).(CMYK) + if cmyk != target { + t.Errorf(AssertTemplate, ce.name, cs, target, cmyk) + } + } + } +} + +func TestToHsl(t *testing.T) { + for _, ce := range getEquivalents() { + target := ce.hsl + for _, cs := range []ColorSpace{ce.pc, ce.rgb, ce.cmyk} { + hsl := HSL{}.FromPrecise(cs.ToPrecise()).(HSL) + if hsl != target { + t.Errorf(AssertTemplate, ce.name, cs, target, hsl) + } + } + } +} diff --git a/internal/colors/hsl.go b/internal/colors/hsl.go index 7477042..2a31f81 100644 --- a/internal/colors/hsl.go +++ b/internal/colors/hsl.go @@ -1 +1,109 @@ package colors + +import ( + "fmt" + "math" +) + +type HSL struct { + H int // 0-360 + S int // 0-100 + L int // 0-100 +} + +func (h HSL) String() string { + return fmt.Sprintf("hsl(%d, %d, %d)", h.H, h.S, h.L) +} + +func (h HSL) ToPrecise() PreciseColor { + // Normalize H, S, L + hue := float64(h.H) / 360.0 + sat := float64(h.S) / 100.0 + light := float64(h.L) / 100.0 + + var r, g, b float64 + + if sat == 0 { + // Achromatic case + r, g, b = light, light, light + } else { + var q float64 + if light < 0.5 { + q = light * (1 + sat) + } else { + q = light + sat - (light * sat) + } + p := 2*light - q + r = hueToRGB(p, q, hue+1.0/3.0) + g = hueToRGB(p, q, hue) + b = hueToRGB(p, q, hue-1.0/3.0) + } + + return PreciseColor{R: r, G: g, B: b} +} + +func hueToRGB(p, q, t float64) float64 { + if t < 0 { + t += 1 + } + if t > 1 { + t -= 1 + } + if t < 1.0/6.0 { + return p + (q-p)*6*t + } + if t < 1.0/2.0 { + return q + } + if t < 2.0/3.0 { + return p + (q-p)*(2.0/3.0-t)*6 + } + return p +} + +func (h HSL) FromPrecise(p PreciseColor) ColorSpace { + r := p.R + g := p.G + b := p.B + + max := math.Max(math.Max(r, g), b) + min := math.Min(math.Min(r, g), b) + delta := max - min + + light := (max + min) / 2 + var sat, hue float64 + + if delta == 0 { + // Achromatic case + hue, sat = 0, 0 + } else { + if light < 0.5 { + sat = delta / (max + min) + } else { + sat = delta / (2 - max - min) + } + + switch max { + case r: + hue = (g-b)/delta + (6 * boolToFloat64(g < b)) + case g: + hue = (b-r)/delta + 2 + case b: + hue = (r-g)/delta + 4 + } + hue /= 6 + } + + return HSL{ + H: int(math.Round(hue * 360)), + S: int(math.Round(sat * 100)), + L: int(math.Round(light * 100)), + } +} + +func boolToFloat64(b bool) float64 { + if b { + return 1 + } + return 0 +} diff --git a/internal/colors/precise.go b/internal/colors/precise.go index fafa104..a58d7c5 100644 --- a/internal/colors/precise.go +++ b/internal/colors/precise.go @@ -11,6 +11,10 @@ type ColorSpace interface { FromPrecise(PreciseColor) ColorSpace } +func (c PreciseColor) String() string { + return fmt.Sprintf("PC(%.4f, %.4f, %.4f)", c.R, c.G, c.B) +} + // PreciseColor is a color with floating point values for red, green, and blue. // The extra precision minimizes rounding errors when converting between different // color spaces. It is used as an intermediate representation when converting between @@ -27,7 +31,7 @@ func (c PreciseColor) FromPrecise(p PreciseColor) ColorSpace { return p } -func ToHexString(cs ColorSpace) string { +func Hex(cs ColorSpace) string { p := cs.ToPrecise() return strings.ToUpper(fmt.Sprintf("#%02x%02x%02x", diff --git a/internal/colors/rgb.go b/internal/colors/rgb.go index e81aefc..0cc48da 100644 --- a/internal/colors/rgb.go +++ b/internal/colors/rgb.go @@ -1,6 +1,9 @@ package colors -import "math" +import ( + "fmt" + "math" +) type RGB struct { R int // 0-255 @@ -8,6 +11,10 @@ type RGB struct { B int // 0-255 } +func (c RGB) String() string { + return fmt.Sprintf("rgb(%d, %d, %d)", c.R, c.G, c.B) +} + func (c RGB) ToPrecise() PreciseColor { return PreciseColor{ R: float64(c.R) / 255, diff --git a/internal/picker/picker.go b/internal/picker/picker.go index e3c396e..6c0a79a 100644 --- a/internal/picker/picker.go +++ b/internal/picker/picker.go @@ -62,7 +62,12 @@ func (m Model) GetColor() colors.ColorSpace { Y: m.sliders[2].Val(), K: m.sliders[3].Val(), } - // TODO: HSL + case "HSL": + return colors.HSL{ + H: m.sliders[0].Val(), + S: m.sliders[1].Val(), + L: m.sliders[2].Val(), + } default: // Default to white if we don't know the color space return colors.RGB{ R: 255, diff --git a/internal/preview/preview.go b/internal/preview/preview.go new file mode 100644 index 0000000..ef4c9f0 --- /dev/null +++ b/internal/preview/preview.go @@ -0,0 +1,42 @@ +package preview + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const runeBlock = "█" + +type Model struct { + size int // height of the square in rows + hex string +} + +func (m *Model) Color(hex string) { + m.hex = hex +} + +func New(hex string) *Model { + return &Model{ + size: 5, + hex: hex, + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m Model) View() string { + style := lipgloss.NewStyle().Foreground(lipgloss.Color(m.hex)) + // size is doubled since terminal cells are 2:1 (h:w) + oneRow := strings.Repeat(runeBlock, m.size*2) + block := strings.Repeat(oneRow+"\n", m.size) + return style.Render(block) +} diff --git a/internal/switcher/switcher.go b/internal/switcher/switcher.go index d542df1..0680996 100644 --- a/internal/switcher/switcher.go +++ b/internal/switcher/switcher.go @@ -5,19 +5,23 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbletea-app-template/internal/colors" "github.com/charmbracelet/bubbletea-app-template/internal/picker" + "github.com/charmbracelet/bubbletea-app-template/internal/preview" "github.com/charmbracelet/bubbletea-app-template/internal/quit" ) type Model struct { active int pickers []picker.Model + preview preview.Model } func New(pickers []picker.Model) Model { return Model{ active: 0, pickers: pickers, + preview: *preview.New(colors.Hex(pickers[0].GetColor())), } } @@ -53,7 +57,7 @@ func (m Model) View() string { v += fmt.Sprintf(" %s |", p.Title()) } } - return fmt.Sprintf("%s\n%s", v, m.pickers[m.active].View()) + return fmt.Sprintf("%s\n%s\n%s", v, m.pickers[m.active].View(), m.preview.View()) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -74,9 +78,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return quit.Model{}, tea.Quit // return m, tea.Quit default: + // Update the picker newActive, cmd := m.pickers[m.active].Update(msg) m.pickers[m.active] = newActive.(picker.Model) cmds = append(cmds, cmd) + // Update the preview + newPreview := preview.New(colors.Hex(m.pickers[m.active].GetColor())) + m.preview = *newPreview + return m, tea.Batch(cmds...) } } @@ -85,5 +94,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pickers[i] = newActive.(picker.Model) cmds = append(cmds, cmd) } + // Update the preview + newPreview := preview.New(colors.Hex(m.pickers[m.active].GetColor())) + m.preview = *newPreview return m, tea.Batch(cmds...) } |