Files
babi/babi/theme.py
2020-03-17 13:13:46 -07:00

152 lines
4.4 KiB
Python

import functools
import json
import os.path
from typing import Any
from typing import Dict
from typing import NamedTuple
from typing import Optional
from typing import Tuple
from babi._types import Protocol
from babi.color import Color
from babi.fdict import FDict
class Style(NamedTuple):
fg: Optional[Color]
bg: Optional[Color]
b: bool
i: bool
u: bool
@classmethod
def blank(cls) -> 'Style':
return cls(fg=None, bg=None, b=False, i=False, u=False)
class PartialStyle(NamedTuple):
fg: Optional[Color] = None
bg: Optional[Color] = None
b: Optional[bool] = None
i: Optional[bool] = None
u: Optional[bool] = None
def overlay_on(self, dct: Dict[str, Any]) -> None:
for attr in self._fields:
value = getattr(self, attr)
if value is not None:
dct[attr] = value
@classmethod
def from_dct(cls, dct: Dict[str, Any]) -> 'PartialStyle':
kv = cls()._asdict()
if 'foreground' in dct:
kv['fg'] = Color.parse(dct['foreground'])
if 'background' in dct:
kv['bg'] = Color.parse(dct['background'])
if dct.get('fontStyle') == 'bold':
kv['b'] = True
elif dct.get('fontStyle') == 'italic':
kv['i'] = True
elif dct.get('fontStyle') == 'underline':
kv['u'] = True
return cls(**kv)
class _TrieNode(Protocol):
@property
def style(self) -> PartialStyle: ...
@property
def children(self) -> FDict[str, '_TrieNode']: ...
class TrieNode(NamedTuple):
style: PartialStyle
children: FDict[str, _TrieNode]
@classmethod
def from_dct(cls, dct: Dict[str, Any]) -> _TrieNode:
children = FDict({
k: TrieNode.from_dct(v) for k, v in dct['children'].items()
})
return cls(PartialStyle.from_dct(dct), children)
class Theme(NamedTuple):
default: Style
rules: _TrieNode
@functools.lru_cache(maxsize=None)
def select(self, scope: Tuple[str, ...]) -> Style:
if not scope:
return self.default
else:
style = self.select(scope[:-1])._asdict()
node = self.rules
for part in scope[-1].split('.'):
if part not in node.children:
break
else:
node = node.children[part]
node.style.overlay_on(style)
return Style(**style)
@classmethod
def from_dct(cls, data: Dict[str, Any]) -> 'Theme':
default = Style.blank()._asdict()
for k in ('foreground', 'editor.foreground'):
if k in data.get('colors', {}):
default['fg'] = Color.parse(data['colors'][k])
break
for k in ('background', 'editor.background'):
if k in data.get('colors', {}):
default['bg'] = Color.parse(data['colors'][k])
break
root: Dict[str, Any] = {'children': {}}
rules = data.get('tokenColors', []) + data.get('settings', [])
for rule in rules:
if 'scope' not in rule:
scopes = ['']
elif rule['scope'] == '':
scopes = ['']
elif isinstance(rule['scope'], str):
scopes = [
s.strip()
# some themes have a buggy trailing/leading comma
for s in rule['scope'].strip().strip(',').split(',')
if s.strip()
]
else:
scopes = rule['scope']
for scope in scopes:
if ' ' in scope:
# TODO: implement parent scopes
continue
elif scope == '':
PartialStyle.from_dct(rule['settings']).overlay_on(default)
continue
cur = root
for part in scope.split('.'):
cur = cur['children'].setdefault(part, {'children': {}})
cur.update(rule['settings'])
return cls(Style(**default), TrieNode.from_dct(root))
@classmethod
def blank(cls) -> 'Theme':
return cls(Style.blank(), TrieNode.from_dct({'children': {}}))
@classmethod
def from_filename(cls, filename: str) -> 'Theme':
if not os.path.exists(filename):
return cls.blank()
else:
with open(filename) as f:
return cls.from_dct(json.load(f))