1"""
2Collection of style transformations.
3
4Think of it as a kind of color post processing after the rendering is done.
5This could be used for instance to change the contrast/saturation; swap light
6and dark colors or even change certain colors for other colors.
7
8When the UI is rendered, these transformations can be applied right after the
9style strings are turned into `Attrs` objects that represent the actual
10formatting.
11"""
12
13from __future__ import annotations
14
15from abc import ABCMeta, abstractmethod
16from colorsys import hls_to_rgb, rgb_to_hls
17from typing import Callable, Hashable, Sequence
18
19from prompt_toolkit.cache import memoized
20from prompt_toolkit.filters import FilterOrBool, to_filter
21from prompt_toolkit.utils import AnyFloat, to_float, to_str
22
23from .base import ANSI_COLOR_NAMES, Attrs
24from .style import parse_color
25
26__all__ = [
27 "StyleTransformation",
28 "SwapLightAndDarkStyleTransformation",
29 "ReverseStyleTransformation",
30 "SetDefaultColorStyleTransformation",
31 "AdjustBrightnessStyleTransformation",
32 "DummyStyleTransformation",
33 "ConditionalStyleTransformation",
34 "DynamicStyleTransformation",
35 "merge_style_transformations",
36]
37
38
39class StyleTransformation(metaclass=ABCMeta):
40 """
41 Base class for any style transformation.
42 """
43
44 @abstractmethod
45 def transform_attrs(self, attrs: Attrs) -> Attrs:
46 """
47 Take an `Attrs` object and return a new `Attrs` object.
48
49 Remember that the color formats can be either "ansi..." or a 6 digit
50 lowercase hexadecimal color (without '#' prefix).
51 """
52
53 def invalidation_hash(self) -> Hashable:
54 """
55 When this changes, the cache should be invalidated.
56 """
57 return f"{self.__class__.__name__}-{id(self)}"
58
59
60class SwapLightAndDarkStyleTransformation(StyleTransformation):
61 """
62 Turn dark colors into light colors and the other way around.
63
64 This is meant to make color schemes that work on a dark background usable
65 on a light background (and the other way around).
66
67 Notice that this doesn't swap foreground and background like "reverse"
68 does. It turns light green into dark green and the other way around.
69 Foreground and background colors are considered individually.
70
71 Also notice that when <reverse> is used somewhere and no colors are given
72 in particular (like what is the default for the bottom toolbar), then this
73 doesn't change anything. This is what makes sense, because when the
74 'default' color is chosen, it's what works best for the terminal, and
75 reverse works good with that.
76 """
77
78 def transform_attrs(self, attrs: Attrs) -> Attrs:
79 """
80 Return the `Attrs` used when opposite luminosity should be used.
81 """
82 # Reverse colors.
83 attrs = attrs._replace(color=get_opposite_color(attrs.color))
84 attrs = attrs._replace(bgcolor=get_opposite_color(attrs.bgcolor))
85
86 return attrs
87
88
89class ReverseStyleTransformation(StyleTransformation):
90 """
91 Swap the 'reverse' attribute.
92
93 (This is still experimental.)
94 """
95
96 def transform_attrs(self, attrs: Attrs) -> Attrs:
97 return attrs._replace(reverse=not attrs.reverse)
98
99
100class SetDefaultColorStyleTransformation(StyleTransformation):
101 """
102 Set default foreground/background color for output that doesn't specify
103 anything. This is useful for overriding the terminal default colors.
104
105 :param fg: Color string or callable that returns a color string for the
106 foreground.
107 :param bg: Like `fg`, but for the background.
108 """
109
110 def __init__(
111 self, fg: str | Callable[[], str], bg: str | Callable[[], str]
112 ) -> None:
113 self.fg = fg
114 self.bg = bg
115
116 def transform_attrs(self, attrs: Attrs) -> Attrs:
117 if attrs.bgcolor in ("", "default"):
118 attrs = attrs._replace(bgcolor=parse_color(to_str(self.bg)))
119
120 if attrs.color in ("", "default"):
121 attrs = attrs._replace(color=parse_color(to_str(self.fg)))
122
123 return attrs
124
125 def invalidation_hash(self) -> Hashable:
126 return (
127 "set-default-color",
128 to_str(self.fg),
129 to_str(self.bg),
130 )
131
132
133class AdjustBrightnessStyleTransformation(StyleTransformation):
134 """
135 Adjust the brightness to improve the rendering on either dark or light
136 backgrounds.
137
138 For dark backgrounds, it's best to increase `min_brightness`. For light
139 backgrounds it's best to decrease `max_brightness`. Usually, only one
140 setting is adjusted.
141
142 This will only change the brightness for text that has a foreground color
143 defined, but no background color. It works best for 256 or true color
144 output.
145
146 .. note:: Notice that there is no universal way to detect whether the
147 application is running in a light or dark terminal. As a
148 developer of an command line application, you'll have to make
149 this configurable for the user.
150
151 :param min_brightness: Float between 0.0 and 1.0 or a callable that returns
152 a float.
153 :param max_brightness: Float between 0.0 and 1.0 or a callable that returns
154 a float.
155 """
156
157 def __init__(
158 self, min_brightness: AnyFloat = 0.0, max_brightness: AnyFloat = 1.0
159 ) -> None:
160 self.min_brightness = min_brightness
161 self.max_brightness = max_brightness
162
163 def transform_attrs(self, attrs: Attrs) -> Attrs:
164 min_brightness = to_float(self.min_brightness)
165 max_brightness = to_float(self.max_brightness)
166 assert 0 <= min_brightness <= 1
167 assert 0 <= max_brightness <= 1
168
169 # Don't do anything if the whole brightness range is acceptable.
170 # This also avoids turning ansi colors into RGB sequences.
171 if min_brightness == 0.0 and max_brightness == 1.0:
172 return attrs
173
174 # If a foreground color is given without a background color.
175 no_background = not attrs.bgcolor or attrs.bgcolor == "default"
176 has_fgcolor = attrs.color and attrs.color != "ansidefault"
177
178 if has_fgcolor and no_background:
179 # Calculate new RGB values.
180 r, g, b = self._color_to_rgb(attrs.color or "")
181 hue, brightness, saturation = rgb_to_hls(r, g, b)
182 brightness = self._interpolate_brightness(
183 brightness, min_brightness, max_brightness
184 )
185 r, g, b = hls_to_rgb(hue, brightness, saturation)
186 new_color = f"{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"
187
188 attrs = attrs._replace(color=new_color)
189
190 return attrs
191
192 def _color_to_rgb(self, color: str) -> tuple[float, float, float]:
193 """
194 Parse `style.Attrs` color into RGB tuple.
195 """
196 # Do RGB lookup for ANSI colors.
197 try:
198 from prompt_toolkit.output.vt100 import ANSI_COLORS_TO_RGB
199
200 r, g, b = ANSI_COLORS_TO_RGB[color]
201 return r / 255.0, g / 255.0, b / 255.0
202 except KeyError:
203 pass
204
205 # Parse RRGGBB format.
206 return (
207 int(color[0:2], 16) / 255.0,
208 int(color[2:4], 16) / 255.0,
209 int(color[4:6], 16) / 255.0,
210 )
211
212 # NOTE: we don't have to support named colors here. They are already
213 # transformed into RGB values in `style.parse_color`.
214
215 def _interpolate_brightness(
216 self, value: float, min_brightness: float, max_brightness: float
217 ) -> float:
218 """
219 Map the brightness to the (min_brightness..max_brightness) range.
220 """
221 return min_brightness + (max_brightness - min_brightness) * value
222
223 def invalidation_hash(self) -> Hashable:
224 return (
225 "adjust-brightness",
226 to_float(self.min_brightness),
227 to_float(self.max_brightness),
228 )
229
230
231class DummyStyleTransformation(StyleTransformation):
232 """
233 Don't transform anything at all.
234 """
235
236 def transform_attrs(self, attrs: Attrs) -> Attrs:
237 return attrs
238
239 def invalidation_hash(self) -> Hashable:
240 # Always return the same hash for these dummy instances.
241 return "dummy-style-transformation"
242
243
244class DynamicStyleTransformation(StyleTransformation):
245 """
246 StyleTransformation class that can dynamically returns any
247 `StyleTransformation`.
248
249 :param get_style_transformation: Callable that returns a
250 :class:`.StyleTransformation` instance.
251 """
252
253 def __init__(
254 self, get_style_transformation: Callable[[], StyleTransformation | None]
255 ) -> None:
256 self.get_style_transformation = get_style_transformation
257
258 def transform_attrs(self, attrs: Attrs) -> Attrs:
259 style_transformation = (
260 self.get_style_transformation() or DummyStyleTransformation()
261 )
262 return style_transformation.transform_attrs(attrs)
263
264 def invalidation_hash(self) -> Hashable:
265 style_transformation = (
266 self.get_style_transformation() or DummyStyleTransformation()
267 )
268 return style_transformation.invalidation_hash()
269
270
271class ConditionalStyleTransformation(StyleTransformation):
272 """
273 Apply the style transformation depending on a condition.
274 """
275
276 def __init__(
277 self, style_transformation: StyleTransformation, filter: FilterOrBool
278 ) -> None:
279 self.style_transformation = style_transformation
280 self.filter = to_filter(filter)
281
282 def transform_attrs(self, attrs: Attrs) -> Attrs:
283 if self.filter():
284 return self.style_transformation.transform_attrs(attrs)
285 return attrs
286
287 def invalidation_hash(self) -> Hashable:
288 return (self.filter(), self.style_transformation.invalidation_hash())
289
290
291class _MergedStyleTransformation(StyleTransformation):
292 def __init__(self, style_transformations: Sequence[StyleTransformation]) -> None:
293 self.style_transformations = style_transformations
294
295 def transform_attrs(self, attrs: Attrs) -> Attrs:
296 for transformation in self.style_transformations:
297 attrs = transformation.transform_attrs(attrs)
298 return attrs
299
300 def invalidation_hash(self) -> Hashable:
301 return tuple(t.invalidation_hash() for t in self.style_transformations)
302
303
304def merge_style_transformations(
305 style_transformations: Sequence[StyleTransformation],
306) -> StyleTransformation:
307 """
308 Merge multiple transformations together.
309 """
310 return _MergedStyleTransformation(style_transformations)
311
312
313# Dictionary that maps ANSI color names to their opposite. This is useful for
314# turning color schemes that are optimized for a black background usable for a
315# white background.
316OPPOSITE_ANSI_COLOR_NAMES = {
317 "ansidefault": "ansidefault",
318 "ansiblack": "ansiwhite",
319 "ansired": "ansibrightred",
320 "ansigreen": "ansibrightgreen",
321 "ansiyellow": "ansibrightyellow",
322 "ansiblue": "ansibrightblue",
323 "ansimagenta": "ansibrightmagenta",
324 "ansicyan": "ansibrightcyan",
325 "ansigray": "ansibrightblack",
326 "ansiwhite": "ansiblack",
327 "ansibrightred": "ansired",
328 "ansibrightgreen": "ansigreen",
329 "ansibrightyellow": "ansiyellow",
330 "ansibrightblue": "ansiblue",
331 "ansibrightmagenta": "ansimagenta",
332 "ansibrightcyan": "ansicyan",
333 "ansibrightblack": "ansigray",
334}
335assert set(OPPOSITE_ANSI_COLOR_NAMES.keys()) == set(ANSI_COLOR_NAMES)
336assert set(OPPOSITE_ANSI_COLOR_NAMES.values()) == set(ANSI_COLOR_NAMES)
337
338
339@memoized()
340def get_opposite_color(colorname: str | None) -> str | None:
341 """
342 Take a color name in either 'ansi...' format or 6 digit RGB, return the
343 color of opposite luminosity (same hue/saturation).
344
345 This is used for turning color schemes that work on a light background
346 usable on a dark background.
347 """
348 if colorname is None: # Because color/bgcolor can be None in `Attrs`.
349 return None
350
351 # Special values.
352 if colorname in ("", "default"):
353 return colorname
354
355 # Try ANSI color names.
356 try:
357 return OPPOSITE_ANSI_COLOR_NAMES[colorname]
358 except KeyError:
359 # Try 6 digit RGB colors.
360 r = int(colorname[:2], 16) / 255.0
361 g = int(colorname[2:4], 16) / 255.0
362 b = int(colorname[4:6], 16) / 255.0
363
364 h, l, s = rgb_to_hls(r, g, b)
365
366 l = 1 - l
367
368 r, g, b = hls_to_rgb(h, l, s)
369
370 r = int(r * 255)
371 g = int(g * 255)
372 b = int(b * 255)
373
374 return f"{r:02x}{g:02x}{b:02x}"