xulbux 1.9.5__cp311-cp311-macosx_11_0_arm64.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.
Files changed (43) hide show
  1. 455848faf89d8974b22a__mypyc.cpython-311-darwin.so +0 -0
  2. xulbux/__init__.cpython-311-darwin.so +0 -0
  3. xulbux/__init__.py +46 -0
  4. xulbux/base/consts.cpython-311-darwin.so +0 -0
  5. xulbux/base/consts.py +172 -0
  6. xulbux/base/decorators.cpython-311-darwin.so +0 -0
  7. xulbux/base/decorators.py +28 -0
  8. xulbux/base/exceptions.cpython-311-darwin.so +0 -0
  9. xulbux/base/exceptions.py +23 -0
  10. xulbux/base/types.cpython-311-darwin.so +0 -0
  11. xulbux/base/types.py +118 -0
  12. xulbux/cli/help.cpython-311-darwin.so +0 -0
  13. xulbux/cli/help.py +77 -0
  14. xulbux/code.cpython-311-darwin.so +0 -0
  15. xulbux/code.py +137 -0
  16. xulbux/color.cpython-311-darwin.so +0 -0
  17. xulbux/color.py +1331 -0
  18. xulbux/console.cpython-311-darwin.so +0 -0
  19. xulbux/console.py +2069 -0
  20. xulbux/data.cpython-311-darwin.so +0 -0
  21. xulbux/data.py +798 -0
  22. xulbux/env_path.cpython-311-darwin.so +0 -0
  23. xulbux/env_path.py +123 -0
  24. xulbux/file.cpython-311-darwin.so +0 -0
  25. xulbux/file.py +74 -0
  26. xulbux/file_sys.cpython-311-darwin.so +0 -0
  27. xulbux/file_sys.py +266 -0
  28. xulbux/format_codes.cpython-311-darwin.so +0 -0
  29. xulbux/format_codes.py +722 -0
  30. xulbux/json.cpython-311-darwin.so +0 -0
  31. xulbux/json.py +200 -0
  32. xulbux/regex.cpython-311-darwin.so +0 -0
  33. xulbux/regex.py +247 -0
  34. xulbux/string.cpython-311-darwin.so +0 -0
  35. xulbux/string.py +161 -0
  36. xulbux/system.cpython-311-darwin.so +0 -0
  37. xulbux/system.py +313 -0
  38. xulbux-1.9.5.dist-info/METADATA +271 -0
  39. xulbux-1.9.5.dist-info/RECORD +43 -0
  40. xulbux-1.9.5.dist-info/WHEEL +6 -0
  41. xulbux-1.9.5.dist-info/entry_points.txt +2 -0
  42. xulbux-1.9.5.dist-info/licenses/LICENSE +21 -0
  43. xulbux-1.9.5.dist-info/top_level.txt +2 -0
