Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 88 additions & 25 deletions core/internal/dank16/dank16.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package dank16
import (
"fmt"
"math"
"strconv"

"github.com/lucasb-eyer/go-colorful"
)
Expand All @@ -11,27 +12,38 @@ type RGB struct {
R, G, B float64
}

type HSL struct {
H, S, L float64
}

type HSV struct {
H, S, V float64
}

type ColorInfo struct {
Hex string `json:"hex"`
HexStripped string `json:"hex_stripped"`
R int `json:"r"`
G int `json:"g"`
B int `json:"b"`
}

type VariantColorValue struct {
Hex string `json:"hex"`
HexStripped string `json:"hex_stripped"`
Hex string `json:"hex"`
HexStripped string `json:"hex_stripped"`
HexAlpha string `json:"hex_alpha"`
HexAlphaStripped string `json:"hex_alpha_stripped"`
AlphaHex string `json:"alpha_hex"`
AlphaHexStripped string `json:"alpha_hex_stripped"`
RGB string `json:"rgb"`
RGBA string `json:"rgba"`
HSL string `json:"hsl"`
HSLA string `json:"hsla"`
R string `json:"red"`
G string `json:"green"`
B string `json:"blue"`
Comment on lines +34 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compat note: this drops the "r", "g", "b" JSON keys (and changes them from numbers to strings). The in-repo templates under quickshell/matugen/templates/ only use hex/hex_stripped, so nothing here breaks, but any user template consuming dank16.colorN.r from dms dank16 --json silently renders empty after this. That makes the change breaking despite the "non-breaking" checkbox — either keep r/g/b as additional aliases or call it out in the PR description / release notes.

@pdf pdf Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a legitimate complaint if users are consuming this data directly using the non-standard keys. I can revert the R, G, B keys to their original int values and add new Red, Green, Blue keys to carry the new string values if this is required.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah I'm fine with this since it's non breaking

A string `json:"alpha"`
H string `json:"hue"`
S string `json:"saturation"`
L string `json:"lightness"`
}

type VariantColorInfo struct {
Dark VariantColorValue `json:"dark"`
Light VariantColorValue `json:"light"`
Default VariantColorValue `json:"default"`
Dark ColorInfo `json:"dark"`
Light ColorInfo `json:"light"`
Default ColorInfo `json:"default"`
}

