123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400 |
- """
- Tool for creating styles from a dictionary.
- """
- from __future__ import annotations
- import itertools
- import re
- from enum import Enum
- from typing import Hashable, TypeVar
- from prompt_toolkit.cache import SimpleCache
- from .base import (
- ANSI_COLOR_NAMES,
- ANSI_COLOR_NAMES_ALIASES,
- DEFAULT_ATTRS,
- Attrs,
- BaseStyle,
- )
- from .named_colors import NAMED_COLORS
- __all__ = [
- "Style",
- "parse_color",
- "Priority",
- "merge_styles",
- ]
- _named_colors_lowercase = {k.lower(): v.lstrip("#") for k, v in NAMED_COLORS.items()}
- def parse_color(text: str) -> str:
- """
- Parse/validate color format.
- Like in Pygments, but also support the ANSI color names.
- (These will map to the colors of the 16 color palette.)
- """
- # ANSI color names.
- if text in ANSI_COLOR_NAMES:
- return text
- if text in ANSI_COLOR_NAMES_ALIASES:
- return ANSI_COLOR_NAMES_ALIASES[text]
- # 140 named colors.
- try:
- # Replace by 'hex' value.
- return _named_colors_lowercase[text.lower()]
- except KeyError:
- pass
- # Hex codes.
- if text[0:1] == "#":
- col = text[1:]
- # Keep this for backwards-compatibility (Pygments does it).
- # I don't like the '#' prefix for named colors.
- if col in ANSI_COLOR_NAMES:
- return col
- elif col in ANSI_COLOR_NAMES_ALIASES:
- return ANSI_COLOR_NAMES_ALIASES[col]
- # 6 digit hex color.
- elif len(col) == 6:
- return col
- # 3 digit hex color.
- elif len(col) == 3:
- return col[0] * 2 + col[1] * 2 + col[2] * 2
- # Default.
- elif text in ("", "default"):
- return text
- raise ValueError("Wrong color format %r" % text)
- # Attributes, when they are not filled in by a style. None means that we take
- # the value from the parent.
- _EMPTY_ATTRS = Attrs(
- color=None,
- bgcolor=None,
- bold=None,
- underline=None,
- strike=None,
- italic=None,
- blink=None,
- reverse=None,
- hidden=None,
- )
- def _expand_classname(classname: str) -> list[str]:
- """
- Split a single class name at the `.` operator, and build a list of classes.
- E.g. 'a.b.c' becomes ['a', 'a.b', 'a.b.c']
- """
- result = []
- parts = classname.split(".")
- for i in range(1, len(parts) + 1):
- result.append(".".join(parts[:i]).lower())
- return result
- def _parse_style_str(style_str: str) -> Attrs:
- """
- Take a style string, e.g. 'bg:red #88ff00 class:title'
- and return a `Attrs` instance.
- """
- # Start from default Attrs.
- if "noinherit" in style_str:
- attrs = DEFAULT_ATTRS
- else:
- attrs = _EMPTY_ATTRS
- # Now update with the given attributes.
- for part in style_str.split():
- if part == "noinherit":
- pass
- elif part == "bold":
- attrs = attrs._replace(bold=True)
- elif part == "nobold":
- attrs = attrs._replace(bold=False)
- elif part == "italic":
- attrs = attrs._replace(italic=True)
- elif part == "noitalic":
- attrs = attrs._replace(italic=False)
- elif part == "underline":
- attrs = attrs._replace(underline=True)
- elif part == "nounderline":
- attrs = attrs._replace(underline=False)
- elif part == "strike":
- attrs = attrs._replace(strike=True)
- elif part == "nostrike":
- attrs = attrs._replace(strike=False)
- # prompt_toolkit extensions. Not in Pygments.
- elif part == "blink":
- attrs = attrs._replace(blink=True)
- elif part == "noblink":
- attrs = attrs._replace(blink=False)
- elif part == "reverse":
- attrs = attrs._replace(reverse=True)
- elif part == "noreverse":
- attrs = attrs._replace(reverse=False)
- elif part == "hidden":
- attrs = attrs._replace(hidden=True)
- elif part == "nohidden":
- attrs = attrs._replace(hidden=False)
- # Pygments properties that we ignore.
- elif part in ("roman", "sans", "mono"):
- pass
- elif part.startswith("border:"):
- pass
- # Ignore pieces in between square brackets. This is internal stuff.
- # Like '[transparent]' or '[set-cursor-position]'.
- elif part.startswith("[") and part.endswith("]"):
- pass
- # Colors.
- elif part.startswith("bg:"):
- attrs = attrs._replace(bgcolor=parse_color(part[3:]))
- elif part.startswith("fg:"): # The 'fg:' prefix is optional.
- attrs = attrs._replace(color=parse_color(part[3:]))
- else:
- attrs = attrs._replace(color=parse_color(part))
- return attrs
- CLASS_NAMES_RE = re.compile(r"^[a-z0-9.\s_-]*$") # This one can't contain a comma!
- class Priority(Enum):
- """
- The priority of the rules, when a style is created from a dictionary.
- In a `Style`, rules that are defined later will always override previous
- defined rules, however in a dictionary, the key order was arbitrary before
- Python 3.6. This means that the style could change at random between rules.
- We have two options:
- - `DICT_KEY_ORDER`: This means, iterate through the dictionary, and take
- the key/value pairs in order as they come. This is a good option if you
- have Python >3.6. Rules at the end will override rules at the beginning.
- - `MOST_PRECISE`: keys that are defined with most precision will get higher
- priority. (More precise means: more elements.)
- """
- DICT_KEY_ORDER = "KEY_ORDER"
- MOST_PRECISE = "MOST_PRECISE"
- # We don't support Python versions older than 3.6 anymore, so we can always
- # depend on dictionary ordering. This is the default.
- default_priority = Priority.DICT_KEY_ORDER
- class Style(BaseStyle):
- """
- Create a ``Style`` instance from a list of style rules.
- The `style_rules` is supposed to be a list of ('classnames', 'style') tuples.
- The classnames are a whitespace separated string of class names and the
- style string is just like a Pygments style definition, but with a few
- additions: it supports 'reverse' and 'blink'.
- Later rules always override previous rules.
- Usage::
- Style([
- ('title', '#ff0000 bold underline'),
- ('something-else', 'reverse'),
- ('class1 class2', 'reverse'),
- ])
- The ``from_dict`` classmethod is similar, but takes a dictionary as input.
- """
- def __init__(self, style_rules: list[tuple[str, str]]) -> None:
- class_names_and_attrs = []
- # Loop through the rules in the order they were defined.
- # Rules that are defined later get priority.
- for class_names, style_str in style_rules:
- assert CLASS_NAMES_RE.match(class_names), repr(class_names)
- # The order of the class names doesn't matter.
- # (But the order of rules does matter.)
- class_names_set = frozenset(class_names.lower().split())
- attrs = _parse_style_str(style_str)
- class_names_and_attrs.append((class_names_set, attrs))
- self._style_rules = style_rules
- self.class_names_and_attrs = class_names_and_attrs
- @property
- def style_rules(self) -> list[tuple[str, str]]:
- return self._style_rules
- @classmethod
- def from_dict(
- cls, style_dict: dict[str, str], priority: Priority = default_priority
- ) -> Style:
- """
- :param style_dict: Style dictionary.
- :param priority: `Priority` value.
- """
- if priority == Priority.MOST_PRECISE:
- def key(item: tuple[str, str]) -> int:
- # Split on '.' and whitespace. Count elements.
- return sum(len(i.split(".")) for i in item[0].split())
- return cls(sorted(style_dict.items(), key=key))
- else:
- return cls(list(style_dict.items()))
- def get_attrs_for_style_str(
- self, style_str: str, default: Attrs = DEFAULT_ATTRS
- ) -> Attrs:
- """
- Get `Attrs` for the given style string.
- """
- list_of_attrs = [default]
- class_names: set[str] = set()
- # Apply default styling.
- for names, attr in self.class_names_and_attrs:
- if not names:
- list_of_attrs.append(attr)
- # Go from left to right through the style string. Things on the right
- # take precedence.
- for part in style_str.split():
- # This part represents a class.
- # Do lookup of this class name in the style definition, as well
- # as all class combinations that we have so far.
- if part.startswith("class:"):
- # Expand all class names (comma separated list).
- new_class_names = []
- for p in part[6:].lower().split(","):
- new_class_names.extend(_expand_classname(p))
- for new_name in new_class_names:
- # Build a set of all possible class combinations to be applied.
- combos = set()
- combos.add(frozenset([new_name]))
- for count in range(1, len(class_names) + 1):
- for c2 in itertools.combinations(class_names, count):
- combos.add(frozenset(c2 + (new_name,)))
- # Apply the styles that match these class names.
- for names, attr in self.class_names_and_attrs:
- if names in combos:
- list_of_attrs.append(attr)
- class_names.add(new_name)
- # Process inline style.
- else:
- inline_attrs = _parse_style_str(part)
- list_of_attrs.append(inline_attrs)
- return _merge_attrs(list_of_attrs)
- def invalidation_hash(self) -> Hashable:
- return id(self.class_names_and_attrs)
- _T = TypeVar("_T")
- def _merge_attrs(list_of_attrs: list[Attrs]) -> Attrs:
- """
- Take a list of :class:`.Attrs` instances and merge them into one.
- Every `Attr` in the list can override the styling of the previous one. So,
- the last one has highest priority.
- """
- def _or(*values: _T) -> _T:
- "Take first not-None value, starting at the end."
- for v in values[::-1]:
- if v is not None:
- return v
- raise ValueError # Should not happen, there's always one non-null value.
- return Attrs(
- color=_or("", *[a.color for a in list_of_attrs]),
- bgcolor=_or("", *[a.bgcolor for a in list_of_attrs]),
- bold=_or(False, *[a.bold for a in list_of_attrs]),
- underline=_or(False, *[a.underline for a in list_of_attrs]),
- strike=_or(False, *[a.strike for a in list_of_attrs]),
- italic=_or(False, *[a.italic for a in list_of_attrs]),
- blink=_or(False, *[a.blink for a in list_of_attrs]),
- reverse=_or(False, *[a.reverse for a in list_of_attrs]),
- hidden=_or(False, *[a.hidden for a in list_of_attrs]),
- )
- def merge_styles(styles: list[BaseStyle]) -> _MergedStyle:
- """
- Merge multiple `Style` objects.
- """
- styles = [s for s in styles if s is not None]
- return _MergedStyle(styles)
- class _MergedStyle(BaseStyle):
- """
- Merge multiple `Style` objects into one.
- This is supposed to ensure consistency: if any of the given styles changes,
- then this style will be updated.
- """
- # NOTE: previously, we used an algorithm where we did not generate the
- # combined style. Instead this was a proxy that called one style
- # after the other, passing the outcome of the previous style as the
- # default for the next one. This did not work, because that way, the
- # priorities like described in the `Style` class don't work.
- # 'class:aborted' was for instance never displayed in gray, because
- # the next style specified a default color for any text. (The
- # explicit styling of class:aborted should have taken priority,
- # because it was more precise.)
- def __init__(self, styles: list[BaseStyle]) -> None:
- self.styles = styles
- self._style: SimpleCache[Hashable, Style] = SimpleCache(maxsize=1)
- @property
- def _merged_style(self) -> Style:
- "The `Style` object that has the other styles merged together."
- def get() -> Style:
- return Style(self.style_rules)
- return self._style.get(self.invalidation_hash(), get)
- @property
- def style_rules(self) -> list[tuple[str, str]]:
- style_rules = []
- for s in self.styles:
- style_rules.extend(s.style_rules)
- return style_rules
- def get_attrs_for_style_str(
- self, style_str: str, default: Attrs = DEFAULT_ATTRS
- ) -> Attrs:
- return self._merged_style.get_attrs_for_style_str(style_str, default)
- def invalidation_hash(self) -> Hashable:
- return tuple(s.invalidation_hash() for s in self.styles)
|