diff --git a/tools/themes/collection.go b/tools/themes/collection.go index 9432894d1..c82321680 100644 --- a/tools/themes/collection.go +++ b/tools/themes/collection.go @@ -4,16 +4,23 @@ package themes import ( "archive/zip" + "bufio" "encoding/json" "errors" "fmt" "io" "io/fs" "kitty/tools/utils" + "kitty/tools/utils/style" "net/http" "os" + "path" "path/filepath" + "regexp" + "strings" "time" + + "golang.org/x/exp/maps" ) var _ = fmt.Print @@ -117,3 +124,192 @@ func fetch_cached(name, url string, max_cache_age time.Duration) (string, error) func FetchCached(max_cache_age time.Duration) (string, error) { return fetch_cached("kitty-themes", "https://codeload.github.com/kovidgoyal/kitty-themes/zip/master", 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(raw string) *ThemeMetadata { + scanner := bufio.NewScanner(strings.NewReader(raw)) + var in_metadata, in_blurb, finished_metadata bool + ans := ThemeMetadata{} + settings := utils.NewSet[string]() + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + is_block := strings.HasPrefix(line, "## ") + if in_metadata && !is_block { + finished_metadata = true + } + if finished_metadata { + if line[0] != '#' { + key, val, found := strings.Cut(line, " ") + if found { + settings.Add(key) + 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 + } + } + } + } + } + continue + } + if !in_metadata && is_block { + in_metadata = true + } + if !in_metadata { + continue + } + line = line[3:] + if in_blurb { + ans.Blurb += " " + line + continue + } + key, val, found := strings.Cut(line, ":") + if !found { + continue + } + 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 + } + } + ans.Num_settings = settings.Len() + return &ans +} + +type Theme struct { + metadata *ThemeMetadata + + code string + zip_reader *zip.File + is_user_defined bool +} + +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) 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") { + confb, err := os.ReadFile(e.Name()) + if err != nil { + return err + } + conf := utils.UnsafeBytesToString(confb) + m := parse_theme_metadata(conf) + if m.Name == "" { + m.Name = theme_name_from_file_name(e.Name()) + } + t := Theme{metadata: m, is_user_defined: true, code: conf} + self.name_map[m.Name] = &t + } + } + return nil +} + +func (self *Themes) add_from_zip_file(zippath string) error { + r, err := zip.OpenReader(zippath) + if err != nil { + return 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 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 fmt.Errorf("Error while reading %s from the ZIP file: %w", file.Name, err) + } + err = json.Unmarshal(raw, &themes) + if err != nil { + return fmt.Errorf("Error while decoding %s: %w", file.Name, err) + } + } + } + if theme_dir == "" { + return 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 nil +} + +func LoadThemes(cache_age_in_days time.Duration, ignore_no_cache bool) (*Themes, error) { + zip_path, err := FetchCached(cache_age_in_days * time.Hour * 24) + ans := Themes{name_map: make(map[string]*Theme)} + if err != nil { + if !errors.Is(err, ErrNoCacheFound) || ignore_no_cache { + return nil, err + } + } else { + if err = ans.add_from_zip_file(zip_path); err != nil { + return nil, err + } + } + if err = ans.add_from_dir(filepath.Join(utils.ConfigDir(), "themes")); err != nil { + return nil, err + } + ans.index_map = maps.Keys(ans.name_map) + ans.index_map = utils.StableSortWithKey(ans.index_map, strings.ToLower) + return &ans, nil +} diff --git a/tools/utils/misc.go b/tools/utils/misc.go index ca5b8702b..b63250f05 100644 --- a/tools/utils/misc.go +++ b/tools/utils/misc.go @@ -57,6 +57,14 @@ func Filter[T any](s []T, f func(x T) bool) []T { return ans } +func Map[T any](s []T, f func(x T) T) []T { + ans := make([]T, 0, len(s)) + for _, x := range s { + ans = append(ans, f(x)) + } + return ans +} + func Sort[T any](s []T, less func(a, b T) bool) []T { sort.Slice(s, func(i, j int) bool { return less(s[i], s[j]) }) return s