type Palette struct {
Expand Down Expand Up @@ -74,16 +86,41 @@ type VariantPalette struct {

func NewColorInfo(hex string) ColorInfo {
rgb := HexToRGB(hex)
hsl := RGBToHSL(rgb)

var r, g, b, a, h, s, l string

r = strconv.FormatInt(int64(math.Round(rgb.R*255)), 10)
g = strconv.FormatInt(int64(math.Round(rgb.G*255)), 10)
b = strconv.FormatInt(int64(math.Round(rgb.B*255)), 10)
a = "1.0"

h = strconv.FormatFloat(math.Round(hsl.H*3600)/10, 'f', 1, 64)
s = fmt.Sprintf("%.1f%%", math.Round(hsl.S*1000.0)/10.0)
l = fmt.Sprintf("%.1f%%", math.Round(hsl.L*1000.0)/10.0)

stripped := hex
if len(hex) > 0 && hex[0] == '#' {
stripped = hex[1:]
}
return ColorInfo{
Hex: hex,
HexStripped: stripped,
R: int(math.Round(rgb.R * 255)),
G: int(math.Round(rgb.G * 255)),
B: int(math.Round(rgb.B * 255)),
Hex: hex,
HexStripped: stripped,
HexAlpha: hex + "ff",
HexAlphaStripped: stripped + "ff",
AlphaHex: "#ff" + stripped,
AlphaHexStripped: "ff" + stripped,
RGB: fmt.Sprintf("rgb(%s, %s, %s)", r, g, b),
RGBA: fmt.Sprintf("rgba(%s, %s, %s, %s)", r, g, b, a),
HSL: fmt.Sprintf("hsl(%s, %s, %s)", h, s, l),
HSLA: fmt.Sprintf("hsla(%s, %s, %s, %s)", h, s, l, a),
R: r,
G: g,
B: b,
A: a,
H: h,
S: s,
L: l,
}
}

Expand Down Expand Up @@ -138,6 +175,35 @@ func RGBToHSV(rgb RGB) HSV {
return HSV{H: h, S: s, V: max}
}

func RGBToHSL(rgb RGB) HSL {
max := math.Max(math.Max(rgb.R, rgb.G), rgb.B)
min := math.Min(math.Min(rgb.R, rgb.G), rgb.B)
delta := max - min
var h, s, l float64

l = (max + min) / 2
if delta == 0 {
return HSL{H: 0, S: 0, L: l}
}

switch max {
case rgb.R:
h = math.Mod((rgb.G-rgb.B)/delta, 6.0) / 6.0
case rgb.G:
h = ((rgb.B-rgb.R)/delta + 2.0) / 6.0
default:
h = ((rgb.R-rgb.G)/delta + 4.0) / 6.0
}
Comment thread
pdf marked this conversation as resolved.

if h < 0 {
h += 1.0
}

s = delta / (1 - math.Abs((2*l)-1))

return HSL{H: h, S: s, L: l}
}

func HSVToRGB(hsv HSV) RGB {
h := hsv.H * 6.0
c := hsv.V * hsv.S
Expand Down Expand Up @@ -623,17 +689,14 @@ type VariantOptions struct {
}

func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
darkVal := VariantColorValue{Hex: dark.Hex, HexStripped: dark.HexStripped}
lightVal := VariantColorValue{Hex: light.Hex, HexStripped: light.HexStripped}

defaultVal := darkVal
defaultVal := dark
if isLightMode {
defaultVal = lightVal
defaultVal = light
}

return VariantColorInfo{
Dark: darkVal,
Light: lightVal,
Dark: dark,
Light: light,
Default: defaultVal,
}
}
Expand Down
151 changes: 132 additions & 19 deletions core/internal/dank16/dank16_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package dank16

import (
"bytes"
"encoding/json"
"math"
"testing"
)
Expand Down Expand Up @@ -91,6 +93,54 @@ func TestRGBToHex(t *testing.T) {
}
}

func TestRGBToHSL(t *testing.T) {
tests := []struct {
name string
input RGB
expected HSL
}{
{
name: "black",
input: RGB{R: 0.0, G: 0.0, B: 0.0},
expected: HSL{H: 0.0, S: 0.0, L: 0.0},
},
{
name: "white",
input: RGB{R: 1.0, G: 1.0, B: 1.0},
expected: HSL{H: 0.0, S: 0.0, L: 1.0},
},
{
name: "red",
input: RGB{R: 1.0, G: 0.0, B: 0.0},
expected: HSL{H: 0.0, S: 1.0, L: 0.5},
},
{
name: "green",
input: RGB{R: 0.0, G: 1.0, B: 0.0},
expected: HSL{H: 120.0 / 360.0, S: 1.0, L: 0.5},
},
{
name: "blue",
input: RGB{R: 0.0, G: 0.0, B: 1.0},
expected: HSL{H: 240.0 / 360.0, S: 1.0, L: 0.5},
},
{
name: "magenta",
input: RGB{R: 1.0, G: 0.0, B: 1.0},
expected: HSL{H: 300 / 360.0, S: 1.0, L: 0.5},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RGBToHSL(tt.input)
if !floatEqual(result.H, tt.expected.H) || !floatEqual(result.S, tt.expected.S) || !floatEqual(result.L, tt.expected.L) {
t.Errorf("RGBToHSL(%v) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}

func TestRGBToHSV(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -309,31 +359,41 @@ func TestEnsureContrast(t *testing.T) {

func TestGeneratePalette(t *testing.T) {
tests := []struct {
name string
base string
opts PaletteOptions
name string
base string
backgroundHex string
backgroundHSL string
opts PaletteOptions
}{
{
name: "dark theme default",
base: "#625690",
opts: PaletteOptions{IsLight: false},
name: "dark theme default",
base: "#625690",
backgroundHex: "#1a1a1a",
backgroundHSL: "hsl(0.0, 0.0%, 10.2%)",
opts: PaletteOptions{IsLight: false},
},
{
name: "light theme default",
base: "#625690",
opts: PaletteOptions{IsLight: true},
name: "light theme default",
base: "#625690",
backgroundHex: "#f8f8f8",
backgroundHSL: "hsl(0.0, 0.0%, 97.3%)",
opts: PaletteOptions{IsLight: true},
},
{
name: "light theme with custom background",
base: "#625690",
name: "light theme with custom background",
base: "#625690",
backgroundHex: "#fafafa",
backgroundHSL: "hsl(0.0, 0.0%, 98.0%)",
opts: PaletteOptions{
IsLight: true,
Background: "#fafafa",
},
},
{
name: "dark theme with custom background",
base: "#625690",
name: "dark theme with custom background",
base: "#625690",
backgroundHex: "#0a0a0a",
backgroundHSL: "hsl(0.0, 0.0%, 3.9%)",
opts: PaletteOptions{
IsLight: false,
Background: "#0a0a0a",
Expand All @@ -358,12 +418,12 @@ func TestGeneratePalette(t *testing.T) {
}
}

if tt.opts.Background != "" && result.Color0.Hex != tt.opts.Background {
t.Errorf("Background color = %s, expected %s", result.Color0.Hex, tt.opts.Background)
} else if !tt.opts.IsLight && tt.opts.Background == "" && result.Color0.Hex != "#1a1a1a" {
t.Errorf("Dark mode background = %s, expected #1a1a1a", result.Color0.Hex)
} else if tt.opts.IsLight && tt.opts.Background == "" && result.Color0.Hex != "#f8f8f8" {
t.Errorf("Light mode background = %s, expected #f8f8f8", result.Color0.Hex)
if result.Color0.Hex != tt.backgroundHex {
t.Errorf("Background color hex = %s, expected %s", result.Color0.Hex, tt.backgroundHex)
}

if result.Color0.HSL != tt.backgroundHSL {
t.Errorf("Background color hsl = %s, expected %s", result.Color0.HSL, tt.backgroundHSL)
}

// Color15 is now derived from primary, so just verify it's a valid color
Expand All @@ -384,6 +444,59 @@ func TestGeneratePalette(t *testing.T) {
}
}

func TestFormatOutput(t *testing.T) {
tests := []struct {
in string
out []byte
}{
{
in: "#000000",
out: []byte(`{"hex":"#000000","hex_stripped":"000000","hex_alpha":"#000000ff","hex_alpha_stripped":"000000ff","alpha_hex":"#ff000000","alpha_hex_stripped":"ff000000","rgb":"rgb(0, 0, 0)","rgba":"rgba(0, 0, 0, 1.0)","hsl":"hsl(0.0, 0.0%, 0.0%)","hsla":"hsla(0.0, 0.0%, 0.0%, 1.0)","red":"0","green":"0","blue":"0","alpha":"1.0","hue":"0.0","saturation":"0.0%","lightness":"0.0%"}`),
},
{
in: "#ffffff",
out: []byte(`{"hex":"#ffffff","hex_stripped":"ffffff","hex_alpha":"#ffffffff","hex_alpha_stripped":"ffffffff","alpha_hex":"#ffffffff","alpha_hex_stripped":"ffffffff","rgb":"rgb(255, 255, 255)","rgba":"rgba(255, 255, 255, 1.0)","hsl":"hsl(0.0, 0.0%, 100.0%)","hsla":"hsla(0.0, 0.0%, 100.0%, 1.0)","red":"255","green":"255","blue":"255","alpha":"1.0","hue":"0.0","saturation":"0.0%","lightness":"100.0%"}`),
},
{
in: "#ff0000",
out: []byte(`{"hex":"#ff0000","hex_stripped":"ff0000","hex_alpha":"#ff0000ff","hex_alpha_stripped":"ff0000ff","alpha_hex":"#ffff0000","alpha_hex_stripped":"ffff0000","rgb":"rgb(255, 0, 0)","rgba":"rgba(255, 0, 0, 1.0)","hsl":"hsl(0.0, 100.0%, 50.0%)","hsla":"hsla(0.0, 100.0%, 50.0%, 1.0)","red":"255","green":"0","blue":"0","alpha":"1.0","hue":"0.0","saturation":"100.0%","lightness":"50.0%"}`),
},
{
in: "#00ff00",
out: []byte(`{"hex":"#00ff00","hex_stripped":"00ff00","hex_alpha":"#00ff00ff","hex_alpha_stripped":"00ff00ff","alpha_hex":"#ff00ff00","alpha_hex_stripped":"ff00ff00","rgb":"rgb(0, 255, 0)","rgba":"rgba(0, 255, 0, 1.0)","hsl":"hsl(120.0, 100.0%, 50.0%)","hsla":"hsla(120.0, 100.0%, 50.0%, 1.0)","red":"0","green":"255","blue":"0","alpha":"1.0","hue":"120.0","saturation":"100.0%","lightness":"50.0%"}`),
},
{
in: "#0000ff",
out: []byte(`{"hex":"#0000ff","hex_stripped":"0000ff","hex_alpha":"#0000ffff","hex_alpha_stripped":"0000ffff","alpha_hex":"#ff0000ff","alpha_hex_stripped":"ff0000ff","rgb":"rgb(0, 0, 255)","rgba":"rgba(0, 0, 255, 1.0)","hsl":"hsl(240.0, 100.0%, 50.0%)","hsla":"hsla(240.0, 100.0%, 50.0%, 1.0)","red":"0","green":"0","blue":"255","alpha":"1.0","hue":"240.0","saturation":"100.0%","lightness":"50.0%"}`),
},
{
in: "#625690",
out: []byte(`{"hex":"#625690","hex_stripped":"625690","hex_alpha":"#625690ff","hex_alpha_stripped":"625690ff","alpha_hex":"#ff625690","alpha_hex_stripped":"ff625690","rgb":"rgb(98, 86, 144)","rgba":"rgba(98, 86, 144, 1.0)","hsl":"hsl(252.4, 25.2%, 45.1%)","hsla":"hsla(252.4, 25.2%, 45.1%, 1.0)","red":"98","green":"86","blue":"144","alpha":"1.0","hue":"252.4","saturation":"25.2%","lightness":"45.1%"}`),
},
{
in: "#ff00ff",
out: []byte(`{"hex":"#ff00ff","hex_stripped":"ff00ff","hex_alpha":"#ff00ffff","hex_alpha_stripped":"ff00ffff","alpha_hex":"#ffff00ff","alpha_hex_stripped":"ffff00ff","rgb":"rgb(255, 0, 255)","rgba":"rgba(255, 0, 255, 1.0)","hsl":"hsl(300.0, 100.0%, 50.0%)","hsla":"hsla(300.0, 100.0%, 50.0%, 1.0)","red":"255","green":"0","blue":"255","alpha":"1.0","hue":"300.0","saturation":"100.0%","lightness":"50.0%"}`),
},
{
in: "#888888",
out: []byte(`{"hex":"#888888","hex_stripped":"888888","hex_alpha":"#888888ff","hex_alpha_stripped":"888888ff","alpha_hex":"#ff888888","alpha_hex_stripped":"ff888888","rgb":"rgb(136, 136, 136)","rgba":"rgba(136, 136, 136, 1.0)","hsl":"hsl(0.0, 0.0%, 53.3%)","hsla":"hsla(0.0, 0.0%, 53.3%, 1.0)","red":"136","green":"136","blue":"136","alpha":"1.0","hue":"0.0","saturation":"0.0%","lightness":"53.3%"}`),
},
}

for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
ci := NewColorInfo(tt.in)
out, err := json.Marshal(ci)
if err != nil {
t.Error("Failed encoding", err)
}
if !bytes.Equal(out, tt.out) {
t.Errorf("Color output mismatch, got = %s, expected %s", out, tt.out)
}
})
}
}

func TestRoundTripConversion(t *testing.T) {
testColors := []string{"#000000", "#ffffff", "#ff0000", "#00ff00", "#0000ff", "#625690", "#808080"}

Expand Down
Loading