// License: GPLv3 Copyright: 2023, Kovid Goyal, 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) }