// Command gen writes themes_gen.go: every theme hunk bundles, turned
// into quarry's palette tokens.
//
// The themes are the VS Code themes Shiki ships in the tm-themes npm
// package (MIT; each theme keeps its own author's licence). Only their
// colour values are read, never their files shipped. To regenerate:
//
// npm pack [email protected] && tar xzf tm-themes-1.12.12.tgz
// go run ./internal/theme/gen -src package/themes > internal/theme/themes_gen.go
//
// Every text colour is moved in lightness only until it reads at 4.5:1
// on every ground it sits on (the code block, the page, both diff rows
// and both word marks), the floor TestPaletteContrast holds quarry's
// own palette to. A theme's added and removed colours come from hunk's
// own table (diffColours), because a theme file's git colours are often
// a grey, and the rows are those hues washed over the code ground.
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"math"
"os"
"path/filepath"
"sort"
"strings"
"craftdesign.group/quarry/internal/theme"
)
type tmTheme struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Type string `json:"type"`
Colors map[string]any `json:"colors"`
TokenColors []struct {
Scope any `json:"scope"`
Settings struct {
Foreground string `json:"foreground"`
} `json:"settings"`
} `json:"tokenColors"`
}
func main() {
src := flag.String("src", "", "the tm-themes package's themes directory")
flag.Parse()
if *src == "" {
log.Fatal("gen: -src is required")
}
files, err := filepath.Glob(filepath.Join(*src, "*.json"))
if err != nil || len(files) == 0 {
log.Fatalf("gen: no themes in %s", *src)
}
var all []theme.Theme
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
log.Fatal(err)
}
var t tmTheme
if err := json.Unmarshal(b, &t); err != nil {
log.Fatalf("gen: %s: %v", f, err)
}
all = append(all, derive(strings.TrimSuffix(filepath.Base(f), ".json"), t))
}
sort.Slice(all, func(i, j int) bool { return strings.ToLower(all[i].Name) < strings.ToLower(all[j].Name) })
var b strings.Builder
b.WriteString("// Code generated by go run ./internal/theme/gen; DO NOT EDIT.\n\npackage theme\n\n")
b.WriteString("// All is every theme a reader may pick, by name.\nvar All = []Theme{\n")
for _, t := range all {
fmt.Fprintf(&b, "\t{ID: %q, Name: %q, Dark: %v, Tokens: []Token{\n", t.ID, t.Name, t.Dark)
for _, k := range t.Tokens {
fmt.Fprintf(&b, "\t\t{%q, %q},\n", k.Name, k.Value)
}
b.WriteString("\t}},\n")
}
b.WriteString("}\n")
os.Stdout.WriteString(b.String())
}
func derive(id string, t tmTheme) theme.Theme {
dark := !strings.EqualFold(t.Type, "light")
pick := func(keys ...string) string {
for _, k := range keys {
if str, _ := t.Colors[k].(string); hex(str) != "" {
return hex(str)
}
}
return ""
}
or := func(v, d string) string {
if v == "" {
return d
}
return v
}
sc := func(scopes ...string) string {
for _, s := range scopes {
if v := scopeColour(t, s); v != "" {
return v
}
}
return ""
}
code := or(pick("editor.background"), map[bool]string{true: "#1e1e1e", false: "#ffffff"}[dark])
bg := over(or(pick("sideBar.background", "editor.background"), code), code, 1)
// A sidebar on the other side of light and dark from the editor (Slack
// Ochin's navy rail on a white editor) is not a page ground for it.
if (luminance(bg) > 0.18) != (luminance(code) > 0.18) {
bg = code
}
fg := or(pick("editor.foreground", "foreground"), map[bool]string{true: "#dddddd", false: "#222222"}[dark])
// hunk's own fallback when a theme has no entry, colour by colour.
dc, fb := diffColours[id], map[bool][3]string{true: {"#5ecc71", "#ff6762", "#69b1ff"}, false: {"#0dbe4e", "#ff2e3f", "#009fff"}}[dark]
green, red, blue := or(dc[0], fb[0]), or(dc[1], fb[1]), or(dc[2], fb[2])
yellow := or(pick("terminal.ansiYellow", "editorWarning.foreground"), "#bf8700")
rowT, markT := 0.12, 0.26
if dark {
rowT, markT = 0.16, 0.30
}
ar, dr := mix(green, code, rowT), mix(red, code, rowT)
am, dm := mix(green, code, markT), mix(red, code, markT)
grounds := []string{code, bg, ar, dr, am, dm}
fg = readable(fg, grounds...)
syn := func(c string) string { return readable(or(c, fg), grounds...) }
// The inline code pill reads as a pill, not a hole or a glare, and
// everything written on the page reads on it too.
pill := within(over(or(pick("textCodeBlock.background", "editor.lineHighlightBackground"), mix(fg, code, 0.08)), code, 1), bg, 1.12, 1.35)
page := []string{bg, code, pill}
fg = readable(fg, append(grounds, pill)...)
// A line is seen on the page and on a code block, as quarry's own
// --rule is (1.38:1 light, 1.5:1 dark), but stays a line: a theme
// whose border is its navy rail draws a hairline, not a bar. A
// high-contrast theme keeps its strong borders on purpose.
ruleMax := 3.0
if strings.Contains(id, "high-contrast") {
ruleMax = 0
}
rule := apart(within(over(or(pick("panel.border", "editorGroup.border", "sideBar.border", "tab.border"), mix(fg, bg, 0.18)), bg, 1), bg, 0, ruleMax), 1.45, bg, code)
// A highlight differs from the code it marks by the luminance
// TestHighlightIsVisibleAgainstItsPage holds quarry's own to.
// It stops there if it can: text is read on it, so it pulls back
// toward the code while it stays visible, and only where no colour is
// both does the ink move instead.
mark := standOut(over(or(pick("editor.findMatchHighlightBackground", "editor.selectionBackground"), mix(yellow, code, 0.3)), code, 1), code, yellow, 0.06)
if theme.Contrast(fg, mark) < 4.5 {
pulled := standOut(code, code, yellow, 0.06) // the faintest visible
for t := 0.02; t <= 1; t += 0.02 {
if x := mix(code, mark, t); theme.Contrast(fg, x) >= 4.5 && math.Abs(luminance(x)-luminance(code)) >= 0.06 {
pulled = x
break
}
}
mark = pulled
}
fg = readable(fg, append(grounds, pill, mark)...)
pairs := [][2]string{
{"--bg", bg}, {"--fg", fg}, {"--code-block", code},
{"--code-bg", pill},
{"--muted", readable(or(pick("editorLineNumber.foreground", "descriptionForeground"), mix(fg, bg, 0.6)), page...)},
{"--rule", rule},
{"--accent", readable(or(pick("textLink.foreground", "focusBorder", "button.background"), blue), page...)},
{"--add", readable(green, append(grounds, pill)...)}, {"--del", readable(red, append(grounds, pill)...)},
{"--add-row", ar}, {"--del-row", dr}, {"--add-mark", am}, {"--del-mark", dm},
{"--mark", mark},
{"--syn-kw", syn(sc("keyword.control", "keyword", "storage"))},
{"--syn-str", syn(sc("string.quoted.double", "string"))},
{"--syn-num", syn(sc("constant.numeric"))},
{"--syn-fn", syn(sc("entity.name.function", "support.function"))},
{"--syn-type", syn(sc("entity.name.type", "support.type", "storage.type"))},
{"--syn-com", syn(sc("comment.line", "comment"))},
{"--syn-builtin", syn(sc("support.function.builtin", "constant.language", "variable.language"))},
{"--syn-attr", syn(sc("entity.other.attribute-name", "support.type.property-name", "variable.other.property"))},
{"--surface", fg[:7] + "14"},
{"--slice-empty", mix(fg, bg, 0.1)},
{"--warn", readable(yellow, bg, code)},
{"--meld-cf", readable(red, code)}, {"--meld-cf-bg", mix(red, code, 0.16)},
{"--meld-chg", readable(blue, code)}, {"--meld-chg-bg", mix(blue, code, 0.16)},
{"--meld-ins", readable(green, code)}, {"--meld-ins-bg", mix(green, code, 0.16)},
{"--idn-l", map[bool]string{true: "72%", false: "56%"}[dark]},
{"--uname-l", map[bool]string{true: "70%", false: "32%"}[dark]},
}
tok := make([]theme.Token, len(pairs))
for i, p := range pairs {
tok[i] = theme.Token{Name: p[0], Value: p[1]}
}
name := or(t.DisplayName, or(t.Name, id))
return theme.Theme{ID: id, Name: name, Dark: dark, Tokens: tok}
}
// scopeColour is the colour a TextMate theme gives a scope: the most
// specific selector that names it or a dotted prefix of it.
func scopeColour(t tmTheme, scope string) string {
best, bestLen := "", -1
for _, r := range t.TokenColors {
if r.Settings.Foreground == "" {
continue
}
var sels []string
switch s := r.Scope.(type) {
case string:
sels = strings.Split(s, ",")
case []any:
for _, x := range s {
if str, ok := x.(string); ok {
sels = append(sels, str)
}
}
}
for _, s := range sels {
s = strings.TrimSpace(s)
if s == "" || strings.Contains(s, " ") {
continue
}
if (scope == s || strings.HasPrefix(scope, s+".")) && len(s) >= bestLen {
best, bestLen = hex(r.Settings.Foreground), len(s)
}
}
}
return best
}
func hex(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if !strings.HasPrefix(s, "#") {
return ""
}
if len(s) == 4 || len(s) == 5 {
var b strings.Builder
b.WriteByte('#')
for _, c := range s[1:] {
b.WriteRune(c)
b.WriteRune(c)
}
s = b.String()
}
if len(s) != 7 && len(s) != 9 {
return ""
}
return s
}
func alpha(h string) float64 {
if len(h) == 9 {
var a uint8
fmt.Sscanf(h[7:9], "%02x", &a)
return float64(a) / 255
}
return 1
}
// over paints h, at its own alpha times a, over an opaque ground.
func over(h, g string, a float64) string {
t := alpha(h) * a
f, b := theme.RGB(h), theme.RGB(g)
var c [3]float64
for i := range c {
c[i] = f[i]*t + b[i]*(1-t)
}
return theme.Hex(c)
}
func mix(a, b string, t float64) string { return over(a[:7], b, t) }
// readable blends c toward black or white until it clears 4.5:1 on
// every ground.
func readable(c string, grounds ...string) string { return apart(c, 4.5, grounds...) }
// apart blends c toward black or white until it clears min:1 on every
// ground.
func apart(c string, min float64, grounds ...string) string {
c = c[:7]
ok := func(x string) bool {
for _, g := range grounds {
if theme.Contrast(x, g) < min {
return false
}
}
return true
}
if ok(c) {
return c
}
anchor := "#ffffff"
if luminance(grounds[0]) > 0.4 {
anchor = "#000000"
}
for t := 0.02; t <= 1; t += 0.02 {
if x := mix(anchor, c, t); ok(x) {
return x
}
}
return anchor
}
// within moves c toward g until it is no more than max:1 from it, then
// away until it is at least min:1. A max of 0 is no ceiling.
func within(c, g string, min, max float64) string {
c = c[:7]
if max > 0 {
for t := 0.0; t <= 1; t += 0.02 {
if x := mix(g, c, t); theme.Contrast(x, g) <= max {
c = x
break
}
}
}
return apart(c, min, g)
}
// standOut blends c toward toward, then black or white, until its
// luminance differs from g's by d.
func standOut(c, g, toward string, d float64) string {
c = c[:7]
ok := func(x string) bool { return math.Abs(luminance(x)-luminance(g)) >= d }
for _, anchor := range []string{toward[:7], map[bool]string{true: "#000000", false: "#ffffff"}[luminance(g) > 0.4]} {
for t := 0.0; t <= 1; t += 0.02 {
if x := mix(anchor, c, t); ok(x) {
return x
}
}
}
return c
}
func luminance(h string) float64 {
// Contrast against black is (L+0.05)/0.05.
return theme.Contrast(h, "#000000")*0.05 - 0.05
}
// diffColours is hunk's table of each theme's added, removed and
// modified colours (packages/hunk/src/core/theme/catalog.ts, MIT).
var diffColours = map[string][3]string{
"andromeeda": {"#9bc53d", "#fc644d", "#5bc0eb"},
"aurora-x": {"#64d389", "#dd5074", "#c778db"},
"ayu-dark": {"#70bf56", "#f26d78", "#73b8ff"},
"ayu-light": {"#6cbf43", "#ff7383", "#478acc"},
"ayu-mirage": {"#87d96c", "#f27983", "#80bfff"},
"catppuccin-frappe": {"#a6d189", "#e78284", "#e5c890"},
"catppuccin-latte": {"#40a02b", "#d20f39", "#df8e1d"},
"catppuccin-macchiato": {"#a6da95", "#ed8796", "#eed49f"},
"catppuccin-mocha": {"#a6e3a1", "#f38ba8", "#f9e2af"},
"dracula": {"#50fa7b", "#ff5555", "#8be9fd"},
"dracula-soft": {"#50fa7b", "#ff5555", "#8be9fd"},
"everforest-dark": {"#899c40", "#da6362", "#5a93a2"},
"everforest-light": {"#8da101", "#f1706f", "#3a94c5"},
"github-dark": {"#28a745", "#ea4a5a", "#2188ff"},
"github-dark-default": {"#2ea043", "#f85149", "#bb8009"},
"github-dark-dimmed": {"#46954a", "#e5534b", "#ae7c14"},
"github-dark-high-contrast": {"#09b43a", "#ff6a69", "#e09b13"},
"github-light": {"#28a745", "#d73a49", "#2188ff"},
"github-light-default": {"#116329", "#cf222e", "#9a6700"},
"github-light-high-contrast": {"#26a148", "#ee5a5d", "#b58407"},
"gruvbox-dark-hard": {"#b8bb26", "#fb4934", "#83a598"},
"gruvbox-dark-medium": {"#b8bb26", "#fb4934", "#83a598"},
"gruvbox-dark-soft": {"#b8bb26", "#fb4934", "#83a598"},
"gruvbox-light-hard": {"#79740e", "#9d0006", "#076678"},
"gruvbox-light-medium": {"#79740e", "#9d0006", "#076678"},
"gruvbox-light-soft": {"#79740e", "#9d0006", "#076678"},
"horizon": {"#09f7a0", "#f43e5c", "#21bfc2"},
"horizon-bright": {"#29d398", "#f43e5c", "#af5427"},
"houston": {"#4bf3c8", "#f06788", "#54b9ff"},
"kanagawa-dragon": {"#76946a", "#c34043", "#dca561"},
"kanagawa-lotus": {"#6e915f", "#d7474b", "#4d699b"},
"kanagawa-wave": {"#76946a", "#c34043", "#dca561"},
"laserwave": {"#74dfc4", "#eb64b9", "#40b4c4"},
"material-theme": {"#c3e88d", "#f07178", "#82aaff"},
"material-theme-darker": {"#c3e88d", "#f07178", "#82aaff"},
"material-theme-lighter": {"#39adb5", "#e53935", "#6182b8"},
"material-theme-ocean": {"#c3e88d", "#f07178", "#82aaff"},
"material-theme-palenight": {"#c3e88d", "#f07178", "#82aaff"},
"min-light": {"#77cc00", "#d32f2f", ""},
"monokai": {"#86b42b", "#c4265e", "#6a7ec8"},
"night-owl": {"#9ccc65", "#ef5350", "#e2b93d"},
"night-owl-light": {"#08916a", "#f76e6e", "#288ed7"},
"nord": {"#a3be8c", "#bf616a", "#ebcb8b"},
"one-dark-pro": {"#109868", "#e05561", "#948b60"},
"one-light": {"#00809b", "", ""},
"plastic": {"#98c379", "#e06c75", "#d19a66"},
"poimandres": {"#5fb3a1", "#d0679d", "#add7ff"},
"rose-pine": {"#9ccfd8", "#eb6f92", "#ebbcba"},
"rose-pine-dawn": {"#56949f", "#b4637a", "#d7827e"},
"rose-pine-moon": {"#9ccfd8", "#eb6f92", "#ea9a97"},
"slack-ochin": {"#91b859", "#e53935", "#ecb22e"},
"snazzy-light": {"#2dae58", "#ff5c57", "#00a39f"},
"solarized-dark": {"#859900", "#dc322f", "#268bd2"},
"solarized-light": {"#859900", "#dc322f", "#268bd2"},
"synthwave-84": {"#0beb99", "#fa2e46", "#b893ce"},
"tokyo-night": {"#41a6b5", "#db4b4b", "#6183bb"},
"vesper": {"#99ffe4", "#ff8080", "#ffc799"},
"vitesse-black": {"#4d9375", "#cb7676", "#6394bf"},
"vitesse-dark": {"#4d9375", "#cb7676", "#6394bf"},
"vitesse-light": {"#1e754f", "#ab5959", "#296aa3"},
}