431 lines
10 KiB
Go
431 lines
10 KiB
Go
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
|
|
|
package themes
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"kitty/tools/config"
|
|
"kitty/tools/utils"
|
|
"kitty/tools/utils/style"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/exp/maps"
|
|
)
|
|
|
|
var _ = fmt.Print
|
|
|
|
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 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 {
|
|
err = json.Unmarshal(utils.UnsafeStringToBytes(zf.Comment), &jm)
|
|
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 {
|
|
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)
|
|
}
|
|
w := zip.NewWriter(tf2)
|
|
jm.Etag = resp.Header.Get("ETag")
|
|
jm.Timestamp = utils.ISO8601Format(time.Now())
|
|
comment, _ := json.Marshal(jm)
|
|
w.SetComment(utils.UnsafeBytesToString(comment))
|
|
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 parse_theme_metadata(path string) (*ThemeMetadata, map[string]string, error) {
|
|
var in_metadata, in_blurb, finished_metadata bool
|
|
ans := ThemeMetadata{}
|
|
settings := map[string]string{}
|
|
read_is_dark := func(key, val string) (err error) {
|
|
settings[key] = val
|
|
if key == "background" {
|
|
val = strings.TrimSpace(val)
|
|
if val != "" {
|
|
bg, err := style.ParseColor(val)
|
|
if err == nil {
|
|
ans.Is_dark = utils.Max(bg.Red, bg.Green, bg.Green) < 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":
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
return self.code, nil
|
|
}
|
|
|
|
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 := bufio.NewScanner(strings.NewReader(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
|
|
}
|
|
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 int) {
|
|
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(num))
|
|
w.WriteByte(';')
|
|
w.WriteString(rgba.AsRGBSharp())
|
|
return
|
|
}
|
|
}
|
|
w.WriteByte('1')
|
|
w.WriteString(strconv.Itoa(num))
|
|
}
|
|
set_default_color("foreground", style.DefaultColors.Foreground, 10)
|
|
set_default_color("background", style.DefaultColors.Background, 11)
|
|
set_default_color("cursor", style.DefaultColors.Cursor, 12)
|
|
set_default_color("selection_background", style.DefaultColors.SelectionBg, 17)
|
|
set_default_color("selection_foreground", style.DefaultColors.SelectionFg, 19)
|
|
|
|
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(), nil
|
|
}
|
|
|
|
type Themes struct {
|
|
name_map map[string]*Theme
|
|
index_map []string
|
|
}
|
|
|
|
var camel_case_pat = (&utils.Once[*regexp.Regexp]{Run: func() *regexp.Regexp {
|
|
return regexp.MustCompile(`([a-z])([A-Z])`)
|
|
}}).Get
|
|
|
|
func theme_name_from_file_name(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.Split(fname, " "), strings.Title), " ")
|
|
}
|
|
|
|
func (self *Themes) AddFromFile(path string) (*Theme, error) {
|
|
m, conf, err := parse_theme_metadata(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if m.Name == "" {
|
|
m.Name = theme_name_from_file_name(filepath.Base(path))
|
|
}
|
|
t := Theme{metadata: m, is_user_defined: true, settings: conf}
|
|
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") {
|
|
if _, err = self.AddFromFile(filepath.Join(dirpath, e.Name())); 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 {
|
|
return self.name_map[name]
|
|
}
|
|
|
|
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.index_map = maps.Keys(ans.name_map)
|
|
ans.index_map = utils.StableSortWithKey(ans.index_map, strings.ToLower)
|
|
return ans, closer, nil
|
|
}
|
|
|
|
func ThemeFromFile(path string) (*Theme, error) {
|
|
ans := &Themes{name_map: make(map[string]*Theme)}
|
|
return ans.AddFromFile(path)
|
|
}
|