// License: GPLv3 Copyright: 2023, Kovid Goyal, package themes import ( "archive/zip" "bytes" "encoding/json" "errors" "fmt" "io" "io/fs" "net/http" "os" "path" "path/filepath" "regexp" "strconv" "strings" "time" "kitty/tools/cli" "kitty/tools/config" "kitty/tools/tui/loop" "kitty/tools/tui/subseq" "kitty/tools/utils" "kitty/tools/utils/style" "github.com/shirou/gopsutil/v3/process" "golang.org/x/exp/maps" "golang.org/x/exp/slices" "golang.org/x/sys/unix" ) 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, "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) dest.SetComment(comment) for _, sf := range src.File { err = dest.Copy(sf) if err != nil { return err } } dest.Close() utils.AtomicUpdateFile(path, buf.Bytes(), 0o644) return nil } 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() 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 { 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) 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 ParseThemeMetadata(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": 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 patch_conf(text, theme_name string) string { addition := fmt.Sprintf("# BEGIN_KITTY_THEME\n# %s\ninclude current-theme.conf\n# END_KITTY_THEME", theme_name) pat := utils.MustCompile(`(?ms)^# BEGIN_KITTY_THEME.+?# END_KITTY_THEME`) replaced := false ntext := pat.ReplaceAllStringFunc(text, func(string) string { replaced = true return addition }) if !replaced { if text != "" { text += "\n\n" } ntext = text + addition } pat = utils.MustCompile(fmt.Sprintf(`(?m)^\s*(%s)\b`, strings.Join(maps.Keys(AllColorSettingNames), "|"))) return pat.ReplaceAllString(ntext, `# $1`) } func is_kitty_gui_cmdline(cmd ...string) bool { if len(cmd) == 0 { return false } if filepath.Base(cmd[0]) != "kitty" { return false } if len(cmd) == 1 { return true } s := cmd[1][:1] switch s { case `@`: return false case `+`: if cmd[1] == `+` { return len(cmd) > 2 && cmd[2] == `open` } return cmd[1] == `+open` } return true } type ReloadDestination string const ( RELOAD_IN_PARENT ReloadDestination = "parent" RELOAD_IN_ALL = "all" ) func reload_config(reload_in ReloadDestination) bool { switch reload_in { case RELOAD_IN_PARENT: if pid, err := strconv.Atoi(os.Getenv("KITTY_PID")); err == nil { if p, err := process.NewProcess(int32(pid)); err == nil { if c, err := p.CmdlineSlice(); err == nil && is_kitty_gui_cmdline(c...) { return p.SendSignal(unix.SIGUSR1) == nil } } } case RELOAD_IN_ALL: if all, err := process.Processes(); err == nil { for _, p := range all { if c, err := p.CmdlineSlice(); err == nil && is_kitty_gui_cmdline(c...) { p.SendSignal(unix.SIGUSR1) } } return true } } return false } 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, 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, utils.UnsafeStringToBytes(code), 0o644) if err != nil { return err } confpath := filepath.Join(config_dir, config_file_name) if q, err := filepath.EvalSymlinks(confpath); err == nil { confpath = q } raw, err := os.ReadFile(confpath) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } nraw := patch_conf(utils.UnsafeBytesToString(raw), self.metadata.Name) if len(raw) > 0 { os.WriteFile(confpath+".bak", raw, 0o600) } err = utils.AtomicUpdateFile(confpath, utils.UnsafeStringToBytes(nraw), 0o600) if err != nil { return err } reload_config(ReloadDestination(reload_in)) 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 = (&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.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 = maps.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(maps.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 = theme_name_from_file_name(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) bool { return a.Score > b.Score }) 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) } } } }