Skip to content
quarry Artifact [048dffdcfd]
Login

Artifact [048dffdcfd]

Artifact 048dffdcfdd2bc751afc778fad2591ddc8c3947f407b53a8c97a99c16b43ec5d:


// 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"
	"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...) }

	pairs := [][2]string{
		{"--bg", bg}, {"--fg", fg}, {"--code-block", code},
		{"--code-bg", over(or(pick("textCodeBlock.background", "editor.lineHighlightBackground"), mix(fg, code, 0.08)), code, 1)},
		{"--muted", readable(or(pick("editorLineNumber.foreground", "descriptionForeground"), mix(fg, bg, 0.6)), bg, code)},
		{"--rule", over(or(pick("panel.border", "editorGroup.border", "sideBar.border", "tab.border"), mix(fg, bg, 0.18)), bg, 1)},
		{"--accent", readable(or(pick("textLink.foreground", "focusBorder", "button.background"), blue), bg, code)},
		{"--add", readable(green, grounds...)}, {"--del", readable(red, grounds...)},
		{"--add-row", ar}, {"--del-row", dr}, {"--add-mark", am}, {"--del-mark", dm},
		{"--mark", over(or(pick("editor.findMatchHighlightBackground", "editor.selectionBackground"), mix(yellow, code, 0.3)), code, 1)},
		{"--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 {
	c = c[:7]
	ok := func(x string) bool {
		for _, g := range grounds {
			if theme.Contrast(x, g) < 4.5 {
				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
}

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"},
}