kitty/kittens/themes/collection.py
2022-11-14 15:41:58 +05:30

663 lines
18 KiB
Python

#!/usr/bin/env python
# License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>
import datetime
import http
import json
import os
import re
import shutil
import signal
import sys
import tempfile
import zipfile
from contextlib import suppress
from typing import (
Any, Callable, Dict, Iterator, Match, Optional, Tuple, Type, Union
)
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from kitty.config import atomic_save, parse_config
from kitty.constants import cache_dir, config_dir
from kitty.fast_data_types import Color
from kitty.options.types import Options as KittyOptions
from kitty.utils import reload_conf_in_all_kitties
from ..choose.match import match
MARK_BEFORE = '\033[33m'
MARK_AFTER = '\033[39m'
def patch_conf(raw: str, theme_name: str) -> str:
addition = f'# BEGIN_KITTY_THEME\n# {theme_name}\ninclude current-theme.conf\n# END_KITTY_THEME'
nraw, num = re.subn(r'^# BEGIN_KITTY_THEME.+?# END_KITTY_THEME', addition, raw, flags=re.MULTILINE | re.DOTALL)
if not num:
if raw:
raw += '\n\n'
nraw = raw + addition
# comment out all existing color definitions
color_conf_items = ( # {{{
# generated by gen-config.py DO NOT edit
# ALL_COLORS_START
'active_border_color',
'active_tab_background',
'active_tab_foreground',
'background',
'bell_border_color',
'color0',
'color1',
'color10',
'color100',
'color101',
'color102',
'color103',
'color104',
'color105',
'color106',
'color107',
'color108',
'color109',
'color11',
'color110',
'color111',
'color112',
'color113',
'color114',
'color115',
'color116',
'color117',
'color118',
'color119',
'color12',
'color120',
'color121',
'color122',
'color123',
'color124',
'color125',
'color126',
'color127',
'color128',
'color129',
'color13',
'color130',
'color131',
'color132',
'color133',
'color134',
'color135',
'color136',
'color137',
'color138',
'color139',
'color14',
'color140',
'color141',
'color142',
'color143',
'color144',
'color145',
'color146',
'color147',
'color148',
'color149',
'color15',
'color150',
'color151',
'color152',
'color153',
'color154',
'color155',
'color156',
'color157',
'color158',
'color159',
'color16',
'color160',
'color161',
'color162',
'color163',
'color164',
'color165',
'color166',
'color167',
'color168',
'color169',
'color17',
'color170',
'color171',
'color172',
'color173',
'color174',
'color175',
'color176',
'color177',
'color178',
'color179',
'color18',
'color180',
'color181',
'color182',
'color183',
'color184',
'color185',
'color186',
'color187',
'color188',
'color189',
'color19',
'color190',
'color191',
'color192',
'color193',
'color194',
'color195',
'color196',
'color197',
'color198',
'color199',
'color2',
'color20',
'color200',
'color201',
'color202',
'color203',
'color204',
'color205',
'color206',
'color207',
'color208',
'color209',
'color21',
'color210',
'color211',
'color212',
'color213',
'color214',
'color215',
'color216',
'color217',
'color218',
'color219',
'color22',
'color220',
'color221',
'color222',
'color223',
'color224',
'color225',
'color226',
'color227',
'color228',
'color229',
'color23',
'color230',
'color231',
'color232',
'color233',
'color234',
'color235',
'color236',
'color237',
'color238',
'color239',
'color24',
'color240',
'color241',
'color242',
'color243',
'color244',
'color245',
'color246',
'color247',
'color248',
'color249',
'color25',
'color250',
'color251',
'color252',
'color253',
'color254',
'color255',
'color26',
'color27',
'color28',
'color29',
'color3',
'color30',
'color31',
'color32',
'color33',
'color34',
'color35',
'color36',
'color37',
'color38',
'color39',
'color4',
'color40',
'color41',
'color42',
'color43',
'color44',
'color45',
'color46',
'color47',
'color48',
'color49',
'color5',
'color50',
'color51',
'color52',
'color53',
'color54',
'color55',
'color56',
'color57',
'color58',
'color59',
'color6',
'color60',
'color61',
'color62',
'color63',
'color64',
'color65',
'color66',
'color67',
'color68',
'color69',
'color7',
'color70',
'color71',
'color72',
'color73',
'color74',
'color75',
'color76',
'color77',
'color78',
'color79',
'color8',
'color80',
'color81',
'color82',
'color83',
'color84',
'color85',
'color86',
'color87',
'color88',
'color89',
'color9',
'color90',
'color91',
'color92',
'color93',
'color94',
'color95',
'color96',
'color97',
'color98',
'color99',
'cursor',
'cursor_text_color',
'foreground',
'inactive_border_color',
'inactive_tab_background',
'inactive_tab_foreground',
'macos_titlebar_color',
'mark1_background',
'mark1_foreground',
'mark2_background',
'mark2_foreground',
'mark3_background',
'mark3_foreground',
'selection_background',
'selection_foreground',
'tab_bar_background',
'tab_bar_margin_color',
'url_color',
'visual_bell_color',
'wayland_titlebar_color',
# ALL_COLORS_END
) # }}}
pat = fr'^\s*({"|".join(color_conf_items)})\b'
return re.sub(pat, r'# \1', nraw, flags=re.MULTILINE)
def set_comment_in_zip_file(path: str, data: str) -> None:
with zipfile.ZipFile(path, 'a') as zf:
zf.comment = data.encode('utf-8')
class NoCacheFound(ValueError):
pass
def fetch_themes(
name: str = 'kitty-themes',
url: str = 'https://codeload.github.com/kovidgoyal/kitty-themes/zip/master',
cache_age: float = 1,
) -> str:
now = datetime.datetime.now(datetime.timezone.utc)
cache_age_delta = datetime.timedelta(days=cache_age)
class Metadata:
def __init__(self) -> None:
self.etag = ''
self.timestamp = now
def __str__(self) -> str:
return json.dumps({'etag': self.etag, 'timestamp': self.timestamp.isoformat()})
dest_path = os.path.join(cache_dir(), f'{name}.zip')
m = Metadata()
with suppress(Exception), zipfile.ZipFile(dest_path, 'r') as zf:
q = json.loads(zf.comment)
m.etag = str(q.get('etag') or '')
m.timestamp = datetime.datetime.fromisoformat(q['timestamp'])
if cache_age < 0 or (now - m.timestamp) < cache_age_delta:
return dest_path
if cache_age < 0:
raise NoCacheFound('No local themes cache found and negative cache age specified, aborting')
rq = Request(url)
m.timestamp = now
if m.etag:
rq.add_header('If-None-Match', m.etag)
try:
res = urlopen(rq, timeout=30)
except HTTPError as e:
if m.etag and e.code == http.HTTPStatus.NOT_MODIFIED:
set_comment_in_zip_file(dest_path, str(m))
return dest_path
raise
m.etag = res.headers.get('etag') or ''
needs_delete = False
try:
with tempfile.NamedTemporaryFile(suffix=f'-{os.path.basename(dest_path)}', dir=os.path.dirname(dest_path), delete=False) as f:
needs_delete = True
shutil.copyfileobj(res, f)
f.flush()
set_comment_in_zip_file(f.name, str(m))
os.replace(f.name, dest_path)
needs_delete = False
finally:
if needs_delete:
os.unlink(f.name)
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) 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]') -> str:
return f'{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, exc_class: Type[BaseException] = SystemExit) -> Dict[str, Any]:
lines = raw.splitlines()
conf = parse_config(lines)
bg: Color = conf.get('background', Color())
is_dark = max((bg.red, bg.green, bg.blue)) < 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 exc_class(
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 and fname != 'default.conf':
raise exc_class(f'The theme {fname} has no settings')
return ans
def update_theme_file(path: str) -> bool:
with open(path) as f:
raw = f.read()
td = parse_theme(os.path.basename(path), raw, exc_class=ValueError)
if 'upstream' not in td:
return False
nraw = urlopen(td['upstream']).read().decode('utf-8')
if raw == nraw:
return False
atomic_save(nraw.encode('utf-8'), path)
return True
def text_as_opts(text: str) -> KittyOptions:
return KittyOptions(options_dict=parse_config(text.splitlines()))
class Theme:
name: str = ''
author: str = ''
license: str = ''
is_dark: bool = False
blurb: str = ''
num_settings: int = 0
is_user_defined: bool = False
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', 'is_user_defined'):
a = d.get(x)
if isinstance(a, int):
setattr(self, x, a)
def __init__(self, loader: Callable[[], str]):
self._loader = loader
self._raw: Optional[str] = None
self._opts: Optional[KittyOptions] = None
@property
def raw(self) -> str:
if self._raw is None:
self._raw = self._loader()
return self._raw
@property
def kitty_opts(self) -> KittyOptions:
if self._opts is None:
self._opts = text_as_opts(self.raw)
return self._opts
def save_in_dir(self, dirpath: str) -> None:
atomic_save(self.raw.encode('utf-8'), os.path.join(dirpath, f'{self.name}.conf'))
def save_in_conf(self, confdir: str, reload_in: str, config_file_name: str = 'kitty.conf') -> None:
os.makedirs(confdir, exist_ok=True)
atomic_save(self.raw.encode('utf-8'), os.path.join(confdir, 'current-theme.conf'))
confpath = os.path.realpath(os.path.join(confdir, config_file_name))
try:
with open(confpath) as f:
raw = f.read()
except FileNotFoundError:
raw = ''
nraw = patch_conf(raw, self.name)
if raw:
with open(f'{confpath}.bak', 'w') as f:
f.write(raw)
atomic_save(nraw.encode('utf-8'), confpath)
if reload_in == 'parent':
if 'KITTY_PID' in os.environ:
os.kill(int(os.environ['KITTY_PID']), signal.SIGUSR1)
elif reload_in == 'all':
reload_conf_in_all_kitties()
class Themes:
def __init__(self) -> None:
self.themes: Dict[str, Theme] = {}
self.index_map: Tuple[str, ...] = ()
def __len__(self) -> int:
return len(self.themes)
def __iter__(self) -> Iterator[Theme]:
return iter(self.themes.values())
def __getitem__(self, key: Union[int, str]) -> Theme:
if isinstance(key, str):
return self.themes[key]
if key < 0:
key += len(self.index_map)
return self.themes[self.index_map[key]]
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) 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
def load_from_dir(self, path: str) -> None:
if not os.path.isdir(path):
return
for name in os.listdir(path):
if name.endswith('.conf'):
with open(os.path.join(path, name), 'rb') as f:
raw = f.read().decode()
try:
d = parse_theme(name, raw)
except (Exception, SystemExit):
continue
d['is_user_defined'] = True
t = Theme(raw.__str__)
t.apply_dict(d)
if t.name:
self.themes[t.name] = t
def filtered(self, is_ok: Callable[[Theme], bool]) -> 'Themes':
ans = Themes()
def sort_key(k: Tuple[str, Theme]) -> str:
return k[1].name.lower()
ans.themes = {k: v for k, v in sorted(self.themes.items(), key=sort_key) if is_ok(v)}
ans.index_map = tuple(ans.themes)
return ans
def copy(self) -> 'Themes':
ans = Themes()
ans.themes = self.themes.copy()
ans.index_map = self.index_map
return ans
def apply_search(
self, expression: str, mark_before: str = MARK_BEFORE, mark_after: str = MARK_AFTER
) -> Iterator[str]:
raw = '\n'.join(self.themes)
results = match(raw, expression, positions=True, level1=' ')
themes: Dict[str, Theme] = {}
for r in results:
pos, k = r.split(':', 1)
positions = tuple(map(int, pos.split(',')))
text = k
for p in reversed(positions):
text = text[:p] + mark_before + text[p] + mark_after + text[p+1:]
themes[k] = self.themes[k]
yield text
self.themes = themes
self.index_map = tuple(self.themes)
def load_themes(cache_age: float = 1., ignore_no_cache: bool = False) -> Themes:
ans = Themes()
try:
fetched = fetch_themes(cache_age=cache_age)
except NoCacheFound:
if not ignore_no_cache:
raise
else:
ans.load_from_zip(fetched)
ans.load_from_dir(os.path.join(config_dir, 'themes'))
ans.index_map = tuple(ans.themes)
return ans
def print_theme_names() -> None:
found = False
for theme in load_themes(cache_age=-1, ignore_no_cache=True):
print(theme.name)
found = True
if not found:
print('Default')
sys.stdout.flush()