summaryrefslogtreecommitdiffstats
path: root/src/prompt_toolkit/styles/style.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/prompt_toolkit/styles/style.py')
-rw-r--r--src/prompt_toolkit/styles/style.py400
1 files changed, 400 insertions, 0 deletions
diff --git a/src/prompt_toolkit/styles/style.py b/src/prompt_toolkit/styles/style.py
new file mode 100644
index 0000000..1abee0f
--- /dev/null
+++ b/src/prompt_toolkit/styles/style.py
@@ -0,0 +1,400 @@
+"""
+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)