diff --git a/kittens/themes/collection.py b/kittens/themes/collection.py index a3a35a336..bf9befc20 100644 --- a/kittens/themes/collection.py +++ b/kittens/themes/collection.py @@ -6,13 +6,17 @@ import datetime import http import json import os +import re import shutil import zipfile from contextlib import suppress +from typing import Any, Callable, Dict, Match from urllib.error import HTTPError from urllib.request import Request, urlopen +from kitty.config import parse_config from kitty.constants import cache_dir +from kitty.rgb import Color def fetch_themes( @@ -49,3 +53,130 @@ def fetch_themes( with zipfile.ZipFile(dest_path, 'a') as zf: zf.comment = json.dumps({'etag': m.etag, 'timestamp': m.timestamp.isoformat()}).encode('utf-8') return dest_path + + +def zip_file_loader(path_to_zip: str, theme_file_name: str, file_name: str) -> Callable[[], str]: + + name = os.path.join(os.path.dirname(theme_file_name), file_name) + + def zip_loader() -> str: + with zipfile.ZipFile(path_to_zip, 'r') as zf, zf.open(name, 'rb') as f: + return f.read().decode('utf-8') + + return zip_loader + + +def theme_name_from_file_name(fname: str) -> str: + ans = fname.rsplit('.', 1)[0] + ans = ans.replace('_', ' ') + + def camel_case(m: Match) -> str: + return str(m.group(1) + ' ' + m.group(2)) + + ans = re.sub(r'([a-z])([A-Z])', camel_case, ans) + return ' '.join(x.capitalize() for x in filter(None, ans.split())) + + +class LineParser: + + def __init__(self) -> None: + self.in_metadata = False + self.in_blurb = False + self.keep_going = True + + def __call__(self, line: str, ans: Dict[str, Any]) -> None: + is_block = line.startswith('## ') + if self.in_metadata and not is_block: + self.keep_going = False + return + if not self.in_metadata and is_block: + self.in_metadata = True + if not self.in_metadata: + return + line = line[3:] + if self.in_blurb: + ans['blurb'] += ' ' + line + return + try: + key, val = line.split(':', 1) + except Exception: + self.keep_going = False + return + key = key.strip().lower() + val = val.strip() + if val: + ans[key] = val + if key == 'blurb': + self.in_blurb = True + + +def parse_theme(fname: str, raw: str) -> Dict[str, Any]: + lines = raw.splitlines() + conf = parse_config(lines) + bg = conf.get('background', Color()) + is_dark = max(bg) < 115 + ans: Dict[str, Any] = {'name': theme_name_from_file_name(fname)} + parser = LineParser() + for i, line in enumerate(raw.splitlines()): + line = line.strip() + if not line: + continue + try: + parser(line, ans) + except Exception as e: + raise SystemExit( + f'Failed to parse {fname} line {i+1} with error: {e}') + if not parser.keep_going: + break + if is_dark: + ans['is_dark'] = True + ans['num_settings'] = len(conf) - len(parse_config(())) + if ans['num_settings'] < 1: + raise SystemExit(f'The theme {fname} has no settings') + return ans + + +class Theme: + name: str = '' + author: str = '' + license: str = '' + is_dark: bool = False + blurb: str = '' + num_settings: int = 0 + + def apply_dict(self, d: Dict[str, Any]) -> None: + self.name = str(d['name']) + for x in ('author', 'license', 'blurb'): + a = d.get(x) + if isinstance(a, str): + setattr(self, x, a) + for x in ('is_dark', 'num_settings'): + a = d.get(x) + if isinstance(a, int): + setattr(self, x, a) + + def __init__(self, loader: Callable[[], str]): + self.loader = loader + + +class Themes: + + def __init__(self) -> None: + self.themes: Dict[str, Theme] = {} + + def load_from_zip(self, path_to_zip: str) -> None: + with zipfile.ZipFile(path_to_zip, 'r') as zf: + for name in zf.namelist(): + if os.path.basename(name) == 'themes.json': + theme_file_name = name + with zf.open(theme_file_name, 'rb') as f: + items = json.loads(f.read()) + break + else: + raise ValueError(f'No themes.json found in {path_to_zip}') + + for item in items: + t = Theme(zip_file_loader(path_to_zip, theme_file_name, item['file'])) + t.apply_dict(item) + if t.name: + self.themes[t.name] = t