Remove themes python code
This commit is contained in:
parent
0805330b77
commit
9443b0e361
@ -1,658 +0,0 @@
|
||||
#!/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
|
||||
|
||||
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()
|
||||
@ -1,523 +1,10 @@
|
||||
#!/usr/bin/env python
|
||||
# License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
from enum import Enum, auto
|
||||
from gettext import gettext as _
|
||||
from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Union
|
||||
|
||||
from kitty.cli import CompletionSpec, create_default_opts, parse_args
|
||||
from kitty.cli_stub import ThemesCLIOptions
|
||||
from kitty.config import cached_values_for
|
||||
from kitty.constants import config_dir
|
||||
from kitty.fast_data_types import truncate_point_for_length, wcswidth
|
||||
from kitty.options.types import Options as KittyOptions
|
||||
from kitty.rgb import color_as_sharp, color_from_int
|
||||
from kitty.typing import KeyEventType
|
||||
from kitty.utils import ScreenSize
|
||||
|
||||
from ..tui.handler import Handler
|
||||
from ..tui.line_edit import LineEdit
|
||||
from ..tui.loop import Loop
|
||||
from ..tui.operations import color_code, set_default_colors, styled
|
||||
from .collection import MARK_AFTER, NoCacheFound, Theme, Themes, load_themes
|
||||
|
||||
separator = '║'
|
||||
|
||||
|
||||
def format_traceback(msg: str) -> str:
|
||||
return traceback.format_exc() + '\n\n' + styled(msg, fg='red')
|
||||
|
||||
|
||||
def limit_length(text: str, limit: int = 32) -> str:
|
||||
x = truncate_point_for_length(text, limit - 1)
|
||||
if x >= len(text):
|
||||
return text
|
||||
return f'{text[:x]}…'
|
||||
|
||||
|
||||
class State(Enum):
|
||||
fetching = auto()
|
||||
browsing = auto()
|
||||
searching = auto()
|
||||
accepting = auto()
|
||||
|
||||
|
||||
def dark_filter(q: Theme) -> bool:
|
||||
return q.is_dark
|
||||
|
||||
|
||||
def light_filter(q: Theme) -> bool:
|
||||
return not q.is_dark
|
||||
|
||||
|
||||
def all_filter(q: Theme) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def create_recent_filter(names: Iterable[str]) -> Callable[[Theme], bool]:
|
||||
allowed = frozenset(names)
|
||||
|
||||
def recent_filter(q: Theme) -> bool:
|
||||
return q.name in allowed
|
||||
|
||||
return recent_filter
|
||||
|
||||
|
||||
def user_filter(q: Theme) -> bool:
|
||||
return q.is_user_defined
|
||||
|
||||
|
||||
def mark_shortcut(text: str, acc: str) -> str:
|
||||
acc_idx = text.lower().index(acc.lower())
|
||||
return text[:acc_idx] + styled(text[acc_idx], underline='straight', bold=True, fg_intense=True) + text[acc_idx+1:]
|
||||
|
||||
|
||||
class ThemesList:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.themes = Themes()
|
||||
self.current_search: str = ''
|
||||
self.display_strings: Tuple[str, ...] = ()
|
||||
self.widths: Tuple[int, ...] = ()
|
||||
self.max_width = 0
|
||||
self.current_idx = 0
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.display_strings)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.themes)
|
||||
|
||||
def next(self, delta: int = 1, allow_wrapping: bool = True) -> bool:
|
||||
if not self:
|
||||
return False
|
||||
idx = self.current_idx + delta
|
||||
if not allow_wrapping and (idx < 0 or idx >= len(self)):
|
||||
return False
|
||||
while idx < 0:
|
||||
idx += len(self)
|
||||
self.current_idx = idx % len(self)
|
||||
return True
|
||||
|
||||
def update_themes(self, themes: Themes) -> None:
|
||||
self.themes = self.all_themes = themes
|
||||
if self.current_search:
|
||||
self.themes = self.all_themes.copy()
|
||||
self.display_strings = tuple(map(limit_length, self.themes.apply_search(self.current_search)))
|
||||
else:
|
||||
self.display_strings = tuple(map(limit_length, (t.name for t in self.themes)))
|
||||
self.widths = tuple(map(wcswidth, self.display_strings))
|
||||
self.max_width = max(self.widths) if self.widths else 0
|
||||
self.current_idx = 0
|
||||
|
||||
def update_search(self, search: str = '') -> bool:
|
||||
if search == self.current_search:
|
||||
return False
|
||||
self.current_search = search
|
||||
self.update_themes(self.all_themes)
|
||||
return True
|
||||
|
||||
def lines(self, num_rows: int) -> Iterator[Tuple[str, int, bool]]:
|
||||
if num_rows < 1:
|
||||
return
|
||||
before_num = min(self.current_idx, num_rows - 1)
|
||||
start = self.current_idx - before_num
|
||||
for i in range(start, min(start + num_rows, len(self.display_strings))):
|
||||
line = self.display_strings[i]
|
||||
yield line, self.widths[i], i == self.current_idx
|
||||
|
||||
@property
|
||||
def current_theme(self) -> Theme:
|
||||
return self.themes[self.current_idx]
|
||||
|
||||
|
||||
def colors_as_escape_codes(o: KittyOptions) -> str:
|
||||
ans = set_default_colors(
|
||||
fg=o.foreground, bg=o.background, cursor=o.cursor, select_bg=o.selection_background, select_fg=o.selection_foreground
|
||||
)
|
||||
cmds = []
|
||||
for i in range(256):
|
||||
col = color_as_sharp(color_from_int(o.color_table[i]))
|
||||
cmds.append(f'{i};{col}')
|
||||
return ans + '\033]4;' + ';'.join(cmds) + '\033\\'
|
||||
|
||||
|
||||
class ThemesHandler(Handler):
|
||||
|
||||
def __init__(self, cached_values: Dict[str, Any], cli_opts: ThemesCLIOptions) -> None:
|
||||
self.cached_values = cached_values
|
||||
self.cli_opts = cli_opts
|
||||
self.state = State.fetching
|
||||
self.report_traceback_on_exit: Optional[str] = None
|
||||
self.filter_map: Dict[str, Callable[[Theme], bool]] = {
|
||||
'dark': dark_filter, 'light': light_filter, 'all': all_filter,
|
||||
'recent': create_recent_filter(self.cached_values.get('recent', ())),
|
||||
'user': user_filter
|
||||
}
|
||||
self.themes_list = ThemesList()
|
||||
self.colors_set_once = False
|
||||
self.line_edit = LineEdit()
|
||||
self.tabs = tuple('all dark light recent user'.split())
|
||||
self.quit_on_next_key_release = -1
|
||||
|
||||
def update_recent(self) -> None:
|
||||
r = list(self.cached_values.get('recent', ()))
|
||||
if self.themes_list:
|
||||
name = self.themes_list.current_theme.name
|
||||
r = [name] + [x for x in r if x != name]
|
||||
self.cached_values['recent'] = r[:20]
|
||||
|
||||
def enforce_cursor_state(self) -> None:
|
||||
self.cmd.set_cursor_visible(self.state == State.fetching)
|
||||
|
||||
def init_terminal_state(self) -> None:
|
||||
self.cmd.save_colors()
|
||||
self.cmd.set_line_wrapping(False)
|
||||
self.cmd.set_window_title('Choose a theme for kitty')
|
||||
self.cmd.set_cursor_shape('beam')
|
||||
|
||||
def initialize(self) -> None:
|
||||
self.init_terminal_state()
|
||||
self.draw_screen()
|
||||
self.fetch_themes()
|
||||
|
||||
def finalize(self) -> None:
|
||||
self.cmd.restore_colors()
|
||||
self.cmd.set_cursor_visible(True)
|
||||
|
||||
@property
|
||||
def current_category(self) -> str:
|
||||
cat: str = self.cached_values.get('category', 'all')
|
||||
if cat not in self.filter_map:
|
||||
cat = 'all'
|
||||
return cat
|
||||
|
||||
@current_category.setter
|
||||
def current_category(self, cat: str) -> None:
|
||||
if cat not in self.filter_map:
|
||||
cat = 'all'
|
||||
self.cached_values['category'] = cat
|
||||
|
||||
def set_colors_to_current_theme(self) -> bool:
|
||||
if not self.themes_list and self.colors_set_once:
|
||||
return False
|
||||
self.colors_set_once = True
|
||||
if self.themes_list:
|
||||
o = self.themes_list.current_theme.kitty_opts
|
||||
else:
|
||||
o = create_default_opts()
|
||||
self.current_opts = o
|
||||
cmd = colors_as_escape_codes(o)
|
||||
self.write(cmd)
|
||||
return True
|
||||
|
||||
def redraw_after_category_change(self) -> None:
|
||||
self.themes_list.update_themes(self.all_themes.filtered(self.filter_map[self.current_category]))
|
||||
self.set_colors_to_current_theme()
|
||||
self.draw_screen()
|
||||
|
||||
# Theme fetching {{{
|
||||
def fetch_themes(self) -> None:
|
||||
|
||||
def fetching_done(themes_or_exception: Union[Themes, str]) -> None:
|
||||
if isinstance(themes_or_exception, str):
|
||||
self.report_traceback_on_exit = themes_or_exception
|
||||
self.quit_loop(1)
|
||||
return
|
||||
self.all_themes: Themes = themes_or_exception
|
||||
self.state = State.browsing
|
||||
self.redraw_after_category_change()
|
||||
|
||||
def fetch() -> None:
|
||||
from urllib.error import URLError
|
||||
try:
|
||||
themes: Union[Themes, str] = load_themes(self.cli_opts.cache_age)
|
||||
except URLError as e:
|
||||
themes = f'Could not download themes, check your internet connection. Error: {e}'
|
||||
except Exception:
|
||||
themes = format_traceback('Failed to download themes')
|
||||
self.asyncio_loop.call_soon_threadsafe(fetching_done, themes)
|
||||
|
||||
self.asyncio_loop.run_in_executor(None, fetch)
|
||||
self.draw_screen()
|
||||
|
||||
def draw_fetching_screen(self) -> None:
|
||||
self.print('Downloading themes from repository, please wait...')
|
||||
|
||||
def on_fetching_key_event(self, key_event: KeyEventType, in_bracketed_paste: bool = False) -> None:
|
||||
if key_event.matches('esc'):
|
||||
self.quit_on_next_key_release = 0
|
||||
return
|
||||
|
||||
# }}}
|
||||
|
||||
# Theme browsing {{{
|
||||
def draw_tab_bar(self) -> None:
|
||||
self.print(styled(' ' * self.screen_size.cols, reverse=True), end='\r')
|
||||
|
||||
def draw_tab(text: str, name: str, acc: str) -> None:
|
||||
is_active = name == self.current_category
|
||||
if is_active:
|
||||
text = styled(f'{text} #{len(self.themes_list)}', italic=True)
|
||||
else:
|
||||
text = mark_shortcut(text, acc)
|
||||
|
||||
self.cmd.styled(f' {text} ', reverse=not is_active)
|
||||
|
||||
for t in self.tabs:
|
||||
draw_tab(t.capitalize(), t, t[0])
|
||||
self.cmd.sgr('0')
|
||||
self.print()
|
||||
|
||||
def draw_bottom_bar(self) -> None:
|
||||
self.cmd.set_cursor_position(0, self.screen_size.rows)
|
||||
self.print(styled(' ' * self.screen_size.cols, reverse=True), end='\r')
|
||||
for (t, sc) in (('search (/)', 's'), ('accept (⏎)', 'c')):
|
||||
text = mark_shortcut(t.capitalize(), sc)
|
||||
self.cmd.styled(f' {text} ', reverse=True)
|
||||
self.cmd.sgr('0')
|
||||
|
||||
def draw_search_bar(self) -> None:
|
||||
self.cmd.set_cursor_position(0, self.screen_size.rows)
|
||||
self.cmd.clear_to_eol()
|
||||
self.line_edit.write(self.write)
|
||||
|
||||
def draw_theme_demo(self) -> None:
|
||||
theme = self.themes_list.current_theme
|
||||
xstart = self.themes_list.max_width + 3
|
||||
sz = self.screen_size.cols - xstart
|
||||
if sz < 20:
|
||||
return
|
||||
sz -= 1
|
||||
y = 0
|
||||
colors = 'black red green yellow blue magenta cyan white'.split()
|
||||
trunc = sz // 8 - 1
|
||||
|
||||
def next_line() -> None:
|
||||
nonlocal y
|
||||
self.write('\r')
|
||||
y += 1
|
||||
self.cmd.set_cursor_position(xstart - 1, y)
|
||||
self.write(separator + ' ')
|
||||
|
||||
def write_para(text: str) -> None:
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
while text:
|
||||
sp = truncate_point_for_length(text, sz)
|
||||
self.write(text[:sp])
|
||||
next_line()
|
||||
text = text[sp:]
|
||||
|
||||
def write_colors(bg: Optional[str] = None) -> None:
|
||||
for intense in (False, True):
|
||||
buf = []
|
||||
for c in colors:
|
||||
buf.append(styled(c[:trunc], fg=c, fg_intense=intense))
|
||||
self.cmd.styled(' '.join(buf), bg=bg, bg_intense=intense)
|
||||
next_line()
|
||||
next_line()
|
||||
|
||||
self.cmd.set_cursor_position()
|
||||
next_line()
|
||||
self.cmd.styled(theme.name.center(sz), bold=True, fg='green')
|
||||
next_line()
|
||||
if theme.author:
|
||||
self.cmd.styled(theme.author.center(sz), italic=True)
|
||||
next_line()
|
||||
if theme.blurb:
|
||||
next_line()
|
||||
write_para(theme.blurb)
|
||||
next_line()
|
||||
write_colors()
|
||||
|
||||
for bg in colors:
|
||||
write_colors(bg)
|
||||
|
||||
def draw_browsing_screen(self) -> None:
|
||||
self.draw_tab_bar()
|
||||
num_rows = self.screen_size.rows - 2
|
||||
mw = self.themes_list.max_width + 1
|
||||
for line, width, is_current in self.themes_list.lines(num_rows):
|
||||
num_rows -= 1
|
||||
if is_current:
|
||||
line = line.replace(MARK_AFTER, f'\033[{color_code("green")}m')
|
||||
self.cmd.styled('>' if is_current else ' ', fg='green')
|
||||
self.cmd.styled(line, bold=is_current, fg='green' if is_current else None)
|
||||
self.cmd.move_cursor_by(mw - width, 'right')
|
||||
self.print(separator)
|
||||
if self.themes_list:
|
||||
self.draw_theme_demo()
|
||||
self.draw_bottom_bar() if self.state is State.browsing else self.draw_search_bar()
|
||||
|
||||
def on_searching_key_event(self, key_event: KeyEventType, in_bracketed_paste: bool = False) -> None:
|
||||
if key_event.matches('enter'):
|
||||
self.state = State.browsing
|
||||
self.draw_bottom_bar()
|
||||
return
|
||||
if key_event.matches('esc'):
|
||||
self.state = State.browsing
|
||||
self.themes_list.update_search('')
|
||||
self.set_colors_to_current_theme()
|
||||
return self.draw_screen()
|
||||
if key_event.text:
|
||||
self.line_edit.on_text(key_event.text, in_bracketed_paste)
|
||||
else:
|
||||
if not self.line_edit.on_key(key_event):
|
||||
if key_event.matches('left') or key_event.matches('shift+tab'):
|
||||
return self.next_category(-1)
|
||||
if key_event.matches('right') or key_event.matches('tab'):
|
||||
return self.next_category(1)
|
||||
if key_event.matches('down'):
|
||||
return self.next(delta=1)
|
||||
if key_event.matches('up'):
|
||||
return self.next(delta=-1)
|
||||
if key_event.matches('page_down'):
|
||||
return self.next(delta=self.screen_size.rows - 3, allow_wrapping=False)
|
||||
if key_event.matches('page_up'):
|
||||
return self.next(delta=3 - self.screen_size.rows, allow_wrapping=False)
|
||||
return
|
||||
if self.line_edit.current_input:
|
||||
q = self.line_edit.current_input[1:]
|
||||
if self.themes_list.update_search(q):
|
||||
self.set_colors_to_current_theme()
|
||||
self.draw_screen()
|
||||
else:
|
||||
self.draw_search_bar()
|
||||
else:
|
||||
self.state = State.browsing
|
||||
self.draw_bottom_bar()
|
||||
|
||||
def on_browsing_key_event(self, key_event: KeyEventType, in_bracketed_paste: bool = False) -> None:
|
||||
if key_event.matches('esc') or key_event.matches_text('q'):
|
||||
self.quit_on_next_key_release = 0
|
||||
return
|
||||
for cat in self.tabs:
|
||||
if key_event.matches_text(cat[0]) or key_event.matches(f'alt+{cat[0]}'):
|
||||
if cat != self.current_category:
|
||||
self.current_category = cat
|
||||
self.redraw_after_category_change()
|
||||
return
|
||||
if key_event.matches('left') or key_event.matches('shift+tab'):
|
||||
return self.next_category(-1)
|
||||
if key_event.matches('right') or key_event.matches('tab'):
|
||||
return self.next_category(1)
|
||||
if key_event.matches_text('j') or key_event.matches('down'):
|
||||
return self.next(delta=1)
|
||||
if key_event.matches_text('k') or key_event.matches('up'):
|
||||
return self.next(delta=-1)
|
||||
if key_event.matches('page_down'):
|
||||
return self.next(delta=self.screen_size.rows - 3, allow_wrapping=False)
|
||||
if key_event.matches('page_up'):
|
||||
return self.next(delta=3 - self.screen_size.rows, allow_wrapping=False)
|
||||
if key_event.matches_text('s') or key_event.matches('/'):
|
||||
return self.start_search()
|
||||
if key_event.matches_text('c') or key_event.matches('enter'):
|
||||
if not self.themes_list:
|
||||
self.cmd.beep()
|
||||
return
|
||||
self.state = State.accepting
|
||||
return self.draw_screen()
|
||||
|
||||
def start_search(self) -> None:
|
||||
self.line_edit.clear()
|
||||
self.line_edit.add_text('/' + self.themes_list.current_search)
|
||||
self.state = State.searching
|
||||
self.draw_screen()
|
||||
|
||||
def next_category(self, delta: int = 1) -> None:
|
||||
idx = self.tabs.index(self.current_category) + delta + len(self.tabs)
|
||||
self.current_category = self.tabs[idx % len(self.tabs)]
|
||||
self.redraw_after_category_change()
|
||||
|
||||
def next(self, delta: int = 1, allow_wrapping: bool = True) -> None:
|
||||
if self.themes_list.next(delta, allow_wrapping):
|
||||
self.set_colors_to_current_theme()
|
||||
self.draw_screen()
|
||||
else:
|
||||
self.cmd.bell()
|
||||
# }}}
|
||||
|
||||
# Accepting {{{
|
||||
def draw_accepting_screen(self) -> None:
|
||||
name = self.themes_list.current_theme.name
|
||||
name = styled(name, bold=True, fg="green")
|
||||
kc = styled(self.cli_opts.config_file_name, italic=True)
|
||||
|
||||
def ac(x: str) -> str:
|
||||
return styled(x, fg='red')
|
||||
|
||||
self.cmd.set_line_wrapping(True)
|
||||
self.print(f'You have chosen the {name} theme')
|
||||
self.print()
|
||||
self.print('What would you like to do?')
|
||||
self.print()
|
||||
self.print(' ', f'{ac("M")}odify {kc} to load', styled(name, bold=True, fg="green"))
|
||||
self.print()
|
||||
self.print(' ', f'{ac("P")}lace the theme file in {config_dir} but do not modify {kc}')
|
||||
self.print()
|
||||
self.print(' ', f'{ac("A")}bort and return to list of themes')
|
||||
self.print()
|
||||
self.print(' ', f'{ac("Q")}uit')
|
||||
|
||||
def on_accepting_key_event(self, key_event: KeyEventType, in_bracketed_paste: bool = False) -> None:
|
||||
if key_event.matches_text('q') or key_event.matches('esc'):
|
||||
self.quit_on_next_key_release = 0
|
||||
return
|
||||
if key_event.matches_text('a'):
|
||||
self.state = State.browsing
|
||||
self.draw_screen()
|
||||
return
|
||||
if key_event.matches_text('p'):
|
||||
self.themes_list.current_theme.save_in_dir(config_dir)
|
||||
self.update_recent()
|
||||
self.quit_on_next_key_release = 0
|
||||
return
|
||||
if key_event.matches_text('m'):
|
||||
self.themes_list.current_theme.save_in_conf(config_dir, self.cli_opts.reload_in, self.cli_opts.config_file_name)
|
||||
self.update_recent()
|
||||
self.quit_on_next_key_release = 0
|
||||
return
|
||||
# }}}
|
||||
|
||||
def on_key_event(self, key_event: KeyEventType, in_bracketed_paste: bool = False) -> None:
|
||||
if self.quit_on_next_key_release > -1 and key_event.is_release:
|
||||
self.quit_loop(self.quit_on_next_key_release)
|
||||
return
|
||||
if self.state is State.fetching:
|
||||
self.on_fetching_key_event(key_event, in_bracketed_paste)
|
||||
elif self.state is State.browsing:
|
||||
self.on_browsing_key_event(key_event, in_bracketed_paste)
|
||||
elif self.state is State.searching:
|
||||
self.on_searching_key_event(key_event, in_bracketed_paste)
|
||||
elif self.state is State.accepting:
|
||||
self.on_accepting_key_event(key_event, in_bracketed_paste)
|
||||
|
||||
@Handler.atomic_update
|
||||
def draw_screen(self) -> None:
|
||||
self.cmd.clear_screen()
|
||||
self.enforce_cursor_state()
|
||||
self.cmd.set_line_wrapping(False)
|
||||
if self.state is State.fetching:
|
||||
self.draw_fetching_screen()
|
||||
elif self.state in (State.browsing, State.searching):
|
||||
self.draw_browsing_screen()
|
||||
elif self.state is State.accepting:
|
||||
self.draw_accepting_screen()
|
||||
|
||||
def on_resize(self, screen_size: ScreenSize) -> None:
|
||||
self.screen_size = screen_size
|
||||
self.draw_screen()
|
||||
|
||||
def on_interrupt(self) -> None:
|
||||
self.quit_loop(1)
|
||||
|
||||
def on_eot(self) -> None:
|
||||
self.quit_loop(1)
|
||||
from typing import List
|
||||
|
||||
from kitty.cli import CompletionSpec
|
||||
|
||||
help_text = (
|
||||
'Change the kitty theme. If no theme name is supplied, run interactively, otherwise'
|
||||
@ -559,58 +46,8 @@ to your kitty.conf and then have the kitten operate only on :file:`themes.conf`,
|
||||
allowing :code:`kitty.conf` to remain unchanged.
|
||||
'''.format
|
||||
|
||||
|
||||
def parse_themes_args(args: List[str]) -> Tuple[ThemesCLIOptions, List[str]]:
|
||||
return parse_args(args, OPTIONS, usage, help_text, 'kitty +kitten themes', result_class=ThemesCLIOptions)
|
||||
|
||||
|
||||
def non_interactive(cli_opts: ThemesCLIOptions, theme_name: str) -> None:
|
||||
try:
|
||||
themes = load_themes(cli_opts.cache_age)
|
||||
except NoCacheFound as e:
|
||||
raise SystemExit(str(e))
|
||||
try:
|
||||
theme = themes[theme_name]
|
||||
except KeyError:
|
||||
theme_name = theme_name.replace('\\', '')
|
||||
try:
|
||||
theme = themes[theme_name]
|
||||
except KeyError:
|
||||
raise SystemExit(f'No theme named: {theme_name}')
|
||||
if cli_opts.dump_theme:
|
||||
print(theme.raw)
|
||||
return
|
||||
theme.save_in_conf(config_dir, cli_opts.reload_in, cli_opts.config_file_name)
|
||||
|
||||
|
||||
def main(args: List[str]) -> None:
|
||||
try:
|
||||
cli_opts, items = parse_themes_args(args[1:])
|
||||
except SystemExit as e:
|
||||
if e.code != 0:
|
||||
print(e.args[0], file=sys.stderr)
|
||||
input(_('Press Enter to quit'))
|
||||
return None
|
||||
if len(items) > 1:
|
||||
items = [' '.join(items)]
|
||||
if len(items) == 1:
|
||||
return non_interactive(cli_opts, items[0])
|
||||
|
||||
loop = Loop()
|
||||
with cached_values_for('themes-kitten') as cached_values:
|
||||
handler = ThemesHandler(cached_values, cli_opts)
|
||||
loop.loop(handler)
|
||||
if loop.return_code != 0:
|
||||
if handler.report_traceback_on_exit:
|
||||
print(handler.report_traceback_on_exit, file=sys.stderr)
|
||||
input('Press Enter to quit.')
|
||||
if handler.state is State.fetching:
|
||||
# asyncio uses non-daemonic threads in its ThreadPoolExecutor
|
||||
# so we will hang here till the download completes without
|
||||
# os._exit
|
||||
os._exit(loop.return_code)
|
||||
raise SystemExit(loop.return_code)
|
||||
|
||||
raise SystemExit('This must be run as kitten themes')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user