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