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