summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/termpicker/main.go4
-rw-r--r--internal/colors/cmyk.go9
-rw-r--r--internal/colors/colors_test.go142
-rw-r--r--internal/colors/hsl.go108
-rw-r--r--internal/colors/precise.go6
-rw-r--r--internal/colors/rgb.go9
-rw-r--r--internal/picker/picker.go7
-rw-r--r--internal/preview/preview.go42
-rw-r--r--internal/switcher/switcher.go14
9 files changed, 334 insertions, 7 deletions
diff --git a/cmd/termpicker/main.go b/cmd/termpicker/main.go
index 31fe1c3..a6694d4 100644
--- a/cmd/termpicker/main.go
+++ b/cmd/termpicker/main.go
@@ -22,10 +22,10 @@ func AppAction(ctx *cli.Context) error {
// }}}
// CYMK {{{
c := slider.New('C', 100, progress.WithGradient("#006666", "#00ffff"))
- y := slider.New('Y', 100, progress.WithGradient("#666600", "#ffff00"))
m := slider.New('M', 100, progress.WithGradient("#660066", "#ff00ff"))
+ y := slider.New('Y', 100, progress.WithGradient("#666600", "#ffff00"))
k := slider.New('K', 100, progress.WithSolidFill("#000000"))
- cmyk := picker.New([]slider.Model{c, y, m, k}, "CMYK")
+ cmyk := picker.New([]slider.Model{c, m, y, k}, "CMYK")
// }}}
// HSL {{{
h := slider.New('H', 360, progress.WithDefaultGradient())
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...)
}