style.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. """
  2. Tool for creating styles from a dictionary.
  3. """
  4. from __future__ import annotations
  5. import itertools
  6. import re
  7. from enum import Enum
  8. from typing import Hashable, TypeVar
  9. from prompt_toolkit.cache import SimpleCache
  10. from .base import (
  11. ANSI_COLOR_NAMES,
  12. ANSI_COLOR_NAMES_ALIASES,
  13. DEFAULT_ATTRS,
  14. Attrs,
  15. BaseStyle,
  16. )
  17. from .named_colors import NAMED_COLORS
  18. __all__ = [
  19. "Style",
  20. "parse_color",
  21. "Priority",
  22. "merge_styles",
  23. ]
  24. _named_colors_lowercase = {k.lower(): v.lstrip("#") for k, v in NAMED_COLORS.items()}
  25. def parse_color(text: str) -> str:
  26. """
  27. Parse/validate color format.
  28. Like in Pygments, but also support the ANSI color names.
  29. (These will map to the colors of the 16 color palette.)
  30. """
  31. # ANSI color names.
  32. if text in ANSI_COLOR_NAMES:
  33. return text
  34. if text in ANSI_COLOR_NAMES_ALIASES:
  35. return ANSI_COLOR_NAMES_ALIASES[text]
  36. # 140 named colors.
  37. try:
  38. # Replace by 'hex' value.
  39. return _named_colors_lowercase[text.lower()]
  40. except KeyError:
  41. pass
  42. # Hex codes.
  43. if text[0:1] == "#":
  44. col = text[1:]
  45. # Keep this for backwards-compatibility (Pygments does it).
  46. # I don't like the '#' prefix for named colors.
  47. if col in ANSI_COLOR_NAMES:
  48. return col
  49. elif col in ANSI_COLOR_NAMES_ALIASES:
  50. return ANSI_COLOR_NAMES_ALIASES[col]
  51. # 6 digit hex color.
  52. elif len(col) == 6:
  53. return col
  54. # 3 digit hex color.
  55. elif len(col) == 3:
  56. return col[0] * 2 + col[1] * 2 + col[2] * 2
  57. # Default.
  58. elif text in ("", "default"):
  59. return text
  60. raise ValueError("Wrong color format %r" % text)
  61. # Attributes, when they are not filled in by a style. None means that we take
  62. # the value from the parent.
  63. _EMPTY_ATTRS = Attrs(
  64. color=None,
  65. bgcolor=None,
  66. bold=None,
  67. underline=None,
  68. strike=None,
  69. italic=None,
  70. blink=None,
  71. reverse=None,
  72. hidden=None,
  73. )
  74. def _expand_classname(classname: str) -> list[str]:
  75. """
  76. Split a single class name at the `.` operator, and build a list of classes.
  77. E.g. 'a.b.c' becomes ['a', 'a.b', 'a.b.c']
  78. """
  79. result = []
  80. parts = classname.split(".")
  81. for i in range(1, len(parts) + 1):
  82. result.append(".".join(parts[:i]).lower())
  83. return result
  84. def _parse_style_str(style_str: str) -> Attrs:
  85. """
  86. Take a style string, e.g. 'bg:red #88ff00 class:title'
  87. and return a `Attrs` instance.
  88. """
  89. # Start from default Attrs.
  90. if "noinherit" in style_str:
  91. attrs = DEFAULT_ATTRS
  92. else:
  93. attrs = _EMPTY_ATTRS
  94. # Now update with the given attributes.
  95. for part in style_str.split():
  96. if part == "noinherit":
  97. pass
  98. elif part == "bold":
  99. attrs = attrs._replace(bold=True)
  100. elif part == "nobold":
  101. attrs = attrs._replace(bold=False)
  102. elif part == "italic":
  103. attrs = attrs._replace(italic=True)
  104. elif part == "noitalic":
  105. attrs = attrs._replace(italic=False)
  106. elif part == "underline":
  107. attrs = attrs._replace(underline=True)
  108. elif part == "nounderline":
  109. attrs = attrs._replace(underline=False)
  110. elif part == "strike":
  111. attrs = attrs._replace(strike=True)
  112. elif part == "nostrike":
  113. attrs = attrs._replace(strike=False)
  114. # prompt_toolkit extensions. Not in Pygments.
  115. elif part == "blink":
  116. attrs = attrs._replace(blink=True)
  117. elif part == "noblink":
  118. attrs = attrs._replace(blink=False)
  119. elif part == "reverse":
  120. attrs = attrs._replace(reverse=True)
  121. elif part == "noreverse":
  122. attrs = attrs._replace(reverse=False)
  123. elif part == "hidden":
  124. attrs = attrs._replace(hidden=True)
  125. elif part == "nohidden":
  126. attrs = attrs._replace(hidden=False)
  127. # Pygments properties that we ignore.
  128. elif part in ("roman", "sans", "mono"):
  129. pass
  130. elif part.startswith("border:"):
  131. pass
  132. # Ignore pieces in between square brackets. This is internal stuff.
  133. # Like '[transparent]' or '[set-cursor-position]'.
  134. elif part.startswith("[") and part.endswith("]"):
  135. pass
  136. # Colors.
  137. elif part.startswith("bg:"):
  138. attrs = attrs._replace(bgcolor=parse_color(part[3:]))
  139. elif part.startswith("fg:"): # The 'fg:' prefix is optional.
  140. attrs = attrs._replace(color=parse_color(part[3:]))
  141. else:
  142. attrs = attrs._replace(color=parse_color(part))
  143. return attrs
  144. CLASS_NAMES_RE = re.compile(r"^[a-z0-9.\s_-]*$") # This one can't contain a comma!
  145. class Priority(Enum):
  146. """
  147. The priority of the rules, when a style is created from a dictionary.
  148. In a `Style`, rules that are defined later will always override previous
  149. defined rules, however in a dictionary, the key order was arbitrary before
  150. Python 3.6. This means that the style could change at random between rules.
  151. We have two options:
  152. - `DICT_KEY_ORDER`: This means, iterate through the dictionary, and take
  153. the key/value pairs in order as they come. This is a good option if you
  154. have Python >3.6. Rules at the end will override rules at the beginning.
  155. - `MOST_PRECISE`: keys that are defined with most precision will get higher
  156. priority. (More precise means: more elements.)
  157. """
  158. DICT_KEY_ORDER = "KEY_ORDER"
  159. MOST_PRECISE = "MOST_PRECISE"
  160. # We don't support Python versions older than 3.6 anymore, so we can always
  161. # depend on dictionary ordering. This is the default.
  162. default_priority = Priority.DICT_KEY_ORDER
  163. class Style(BaseStyle):
  164. """
  165. Create a ``Style`` instance from a list of style rules.
  166. The `style_rules` is supposed to be a list of ('classnames', 'style') tuples.
  167. The classnames are a whitespace separated string of class names and the
  168. style string is just like a Pygments style definition, but with a few
  169. additions: it supports 'reverse' and 'blink'.
  170. Later rules always override previous rules.
  171. Usage::
  172. Style([
  173. ('title', '#ff0000 bold underline'),
  174. ('something-else', 'reverse'),
  175. ('class1 class2', 'reverse'),
  176. ])
  177. The ``from_dict`` classmethod is similar, but takes a dictionary as input.
  178. """
  179. def __init__(self, style_rules: list[tuple[str, str]]) -> None:
  180. class_names_and_attrs = []
  181. # Loop through the rules in the order they were defined.
  182. # Rules that are defined later get priority.
  183. for class_names, style_str in style_rules:
  184. assert CLASS_NAMES_RE.match(class_names), repr(class_names)
  185. # The order of the class names doesn't matter.
  186. # (But the order of rules does matter.)
  187. class_names_set = frozenset(class_names.lower().split())
  188. attrs = _parse_style_str(style_str)
  189. class_names_and_attrs.append((class_names_set, attrs))
  190. self._style_rules = style_rules
  191. self.class_names_and_attrs = class_names_and_attrs
  192. @property
  193. def style_rules(self) -> list[tuple[str, str]]:
  194. return self._style_rules
  195. @classmethod
  196. def from_dict(
  197. cls, style_dict: dict[str, str], priority: Priority = default_priority
  198. ) -> Style:
  199. """
  200. :param style_dict: Style dictionary.
  201. :param priority: `Priority` value.
  202. """
  203. if priority == Priority.MOST_PRECISE:
  204. def key(item: tuple[str, str]) -> int:
  205. # Split on '.' and whitespace. Count elements.
  206. return sum(len(i.split(".")) for i in item[0].split())
  207. return cls(sorted(style_dict.items(), key=key))
  208. else:
  209. return cls(list(style_dict.items()))
  210. def get_attrs_for_style_str(
  211. self, style_str: str, default: Attrs = DEFAULT_ATTRS
  212. ) -> Attrs:
  213. """
  214. Get `Attrs` for the given style string.
  215. """
  216. list_of_attrs = [default]
  217. class_names: set[str] = set()
  218. # Apply default styling.
  219. for names, attr in self.class_names_and_attrs:
  220. if not names:
  221. list_of_attrs.append(attr)
  222. # Go from left to right through the style string. Things on the right
  223. # take precedence.
  224. for part in style_str.split():
  225. # This part represents a class.
  226. # Do lookup of this class name in the style definition, as well
  227. # as all class combinations that we have so far.
  228. if part.startswith("class:"):
  229. # Expand all class names (comma separated list).
  230. new_class_names = []
  231. for p in part[6:].lower().split(","):
  232. new_class_names.extend(_expand_classname(p))
  233. for new_name in new_class_names:
  234. # Build a set of all possible class combinations to be applied.
  235. combos = set()
  236. combos.add(frozenset([new_name]))
  237. for count in range(1, len(class_names) + 1):
  238. for c2 in itertools.combinations(class_names, count):
  239. combos.add(frozenset(c2 + (new_name,)))
  240. # Apply the styles that match these class names.
  241. for names, attr in self.class_names_and_attrs:
  242. if names in combos:
  243. list_of_attrs.append(attr)
  244. class_names.add(new_name)
  245. # Process inline style.
  246. else:
  247. inline_attrs = _parse_style_str(part)
  248. list_of_attrs.append(inline_attrs)
  249. return _merge_attrs(list_of_attrs)
  250. def invalidation_hash(self) -> Hashable:
  251. return id(self.class_names_and_attrs)
  252. _T = TypeVar("_T")
  253. def _merge_attrs(list_of_attrs: list[Attrs]) -> Attrs:
  254. """
  255. Take a list of :class:`.Attrs` instances and merge them into one.
  256. Every `Attr` in the list can override the styling of the previous one. So,
  257. the last one has highest priority.
  258. """
  259. def _or(*values: _T) -> _T:
  260. "Take first not-None value, starting at the end."
  261. for v in values[::-1]:
  262. if v is not None:
  263. return v
  264. raise ValueError # Should not happen, there's always one non-null value.
  265. return Attrs(
  266. color=_or("", *[a.color for a in list_of_attrs]),
  267. bgcolor=_or("", *[a.bgcolor for a in list_of_attrs]),
  268. bold=_or(False, *[a.bold for a in list_of_attrs]),
  269. underline=_or(False, *[a.underline for a in list_of_attrs]),
  270. strike=_or(False, *[a.strike for a in list_of_attrs]),
  271. italic=_or(False, *[a.italic for a in list_of_attrs]),
  272. blink=_or(False, *[a.blink for a in list_of_attrs]),
  273. reverse=_or(False, *[a.reverse for a in list_of_attrs]),
  274. hidden=_or(False, *[a.hidden for a in list_of_attrs]),
  275. )
  276. def merge_styles(styles: list[BaseStyle]) -> _MergedStyle:
  277. """
  278. Merge multiple `Style` objects.
  279. """
  280. styles = [s for s in styles if s is not None]
  281. return _MergedStyle(styles)
  282. class _MergedStyle(BaseStyle):
  283. """
  284. Merge multiple `Style` objects into one.
  285. This is supposed to ensure consistency: if any of the given styles changes,
  286. then this style will be updated.
  287. """
  288. # NOTE: previously, we used an algorithm where we did not generate the
  289. # combined style. Instead this was a proxy that called one style
  290. # after the other, passing the outcome of the previous style as the
  291. # default for the next one. This did not work, because that way, the
  292. # priorities like described in the `Style` class don't work.
  293. # 'class:aborted' was for instance never displayed in gray, because
  294. # the next style specified a default color for any text. (The
  295. # explicit styling of class:aborted should have taken priority,
  296. # because it was more precise.)
  297. def __init__(self, styles: list[BaseStyle]) -> None:
  298. self.styles = styles
  299. self._style: SimpleCache[Hashable, Style] = SimpleCache(maxsize=1)
  300. @property
  301. def _merged_style(self) -> Style:
  302. "The `Style` object that has the other styles merged together."
  303. def get() -> Style:
  304. return Style(self.style_rules)
  305. return self._style.get(self.invalidation_hash(), get)
  306. @property
  307. def style_rules(self) -> list[tuple[str, str]]:
  308. style_rules = []
  309. for s in self.styles:
  310. style_rules.extend(s.style_rules)
  311. return style_rules
  312. def get_attrs_for_style_str(
  313. self, style_str: str, default: Attrs = DEFAULT_ATTRS
  314. ) -> Attrs:
  315. return self._merged_style.get_attrs_for_style_str(style_str, default)
  316. def invalidation_hash(self) -> Hashable:
  317. return tuple(s.invalidation_hash() for s in self.styles)