kitty/tools/themes/collection.go

935 lines
26 KiB
Go

// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package themes
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"maps"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
"kitty/tools/cli"
"kitty/tools/config"
"kitty/tools/tui/loop"
"kitty/tools/tui/subseq"
"kitty/tools/utils"
"kitty/tools/utils/style"
)
var _ = fmt.Print
var AllColorSettingNames = map[string]bool{ // {{{
// generated by gen-config.py do not edit
// ALL_COLORS_START
"active_border_color": true,
"active_tab_background": true,
"active_tab_foreground": true,
"background": true,
"bell_border_color": true,
"color0": true,
"color1": true,
"color10": true,
"color100": true,
"color101": true,
"color102": true,
"color103": true,
"color104": true,
"color105": true,
"color106": true,
"color107": true,
"color108": true,
"color109": true,
"color11": true,
"color110": true,
"color111": true,
"color112": true,
"color113": true,
"color114": true,
"color115": true,
"color116": true,
"color117": true,
"color118": true,
"color119": true,
"color12": true,
"color120": true,
"color121": true,
"color122": true,
"color123": true,
"color124": true,
"color125": true,
"color126": true,
"color127": true,
"color128": true,
"color129": true,
"color13": true,
"color130": true,
"color131": true,
"color132": true,
"color133": true,
"color134": true,
"color135": true,
"color136": true,
"color137": true,
"color138": true,
"color139": true,
"color14": true,
"color140": true,
"color141": true,
"color142": true,
"color143": true,
"color144": true,
"color145": true,
"color146": true,
"color147": true,
"color148": true,
"color149": true,
"color15": true,
"color150": true,
"color151": true,
"color152": true,
"color153": true,
"color154": true,
"color155": true,
"color156": true,
"color157": true,
"color158": true,
"color159": true,
"color16": true,
"color160": true,
"color161": true,
"color162": true,
"color163": true,
"color164": true,
"color165": true,
"color166": true,
"color167": true,
"color168": true,
"color169": true,
"color17": true,
"color170": true,
"color171": true,
"color172": true,
"color173": true,
"color174": true,
"color175": true,
"color176": true,
"color177": true,
"color178": true,
"color179": true,
"color18": true,
"color180": true,
"color181": true,
"color182": true,
"color183": true,
"color184": true,
"color185": true,
"color186": true,
"color187": true,
"color188": true,
"color189": true,
"color19": true,
"color190": true,
"color191": true,
"color192": true,
"color193": true,
"color194": true,
"color195": true,
"color196": true,
"color197": true,
"color198": true,
"color199": true,
"color2": true,
"color20": true,
"color200": true,
"color201": true,
"color202": true,
"color203": true,
"color204": true,
"color205": true,
"color206": true,
"color207": true,
"color208": true,
"color209": true,
"color21": true,
"color210": true,
"color211": true,
"color212": true,
"color213": true,
"color214": true,
"color215": true,
"color216": true,
"color217": true,
"color218": true,
"color219": true,
"color22": true,
"color220": true,
"color221": true,
"color222": true,
"color223": true,
"color224": true,
"color225": true,
"color226": true,
"color227": true,
"color228": true,
"color229": true,
"color23": true,
"color230": true,
"color231": true,
"color232": true,
"color233": true,
"color234": true,
"color235": true,
"color236": true,
"color237": true,
"color238": true,
"color239": true,
"color24": true,
"color240": true,
"color241": true,
"color242": true,
"color243": true,
"color244": true,
"color245": true,
"color246": true,
"color247": true,
"color248": true,
"color249": true,
"color25": true,
"color250": true,
"color251": true,
"color252": true,
"color253": true,
"color254": true,
"color255": true,
"color26": true,
"color27": true,
"color28": true,
"color29": true,
"color3": true,
"color30": true,
"color31": true,
"color32": true,
"color33": true,
"color34": true,
"color35": true,
"color36": true,
"color37": true,
"color38": true,
"color39": true,
"color4": true,
"color40": true,
"color41": true,
"color42": true,
"color43": true,
"color44": true,
"color45": true,
"color46": true,
"color47": true,
"color48": true,
"color49": true,
"color5": true,
"color50": true,
"color51": true,
"color52": true,
"color53": true,
"color54": true,
"color55": true,
"color56": true,
"color57": true,
"color58": true,
"color59": true,
"color6": true,
"color60": true,
"color61": true,
"color62": true,
"color63": true,
"color64": true,
"color65": true,
"color66": true,
"color67": true,
"color68": true,
"color69": true,
"color7": true,
"color70": true,
"color71": true,
"color72": true,
"color73": true,
"color74": true,
"color75": true,
"color76": true,
"color77": true,
"color78": true,
"color79": true,
"color8": true,
"color80": true,
"color81": true,
"color82": true,
"color83": true,
"color84": true,
"color85": true,
"color86": true,
"color87": true,
"color88": true,
"color89": true,
"color9": true,
"color90": true,
"color91": true,
"color92": true,
"color93": true,
"color94": true,
"color95": true,
"color96": true,
"color97": true,
"color98": true,
"color99": true,
"cursor": true,
"cursor_text_color": true,
"foreground": true,
"inactive_border_color": true,
"inactive_tab_background": true,
"inactive_tab_foreground": true,
"macos_titlebar_color": true,
"mark1_background": true,
"mark1_foreground": true,
"mark2_background": true,
"mark2_foreground": true,
"mark3_background": true,
"mark3_foreground": true,
"second_transparent_bg": true,
"selection_background": true,
"selection_foreground": true,
"tab_bar_background": true,
"tab_bar_margin_color": true,
"url_color": true,
"visual_bell_color": true,
"wayland_titlebar_color": true, // ALL_COLORS_END
} // }}}
type JSONMetadata struct {
Etag string `json:"etag"`
Timestamp string `json:"timestamp"`
}
var ErrNoCacheFound = errors.New("No cache found and max cache age is negative")
func set_comment_in_zip_file(path string, comment string) error {
src, err := zip.OpenReader(path)
if err != nil {
return err
}
defer src.Close()
buf := bytes.Buffer{}
dest := zip.NewWriter(&buf)
if err = dest.SetComment(comment); err != nil {
return err
}
for _, sf := range src.File {
err = dest.Copy(sf)
if err != nil {
return err
}
}
dest.Close()
return utils.AtomicUpdateFile(path, bytes.NewReader(buf.Bytes()), 0o644)
}
func fetch_cached(name, url, cache_path string, max_cache_age time.Duration) (string, error) {
cache_path = filepath.Join(cache_path, name+".zip")
zf, err := zip.OpenReader(cache_path)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return "", err
}
var jm JSONMetadata
if err == nil {
defer zf.Close()
if err = json.Unmarshal(utils.UnsafeStringToBytes(zf.Comment), &jm); err == nil {
if max_cache_age < 0 {
return cache_path, nil
}
cache_age, err := utils.ISO8601Parse(jm.Timestamp)
if err == nil {
if time.Now().Before(cache_age.Add(max_cache_age)) {
return cache_path, nil
}
}
}
}
if max_cache_age < 0 {
return "", ErrNoCacheFound
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", err
}
if jm.Etag != "" {
req.Header.Add("If-None-Match", jm.Etag)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("Failed to download %s with error: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotModified {
jm.Timestamp = utils.ISO8601Format(time.Now())
comment, _ := json.Marshal(jm)
err = set_comment_in_zip_file(cache_path, utils.UnsafeBytesToString(comment))
if err != nil {
return "", err
}
return cache_path, nil
}
return "", fmt.Errorf("Failed to download %s with HTTP error: %s", url, resp.Status)
}
var tf, tf2 *os.File
tf, err = os.CreateTemp(filepath.Dir(cache_path), name+".temp-*")
if err == nil {
tf2, err = os.CreateTemp(filepath.Dir(cache_path), name+".temp-*")
}
defer func() {
if tf != nil {
tf.Close()
os.Remove(tf.Name())
tf = nil
}
if tf2 != nil {
tf2.Close()
os.Remove(tf2.Name())
tf2 = nil
}
}()
if err != nil {
return "", fmt.Errorf("Failed to create temp file in %s with error: %w", filepath.Dir(cache_path), err)
}
_, err = io.Copy(tf, resp.Body)
if err != nil {
return "", fmt.Errorf("Failed to download %s with error: %w", url, err)
}
r, err := zip.OpenReader(tf.Name())
if err != nil {
return "", fmt.Errorf("Failed to open downloaded zip file with error: %w", err)
}
defer r.Close()
w := zip.NewWriter(tf2)
jm.Etag = resp.Header.Get("ETag")
jm.Timestamp = utils.ISO8601Format(time.Now())
comment, _ := json.Marshal(jm)
if err = w.SetComment(utils.UnsafeBytesToString(comment)); err != nil {
return "", err
}
for _, file := range r.File {
err = w.Copy(file)
if err != nil {
return "", fmt.Errorf("Failed to copy zip file from source to destination archive")
}
}
err = w.Close()
if err != nil {
return "", err
}
tf2.Close()
err = os.Rename(tf2.Name(), cache_path)
if err != nil {
return "", fmt.Errorf("Failed to atomic rename temp file to %s with error: %w", cache_path, err)
}
tf2 = nil
return cache_path, nil
}
func FetchCached(max_cache_age time.Duration) (string, error) {
return fetch_cached("kitty-themes", "https://codeload.github.com/kovidgoyal/kitty-themes/zip/master", utils.CacheDir(), max_cache_age)
}
type ThemeMetadata struct {
Name string `json:"name"`
Filepath string `json:"file"`
Is_dark bool `json:"is_dark"`
Num_settings int `json:"num_settings"`
Blurb string `json:"blurb"`
License string `json:"license"`
Upstream string `json:"upstream"`
Author string `json:"author"`
}
func ParseThemeMetadata(path string) (*ThemeMetadata, map[string]string, error) {
var in_metadata, in_blurb, finished_metadata bool
ans := ThemeMetadata{Is_dark: true} // the default background in kitty is dark
settings := map[string]string{}
read_is_dark := func(key, val string) (err error) {
settings[key] = val
if key == "background" {
if val != "" {
bg, err := style.ParseColor(val)
if err == nil {
ans.Is_dark = utils.Max(bg.Red, bg.Green, bg.Blue) < 115
}
}
}
return
}
read_metadata := func(line string) (err error) {
is_block := strings.HasPrefix(line, "## ")
if in_metadata && !is_block {
finished_metadata = true
}
if finished_metadata {
return
}
if !in_metadata && is_block {
in_metadata = true
}
if !in_metadata {
return
}
line = line[3:]
if in_blurb {
ans.Blurb += " " + line
return
}
key, val, found := strings.Cut(line, ":")
if !found {
return
}
key = strings.TrimSpace(strings.ToLower(key))
val = strings.TrimSpace(val)
switch key {
case "name":
if val != "The name of the theme (if not present, derived from filename)" {
ans.Name = val
}
case "author":
ans.Author = val
case "upstream":
ans.Upstream = val
case "blurb":
ans.Blurb = val
in_blurb = true
case "license":
ans.License = val
}
return
}
cp := config.ConfigParser{LineHandler: read_is_dark, CommentsHandler: read_metadata}
err := cp.ParseFiles(path)
if err != nil {
return nil, nil, err
}
ans.Num_settings = len(settings)
return &ans, settings, nil
}
type Theme struct {
metadata *ThemeMetadata
code string
settings map[string]string
zip_reader *zip.File
is_user_defined bool
path_for_user_defined_theme string
}
func (self *Theme) Name() string { return self.metadata.Name }
func (self *Theme) Author() string { return self.metadata.Author }
func (self *Theme) Blurb() string { return self.metadata.Blurb }
func (self *Theme) IsDark() bool { return self.metadata.Is_dark }
func (self *Theme) IsUserDefined() bool { return self.is_user_defined }
func (self *Theme) load_code() (string, error) {
if self.zip_reader != nil {
f, err := self.zip_reader.Open()
self.zip_reader = nil
if err != nil {
return "", err
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return "", err
}
self.code = utils.UnsafeBytesToString(data)
}
if self.is_user_defined && self.path_for_user_defined_theme != "" && self.code == "" {
raw, err := os.ReadFile(self.path_for_user_defined_theme)
if err != nil {
return "", err
}
self.code = utils.UnsafeBytesToString(raw)
}
return self.code, nil
}
func (self *Theme) Code() (string, error) {
return self.load_code()
}
func (self *Theme) SaveInDir(dirpath string) (err error) {
path := filepath.Join(dirpath, self.Name()+".conf")
code, err := self.Code()
if err != nil {
return err
}
return utils.AtomicUpdateFile(path, bytes.NewReader(utils.UnsafeStringToBytes(code)), 0o644)
}
func (self *Theme) SaveInConf(config_dir, reload_in, config_file_name string) (err error) {
_ = os.MkdirAll(config_dir, 0o755)
path := filepath.Join(config_dir, `current-theme.conf`)
code, err := self.Code()
if err != nil {
return err
}
err = utils.AtomicUpdateFile(path, bytes.NewReader(utils.UnsafeStringToBytes(code)), 0o644)
if err != nil {
return err
}
confpath := config_file_name
if !filepath.IsAbs(config_file_name) {
confpath = filepath.Join(config_dir, config_file_name)
}
patcher := config.Patcher{Write_backup: true}
if _, err = patcher.Patch(
confpath, "KITTY_THEME", fmt.Sprintf("# %s\ninclude current-theme.conf", self.metadata.Name),
utils.Keys(AllColorSettingNames)...); err != nil {
return
}
switch reload_in {
case "parent":
config.ReloadConfigInKitty(true)
case "all":
config.ReloadConfigInKitty(false)
}
return
}
func (self *Theme) Settings() (map[string]string, error) {
if self.zip_reader != nil {
code, err := self.load_code()
if err != nil {
return nil, err
}
self.settings = make(map[string]string, 64)
scanner := utils.NewLineScanner(code)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" && line[0] != '#' {
key, val, found := strings.Cut(line, " ")
if found {
self.settings[key] = val
}
}
}
}
return self.settings, nil
}
func (self *Theme) AsEscapeCodes() (string, error) {
settings, err := self.Settings()
if err != nil {
return "", err
}
return ColorSettingsAsEscapeCodes(settings), nil
}
func ColorSettingsAsEscapeCodes(settings map[string]string) string {
w := strings.Builder{}
w.Grow(4096)
set_color := func(i int, sharp string) {
w.WriteByte(';')
w.WriteString(strconv.Itoa(i))
w.WriteByte(';')
w.WriteString(sharp)
}
set_default_color := func(name, defval string, num loop.DefaultColor) {
w.WriteString("\033]")
defer func() { w.WriteString("\033\\") }()
val, found := settings[name]
if !found {
val = defval
}
if val != "" {
rgba, err := style.ParseColor(val)
if err == nil {
w.WriteString(strconv.Itoa(int(num)))
w.WriteByte(';')
w.WriteString(rgba.AsRGBSharp())
return
}
}
w.WriteByte('1')
w.WriteString(strconv.Itoa(int(num)))
}
set_default_color("foreground", style.DefaultColors.Foreground, loop.FOREGROUND)
set_default_color("background", style.DefaultColors.Background, loop.BACKGROUND)
set_default_color("cursor", style.DefaultColors.Cursor, loop.CURSOR)
set_default_color("selection_background", style.DefaultColors.SelectionBg, loop.SELECTION_BG)
set_default_color("selection_foreground", style.DefaultColors.SelectionFg, loop.SELECTION_FG)
w.WriteString("\033]4")
for i := 0; i < 256; i++ {
key := "color" + strconv.Itoa(i)
val := settings[key]
if val != "" {
rgba, err := style.ParseColor(val)
if err == nil {
set_color(i, rgba.AsRGBSharp())
continue
}
}
rgba := style.RGBA{}
rgba.FromRGB(style.ColorTable[i])
set_color(i, rgba.AsRGBSharp())
}
w.WriteString("\033\\")
return w.String()
}
type Themes struct {
name_map map[string]*Theme
index_map []string
}
func (self *Themes) Copy() *Themes {
ans := &Themes{name_map: make(map[string]*Theme, len(self.name_map)), index_map: slices.Clone(self.index_map)}
maps.Copy(ans.name_map, self.name_map)
return ans
}
var camel_case_pat = sync.OnceValue(func() *regexp.Regexp {
return regexp.MustCompile(`([a-z])([A-Z])`)
})
func ThemeNameFromFileName(fname string) string {
fname = fname[:len(fname)-len(path.Ext(fname))]
fname = strings.ReplaceAll(fname, "_", " ")
fname = camel_case_pat().ReplaceAllString(fname, "$1 $2")
return strings.Join(utils.Map(strings.Title, strings.Split(fname, " ")), " ")
}
func (self *Themes) Len() int { return len(self.name_map) }
func (self *Themes) At(x int) *Theme {
if x >= len(self.index_map) || x < 0 {
return nil
}
return self.name_map[self.index_map[x]]
}
func (self *Themes) Names() []string { return self.index_map }
func (self *Themes) create_index_map() {
self.index_map = utils.Keys(self.name_map)
self.index_map = utils.StableSortWithKey(self.index_map, strings.ToLower)
}
func (self *Themes) Filtered(is_ok func(*Theme) bool) *Themes {
themes := utils.Filter(utils.Values(self.name_map), is_ok)
ans := Themes{name_map: make(map[string]*Theme, len(themes))}
for _, theme := range themes {
ans.name_map[theme.metadata.Name] = theme
}
ans.create_index_map()
return &ans
}
func (self *Themes) AddFromFile(path string) (*Theme, error) {
m, conf, err := ParseThemeMetadata(path)
if err != nil {
return nil, err
}
if m.Name == "" {
m.Name = ThemeNameFromFileName(filepath.Base(path))
}
t := Theme{metadata: m, is_user_defined: true, settings: conf, path_for_user_defined_theme: path}
self.name_map[m.Name] = &t
return &t, nil
}
func (self *Themes) add_from_dir(dirpath string) error {
entries, err := os.ReadDir(dirpath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
err = nil
}
return err
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".conf") {
path := filepath.Join(dirpath, e.Name())
// ignore files if they are the STDOUT of the current processes
// allows using kitten theme --dump-theme name > ~/.config/kitty/themes/name.conf
if utils.Samefile(path, os.Stdout) {
continue
}
if _, err = self.AddFromFile(path); err != nil {
return err
}
}
}
return nil
}
func (self *Themes) add_from_zip_file(zippath string) (io.Closer, error) {
r, err := zip.OpenReader(zippath)
if err != nil {
return nil, err
}
name_map := make(map[string]*zip.File, len(r.File))
var themes []*ThemeMetadata
theme_dir := ""
for _, file := range r.File {
name_map[file.Name] = file
if path.Base(file.Name) == "themes.json" {
theme_dir = path.Dir(file.Name)
fr, err := file.Open()
if err != nil {
return nil, fmt.Errorf("Error while opening %s from the ZIP file: %w", file.Name, err)
}
defer fr.Close()
raw, err := io.ReadAll(fr)
if err != nil {
return nil, fmt.Errorf("Error while reading %s from the ZIP file: %w", file.Name, err)
}
err = json.Unmarshal(raw, &themes)
if err != nil {
return nil, fmt.Errorf("Error while decoding %s: %w", file.Name, err)
}
}
}
if theme_dir == "" {
return nil, fmt.Errorf("No themes.json found in ZIP file")
}
for _, theme := range themes {
key := path.Join(theme_dir, theme.Filepath)
f := name_map[key]
if f != nil {
t := Theme{metadata: theme, zip_reader: f}
self.name_map[theme.Name] = &t
}
}
return r, nil
}
func (self *Themes) ThemeByName(name string) *Theme {
ans := self.name_map[name]
if ans == nil {
q := strings.ToLower(name)
for k, t := range self.name_map {
if strings.ToLower(k) == q {
return t
}
}
}
return ans
}
func match(expression string, items []string) []*subseq.Match {
matches := subseq.ScoreItems(expression, items, subseq.Options{Level1: " "})
matches = utils.StableSort(matches, func(a, b *subseq.Match) int {
if b.Score < a.Score {
return -1
}
if b.Score > a.Score {
return 1
}
return 0
})
return matches
}
const (
MARK_BEFORE = "\033[33m"
MARK_AFTER = "\033[39m"
)
func (self *Themes) ApplySearch(expression string, marks ...string) []string {
mark_before, mark_after := MARK_BEFORE, MARK_AFTER
if len(marks) == 2 {
mark_before, mark_after = marks[0], marks[1]
}
results := utils.Filter(match(expression, self.index_map), func(x *subseq.Match) bool { return x.Score > 0 })
name_map := make(map[string]*Theme, len(results))
for _, m := range results {
name_map[m.Text] = self.name_map[m.Text]
}
self.name_map = name_map
self.index_map = self.index_map[:0]
ans := make([]string, 0, len(results))
for _, m := range results {
text := m.Text
positions := m.Positions
for i := len(positions) - 1; i >= 0; i-- {
p := positions[i]
text = text[:p] + mark_before + text[p:p+1] + mark_after + text[p+1:]
}
ans = append(ans, text)
self.index_map = append(self.index_map, m.Text)
}
return ans
}
func LoadThemes(cache_age time.Duration) (ans *Themes, closer io.Closer, err error) {
zip_path, err := FetchCached(cache_age)
ans = &Themes{name_map: make(map[string]*Theme)}
if err != nil {
return nil, nil, err
}
if closer, err = ans.add_from_zip_file(zip_path); err != nil {
return nil, nil, err
}
if err = ans.add_from_dir(filepath.Join(utils.ConfigDir(), "themes")); err != nil {
return nil, nil, err
}
ans.create_index_map()
return ans, closer, nil
}
func ThemeFromFile(path string) (*Theme, error) {
ans := &Themes{name_map: make(map[string]*Theme)}
return ans.AddFromFile(path)
}
func GetThemeNames(cache_age time.Duration) (ans []string, err error) {
themes, closer, err := LoadThemes(cache_age)
if err != nil {
if errors.Is(err, ErrNoCacheFound) {
return []string{"Default"}, nil
}
return nil, err
}
defer closer.Close()
for name := range themes.name_map {
ans = append(ans, name)
}
return
}
func CompleteThemes(completions *cli.Completions, word string, arg_num int) {
names, err := GetThemeNames(-1)
if err == nil {
mg := completions.AddMatchGroup("Themes")
for _, theme_name := range names {
theme_name = strings.TrimSpace(theme_name)
if theme_name != "" && strings.HasPrefix(theme_name, word) {
mg.AddMatch(theme_name)
}
}
}
}