codehs-utils 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codehs_utils/__init__.py +78 -0
- codehs_utils/_buffer.py +51 -0
- codehs_utils/_pixelbuf.py +10 -0
- codehs_utils/_platform.py +156 -0
- codehs_utils/colors.py +510 -0
- codehs_utils/drawing.py +337 -0
- codehs_utils/geometry.py +70 -0
- codehs_utils/terminal.py +573 -0
- codehs_utils/text.py +199 -0
- codehs_utils-1.0.0.dist-info/METADATA +510 -0
- codehs_utils-1.0.0.dist-info/RECORD +13 -0
- codehs_utils-1.0.0.dist-info/WHEEL +4 -0
- codehs_utils-1.0.0.dist-info/licenses/LICENSE +21 -0
codehs_utils/colors.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""RGB colors, ANSI styles, and styled/gradient text.
|
|
2
|
+
|
|
3
|
+
`ColorLike` parses names/hex/RGB tuples into a normalized RGB color.
|
|
4
|
+
`ColorText` and `GradientText` render styled strings (solid or gradient
|
|
5
|
+
foreground/background, bold/italic/etc.) as ANSI escape sequences.
|
|
6
|
+
`StyledText` concatenates pieces of any of the above.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import gzip
|
|
11
|
+
import base64
|
|
12
|
+
from typing import Dict, List, Optional, Sequence, Tuple, Union
|
|
13
|
+
|
|
14
|
+
from .text import _visible_len, _tokenize_ansi, _is_reset, wrap_text, align_text
|
|
15
|
+
|
|
16
|
+
def _fg_code(rgb) -> str:
|
|
17
|
+
if rgb is None:
|
|
18
|
+
return ""
|
|
19
|
+
r, g, b = rgb
|
|
20
|
+
return f"\033[38;2;{r};{g};{b}m"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _bg_code(rgb) -> str:
|
|
24
|
+
if rgb is None:
|
|
25
|
+
return ""
|
|
26
|
+
r, g, b = rgb
|
|
27
|
+
return f"\033[48;2;{r};{g};{b}m"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_STYLE_CODES = {
|
|
31
|
+
"bold": "1",
|
|
32
|
+
"dim": "2",
|
|
33
|
+
"italic": "3",
|
|
34
|
+
"underline": "4",
|
|
35
|
+
"blink": "5",
|
|
36
|
+
"reverse": "7",
|
|
37
|
+
"strikethrough": "9",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_styles(styles) -> List[str]:
|
|
42
|
+
result = []
|
|
43
|
+
for s in styles:
|
|
44
|
+
key = str(s).strip().lower()
|
|
45
|
+
if key not in _STYLE_CODES:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Unknown style: '{s}'. Available styles: {', '.join(sorted(_STYLE_CODES))}"
|
|
48
|
+
)
|
|
49
|
+
if key not in result:
|
|
50
|
+
result.append(key)
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ColorLike:
|
|
55
|
+
COMP_COL = "H4sIAP+HsmoC/31Xy47rNgz9lcGs78KSX3F/pehCsZVYjSN5ZDuBW/TfK5J62Zi5QKKFjihSfBzS/36KSfXyOm3y84+PP3lV/Prg1cUtdf3Xr49PoVf1tcn3qFY6UMOBsnYLowNfmwCgQJEk57afwiqNUoy3AWYc4X82mynMBK9S3T0Cm7jwAhG1fHkr8Tx3VrKuQWgS/cNbAT+/p/tRDmJ6Gj0kMbS9IF3+1SQVDHCbL2UmuaLlpdNSlaCONFnz1gg07p6Kwx/3Nzvtb2NIEXf77FK5pcQ7ezHINWjrnCCrwfgGDe1HYVcrt+XkKg+a3kzCO585M1nhsJJAY8UUX4aSlwDo22Te0galrADRqgMHtOHIoqZHcgxGnVzdW/VcjKa3gGvc3xu7C/1NuAdhH0dvsrILQCbjdg/Q3UyD1Jbc5j0GC4sHrNjJ3SDolwRKGW8uQuA98rPYYxQPRQoBuJQg3Qb0Ke5Sr4KC353fYib1klHvpUZRlwRR3IVE31OWsio3y9h+VPTW2mnFWiqqgFo55Fqj1IIpjFeWYCuIMc4jLEU0iEGqsgskV1XGA5A/ITqtS82GHd6EeHB05V7TdvA/wfIHeN3s12bUEmLPiwaWiGe1BBkGJ3x4pZxnpbMMxBRtA7Y89iylWMdStqlnzIui9iXBioTJHzAz3FNJlKiuirfelJVXq4hIWOtshVQsMTyumFypZSyIqY9ciWG6GSuXNYYBcxjC6KW3flyUiJKJbO5C6eVqrEml5hdER7OsSSkWaMbNh+LhDBkFuIEHMJnKAp9EvyF7+oWArJZgv4i7u5wcl5BXSl/3FKbo6fNto9FyH+T7RPHkrNGsh7hTiIi5lB6U0L4SOAAdh3/A7uiptvZ1STIvY/csLElTrHSygSKOyCReUg+YDMuYcSCGtM5PUN0FcRd0At86FR3HJOLeZ5N01erq/HbzRZuShdJwUvcxdgNyKWvw/ogmZq+Ko2cJ9ozKSXPiYURjXqTAkXpcWNISksEVJFZlLE2PRlIBLSWSSpXDvxE+BvgC/bArI5oxGuJNYjTCM0orQRaqkbXJ8owbGDX0JsUGD+Scxhh2nQa4szwfkb87sko5pUg1OHBAeSZTcx/H7OPeTU+Z98oibMa3+aSATkCYljqFy+csIFlTOjGIG7KM0bEIQ994ykFtz9MUVnCvzruSDp2mIMpRgrJ+dXHvvtQxyITPm50nuhsaA2OggHXpQB5IDDL0DsbK7ETenBjHXkzplp2Z3RPuOUOhg1hdpTOHNtTiQ7vYXekIdaLQZTuAsRGSNWrQeV1yDCa+iWC99laKZxpNfVkTuqy7NctpOuWcfGn6XixKn0bXCzpSi5f425w6C86PbenxPZuqiARcgbuJ1wuUYUymVMEJ5UjKaX+w4kqpAOGC8ZUG1PPU0oR0JSDwMWAwTHkoJAf1Hgo++nsWkzx2J2APTqVcxBORYIA7eQ3BqHlADwFlbe0pyCcGnDjEk2MBgw1+hJjFLHbhHDtnsz/GHP06S9GP83a7ZU0So1ITarfYgRhMXQ1JHWitwzQjYNooNTClGuzhGN/ZvIdsDG+RPqoYrayCfPn6ELtZRLq0yXCoXvJRSQeGnA7wNpeCe/pC8WNgnAWt2UVksyY0Xp+kixiGSSZhcBXOzR2hibBjQ6JYL0IPSSmHXsEat9CXWU4AVeOnokvroWV07Jl13zrGd1FSaxrCwZnQQKgtu2+WFzVldH9YEPq2KVCCH2kGgC6R3alZ8PBA6nanPnFG9YH904jwHWnRVxqieWdpaZaJQ9DquzujrTi0rJKGgtNIsI6Ofib/hdiEUZmh/1fzFKuJBgLptZiXh/KCeFFWFnhjmtmp7dP4g9B7lGJNJEhM1RFynI7TVILI8jSP84c9hfS7FlokIKOJKrXL//4HY/qhiboQAAA="
|
|
56
|
+
NAMED_COLORS = json.loads(gzip.decompress(base64.b64decode(COMP_COL)).decode("utf-8"))
|
|
57
|
+
|
|
58
|
+
def __init__(self, color: Union[str, Tuple[int, int, int], "ColorLike", None] = None):
|
|
59
|
+
self.rgb = self._parse(color)
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def print_samples():
|
|
63
|
+
# Imported lazily to avoid a circular import: .terminal and .drawing
|
|
64
|
+
# both import from this module at load time, so this module can't
|
|
65
|
+
# import them back at load time too.
|
|
66
|
+
from .terminal import get_terminal_size
|
|
67
|
+
from .drawing import banner
|
|
68
|
+
width = get_terminal_size()[0]
|
|
69
|
+
for name, color in ColorLike.NAMED_COLORS.items():
|
|
70
|
+
c = ColorLike(color)
|
|
71
|
+
print(banner(name, width, color=c.contrast(), background=c))
|
|
72
|
+
|
|
73
|
+
def _parse(self, color) -> Optional[Tuple[int, int, int]]:
|
|
74
|
+
if color is None:
|
|
75
|
+
return None
|
|
76
|
+
if isinstance(color, ColorLike):
|
|
77
|
+
return color.rgb
|
|
78
|
+
|
|
79
|
+
if isinstance(color, (tuple, list)):
|
|
80
|
+
if len(color) != 3 or not all(0 <= int(c) <= 255 for c in color):
|
|
81
|
+
raise ValueError(f"RGB must be 3 integers between 0 and 255, got: {color}")
|
|
82
|
+
return tuple(int(c) for c in color)
|
|
83
|
+
|
|
84
|
+
if isinstance(color, str):
|
|
85
|
+
clean = color.strip().lower()
|
|
86
|
+
|
|
87
|
+
if clean.startswith("#"):
|
|
88
|
+
hex_str = clean[1:]
|
|
89
|
+
if len(hex_str) == 3:
|
|
90
|
+
hex_str = "".join(ch * 2 for ch in hex_str)
|
|
91
|
+
if len(hex_str) == 6:
|
|
92
|
+
try:
|
|
93
|
+
return tuple(int(hex_str[i: i + 2], 16) for i in (0, 2, 4))
|
|
94
|
+
except ValueError:
|
|
95
|
+
pass
|
|
96
|
+
raise ValueError(f"Invalid hex color format: '{color}'")
|
|
97
|
+
|
|
98
|
+
clean = clean.replace(" ", "").replace("_", "").replace("-", "")
|
|
99
|
+
if clean in self.NAMED_COLORS:
|
|
100
|
+
return tuple(self.NAMED_COLORS[clean])
|
|
101
|
+
|
|
102
|
+
raise ValueError(f"Unknown color name: '{color}'")
|
|
103
|
+
|
|
104
|
+
raise TypeError(f"Unsupported color type: {type(color).__name__}")
|
|
105
|
+
|
|
106
|
+
def _require(self) -> Tuple[int, int, int]:
|
|
107
|
+
if self.rgb is None:
|
|
108
|
+
raise ValueError("This ColorLike has no color (it was created from None).")
|
|
109
|
+
return self.rgb
|
|
110
|
+
|
|
111
|
+
def to_hex(self) -> str:
|
|
112
|
+
r, g, b = self._require()
|
|
113
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
114
|
+
|
|
115
|
+
def luminance(self) -> float:
|
|
116
|
+
r, g, b = self._require()
|
|
117
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
118
|
+
|
|
119
|
+
def contrast(self) -> "ColorLike":
|
|
120
|
+
return ColorLike((0, 0, 0) if self.luminance() >= 0.5 else (255, 255, 255))
|
|
121
|
+
|
|
122
|
+
def mix(self, other, t: float = 0.5) -> "ColorLike":
|
|
123
|
+
a = self._require()
|
|
124
|
+
b = ColorLike(other)._require()
|
|
125
|
+
t = max(0.0, min(1.0, t))
|
|
126
|
+
return ColorLike(tuple(round(x + (y - x) * t) for x, y in zip(a, b)))
|
|
127
|
+
|
|
128
|
+
def lighten(self, amount: float = 0.2) -> "ColorLike":
|
|
129
|
+
return self.mix((255, 255, 255), amount)
|
|
130
|
+
|
|
131
|
+
def darken(self, amount: float = 0.2) -> "ColorLike":
|
|
132
|
+
return self.mix((0, 0, 0), amount)
|
|
133
|
+
|
|
134
|
+
def __eq__(self, other):
|
|
135
|
+
if isinstance(other, ColorLike):
|
|
136
|
+
return self.rgb == other.rgb
|
|
137
|
+
return NotImplemented
|
|
138
|
+
|
|
139
|
+
def __hash__(self):
|
|
140
|
+
return hash(self.rgb)
|
|
141
|
+
|
|
142
|
+
def __repr__(self):
|
|
143
|
+
return f"ColorLike({self.rgb})"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _vlen(part) -> int:
|
|
147
|
+
return _visible_len(part) if isinstance(part, str) else len(part)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class StyledText:
|
|
151
|
+
def __init__(self, parts: Optional[List[Union[str, "ColorText", "GradientText"]]] = None):
|
|
152
|
+
self.parts: List[Union[str, "ColorText", "GradientText"]] = list(parts) if parts else []
|
|
153
|
+
|
|
154
|
+
def __str__(self) -> str:
|
|
155
|
+
return "".join(str(p) for p in self.parts)
|
|
156
|
+
|
|
157
|
+
def __len__(self) -> int:
|
|
158
|
+
return sum(_vlen(p) for p in self.parts)
|
|
159
|
+
|
|
160
|
+
def __bool__(self) -> bool:
|
|
161
|
+
return bool(self.parts)
|
|
162
|
+
|
|
163
|
+
def wrap(self, width: int, collapse_space: bool = True) -> str:
|
|
164
|
+
return wrap_text(str(self), width, collapse_space=collapse_space)
|
|
165
|
+
|
|
166
|
+
def align(self, width: int, align: str = "left", fillchar: str = " ") -> str:
|
|
167
|
+
return align_text(str(self), width, align=align, fillchar=fillchar)
|
|
168
|
+
|
|
169
|
+
def __add__(self, other):
|
|
170
|
+
if isinstance(other, StyledText):
|
|
171
|
+
return StyledText(self.parts + other.parts)
|
|
172
|
+
if isinstance(other, (ColorText, GradientText, str)):
|
|
173
|
+
return StyledText(self.parts + [other])
|
|
174
|
+
return NotImplemented
|
|
175
|
+
|
|
176
|
+
def __radd__(self, other):
|
|
177
|
+
if isinstance(other, str):
|
|
178
|
+
return StyledText([other] + self.parts)
|
|
179
|
+
return NotImplemented
|
|
180
|
+
|
|
181
|
+
def __repr__(self):
|
|
182
|
+
return f"StyledText({self.parts!r})"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _StyleMixin:
|
|
186
|
+
def set_text(self, text: str = ""):
|
|
187
|
+
self.text = "" if text is None else str(text)
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
def set_reset(self, reset: bool = True):
|
|
191
|
+
self.reset = reset
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
def set_background(self, col: Union[ColorLike, str, tuple, None] = None):
|
|
195
|
+
self.background = ColorLike(col) if col is not None else None
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def set_styles(self, *styles: str):
|
|
199
|
+
if len(styles) == 1 and isinstance(styles[0], (list, tuple)):
|
|
200
|
+
styles = tuple(styles[0])
|
|
201
|
+
self.styles = _validate_styles(styles)
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
def add_style(self, style: str):
|
|
205
|
+
key = _validate_styles([style])[0]
|
|
206
|
+
if key not in self.styles:
|
|
207
|
+
self.styles.append(key)
|
|
208
|
+
return self
|
|
209
|
+
|
|
210
|
+
def remove_style(self, style: str):
|
|
211
|
+
key = str(style).strip().lower()
|
|
212
|
+
if key in self.styles:
|
|
213
|
+
self.styles.remove(key)
|
|
214
|
+
return self
|
|
215
|
+
|
|
216
|
+
def bold(self):
|
|
217
|
+
return self.add_style("bold")
|
|
218
|
+
|
|
219
|
+
def italic(self):
|
|
220
|
+
return self.add_style("italic")
|
|
221
|
+
|
|
222
|
+
def underline(self):
|
|
223
|
+
return self.add_style("underline")
|
|
224
|
+
|
|
225
|
+
def strikethrough(self):
|
|
226
|
+
return self.add_style("strikethrough")
|
|
227
|
+
|
|
228
|
+
def dim(self):
|
|
229
|
+
return self.add_style("dim")
|
|
230
|
+
|
|
231
|
+
def blink(self):
|
|
232
|
+
return self.add_style("blink")
|
|
233
|
+
|
|
234
|
+
def reverse(self):
|
|
235
|
+
return self.add_style("reverse")
|
|
236
|
+
|
|
237
|
+
def __add__(self, other):
|
|
238
|
+
if isinstance(other, StyledText):
|
|
239
|
+
return StyledText([self] + other.parts)
|
|
240
|
+
if isinstance(other, (ColorText, GradientText, str)):
|
|
241
|
+
return StyledText([self, other])
|
|
242
|
+
return NotImplemented
|
|
243
|
+
|
|
244
|
+
def __radd__(self, other):
|
|
245
|
+
if isinstance(other, str):
|
|
246
|
+
return StyledText([other, self])
|
|
247
|
+
return NotImplemented
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
ColorSpec = Union[ColorLike, str, tuple, list]
|
|
251
|
+
|
|
252
|
+
GradientColors = Union[str, Sequence[ColorSpec]]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class ColorText(_StyleMixin):
|
|
256
|
+
_validate_styles = staticmethod(_validate_styles)
|
|
257
|
+
|
|
258
|
+
def __init__(
|
|
259
|
+
self,
|
|
260
|
+
text: str = "",
|
|
261
|
+
foreground: Union[ColorLike, str, tuple, None] = None,
|
|
262
|
+
background: Union[ColorLike, str, tuple, None] = None,
|
|
263
|
+
styles: Optional[Sequence[str]] = None,
|
|
264
|
+
reset: bool = True,
|
|
265
|
+
*,
|
|
266
|
+
color: Union[ColorLike, str, tuple, None] = None,
|
|
267
|
+
):
|
|
268
|
+
if foreground is None:
|
|
269
|
+
foreground = color
|
|
270
|
+
self.text = "" if text is None else str(text)
|
|
271
|
+
self.fore = ColorLike(foreground) if foreground is not None else None
|
|
272
|
+
self.background = ColorLike(background) if background is not None else None
|
|
273
|
+
self.styles: List[str] = _validate_styles(styles) if styles else []
|
|
274
|
+
self.reset = reset
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def color(self) -> Optional[ColorLike]:
|
|
278
|
+
return self.fore
|
|
279
|
+
|
|
280
|
+
@color.setter
|
|
281
|
+
def color(self, value):
|
|
282
|
+
self.set_color(value)
|
|
283
|
+
|
|
284
|
+
def set_color(self, col: Union[ColorLike, str, tuple, None] = None):
|
|
285
|
+
self.fore = ColorLike(col) if col is not None else None
|
|
286
|
+
return self
|
|
287
|
+
|
|
288
|
+
def __str__(self) -> str:
|
|
289
|
+
codes = [f"\033[{_STYLE_CODES[s]}m" for s in self.styles]
|
|
290
|
+
|
|
291
|
+
if self.fore and self.fore.rgb:
|
|
292
|
+
codes.append(_fg_code(self.fore.rgb))
|
|
293
|
+
|
|
294
|
+
if self.background and self.background.rgb:
|
|
295
|
+
codes.append(_bg_code(self.background.rgb))
|
|
296
|
+
|
|
297
|
+
prefix = "".join(codes)
|
|
298
|
+
suffix = "\033[0m" if prefix and self.reset else ""
|
|
299
|
+
|
|
300
|
+
return f"{prefix}{self.text}{suffix}"
|
|
301
|
+
|
|
302
|
+
def __len__(self) -> int:
|
|
303
|
+
return _visible_len(self.text)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class GradientText(_StyleMixin):
|
|
307
|
+
|
|
308
|
+
PRESETS = {
|
|
309
|
+
"rainbow": ["red", "orange", "yellow", "green", "blue", "indigo", "violet"],
|
|
310
|
+
"fire": ["darkred", "red", "orange", "gold", "yellow"],
|
|
311
|
+
"ocean": ["midnightblue", "navy", "blue", "deepskyblue", "cyan"],
|
|
312
|
+
"sunset": ["indigo", "purple", "orangered", "gold"],
|
|
313
|
+
"pastel": ["lightpink", "lightyellow", "lightgreen", "lightskyblue", "plum"],
|
|
314
|
+
"grayscale": ["black", "white"],
|
|
315
|
+
"neon": ["deeppink", "fuchsia", "cyan", "yellow"],
|
|
316
|
+
"forest": ["darkgreen", "forestgreen", "yellowgreen", "greenyellow"],
|
|
317
|
+
"mint": ["teal", "mediumspringgreen", "honeydew"],
|
|
318
|
+
"gold": ["saddlebrown", "goldenrod", "gold", "lightyellow"],
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
def __init__(
|
|
322
|
+
self,
|
|
323
|
+
text: str = "",
|
|
324
|
+
colors: Optional[GradientColors] = None,
|
|
325
|
+
color: Union[ColorLike, str, tuple, None] = None,
|
|
326
|
+
background: Union[ColorLike, str, tuple, None] = None,
|
|
327
|
+
background_colors: Optional[GradientColors] = None,
|
|
328
|
+
styles: Optional[Sequence[str]] = None,
|
|
329
|
+
reset: bool = True,
|
|
330
|
+
*,
|
|
331
|
+
foreground: Union[ColorLike, str, tuple, None] = None,
|
|
332
|
+
):
|
|
333
|
+
if color is None:
|
|
334
|
+
color = foreground
|
|
335
|
+
self.text = "" if text is None else str(text)
|
|
336
|
+
self.color = ColorLike(color) if color is not None else None
|
|
337
|
+
self.background = ColorLike(background) if background is not None else None
|
|
338
|
+
self.bg_colors: List[ColorLike] = (
|
|
339
|
+
self._resolve_colors(background_colors) if background_colors is not None else []
|
|
340
|
+
)
|
|
341
|
+
self.styles: List[str] = _validate_styles(styles) if styles else []
|
|
342
|
+
self.reset = reset
|
|
343
|
+
self.colors: List[ColorLike] = self._resolve_colors(colors) if colors is not None else []
|
|
344
|
+
|
|
345
|
+
def _resolve_colors(self, colors: GradientColors) -> List[ColorLike]:
|
|
346
|
+
available = ", ".join(sorted(self.PRESETS))
|
|
347
|
+
if isinstance(colors, ColorLike):
|
|
348
|
+
return [colors]
|
|
349
|
+
if isinstance(colors, str):
|
|
350
|
+
key = colors.strip().lower()
|
|
351
|
+
if key in self.PRESETS:
|
|
352
|
+
colors = self.PRESETS[key]
|
|
353
|
+
else:
|
|
354
|
+
try:
|
|
355
|
+
return [ColorLike(colors)]
|
|
356
|
+
except ValueError:
|
|
357
|
+
raise ValueError(
|
|
358
|
+
f"Unknown preset or color: '{colors}'. Available presets: {available}"
|
|
359
|
+
) from None
|
|
360
|
+
elif (isinstance(colors, (tuple, list)) and len(colors) == 3
|
|
361
|
+
and all(isinstance(c, (int, float)) and not isinstance(c, bool) for c in colors)):
|
|
362
|
+
return [ColorLike(tuple(colors))]
|
|
363
|
+
|
|
364
|
+
if not colors:
|
|
365
|
+
raise ValueError(
|
|
366
|
+
"GradientText requires at least one color: pass a preset name "
|
|
367
|
+
"(e.g. 'rainbow') or a sequence of colors (e.g. ['red', 'blue'])."
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
return [ColorLike(c) for c in colors]
|
|
371
|
+
|
|
372
|
+
def set_colors(self, colors: GradientColors):
|
|
373
|
+
self.colors = self._resolve_colors(colors)
|
|
374
|
+
return self
|
|
375
|
+
|
|
376
|
+
def set_color(self, col: Union[ColorLike, str, tuple, None] = None):
|
|
377
|
+
self.color = ColorLike(col) if col is not None else None
|
|
378
|
+
return self
|
|
379
|
+
|
|
380
|
+
def set_background_gradient(self, colors: GradientColors):
|
|
381
|
+
self.bg_colors = self._resolve_colors(colors)
|
|
382
|
+
return self
|
|
383
|
+
|
|
384
|
+
@classmethod
|
|
385
|
+
def from_preset(cls, text: str = "", preset: str = "rainbow", **kwargs):
|
|
386
|
+
return cls(text=text, colors=preset, **kwargs)
|
|
387
|
+
|
|
388
|
+
@classmethod
|
|
389
|
+
def list_presets(cls) -> List[str]:
|
|
390
|
+
return sorted(cls.PRESETS)
|
|
391
|
+
|
|
392
|
+
@classmethod
|
|
393
|
+
def preview_presets(cls, sample_text: str = "Sample Text", print_output: bool = True):
|
|
394
|
+
results = []
|
|
395
|
+
for name in cls.list_presets():
|
|
396
|
+
rendered = str(cls.from_preset(text=sample_text, preset=name))
|
|
397
|
+
results.append((name, rendered))
|
|
398
|
+
if print_output:
|
|
399
|
+
print(f"{name:12s} {rendered}")
|
|
400
|
+
return results
|
|
401
|
+
|
|
402
|
+
def _color_at_from(self, colors: List[ColorLike], t: float) -> Tuple[int, int, int]:
|
|
403
|
+
n = len(colors)
|
|
404
|
+
if n == 1:
|
|
405
|
+
return colors[0].rgb
|
|
406
|
+
|
|
407
|
+
pos = t * (n - 1)
|
|
408
|
+
seg = int(pos)
|
|
409
|
+
if seg >= n - 1:
|
|
410
|
+
seg = n - 2
|
|
411
|
+
local_t = pos - seg
|
|
412
|
+
|
|
413
|
+
r1, g1, b1 = colors[seg].rgb
|
|
414
|
+
r2, g2, b2 = colors[seg + 1].rgb
|
|
415
|
+
return (
|
|
416
|
+
round(r1 + (r2 - r1) * local_t),
|
|
417
|
+
round(g1 + (g2 - g1) * local_t),
|
|
418
|
+
round(b1 + (b2 - b1) * local_t),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def _color_at(self, t: float) -> Tuple[int, int, int]:
|
|
422
|
+
return self._color_at_from(self.colors, t)
|
|
423
|
+
|
|
424
|
+
def __str__(self) -> str:
|
|
425
|
+
if not self.text:
|
|
426
|
+
return ""
|
|
427
|
+
|
|
428
|
+
style_prefix = "".join(f"\033[{_STYLE_CODES[s]}m" for s in self.styles)
|
|
429
|
+
fg_solid = _fg_code(self.color.rgb) if self.color and self.color.rgb else ""
|
|
430
|
+
bg_solid = _bg_code(self.background.rgb) if self.background and self.background.rgb else ""
|
|
431
|
+
|
|
432
|
+
grad_fg = bool(self.colors)
|
|
433
|
+
grad_bg = bool(self.bg_colors)
|
|
434
|
+
want_fg = grad_fg or bool(fg_solid)
|
|
435
|
+
want_bg = grad_bg or bool(bg_solid)
|
|
436
|
+
|
|
437
|
+
tokens = _tokenize_ansi(self.text)
|
|
438
|
+
length = sum(1 for kind, _ in tokens if kind == 'char')
|
|
439
|
+
|
|
440
|
+
any_code = bool(style_prefix or want_fg or want_bg)
|
|
441
|
+
reset_code = "\033[0m" if any_code and self.reset else ""
|
|
442
|
+
|
|
443
|
+
parts = []
|
|
444
|
+
idx = 0
|
|
445
|
+
has_fg = has_bg = False
|
|
446
|
+
sent_fg = sent_bg = False
|
|
447
|
+
need_style = True
|
|
448
|
+
for kind, val in tokens:
|
|
449
|
+
if kind == 'ansi':
|
|
450
|
+
if _is_reset(val):
|
|
451
|
+
has_fg = has_bg = sent_fg = sent_bg = False
|
|
452
|
+
need_style = True
|
|
453
|
+
elif val.startswith("\033[38"):
|
|
454
|
+
has_fg = True
|
|
455
|
+
elif val.startswith("\033[48"):
|
|
456
|
+
has_bg = True
|
|
457
|
+
parts.append(val)
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
ch = val
|
|
461
|
+
if ch == '\n':
|
|
462
|
+
if reset_code:
|
|
463
|
+
parts.append(reset_code)
|
|
464
|
+
parts.append(ch)
|
|
465
|
+
idx += 1
|
|
466
|
+
has_fg = has_bg = sent_fg = sent_bg = False
|
|
467
|
+
need_style = True
|
|
468
|
+
continue
|
|
469
|
+
|
|
470
|
+
t = idx / (length - 1) if length > 1 else 0.0
|
|
471
|
+
prefix = ""
|
|
472
|
+
if need_style:
|
|
473
|
+
prefix += style_prefix
|
|
474
|
+
need_style = False
|
|
475
|
+
if want_fg and not has_fg:
|
|
476
|
+
if grad_fg:
|
|
477
|
+
prefix += _fg_code(self._color_at(t))
|
|
478
|
+
elif not sent_fg:
|
|
479
|
+
prefix += fg_solid
|
|
480
|
+
sent_fg = True
|
|
481
|
+
if want_bg and not has_bg:
|
|
482
|
+
if grad_bg:
|
|
483
|
+
prefix += _bg_code(self._color_at_from(self.bg_colors, t))
|
|
484
|
+
elif not sent_bg:
|
|
485
|
+
prefix += bg_solid
|
|
486
|
+
sent_bg = True
|
|
487
|
+
parts.append(f"{prefix}{ch}")
|
|
488
|
+
idx += 1
|
|
489
|
+
|
|
490
|
+
return "".join(parts) + reset_code
|
|
491
|
+
|
|
492
|
+
def __repr__(self):
|
|
493
|
+
stops = [c.rgb for c in self.colors]
|
|
494
|
+
return f"GradientText(text={self.text!r}, colors={stops})"
|
|
495
|
+
|
|
496
|
+
def __len__(self) -> int:
|
|
497
|
+
return _visible_len(self.text)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _make_preset_classmethod(preset_name: str):
|
|
501
|
+
@classmethod
|
|
502
|
+
def _preset_method(cls, text: str = "", **kwargs):
|
|
503
|
+
return cls.from_preset(text=text, preset=preset_name, **kwargs)
|
|
504
|
+
|
|
505
|
+
return _preset_method
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
for _preset_name in GradientText.PRESETS:
|
|
509
|
+
setattr(GradientText, _preset_name, _make_preset_classmethod(_preset_name))
|
|
510
|
+
del _preset_name
|