xulbux/color.py ADDED
@@ -0,0 +1,1331 @@
1
+ """
2
+ This module provides the `rgba`, `hsla` and `hexa` classes, which offer
3
+ methods to manipulate colors in their respective color spaces.<br>
4
+
5
+ This module also provides the `Color` class, which
6
+ includes methods to work with colors in various formats.
7
+ """
8
+
9
+ from .base.types import AnyRgba, AnyHsla, AnyHexa, Rgba, Hsla, Hexa
10
+ from .regex import Regex
11
+
12
+ from typing import Iterator, Optional, Literal, cast
13
+ import re as _re
14
+
15
+
16
+ class rgba:
17
+ """An RGB/RGBA color object that includes a bunch of methods to manipulate the color.\n
18
+ ----------------------------------------------------------------------------------------
19
+ - `r` -⠀the red channel in range [0, 255] inclusive
20
+ - `g` -⠀the green channel in range [0, 255] inclusive
21
+ - `b` -⠀the blue channel in range [0, 255] inclusive
22
+ - `a` -⠀the alpha channel in range [0.0, 1.0] inclusive
23
+ or `None` if the color has no alpha channel\n
24
+ ----------------------------------------------------------------------------------------
25
+ Includes methods:
26
+ - `to_hsla()` to convert to HSL color
27
+ - `to_hexa()` to convert to HEX color
28
+ - `has_alpha()` to check if the color has an alpha channel
29
+ - `lighten(amount)` to create a lighter version of the color
30
+ - `darken(amount)` to create a darker version of the color
31
+ - `saturate(amount)` to increase color saturation
32
+ - `desaturate(amount)` to decrease color saturation
33
+ - `rotate(degrees)` to rotate the hue by degrees
34
+ - `invert()` to get the inverse color
35
+ - `grayscale()` to convert to grayscale
36
+ - `blend(other, ratio)` to blend with another color
37
+ - `is_dark()` to check if the color is considered dark
38
+ - `is_light()` to check if the color is considered light
39
+ - `is_grayscale()` to check if the color is grayscale
40
+ - `is_opaque()` to check if the color has no transparency
41
+ - `with_alpha(alpha)` to create a new color with different alpha
42
+ - `complementary()` to get the complementary color"""
43
+
44
+ def __init__(self, r: int, g: int, b: int, a: Optional[float] = None, _validate: bool = True):
45
+ self.r: int
46
+ """The red channel in range [0, 255] inclusive."""
47
+ self.g: int
48
+ """The green channel in range [0, 255] inclusive."""
49
+ self.b: int
50
+ """The blue channel in range [0, 255] inclusive."""
51
+ self.a: Optional[float]
52
+ """The alpha channel in range [0.0, 1.0] inclusive or `None` if not set."""
53
+
54
+ if not _validate:
55
+ self.r, self.g, self.b, self.a = r, g, b, a
56
+ return
57
+
58
+ if not all((0 <= x <= 255) for x in (r, g, b)):
59
+ raise ValueError(
60
+ f"The 'r', 'g' and 'b' parameters must be integers in range [0, 255] inclusive, got {r=} {g=} {b=}"
61
+ )
62
+ if a is not None and not (0.0 <= a <= 1.0):
63
+ raise ValueError(f"The 'a' parameter must be in range [0.0, 1.0] inclusive, got {a!r}")
64
+
65
+ self.r, self.g, self.b = r, g, b
66
+ self.a = None if a is None else (1.0 if a > 1.0 else float(a))
67
+
68
+ def __len__(self) -> int:
69
+ """The number of components in the color (3 or 4)."""
70
+ return 3 if self.a is None else 4
71
+
72
+ def __iter__(self) -> Iterator:
73
+ return iter((self.r, self.g, self.b) + (() if self.a is None else (self.a, )))
74
+
75
+ def __getitem__(self, index: int) -> int | float:
76
+ return ((self.r, self.g, self.b) + (() if self.a is None else (self.a, )))[index]
77
+
78
+ def __eq__(self, other: object) -> bool:
79
+ """Check if two `rgba` objects are the same color."""
80
+ if not isinstance(other, rgba):
81
+ return False
82
+ return (self.r, self.g, self.b, self.a) == (other.r, other.g, other.b, other.a)
83
+
84
+ def __ne__(self, other: object) -> bool:
85
+ """Check if two `rgba` objects are different colors."""
86
+ return not self.__eq__(other)
87
+
88
+ def __repr__(self) -> str:
89
+ return f"rgba({self.r}, {self.g}, {self.b}{'' if self.a is None else f', {self.a}'})"
90
+
91
+ def __str__(self) -> str:
92
+ return self.__repr__()
93
+
94
+ def dict(self) -> dict:
95
+ """Returns the color components as a dictionary with keys `"r"`, `"g"`, `"b"` and optionally `"a"`."""
96
+ return dict(r=self.r, g=self.g, b=self.b) if self.a is None else dict(r=self.r, g=self.g, b=self.b, a=self.a)
97
+
98
+ def values(self) -> tuple:
99
+ """Returns the color components as separate values `r, g, b, a`."""
100
+ return self.r, self.g, self.b, self.a
101
+
102
+ def to_hsla(self) -> "hsla":
103
+ """Returns the color as `hsla()` color object."""
104
+ h, s, l = self._rgb_to_hsl(self.r, self.g, self.b)
105
+ return hsla(h, s, l, self.a, _validate=False)
106
+
107
+ def to_hexa(self) -> "hexa":
108
+ """Returns the color as `hexa()` color object."""
109
+ return hexa("", self.r, self.g, self.b, self.a)
110
+
111
+ def has_alpha(self) -> bool:
112
+ """Returns `True` if the color has an alpha channel and `False` otherwise."""
113
+ return self.a is not None
114
+
115
+ def lighten(self, amount: float) -> "rgba":
116
+ """Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive."""
117
+ if not isinstance(amount, float):
118
+ raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
119
+ elif not (0.0 <= amount <= 1.0):
120
+ raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
121
+
122
+ self.r, self.g, self.b, self.a = self.to_hsla().lighten(amount).to_rgba().values()
123
+ return rgba(self.r, self.g, self.b, self.a, _validate=False)
124
+
125
+ def darken(self, amount: float) -> "rgba":
126
+ """Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive."""
127
+ if not isinstance(amount, float):
128
+ raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
129
+ elif not (0.0 <= amount <= 1.0):
130
+ raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
131
+
132
+ self.r, self.g, self.b, self.a = self.to_hsla().darken(amount).to_rgba().values()
133
+ return rgba(self.r, self.g, self.b, self.a, _validate=False)
134
+
135
+ def saturate(self, amount: float) -> "rgba":
136
+ """Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive."""
137
+ if not isinstance(amount, float):
138
+ raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
139
+ elif not (0.0 <= amount <= 1.0):
140
+ raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
141
+
142
+ self.r, self.g, self.b, self.a = self.to_hsla().saturate(amount).to_rgba().values()
143
+ return rgba(self.r, self.g, self.b, self.a, _validate=False)
144
+
145
+ def desaturate(self, amount: float) -> "rgba":
146
+ """Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive."""
147
+ if not isinstance(amount, float):
148
+ raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
149
+ elif not (0.0 <= amount <= 1.0):
150
+ raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
151
+
152
+ self.r, self.g, self.b, self.a = self.to_hsla().desaturate(amount).to_rgba().values()
153
+ return rgba(self.r, self.g, self.b, self.a, _validate=False)
154
+
155
+ def rotate(self, degrees: int) -> "rgba":
156
+ """Rotates the colors hue by the specified number of degrees."""
157
+ if not isinstance(degrees, int):
158
+ raise TypeError(f"The 'degrees' parameter must be an integer, got {type(degrees)}")
159
+
160
+ self.r, self.g, self.b, self.a = self.to_hsla().rotate(degrees).to_rgba().values()
161
+ return rgba(self.r, self.g, self.b, self.a, _validate=False)
162
+
163
+ def invert(self, invert_alpha: bool = False) -> "rgba":
164
+ """Inverts the color by rotating hue by 180 degrees and inverting lightness."""
165
+ if not isinstance(invert_alpha, bool):
166
+ raise TypeError(f"The 'invert_alpha' parameter must be a boolean, got {type(invert_alpha)}")
167
+
168
+ self.r, self.g, self.b = 255 - self.r, 255 - self.g, 255 - self.b
169
+ if invert_alpha and self.a is not None:
170
+ self.a = 1 - self.a
171
+
172
+ return rgba(self.r, self.g, self.b, self.a, _validate=False)
173
+
174
+ def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> "rgba":
175
+ """Converts the color to grayscale using the luminance formula.\n
176
+ ---------------------------------------------------------------------------
177
+ - `method` -⠀the luminance calculation method to use:
178
+ * `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
179
+ * `"wcag3"` Draft WCAG 3.0 standard with improved coefficients
180
+ * `"simple"` Simple arithmetic mean (less accurate)
181
+ * `"bt601"` ITU-R BT.601 standard (older TV standard)"""
182
+ # THE 'method' PARAM IS CHECKED IN 'Color.luminance()'
183
+ self.r = self.g = self.b = int(Color.luminance(self.r, self.g, self.b, method=method))
184
+ return rgba(self.r, self.g, self.b, self.a, _validate=False)
185
+
186
+ def blend(self, other: Rgba, ratio: float = 0.5, additive_alpha: bool = False) -> "rgba":
187
+ """Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n
188
+ ----------------------------------------------------------------------------------------------------------
189
+ - `other` -⠀the other RGBA color to blend with
190
+ - `ratio` -⠀the blend ratio between the two colors:
191
+ * if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture)
192
+ * if `ratio` is `0.5` it means 50% of both colors (1:1 mixture)
193
+ * if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture)
194
+ - `additive_alpha` -⠀whether to blend the alpha channels additively or not"""
195
+ if not isinstance(other, rgba):
196
+ if Color.is_valid_rgba(other):
197
+ other = Color.to_rgba(other)
198
+ else:
199
+ raise TypeError(f"The 'other' parameter must be a valid RGBA color, got {type(other)}")
200
+ if not isinstance(ratio, float):
201
+ raise TypeError(f"The 'ratio' parameter must be a float, got {type(ratio)}")
202
+ elif not (0.0 <= ratio <= 1.0):
203
+ raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}")
204
+ if not isinstance(additive_alpha, bool):
205
+ raise TypeError(f"The 'additive_alpha' parameter must be a boolean, got {type(additive_alpha)}")
206
+
207
+ ratio *= 2
208
+ self.r = int(max(0, min(255, int(round((self.r * (2 - ratio)) + (other.r * ratio))))))
209
+ self.g = int(max(0, min(255, int(round((self.g * (2 - ratio)) + (other.g * ratio))))))
210
+ self.b = int(max(0, min(255, int(round((self.b * (2 - ratio)) + (other.b * ratio))))))
211
+ none_alpha = self.a is None and (len(other) <= 3 or other[3] is None)
212
+
213
+ if not none_alpha:
214
+ self_a = 1 if self.a is None else self.a
215
+ other_a = (other[3] if other[3] is not None else 1) if len(other) > 3 else 1
216
+
217
+ if additive_alpha:
218
+ self.a = max(0, min(1, (self_a * (2 - ratio)) + (other_a * ratio)))
219
+ else:
220
+ self.a = max(0, min(1, (self_a * (1 - (ratio / 2))) + (other_a * (ratio / 2))))
221
+
222
+ else:
223
+ self.a = None
224
+
225
+ return rgba(self.r, self.g, self.b, None if none_alpha else self.a, _validate=False)
226
+
227
+ def is_dark(self) -> bool:
228
+ """Returns `True` if the color is considered dark (`lightness < 50%`)."""
229
+ return self.to_hsla().is_dark()
230
+
231
+ def is_light(self) -> bool:
232
+ """Returns `True` if the color is considered light (`lightness >= 50%`)."""
233
+ return not self.is_dark()
234
+
235
+ def is_grayscale(self) -> bool:
236
+ """Returns `True` if the color is grayscale."""
237
+ return self.r == self.g == self.b
238
+
239
+ def is_opaque(self) -> bool:
240
+ """Returns `True` if the color has no transparency."""
241
+ return self.a == 1 or self.a is None
242
+
243
+ def with_alpha(self, alpha: float) -> "rgba":
244
+ """Returns a new color with the specified alpha value."""
245
+ if not isinstance(alpha, float):
246
+ raise TypeError(f"The 'alpha' parameter must be a float, got {type(alpha)}")
247
+ elif not (0.0 <= alpha <= 1.0):
248
+ raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}")
249
+
250
+ return rgba(self.r, self.g, self.b, alpha, _validate=False)
251
+
252
+ def complementary(self) -> "rgba":
253
+ """Returns the complementary color (180 degrees on the color wheel)."""
254
+ return self.to_hsla().complementary().to_rgba()
255
+
256
+ @staticmethod
257
+ def _rgb_to_hsl(r: int, g: int, b: int) -> tuple:
258
+ """Internal method to convert RGB to HSL color space."""
259
+ _r, _g, _b = r / 255.0, g / 255.0, b / 255.0
260
+ max_c, min_c = max(_r, _g, _b), min(_r, _g, _b)
261
+ l = (max_c + min_c) / 2
262
+
263
+ if max_c == min_c:
264
+ h = s = 0.0
265
+ else:
266
+ delta = max_c - min_c
267
+ s = delta / (1 - abs(2 * l - 1))
268
+
269
+ if max_c == _r:
270
+ h = ((_g - _b) / delta) % 6
271
+ elif max_c == _g:
272
+ h = ((_b - _r) / delta) + 2
273
+ else:
274
+ h = ((_r - _g) / delta) + 4
275
+ h /= 6
276
+
277
+ return int(round(h * 360)), int(round(s * 100)), int(round(l * 100))
278
+
279
+
280
+ class hsla:
281
+ """A HSL/HSLA color object that includes a bunch of methods to manipulate the color.\n
282
+ ---------------------------------------------------------------------------------------
283
+ - `h` -⠀the hue channel in range [0, 360] inclusive
284
+ - `s` -⠀the saturation channel in range [0, 100] inclusive
285
+ - `l` -⠀the lightness channel in range [0, 100] inclusive
286
+ - `a` -⠀the alpha channel in range [0.0, 1.0] inclusive
287
+ or `None` if the color has no alpha channel\n
288
+ ---------------------------------------------------------------------------------------
289
+ Includes methods:
290
+ - `to_rgba()` to convert to RGB color
291
+ - `to_hexa()` to convert to HEX color
292
+ - `has_alpha()` to check if the color has an alpha channel
293
+ - `lighten(amount)` to create a lighter version of the color
294
+ - `darken(amount)` to create a darker version of the color
295
+ - `saturate(amount)` to increase color saturation
296
+ - `desaturate(amount)` to decrease color saturation
297
+ - `rotate(degrees)` to rotate the hue by degrees
298
+ - `invert()` to get the inverse color
299
+ - `grayscale()` to convert to grayscale
300
+ - `blend(other, ratio)` to blend with another color
301
+ - `is_dark()` to check if the color is considered dark
302
+ - `is_light()` to check if the color is considered light
303
+ - `is_grayscale()` to check if the color is grayscale
304
+ - `is_opaque()` to check if the color has no transparency
305
+ - `with_alpha(alpha)` to create a new color with different alpha
306
+ - `complementary()` to get the complementary color"""
307
+
308
+ def __init__(self, h: int, s: int, l: int, a: Optional[float] = None, _validate: bool = True):
309
+ self.h: int
310
+ """The hue channel in range [0, 360] inclusive."""
311
+ self.s: int
312
+ """The saturation channel in range [0, 100] inclusive."""
313
+ self.l: int
314
+ """The lightness channel in range [0, 100] inclusive."""
315
+ self.a: Optional[float]
316
+ """The alpha channel in range [0.0, 1.0] inclusive or `None` if not set."""
317
+
318
+ if not _validate:
319
+ self.h, self.s, self.l, self.a = h, s, l, a
320
+ return
321
+
322
+ if not (0 <= h <= 360):
323
+ raise ValueError(f"The 'h' parameter must be in range [0, 360] inclusive, got {h!r}")
324
+ if not all((0 <= x <= 100) for x in (s, l)):
325
+ raise ValueError(f"The 's' and 'l' parameters must be in range [0, 100] inclusive, got {s=} {l=}")
326
+ if a is not None and not (0.0 <= a <= 1.0):
327
+ raise ValueError(f"The 'a' parameter must be in range [0.0, 1.0] inclusive, got {a!r}")
328
+
329
+ self.h, self.s, self.l = h, s, l
330
+ self.a = None if a is None else (1.0 if a > 1.0 else float(a))
331
+
332
+ def __len__(self) -> int:
333
+ """The number of components in the color (3 or 4)."""
334
+ return 3 if self.a is None else 4
335
+
336
+ def __iter__(self) -> Iterator:
337
+ return iter((self.h, self.s, self.l) + (() if self.a is None else (self.a, )))
338
+
339
+ def __getitem__(self, index: int) -> int | float:
340
+ return ((self.h, self.s, self.l) + (() if self.a is None else (self.a, )))[index]
341
+
342
+ def __eq__(self, other: object) -> bool:
343
+ """Check if two `hsla` objects are the same color."""
344
+ if not isinstance(other, hsla):
345
+ return False
346
+ return (self.h, self.s, self.l, self.a) == (other.h, other.s, other.l, other.a)
347
+
348
+ def __ne__(self, other: object) -> bool:
349
+ """Check if two `hsla` objects are different colors."""
350
+ return not self.__eq__(other)
351
+
352
+ def __repr__(self) -> str:
353
+ return f"hsla({self.h}°, {self.s}%, {self.l}%{'' if self.a is None else f', {self.a}'})"
354
+
355
+ def __str__(self) -> str:
356
+ return self.__repr__()
357
+
358
+ def dict(self) -> dict:
359
+ """Returns the color components as a dictionary with keys `"h"`, `"s"`, `"l"` and optionally `"a"`."""
360
+ return dict(h=self.h, s=self.s, l=self.l) if self.a is None else dict(h=self.h, s=self.s, l=self.l, a=self.a)
361
+
362
+ def values(self) -> tuple:
363
+ """Returns the color components as separate values `h, s, l, a`."""
364
+ return self.h, self.s, self.l, self.a
365
+
366
+ def to_rgba(self) -> "rgba":
367
+ """Returns the color as `rgba()` color object."""
368
+ r, g, b = self._hsl_to_rgb(self.h, self.s, self.l)
369
+ return rgba(r, g, b, self.a, _validate=False)
370
+
371
+ def to_hexa(self) -> "hexa":
372
+ """Returns the color as `hexa()` color object."""
373
+ r, g, b = self._hsl_to_rgb(self.h, self.s, self.l)
374
+ return hexa("", r, g, b, self.a)
375
+
376
+ def has_alpha(self) -> bool:
377
+ """Returns `True` if the color has an alpha channel and `False` otherwise."""
378
+ return self.a is not None
379
+
380
+ def lighten(self, amount: float) -> "hsla":
381
+ """Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive."""
382
+ if not isinstance(amount, float):
383
+ raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
384
+ elif not (0.0 <= amount <= 1.0):
385
+ raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
386
+
387
+ self.l = int(min(100, self.l + (100 - self.l) * amount))
388
+ return hsla(self.h, self.s, self.l, self.a, _validate=False)
389
+
390
+ def darken(self, amount: float) -> "hsla":
391
+ """Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive."""
392
+ if not isinstance(amount, float):
393
+ raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
394
+ elif not (0.0 <= amount <= 1.0):
395
+ raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
396
+
397
+ self.l = int(max(0, self.l * (1 - amount)))
398
+ return hsla(self.h, self.s, self.l, self.a, _validate=False)
399
+
400
+ def saturate(self, amount: float) -> "hsla":
401
+ """Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive."""
402
+ if not isinstance(amount, float):
403
+ raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
404
+ elif not (0.0 <= amount <= 1.0):
405
+ raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
406
+
407
+ self.s = int(min(100, self.s + (100 - self.s) * amount))
408
+ return hsla(self.h, self.s, self.l, self.a, _validate=False)
409
+
410
+ def desaturate(self, amount: float) -> "hsla":
411
+ """Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive."""
412
+ if not isinstance(amount, float):
413
+ raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
414
+ elif not (0.0 <= amount <= 1.0):
415
+ raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
416
+
417
+ self.s = int(max(0, self.s * (1 - amount)))
418
+ return hsla(self.h, self.s, self.l, self.a, _validate=False)
419
+
420
+ def rotate(self, degrees: int) -> "hsla":
421
+ """Rotates the colors hue by the specified number of degrees."""
422
+ if not isinstance(degrees, int):
423
+ raise TypeError(f"The 'degrees' parameter must be an integer, got {type(degrees)}")
424
+
425
+ self.h = (self.h + degrees) % 360
426
+ return hsla(self.h, self.s, self.l, self.a, _validate=False)
427
+
428
+ def invert(self, invert_alpha: bool = False) -> "hsla":
429
+ """Inverts the color by rotating hue by 180 degrees and inverting lightness."""
430
+ if not isinstance(invert_alpha, bool):
431
+ raise TypeError(f"The 'invert_alpha' parameter must be a boolean, got {type(invert_alpha)}")
432
+
433
+ self.h = (self.h + 180) % 360
434
+ self.l = 100 - self.l
435
+ if invert_alpha and self.a is not None:
436
+ self.a = 1 - self.a
437
+
438
+ return hsla(self.h, self.s, self.l, self.a, _validate=False)
439
+
440
+ def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> "hsla":
441
+ """Converts the color to grayscale using the luminance formula.\n
442
+ ---------------------------------------------------------------------------
443
+ - `method` -⠀the luminance calculation method to use:
444
+ * `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
445
+ * `"wcag3"` Draft WCAG 3.0 standard with improved coefficients
446
+ * `"simple"` Simple arithmetic mean (less accurate)
447
+ * `"bt601"` ITU-R BT.601 standard (older TV standard)"""
448
+ # THE 'method' PARAM IS CHECKED IN 'Color.luminance()'
449
+ r, g, b = self._hsl_to_rgb(self.h, self.s, self.l)
450
+ l = int(Color.luminance(r, g, b, output_type=None, method=method))
451
+ self.h, self.s, self.l, _ = rgba(l, l, l, _validate=False).to_hsla().values()
452
+ return hsla(self.h, self.s, self.l, self.a, _validate=False)
453
+
454
+ def blend(self, other: Hsla, ratio: float = 0.5, additive_alpha: bool = False) -> "hsla":
455
+ """Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n
456
+ ----------------------------------------------------------------------------------------------------------
457
+ - `other` -⠀the other HSLA color to blend with
458
+ - `ratio` -⠀the blend ratio between the two colors:
459
+ * if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture)
460
+ * if `ratio` is `0.5` it means 50% of both colors (1:1 mixture)
461
+ * if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture)
462
+ - `additive_alpha` -⠀whether to blend the alpha channels additively or not"""
463
+ if not Color.is_valid_hsla(other):
464
+ raise TypeError(f"The 'other' parameter must be a valid HSLA color, got {type(other)}")
465
+ if not isinstance(ratio, float):
466
+ raise TypeError(f"The 'ratio' parameter must be a float, got {type(ratio)}")
467
+ elif not (0.0 <= ratio <= 1.0):
468
+ raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}")
469
+ if not isinstance(additive_alpha, bool):
470
+ raise TypeError(f"The 'additive_alpha' parameter must be a boolean, got {type(additive_alpha)}")
471
+
472
+ self.h, self.s, self.l, self.a = self.to_rgba().blend(Color.to_rgba(other), ratio, additive_alpha).to_hsla().values()
473
+ return hsla(self.h, self.s, self.l, self.a, _validate=False)
474
+
475
+ def is_dark(self) -> bool:
476
+ """Returns `True` if the color is considered dark (`lightness < 50%`)."""
477
+ return self.l < 50
478
+
479
+ def is_light(self) -> bool:
480
+ """Returns `True` if the color is considered light (`lightness >= 50%`)."""
481
+ return not self.is_dark()
482
+
483
+ def is_grayscale(self) -> bool:
484
+ """Returns `True` if the color is considered grayscale."""
485
+ return self.s == 0
486
+
487
+ def is_opaque(self) -> bool:
488
+ """Returns `True` if the color has no transparency."""
489
+ return self.a == 1 or self.a is None
490
+
491
+ def with_alpha(self, alpha: float) -> "hsla":
492
+ """Returns a new color with the specified alpha value."""
493
+ if not isinstance(alpha, float):
494
+ raise TypeError(f"The 'alpha' parameter must be a float, got {type(alpha)}")
495
+ elif not (0.0 <= alpha <= 1.0):
496
+ raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}")
497
+
498
+ return hsla(self.h, self.s, self.l, alpha, _validate=False)
499
+
500
+ def complementary(self) -> "hsla":
501
+ """Returns the complementary color (180 degrees on the color wheel)."""
502
+ return hsla((self.h + 180) % 360, self.s, self.l, self.a, _validate=False)
503
+
504
+ @classmethod
505
+ def _hsl_to_rgb(cls, h: int, s: int, l: int) -> tuple:
506
+ """Internal method to convert HSL to RGB color space."""
507
+ _h, _s, _l = h / 360, s / 100, l / 100
508
+
509
+ if _s == 0:
510
+ r = g = b = int(_l * 255)
511
+ else:
512
+ q = _l * (1 + _s) if _l < 0.5 else _l + _s - _l * _s
513
+ p = 2 * _l - q
514
+ r = int(round(cls._hue_to_rgb(p, q, _h + 1 / 3) * 255))
515
+ g = int(round(cls._hue_to_rgb(p, q, _h) * 255))
516
+ b = int(round(cls._hue_to_rgb(p, q, _h - 1 / 3) * 255))
517
+
518
+ return r, g, b
519
+
520
+ @staticmethod
521
+ def _hue_to_rgb(p: float, q: float, t: float) -> float:
522
+ if t < 0:
523
+ t += 1
524
+ if t > 1:
525
+ t -= 1
526
+ if t < 1 / 6:
527
+ return p + (q - p) * 6 * t
528
+ if t < 1 / 2:
529
+ return q
530
+ if t < 2 / 3:
531
+ return p + (q - p) * (2 / 3 - t) * 6
532
+ return p
533
+
534
+
535
+ class hexa:
536
+ """A HEXA color object that includes a bunch of methods to manipulate the color.\n
537
+ --------------------------------------------------------------------------------------------
538
+ - `color` -⠀the HEXA color string (prefix optional) or HEX integer, that can be in formats:
539
+ * `RGB` short format without alpha (only for strings)
540
+ * `RGBA` short format with alpha (only for strings)
541
+ * `RRGGBB` long format without alpha (for strings and HEX integers)
542
+ * `RRGGBBAA` long format with alpha (for strings and HEX integers)
543
+ --------------------------------------------------------------------------------------------
544
+ Includes methods:
545
+ - `to_rgba()` to convert to RGB color
546
+ - `to_hsla()` to convert to HSL color
547
+ - `has_alpha()` to check if the color has an alpha channel
548
+ - `lighten(amount)` to create a lighter version of the color
549
+ - `darken(amount)` to create a darker version of the color
550
+ - `saturate(amount)` to increase color saturation
551
+ - `desaturate(amount)` to decrease color saturation
552
+ - `rotate(degrees)` to rotate the hue by degrees
553
+ - `invert()` to get the inverse color
554
+ - `grayscale()` to convert to grayscale
555
+ - `blend(other, ratio)` to blend with another color
556
+ - `is_dark()` to check if the color is considered dark
557
+ - `is_light()` to check if the color is considered light
558
+ - `is_grayscale()` to check if the color is grayscale
559
+ - `is_opaque()` to check if the color has no transparency
560
+ - `with_alpha(alpha)` to create a new color with different alpha
561
+ - `complementary()` to get the complementary color"""
562
+
563
+ def __init__(
564
+ self,
565
+ color: str | int,
566
+ _r: Optional[int] = None,
567
+ _g: Optional[int] = None,
568
+ _b: Optional[int] = None,
569
+ _a: Optional[float] = None,
570
+ ):
571
+ self.r: int
572
+ """The red channel in range [0, 255] inclusive."""
573
+ self.g: int
574
+ """The green channel in range [0, 255] inclusive."""
575
+ self.b: int
576
+ """The blue channel in range [0, 255] inclusive."""
577
+ self.a: Optional[float]
578
+ """The alpha channel in range [0.0, 1.0] inclusive or `None` if not set."""
579
+
580
+ if all(x is not None for x in (_r, _g, _b)):
581
+ self.r, self.g, self.b, self.a = cast(int, _r), cast(int, _g), cast(int, _b), _a
582
+ return
583
+
584
+ if isinstance(color, hexa):
585
+ raise ValueError("Color is already a hexa() color object.")
586
+
587
+ elif isinstance(color, str):
588
+ if color.startswith("#"):
589
+ color = color[1:].upper()
590
+ elif color.startswith("0x"):
591
+ color = color[2:].upper()
592
+
593
+ if len(color) == 3: # RGB
594
+ self.r, self.g, self.b, self.a = (
595
+ int(color[0] * 2, 16),
596
+ int(color[1] * 2, 16),
597
+ int(color[2] * 2, 16),
598
+ None,
599
+ )
600
+ elif len(color) == 4: # RGBA
601
+ self.r, self.g, self.b, self.a = (
602
+ int(color[0] * 2, 16),
603
+ int(color[1] * 2, 16),
604
+ int(color[2] * 2, 16),
605
+ int(color[3] * 2, 16) / 255.0,
606
+ )
607
+ elif len(color) == 6: # RRGGBB
608
+ self.r, self.g, self.b, self.a = (
609
+ int(color[0:2], 16),
610
+ int(color[2:4], 16),
611
+ int(color[4:6], 16),
612
+ None,
613
+ )
614
+ elif len(color) == 8: # RRGGBBAA
615
+ self.r, self.g, self.b, self.a = (
616
+ int(color[0:2], 16),
617
+ int(color[2:4], 16),
618
+ int(color[4:6], 16),
619
+ int(color[6:8], 16) / 255.0,
620
+ )
621
+ else:
622
+ raise ValueError(f"Invalid HEXA color string '{color}'. Must be in formats RGB, RGBA, RRGGBB or RRGGBBAA.")
623
+
624
+ elif isinstance(color, int):
625
+ self.r, self.g, self.b, self.a = Color.hex_int_to_rgba(color).values()
626
+ else:
627
+ raise TypeError(f"The 'color' parameter must be a string or integer, got {type(color)}")
628
+
629
+ def __len__(self) -> int:
630
+ """The number of components in the color (3 or 4)."""
631
+ return 3 if self.a is None else 4
632
+
633
+ def __iter__(self) -> Iterator:
634
+ return iter((f"{self.r:02X}", f"{self.g:02X}", f"{self.b:02X}")
635
+ + (() if self.a is None else (f"{int(self.a * 255):02X}", )))
636
+
637
+ def __getitem__(self, index: int) -> str | int:
638
+ return ((f"{self.r:02X}", f"{self.g:02X}", f"{self.b:02X}") \
639
+ + (() if self.a is None else (f"{int(self.a * 255):02X}", )))[index]
640
+
641
+ def __eq__(self, other: object) -> bool:
642
+ """Check if two `hexa` objects are the same color."""
643
+ if not isinstance(other, hexa):
644
+ return False
645
+ return (self.r, self.g, self.b, self.a) == (other.r, other.g, other.b, other.a)
646
+
647
+ def __ne__(self, other: object) -> bool:
648
+ """Check if two `hexa` objects are different colors."""
649
+ return not self.__eq__(other)
650
+
651
+ def __repr__(self) -> str:
652
+ return f"hexa(#{self.r:02X}{self.g:02X}{self.b:02X}{'' if self.a is None else f'{int(self.a * 255):02X}'})"
653
+
654
+ def __str__(self) -> str:
655
+ return f"#{self.r:02X}{self.g:02X}{self.b:02X}{'' if self.a is None else f'{int(self.a * 255):02X}'}"
656
+
657
+ def dict(self) -> dict:
658
+ """Returns the color components as a dictionary with hex string values for keys `"r"`, `"g"`, `"b"` and optionally `"a"`."""
659
+ return (
660
+ dict(r=f"{self.r:02X}", g=f"{self.g:02X}", b=f"{self.b:02X}") if self.a is None else dict(
661
+ r=f"{self.r:02X}",
662
+ g=f"{self.g:02X}",
663
+ b=f"{self.b:02X}",
664
+ a=f"{int(self.a * 255):02X}",
665
+ )
666
+ )
667
+
668
+ def values(self, round_alpha: bool = True) -> tuple:
669
+ """Returns the color components as separate values `r, g, b, a`."""
670
+ if not isinstance(round_alpha, bool):
671
+ raise TypeError(f"The 'round_alpha' parameter must be a boolean, got {type(round_alpha)}")
672
+
673
+ return self.r, self.g, self.b, None if self.a is None else (round(self.a, 2) if round_alpha else self.a)
674
+
675
+ def to_rgba(self, round_alpha: bool = True) -> "rgba":
676
+ """Returns the color as `rgba()` color object."""
677
+ if not isinstance(round_alpha, bool):
678
+ raise TypeError(f"The 'round_alpha' parameter must be a boolean, got {type(round_alpha)}")
679
+
680
+ return rgba(
681
+ self.r,
682
+ self.g,
683
+ self.b,
684
+ None if self.a is None else (round(self.a, 2) if round_alpha else self.a),
685
+ _validate=False,
686
+ )
687
+
688
+ def to_hsla(self, round_alpha: bool = True) -> "hsla":
689
+ """Returns the color as `hsla()` color object."""
690
+ if not isinstance(round_alpha, bool):
691
+ raise TypeError(f"The 'round_alpha' parameter must be a boolean, got {type(round_alpha)}")
692
+
693
+ return self.to_rgba(round_alpha).to_hsla()
694
+
695
+ def has_alpha(self) -> bool:
696
+ """Returns `True` if the color has an alpha channel and `False` otherwise."""
697
+ return self.a is not None
698
+
699
+ def lighten(self, amount: float) -> "hexa":
700
+ """Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive."""
701
+ if not isinstance(amount, float):
702
+ raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
703
+ elif not (0.0 <= amount <= 1.0):
704
+ raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
705
+
706
+ self.r, self.g, self.b, self.a = self.to_rgba(False).lighten(amount).values()
707
+ return hexa("", self.r, self.g, self.b, self.a)
708
+
709
+ def darken(self, amount: float) -> "hexa":
710
+ """Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive."""
711
+ if not isinstance(amount, float):
712
+ raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
713
+ elif not (0.0 <= amount <= 1.0):
714
+ raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
715
+
716
+ self.r, self.g, self.b, self.a = self.to_rgba(False).darken(amount).values()
717
+ return hexa("", self.r, self.g, self.b, self.a)
718
+
719
+ def saturate(self, amount: float) -> "hexa":
720
+ """Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive."""
721
+ if not isinstance(amount, float):
722
+ raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
723
+ elif not (0.0 <= amount <= 1.0):
724
+ raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
725
+
726
+ self.r, self.g, self.b, self.a = self.to_rgba(False).saturate(amount).values()
727
+ return hexa("", self.r, self.g, self.b, self.a)
728
+
729
+ def desaturate(self, amount: float) -> "hexa":
730
+ """Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive."""
731
+ if not isinstance(amount, float):
732
+ raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
733
+ elif not (0.0 <= amount <= 1.0):
734
+ raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
735
+
736
+ self.r, self.g, self.b, self.a = self.to_rgba(False).desaturate(amount).values()
737
+ return hexa("", self.r, self.g, self.b, self.a)
738
+
739
+ def rotate(self, degrees: int) -> "hexa":
740
+ """Rotates the colors hue by the specified number of degrees."""
741
+ if not isinstance(degrees, int):
742
+ raise TypeError(f"The 'degrees' parameter must be an integer, got {type(degrees)}")
743
+
744
+ self.r, self.g, self.b, self.a = self.to_rgba(False).rotate(degrees).values()
745
+ return hexa("", self.r, self.g, self.b, self.a)
746
+
747
+ def invert(self, invert_alpha: bool = False) -> "hexa":
748
+ """Inverts the color by rotating hue by 180 degrees and inverting lightness."""
749
+ if not isinstance(invert_alpha, bool):
750
+ raise TypeError(f"The 'invert_alpha' parameter must be a boolean, got {type(invert_alpha)}")
751
+
752
+ self.r, self.g, self.b, self.a = self.to_rgba(False).invert().values()
753
+ if invert_alpha and self.a is not None:
754
+ self.a = 1 - self.a
755
+
756
+ return hexa("", self.r, self.g, self.b, self.a)
757
+
758
+ def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> "hexa":
759
+ """Converts the color to grayscale using the luminance formula.\n
760
+ ---------------------------------------------------------------------------
761
+ - `method` -⠀the luminance calculation method to use:
762
+ * `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
763
+ * `"wcag3"` Draft WCAG 3.0 standard with improved coefficients
764
+ * `"simple"` Simple arithmetic mean (less accurate)
765
+ * `"bt601"` ITU-R BT.601 standard (older TV standard)"""
766
+ # THE 'method' PARAM IS CHECKED IN 'Color.luminance()'
767
+ self.r = self.g = self.b = int(Color.luminance(self.r, self.g, self.b, method=method))
768
+ return hexa("", self.r, self.g, self.b, self.a)
769
+
770
+ def blend(self, other: Hexa, ratio: float = 0.5, additive_alpha: bool = False) -> "hexa":
771
+ """Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n
772
+ ----------------------------------------------------------------------------------------------------------
773
+ - `other` -⠀the other HEXA color to blend with
774
+ - `ratio` -⠀the blend ratio between the two colors:
775
+ * if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture)
776
+ * if `ratio` is `0.5` it means 50% of both colors (1:1 mixture)
777
+ * if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture)
778
+ - `additive_alpha` -⠀whether to blend the alpha channels additively or not"""
779
+ if not Color.is_valid_hexa(other):
780
+ raise TypeError(f"The 'other' parameter must be a valid HEXA color, got {type(other)}")
781
+ if not isinstance(ratio, float):
782
+ raise TypeError(f"The 'ratio' parameter must be a float, got {type(ratio)}")
783
+ elif not (0.0 <= ratio <= 1.0):
784
+ raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}")
785
+ if not isinstance(additive_alpha, bool):
786
+ raise TypeError(f"The 'additive_alpha' parameter must be a boolean, got {type(additive_alpha)}")
787
+
788
+ self.r, self.g, self.b, self.a = self.to_rgba(False).blend(Color.to_rgba(other), ratio, additive_alpha).values()
789
+ return hexa("", self.r, self.g, self.b, self.a)
790
+
791
+ def is_dark(self) -> bool:
792
+ """Returns `True` if the color is considered dark (`lightness < 50%`)."""
793
+ return self.to_hsla(False).is_dark()
794
+
795
+ def is_light(self) -> bool:
796
+ """Returns `True` if the color is considered light (`lightness >= 50%`)."""
797
+ return not self.is_dark()
798
+
799
+ def is_grayscale(self) -> bool:
800
+ """Returns `True` if the color is grayscale (`saturation == 0`)."""
801
+ return self.to_hsla(False).is_grayscale()
802
+
803
+ def is_opaque(self) -> bool:
804
+ """Returns `True` if the color has no transparency (`alpha == 1.0`)."""
805
+ return self.a == 1 or self.a is None
806
+
807
+ def with_alpha(self, alpha: float) -> "hexa":
808
+ """Returns a new color with the specified alpha value."""
809
+ if not isinstance(alpha, float):
810
+ raise TypeError(f"The 'alpha' parameter must be a float, got {type(alpha)}")
811
+ elif not (0.0 <= alpha <= 1.0):
812
+ raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}")
813
+
814
+ return hexa("", self.r, self.g, self.b, alpha)
815
+
816
+ def complementary(self) -> "hexa":
817
+ """Returns the complementary color (180 degrees on the color wheel)."""
818
+ return self.to_hsla(False).complementary().to_hexa()
819
+
820
+
821
+ class Color:
822
+ """This class includes methods to work with colors in different formats."""
823
+
824
+ @classmethod
825
+ def is_valid_rgba(cls, color: AnyRgba, allow_alpha: bool = True) -> bool:
826
+ """Check if the given color is a valid RGBA color.\n
827
+ -----------------------------------------------------------------
828
+ - `color` -⠀the color to check (can be in any supported format)
829
+ - `allow_alpha` -⠀whether to allow alpha channel in the color"""
830
+ if not isinstance(allow_alpha, bool):
831
+ raise TypeError(f"The 'new_tab_size' parameter must be an boolean, got {type(allow_alpha)}")
832
+
833
+ try:
834
+ if isinstance(color, rgba):
835
+ return True
836
+
837
+ elif isinstance(color, (list, tuple)):
838
+ if allow_alpha and cls.has_alpha(color):
839
+ return (
840
+ 0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255
841
+ and (0 <= color[3] <= 1 or color[3] is None)
842
+ )
843
+ elif len(color) == 3:
844
+ return 0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255
845
+ else:
846
+ return False
847
+
848
+ elif isinstance(color, dict):
849
+ if allow_alpha and cls.has_alpha(color):
850
+ return (
851
+ 0 <= color["r"] <= 255 and 0 <= color["g"] <= 255 and 0 <= color["b"] <= 255
852
+ and (0 <= color["a"] <= 1 or color["a"] is None)
853
+ )
854
+ elif len(color) == 3:
855
+ return 0 <= color["r"] <= 255 and 0 <= color["g"] <= 255 and 0 <= color["b"] <= 255
856
+ else:
857
+ return False
858
+
859
+ elif isinstance(color, str):
860
+ return bool(_re.fullmatch(Regex.rgba_str(fix_sep=None, allow_alpha=allow_alpha), color))
861
+
862
+ except Exception:
863
+ pass
864
+ return False
865
+
866
+ @classmethod
867
+ def is_valid_hsla(cls, color: AnyHsla, allow_alpha: bool = True) -> bool:
868
+ """Check if the given color is a valid HSLA color.\n
869
+ -----------------------------------------------------------------
870
+ - `color` -⠀the color to check (can be in any supported format)
871
+ - `allow_alpha` -⠀whether to allow alpha channel in the color"""
872
+ try:
873
+ if isinstance(color, hsla):
874
+ return True
875
+
876
+ elif isinstance(color, (list, tuple)):
877
+ if allow_alpha and cls.has_alpha(color):
878
+ return (
879
+ 0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100
880
+ and (0 <= color[3] <= 1 or color[3] is None)
881
+ )
882
+ elif len(color) == 3:
883
+ return 0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100
884
+ else:
885
+ return False
886
+
887
+ elif isinstance(color, dict):
888
+ if allow_alpha and cls.has_alpha(color):
889
+ return (
890
+ 0 <= color["h"] <= 360 and 0 <= color["s"] <= 100 and 0 <= color["l"] <= 100
891
+ and (0 <= color["a"] <= 1 or color["a"] is None)
892
+ )
893
+ elif len(color) == 3:
894
+ return 0 <= color["h"] <= 360 and 0 <= color["s"] <= 100 and 0 <= color["l"] <= 100
895
+ else:
896
+ return False
897
+
898
+ elif isinstance(color, str):
899
+ return bool(_re.fullmatch(Regex.hsla_str(fix_sep=None, allow_alpha=allow_alpha), color))
900
+
901
+ except Exception:
902
+ pass
903
+ return False
904
+
905
+ @classmethod
906
+ def is_valid_hexa(
907
+ cls,
908
+ color: AnyHexa,
909
+ allow_alpha: bool = True,
910
+ get_prefix: bool = False,
911
+ ) -> bool | tuple[bool, Optional[Literal["#", "0x"]]]:
912
+ """Check if the given color is a valid HEXA color.\n
913
+ ---------------------------------------------------------------------------------------------------
914
+ - `color` -⠀the color to check (can be in any supported format)
915
+ - `allow_alpha` -⠀whether to allow alpha channel in the color
916
+ - `get_prefix` -⠀if true, the prefix used in the color (if any) is returned along with validity"""
917
+ try:
918
+ if isinstance(color, hexa):
919
+ return (True, "#") if get_prefix else True
920
+
921
+ elif isinstance(color, int):
922
+ is_valid = 0x000000 <= color <= (0xFFFFFFFF if allow_alpha else 0xFFFFFF)
923
+ return (is_valid, "0x") if get_prefix else is_valid
924
+
925
+ elif isinstance(color, str):
926
+ prefix: Optional[Literal["#", "0x"]]
927
+ color, prefix = ((color[1:], "#") if color.startswith("#") else
928
+ (color[2:], "0x") if color.startswith("0x") else (color, None))
929
+ return (
930
+ (bool(_re.fullmatch(Regex.hexa_str(allow_alpha=allow_alpha), color)), prefix) \
931
+ if get_prefix else bool(_re.fullmatch(Regex.hexa_str(allow_alpha=allow_alpha), color))
932
+ )
933
+
934
+ except Exception:
935
+ pass
936
+ return (False, None) if get_prefix else False
937
+
938
+ @classmethod
939
+ def is_valid(cls, color: AnyRgba | AnyHsla | AnyHexa, allow_alpha: bool = True) -> bool:
940
+ """Check if the given color is a valid RGBA, HSLA or HEXA color.\n
941
+ -------------------------------------------------------------------
942
+ - `color` -⠀the color to check (can be in any supported format)
943
+ - `allow_alpha` -⠀whether to allow alpha channel in the color"""
944
+ return bool(
945
+ cls.is_valid_rgba(color, allow_alpha) \
946
+ or cls.is_valid_hsla(color, allow_alpha) \
947
+ or cls.is_valid_hexa(color, allow_alpha)
948
+ )
949
+
950
+ @classmethod
951
+ def has_alpha(cls, color: Rgba | Hsla | Hexa) -> bool:
952
+ """Check if the given color has an alpha channel.\n
953
+ ---------------------------------------------------------------------------
954
+ - `color` -⠀the color to check (can be in any supported format)"""
955
+ if isinstance(color, (rgba, hsla, hexa)):
956
+ return color.has_alpha()
957
+
958
+ if cls.is_valid_hexa(color):
959
+ if isinstance(color, str):
960
+ if color.startswith("#"):
961
+ color = color[1:]
962
+ elif color.startswith("0x"):
963
+ color = color[2:]
964
+ return len(color) == 4 or len(color) == 8
965
+ if isinstance(color, int):
966
+ hex_length = len(f"{color:X}")
967
+ return hex_length == 4 or hex_length == 8
968
+
969
+ elif isinstance(color, str):
970
+ if parsed_rgba := cls.str_to_rgba(color, only_first=True):
971
+ return cast(rgba, parsed_rgba).has_alpha()
972
+ if parsed_hsla := cls.str_to_hsla(color, only_first=True):
973
+ return cast(hsla, parsed_hsla).has_alpha()
974
+
975
+ elif isinstance(color, (list, tuple)) and len(color) == 4 and color[3] is not None:
976
+ return True
977
+ elif isinstance(color, dict) and len(color) == 4 and color["a"] is not None:
978
+ return True
979
+
980
+ return False
981
+
982
+ @classmethod
983
+ def to_rgba(cls, color: Rgba | Hsla | Hexa) -> rgba:
984
+ """Will try to convert any color type to a color of type RGBA.\n
985
+ ---------------------------------------------------------------------
986
+ - `color` -⠀the color to convert (can be in any supported format)"""
987
+ if isinstance(color, (hsla, hexa)):
988
+ return color.to_rgba()
989
+ elif cls.is_valid_hsla(color):
990
+ return cls._parse_hsla(color).to_rgba()
991
+ elif cls.is_valid_hexa(color):
992
+ return hexa(cast(str | int, color)).to_rgba()
993
+ elif cls.is_valid_rgba(color):
994
+ return cls._parse_rgba(color)
995
+ raise ValueError(f"Could not convert color {color!r} to RGBA.")
996
+
997
+ @classmethod
998
+ def to_hsla(cls, color: Rgba | Hsla | Hexa) -> hsla:
999
+ """Will try to convert any color type to a color of type HSLA.\n
1000
+ ---------------------------------------------------------------------
1001
+ - `color` -⠀the color to convert (can be in any supported format)"""
1002
+ if isinstance(color, (rgba, hexa)):
1003
+ return color.to_hsla()
1004
+ elif cls.is_valid_rgba(color):
1005
+ return cls._parse_rgba(color).to_hsla()
1006
+ elif cls.is_valid_hexa(color):
1007
+ return hexa(cast(str | int, color)).to_hsla()
1008
+ elif cls.is_valid_hsla(color):
1009
+ return cls._parse_hsla(color)
1010
+ raise ValueError(f"Could not convert color {color!r} to HSLA.")
1011
+
1012
+ @classmethod
1013
+ def to_hexa(cls, color: Rgba | Hsla | Hexa) -> hexa:
1014
+ """Will try to convert any color type to a color of type HEXA.\n
1015
+ ---------------------------------------------------------------------
1016
+ - `color` -⠀the color to convert (can be in any supported format)"""
1017
+ if isinstance(color, (rgba, hsla)):
1018
+ return color.to_hexa()
1019
+ elif cls.is_valid_rgba(color):
1020
+ return cls._parse_rgba(color).to_hexa()
1021
+ elif cls.is_valid_hsla(color):
1022
+ return cls._parse_hsla(color).to_hexa()
1023
+ elif cls.is_valid_hexa(color):
1024
+ return color if isinstance(color, hexa) else hexa(cast(str | int, color))
1025
+ raise ValueError(f"Could not convert color {color!r} to HEXA")
1026
+
1027
+ @classmethod
1028
+ def str_to_rgba(cls, string: str, only_first: bool = False) -> Optional[rgba | list[rgba]]:
1029
+ """Will try to recognize RGBA colors inside a string and output the found ones as RGBA objects.\n
1030
+ ---------------------------------------------------------------------------------------------------------------
1031
+ - `string` -⠀the string to search for RGBA colors
1032
+ - `only_first` -⠀if true, only the first found color will be returned, otherwise a list of all found colors"""
1033
+ if only_first:
1034
+ if not (match := _re.search(Regex.rgba_str(allow_alpha=True), string)):
1035
+ return None
1036
+ m = match.groups()
1037
+ return rgba(
1038
+ int(m[0]),
1039
+ int(m[1]),
1040
+ int(m[2]),
1041
+ ((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None),
1042
+ _validate=False,
1043
+ )
1044
+
1045
+ else:
1046
+ if not (matches := _re.findall(Regex.rgba_str(allow_alpha=True), string)):
1047
+ return None
1048
+ return [
1049
+ rgba(
1050
+ int(m[0]),
1051
+ int(m[1]),
1052
+ int(m[2]),
1053
+ ((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None),
1054
+ _validate=False,
1055
+ ) for m in matches
1056
+ ]
1057
+
1058
+ @classmethod
1059
+ def str_to_hsla(cls, string: str, only_first: bool = False) -> Optional[hsla | list[hsla]]:
1060
+ """Will try to recognize HSLA colors inside a string and output the found ones as HSLA objects.\n
1061
+ ---------------------------------------------------------------------------------------------------------------
1062
+ - `string` -⠀the string to search for HSLA colors
1063
+ - `only_first` -⠀if true, only the first found color will be returned, otherwise a list of all found colors"""
1064
+ if only_first:
1065
+ if not (match := _re.search(Regex.hsla_str(allow_alpha=True), string)):
1066
+ return None
1067
+ m = match.groups()
1068
+ return hsla(
1069
+ int(m[0]),
1070
+ int(m[1]),
1071
+ int(m[2]),
1072
+ ((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None),
1073
+ _validate=False,
1074
+ )
1075
+
1076
+ else:
1077
+ if not (matches := _re.findall(Regex.hsla_str(allow_alpha=True), string)):
1078
+ return None
1079
+ return [
1080
+ hsla(
1081
+ int(m[0]),
1082
+ int(m[1]),
1083
+ int(m[2]),
1084
+ ((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None),
1085
+ _validate=False,
1086
+ ) for m in matches
1087
+ ]
1088
+
1089
+ @classmethod
1090
+ def rgba_to_hex_int(
1091
+ cls,
1092
+ r: int,
1093
+ g: int,
1094
+ b: int,
1095
+ a: Optional[float] = None,
1096
+ preserve_original: bool = False,
1097
+ ) -> int:
1098
+ """Convert RGBA channels to a HEXA integer (alpha is optional).\n
1099
+ --------------------------------------------------------------------------------------------
1100
+ - `r`, `g`, `b` -⠀the red, green and blue channels (`0` – `255`)
1101
+ - `a` -⠀the alpha channel (`0.0` – `1.0`) or `None` if not set
1102
+ - `preserve_original` -⠀whether to preserve the original color exactly (explained below)\n
1103
+ --------------------------------------------------------------------------------------------
1104
+ To preserve leading zeros, the function will add a `1` at the beginning, if the HEX integer
1105
+ would start with a `0`.
1106
+ This could affect the color a little bit, but will make sure, that it won't be interpreted
1107
+ as a completely different color, when initializing it as a `hexa()` color or changing it
1108
+ back to RGBA using `Color.hex_int_to_rgba()`."""
1109
+ if not all((0 <= c <= 255) for c in (r, g, b)):
1110
+ raise ValueError(f"The 'r', 'g' and 'b' parameters must be integers in [0, 255], got {r=} {g=} {b=}")
1111
+ if a is not None and not (0.0 <= a <= 1.0):
1112
+ raise ValueError(f"The 'a' parameter must be a float in [0.0, 1.0] or None, got {a!r}")
1113
+
1114
+ r = max(0, min(255, int(r)))
1115
+ g = max(0, min(255, int(g)))
1116
+ b = max(0, min(255, int(b)))
1117
+
1118
+ if a is None:
1119
+ hex_int = (r << 16) | (g << 8) | b
1120
+ if not preserve_original and (hex_int & 0xF00000) == 0:
1121
+ hex_int |= 0x010000
1122
+ else:
1123
+ a = max(0, min(255, int(a * 255)))
1124
+ hex_int = (r << 24) | (g << 16) | (b << 8) | a
1125
+ if not preserve_original and r == 0:
1126
+ hex_int |= 0x01000000
1127
+
1128
+ return hex_int
1129
+
1130
+ @classmethod
1131
+ def hex_int_to_rgba(cls, hex_int: int, preserve_original: bool = False) -> rgba:
1132
+ """Convert a HEX integer to RGBA channels.\n
1133
+ -------------------------------------------------------------------------------------------
1134
+ - `hex_int` -⠀the HEX integer to convert
1135
+ - `preserve_original` -⠀whether to preserve the original color exactly (explained below)\n
1136
+ -------------------------------------------------------------------------------------------
1137
+ If the red channel is `1` after conversion, it will be set to `0`, because when converting
1138
+ from RGBA to a HEX integer, the first `0` will be set to `1` to preserve leading zeros.
1139
+ This is the correction, so the color doesn't even look slightly different."""
1140
+ if not (0 <= hex_int <= 0xFFFFFFFF):
1141
+ raise ValueError(f"Expected HEX integer in range [0x000000, 0xFFFFFFFF] inclusive, got 0x{hex_int:X}")
1142
+
1143
+ if len(hex_str := f"{hex_int:X}") <= 6:
1144
+ hex_str = hex_str.zfill(6)
1145
+ return rgba(
1146
+ r if (r := int(hex_str[0:2], 16)) != 1 or preserve_original else 0,
1147
+ int(hex_str[2:4], 16),
1148
+ int(hex_str[4:6], 16),
1149
+ None,
1150
+ _validate=False,
1151
+ )
1152
+
1153
+ elif len(hex_str) <= 8:
1154
+ hex_str = hex_str.zfill(8)
1155
+ return rgba(
1156
+ r if (r := int(hex_str[0:2], 16)) != 1 or preserve_original else 0,
1157
+ int(hex_str[2:4], 16),
1158
+ int(hex_str[4:6], 16),
1159
+ int(hex_str[6:8], 16) / 255.0,
1160
+ _validate=False,
1161
+ )
1162
+
1163
+ else:
1164
+ raise ValueError(f"Could not convert HEX integer 0x{hex_int:X} to RGBA color.")
1165
+
1166
+ @classmethod
1167
+ def luminance(
1168
+ cls,
1169
+ r: int,
1170
+ g: int,
1171
+ b: int,
1172
+ output_type: Optional[type[int | float]] = None,
1173
+ method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2",
1174
+ ) -> int | float:
1175
+ """Calculates the relative luminance of a color according to various standards.\n
1176
+ ----------------------------------------------------------------------------------
1177
+ - `r`, `g`, `b` -⠀the red, green and blue channels in range [0, 255] inclusive
1178
+ - `output_type` -⠀the range of the returned luminance value:
1179
+ * `int` returns integer in range [0, 100] inclusive
1180
+ * `float` returns float in range [0.0, 1.0] inclusive
1181
+ * `None` returns integer in range [0, 255] inclusive
1182
+ - `method` -⠀the luminance calculation method to use:
1183
+ * `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
1184
+ * `"wcag3"` Draft WCAG 3.0 standard with improved coefficients
1185
+ * `"simple"` Simple arithmetic mean (less accurate)
1186
+ * `"bt601"` ITU-R BT.601 standard (older TV standard)"""
1187
+ if not all(0 <= c <= 255 for c in (r, g, b)):
1188
+ raise ValueError(f"The 'r', 'g' and 'b' parameters must be integers in [0, 255], got {r=} {g=} {b=}")
1189
+ if output_type not in {int, float, None}:
1190
+ raise TypeError(f"The 'output_type' parameter must be either 'int', 'float' or 'None', got {output_type!r}")
1191
+
1192
+ _r, _g, _b = r / 255.0, g / 255.0, b / 255.0
1193
+
1194
+ if method == "simple":
1195
+ luminance = (_r + _g + _b) / 3
1196
+ elif method == "bt601":
1197
+ luminance = 0.299 * _r + 0.587 * _g + 0.114 * _b
1198
+ elif method == "wcag3":
1199
+ _r = cls._linearize_srgb(_r)
1200
+ _g = cls._linearize_srgb(_g)
1201
+ _b = cls._linearize_srgb(_b)
1202
+ luminance = 0.2126729 * _r + 0.7151522 * _g + 0.0721750 * _b
1203
+ else:
1204
+ _r = cls._linearize_srgb(_r)
1205
+ _g = cls._linearize_srgb(_g)
1206
+ _b = cls._linearize_srgb(_b)
1207
+ luminance = 0.2126 * _r + 0.7152 * _g + 0.0722 * _b
1208
+
1209
+ if output_type == int:
1210
+ return round(luminance * 100)
1211
+ elif output_type == float:
1212
+ return luminance
1213
+ else:
1214
+ return round(luminance * 255)
1215
+
1216
+ @classmethod
1217
+ def text_color_for_on_bg(cls, text_bg_color: Rgba | Hexa) -> rgba | hexa | int:
1218
+ """Returns either black or white text color for optimal contrast on the given background color.\n
1219
+ --------------------------------------------------------------------------------------------------
1220
+ - `text_bg_color` -⠀the background color (can be in RGBA or HEXA format)"""
1221
+ was_hexa, was_int = cls.is_valid_hexa(text_bg_color), isinstance(text_bg_color, int)
1222
+
1223
+ text_bg_color = cls.to_rgba(text_bg_color)
1224
+ brightness = 0.2126 * text_bg_color[0] + 0.7152 * text_bg_color[1] + 0.0722 * text_bg_color[2]
1225
+
1226
+ return (
1227
+ (0xFFFFFF if was_int else hexa("", 255, 255, 255)) if was_hexa \
1228
+ else rgba(255, 255, 255, _validate=False)
1229
+ ) if brightness < 128 else (
1230
+ (0x000 if was_int else hexa("", 0, 0, 0)) if was_hexa \
1231
+ else rgba(0, 0, 0, _validate=False)
1232
+ )
1233
+
1234
+ @classmethod
1235
+ def adjust_lightness(cls, color: Rgba | Hexa, lightness_change: float) -> rgba | hexa:
1236
+ """In- or decrease the lightness of the input color.\n
1237
+ ------------------------------------------------------------------
1238
+ - `color` -⠀the color to adjust (can be in RGBA or HEXA format)
1239
+ - `lightness_change` -⠀the amount to change the lightness by,
1240
+ in range `-1.0` (darken by 100%) and `1.0` (lighten by 100%)"""
1241
+ was_hexa = cls.is_valid_hexa(color)
1242
+
1243
+ if not (-1.0 <= lightness_change <= 1.0):
1244
+ raise ValueError(
1245
+ f"The 'lightness_change' parameter must be in range [-1.0, 1.0] inclusive, got {lightness_change!r}"
1246
+ )
1247
+
1248
+ hsla_color: hsla = cls.to_hsla(color)
1249
+
1250
+ h, s, l, a = (
1251
+ int(hsla_color[0]), int(hsla_color[1]), int(hsla_color[2]), \
1252
+ hsla_color[3] if cls.has_alpha(hsla_color) else None
1253
+ )
1254
+ l = int(max(0, min(100, l + lightness_change * 100)))
1255
+
1256
+ return (
1257
+ hsla(h, s, l, a, _validate=False).to_hexa() if was_hexa \
1258
+ else hsla(h, s, l, a, _validate=False).to_rgba()
1259
+ )
1260
+
1261
+ @classmethod
1262
+ def adjust_saturation(cls, color: Rgba | Hexa, saturation_change: float) -> rgba | hexa:
1263
+ """In- or decrease the saturation of the input color.\n
1264
+ -----------------------------------------------------------------------
1265
+ - `color` -⠀the color to adjust (can be in RGBA or HEXA format)
1266
+ - `saturation_change` -⠀the amount to change the saturation by,
1267
+ in range `-1.0` (saturate by 100%) and `1.0` (desaturate by 100%)"""
1268
+ was_hexa = cls.is_valid_hexa(color)
1269
+
1270
+ if not (-1.0 <= saturation_change <= 1.0):
1271
+ raise ValueError(
1272
+ f"The 'saturation_change' parameter must be in range [-1.0, 1.0] inclusive, got {saturation_change!r}"
1273
+ )
1274
+
1275
+ hsla_color: hsla = cls.to_hsla(color)
1276
+
1277
+ h, s, l, a = (
1278
+ int(hsla_color[0]), int(hsla_color[1]), int(hsla_color[2]), \
1279
+ hsla_color[3] if cls.has_alpha(hsla_color) else None
1280
+ )
1281
+ s = int(max(0, min(100, s + saturation_change * 100)))
1282
+
1283
+ return (
1284
+ hsla(h, s, l, a, _validate=False).to_hexa() if was_hexa \
1285
+ else hsla(h, s, l, a, _validate=False).to_rgba()
1286
+ )
1287
+
1288
+ @classmethod
1289
+ def _parse_rgba(cls, color: AnyRgba) -> rgba:
1290
+ """Internal method to parse a color to an RGBA object."""
1291
+ if isinstance(color, rgba):
1292
+ return color
1293
+ elif isinstance(color, (list, tuple)):
1294
+ if len(color) == 4:
1295
+ return rgba(color[0], color[1], color[2], color[3], _validate=False)
1296
+ elif len(color) == 3:
1297
+ return rgba(color[0], color[1], color[2], None, _validate=False)
1298
+ elif isinstance(color, dict):
1299
+ return rgba(color["r"], color["g"], color["b"], color.get("a"), _validate=False)
1300
+ elif isinstance(color, str):
1301
+ if parsed := cls.str_to_rgba(color, only_first=True):
1302
+ return cast(rgba, parsed)
1303
+ raise ValueError(f"Could not parse RGBA color: {color!r}")
1304
+
1305
+ @classmethod
1306
+ def _parse_hsla(cls, color: AnyHsla) -> hsla:
1307
+ """Internal method to parse a color to an HSLA object."""
1308
+ if isinstance(color, hsla):
1309
+ return color
1310
+ elif isinstance(color, (list, tuple)):
1311
+ if len(color) == 4:
1312
+ return hsla(color[0], color[1], color[2], color[3], _validate=False)
1313
+ elif len(color) == 3:
1314
+ return hsla(color[0], color[1], color[2], None, _validate=False)
1315
+ elif isinstance(color, dict):
1316
+ return hsla(color["h"], color["s"], color["l"], color.get("a"), _validate=False)
1317
+ elif isinstance(color, str):
1318
+ if parsed := cls.str_to_hsla(color, only_first=True):
1319
+ return cast(hsla, parsed)
1320
+ raise ValueError(f"Could not parse HSLA color: {color!r}")
1321
+
1322
+ @staticmethod
1323
+ def _linearize_srgb(c: float) -> float:
1324
+ """Helper method to linearize sRGB component following the WCAG standard."""
1325
+ if not (0.0 <= c <= 1.0):
1326
+ raise ValueError(f"The 'c' parameter must be in range [0.0, 1.0] inclusive, got {c!r}")
1327
+
1328
+ if c <= 0.03928:
1329
+ return c / 12.92
1330
+ else:
1331
+ return ((c + 0.055) / 1.055)**2.4