Remove themes python code

This commit is contained in:
Kovid Goyal 2023-03-14 20:28:45 +05:30
parent 0805330b77
commit 9443b0e361
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
2 changed files with 3 additions and 1224 deletions

View File

@ -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()

View File

@ -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)