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