xulbux 1.5.5__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.
xulbux/xx_color.py ADDED
@@ -0,0 +1,799 @@
1
+ """
2
+ `rgba`:
3
+ An RGB/RGBA color: is a tuple of 3 integers, representing the red (`0`-`255`), green (`0`-`255`), and blue (`0`-`255`).
4
+ Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`).
5
+ `hsla`:
6
+ An HSL/HSB color: is a tuple of 3 integers, representing the hue (`0`-`360`), saturation (`0`-`100`), and lightness (`0`-`100`).
7
+ Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`).
8
+ `hexa`:
9
+ A HEX color: is a string in the format `RGB`, `RGBA`, `RRGGBB` or `RRGGBBAA` (where `R` `G` `B` `A` are hexadecimal digits).
10
+
11
+ ---------------------------------------------------------------------------------------------------------------------------------------
12
+ The `Color` class, which contains all sorts of different color-related methods:
13
+ - validate colors:
14
+ - is valid rgba
15
+ - is valid hsla
16
+ - is valid hexa
17
+ - is valid in any format
18
+ - check if a color has an alpha channel
19
+ - convert between different color formats:
20
+ - color to rgba
21
+ - color to hsla
22
+ - color to hexa
23
+ - recognize colors inside strings and convert them to color types:
24
+ - string to rgba
25
+ - convert an RGBA color to a HEX integer
26
+ - convert a HEX integer to an RGBA color
27
+ - get a colors luminance from the RGB channels
28
+ - get the optimal text color for on a colored background
29
+ - adjust different color channels:
30
+ - brightness
31
+ - saturation
32
+ """
33
+
34
+
35
+ from .xx_regex import *
36
+
37
+ import re as _re
38
+
39
+
40
+
41
+
42
+ class rgba:
43
+ """An RGB/RGBA color: is a tuple of 3 integers, representing the red (`0`-`255`), green (`0`-`255`), and blue (`0`-`255`).<br>
44
+ Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`).\n
45
+ -------------------------------------------------------------------------------------------------------------------------------
46
+ Includes methods:
47
+ - `to_hsla()` to convert to HSL color
48
+ - `to_hexa()` to convert to HEX color
49
+ - `has_alpha()` to check if the color has an alpha channel
50
+ - `lighten(amount)` to create a lighter version of the color
51
+ - `darken(amount)` to create a darker version of the color
52
+ - `saturate(amount)` to increase color saturation
53
+ - `desaturate(amount)` to decrease color saturation
54
+ - `rotate(degrees)` to rotate the hue by degrees
55
+ - `invert()` to get the inverse color
56
+ - `grayscale()` to convert to grayscale
57
+ - `blend(other, ratio)` to blend with another color
58
+ - `is_dark()` to check if the color is considered dark
59
+ - `is_light()` to check if the color is considered light
60
+ - `is_grayscale()` to check if the color is grayscale
61
+ - `is_opaque()` to check if the color has no transparency
62
+ - `with_alpha(alpha)` to create a new color with different alpha
63
+ - `complementary()` to get the complementary color"""
64
+
65
+ def __init__(self, r:int, g:int, b:int, a:float = None):
66
+ if any(isinstance(x, rgba) for x in (r, g, b)):
67
+ raise ValueError('Color is already a rgba() color')
68
+ elif not all(isinstance(x, int) and 0 <= x <= 255 for x in (r, g, b)):
69
+ raise ValueError('RGBA color must have R G B as integers in [0, 255]: got', (r, g, b))
70
+ elif not a is None and not (isinstance(a, (int, float)) and 0 <= a <= 1):
71
+ raise ValueError('Alpha channel must be a float/int in [0.0, 1.0]: got', a)
72
+ self.r, self.g, self.b, self.a = r, g, b, (1.0 if a > 1.0 else float(a)) if a else None
73
+
74
+ def __len__(self):
75
+ return 4 if self.a else 3
76
+
77
+ def __iter__(self):
78
+ return iter((self.r, self.g, self.b) + ((self.a,) if self.a else ()))
79
+
80
+ def __getitem__(self, index):
81
+ return ((self.r, self.g, self.b) + ((self.a,) if self.a else ()))[index]
82
+
83
+ def __repr__(self):
84
+ return f'rgba({self.r}, {self.g}, {self.b}{f", {self.a}" if self.a else ""})'
85
+
86
+ def __str__(self):
87
+ return f'({self.r}, {self.g}, {self.b}{f", {self.a}" if self.a else ""})'
88
+
89
+ def __eq__(self, other):
90
+ if not isinstance(other, rgba): return False
91
+ return (self.r, self.g, self.b, self.a) == (other[0], other[1], other[2], other[3])
92
+
93
+ def list(self) -> list:
94
+ """Returns the color components as a list `[r, g, b]` or `[r, g, b, a]` if alpha is present"""
95
+ return [self.r, self.g, self.b] + ([self.a] if self.a else [])
96
+
97
+ def tuple(self) -> tuple:
98
+ """Returns the color components as a tuple `(r, g, b)` or `(r, g, b, a)` if alpha is present"""
99
+ return tuple(self.list())
100
+
101
+ def dict(self) -> dict:
102
+ """Returns the color components as a dictionary with keys `'r'`, `'g'`, `'b'` and optionally `'a'`"""
103
+ return dict(r=self.r, g=self.g, b=self.b, a=self.a) if self.a else dict(r=self.r, g=self.g, b=self.b)
104
+
105
+ def values(self) -> tuple:
106
+ """Returns the color components as separate values `r, g, b, a`"""
107
+ return self.r, self.g, self.b, self.a
108
+
109
+ def to_hsla(self) -> 'hsla':
110
+ """Returns the color as a `hsla()` color"""
111
+ return hsla(*self._rgb_to_hsl(self.r, self.g, self.b), self.a)
112
+
113
+ def to_hexa(self) -> 'hexa':
114
+ """Returns the color as a `hexa()` color"""
115
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
116
+
117
+ def has_alpha(self) -> bool:
118
+ """Returns `True` if the color has an alpha channel and `False` otherwise"""
119
+ return self.a != None
120
+
121
+ def lighten(self, amount:float) -> 'rgba':
122
+ """Increases the colors lightness by the specified amount (`0.0`-`1.0`)"""
123
+ self.r, self.g, self.b, self.a = self.to_hsla().lighten(amount).to_rgba().values()
124
+ return rgba(self.r, self.g, self.b, self.a)
125
+
126
+ def darken(self, amount:float) -> 'rgba':
127
+ """Decreases the colors lightness by the specified amount (`0.0`-`1.0`)"""
128
+ self.r, self.g, self.b, self.a = self.to_hsla().darken(amount).to_rgba().values()
129
+ return rgba(self.r, self.g, self.b, self.a)
130
+
131
+ def saturate(self, amount:float) -> 'rgba':
132
+ """Increases the colors saturation by the specified amount (`0.0`-`1.0`)"""
133
+ self.r, self.g, self.b, self.a = self.to_hsla().saturate(amount).to_rgba().values()
134
+ return rgba(self.r, self.g, self.b, self.a)
135
+
136
+ def desaturate(self, amount:float) -> 'rgba':
137
+ """Decreases the colors saturation by the specified amount (`0.0`-`1.0`)"""
138
+ self.r, self.g, self.b, self.a = self.to_hsla().desaturate(amount).to_rgba().values()
139
+ return rgba(self.r, self.g, self.b, self.a)
140
+
141
+ def rotate(self, degrees:int) -> 'rgba':
142
+ """Rotates the colors hue by the specified number of degrees"""
143
+ self.r, self.g, self.b, self.a = self.to_hsla().rotate(degrees).to_rgba().values()
144
+ return rgba(self.r, self.g, self.b, self.a)
145
+
146
+ def invert(self, invert_alpha:bool = False) -> 'rgba':
147
+ """Inverts the color by rotating hue by 180 degrees and inverting lightness"""
148
+ self.r, self.g, self.b = 255 - self.r, 255 - self.g, 255 - self.b
149
+ if invert_alpha: self.a = 1 - self.a
150
+ return rgba(self.r, self.g, self.b, self.a)
151
+
152
+ def grayscale(self) -> 'rgba':
153
+ """Converts the color to grayscale using the luminance formula"""
154
+ self.r = self.g = self.b = Color.luminance(self.r, self.g, self.b)
155
+ return rgba(self.r, self.g, self.b, self.a)
156
+
157
+ def blend(self, other:'rgba', ratio:float = 0.5, additive_alpha:bool = False) -> 'rgba':
158
+ """Blends the current color with another color using the specified ratio (`0.0`-`1.0`):<br>
159
+ If `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (1:0 mixture)<br>
160
+ If `ratio` is `0.5` it means 50% of both colors (1:1 mixture)<br>
161
+ If `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:1 mixture)"""
162
+ if not (isinstance(ratio, (int, float)) and 0 <= ratio <= 1):
163
+ raise ValueError("'ratio' must be a float/int in [0.0, 1.0]")
164
+ elif not isinstance(other, rgba):
165
+ if Color.is_valid_rgba(other):
166
+ other = rgba(*other)
167
+ else:
168
+ raise TypeError("'other' must be a valid RGBA color")
169
+ ratio *= 2
170
+ self.r = max(0, min(255, int(round((self.r * (2 - ratio)) + (other.r * ratio)))))
171
+ self.g = max(0, min(255, int(round((self.g * (2 - ratio)) + (other.g * ratio)))))
172
+ self.b = max(0, min(255, int(round((self.b * (2 - ratio)) + (other.b * ratio)))))
173
+ none_alpha = self.a is None and (len(other) <= 3 or other[3] is None)
174
+ if not none_alpha:
175
+ self_a = self.a if self.a is not None else 1
176
+ other_a = (other[3] if other[3] is not None else 1) if len(other) > 3 else 1
177
+ if additive_alpha:
178
+ self.a = max(0, min(1, (self_a * (2 - ratio)) + (other_a * ratio)))
179
+ else:
180
+ self.a = max(0, min(1, (self_a * (1 - (ratio / 2))) + (other_a * (ratio / 2))))
181
+ else:
182
+ self.a = None
183
+ return rgba(self.r, self.g, self.b, None if none_alpha else self.a)
184
+
185
+ def is_dark(self) -> bool:
186
+ """Returns `True` if the color is considered dark (luminance < 128)"""
187
+ return (0.299 * self.r + 0.587 * self.g + 0.114 * self.b) < 128
188
+
189
+ def is_light(self) -> bool:
190
+ """Returns `True` if the color is considered light (luminance >= 128)"""
191
+ return not self.is_dark()
192
+
193
+ def is_grayscale(self) -> bool:
194
+ """Returns `True` if the color is grayscale"""
195
+ return self.r == self.g == self.b
196
+
197
+ def is_opaque(self) -> bool:
198
+ """Returns `True` if the color has no transparency"""
199
+ return self.a == 1 or self.a is None
200
+
201
+ def with_alpha(self, alpha:float) -> 'rgba':
202
+ """Returns a new color with the specified alpha value"""
203
+ if not (isinstance(alpha, (int, float)) and 0 <= alpha <= 1):
204
+ raise ValueError("'alpha' must be a float/int in [0.0, 1.0]")
205
+ return rgba(self.r, self.g, self.b, alpha)
206
+
207
+ def complementary(self) -> 'rgba':
208
+ """Returns the complementary color (180 degrees on the color wheel)"""
209
+ return self.to_hsla().complementary().to_rgba()
210
+
211
+ def _rgb_to_hsl(self, r:int, g:int, b:int) -> tuple:
212
+ r, g, b = r / 255.0, g / 255.0, b / 255.0
213
+ max_c, min_c = max(r, g, b), min(r, g, b)
214
+ l = (max_c + min_c) / 2
215
+ if max_c == min_c:
216
+ h = s = 0
217
+ else:
218
+ delta = max_c - min_c
219
+ s = delta / (1 - abs(2 * l - 1))
220
+ if max_c == r: h = ((g - b) / delta) % 6
221
+ elif max_c == g: h = ((b - r) / delta) + 2
222
+ else: h = ((r - g) / delta) + 4
223
+ h /= 6
224
+ return int(round(h * 360)), int(round(s * 100)), int(round(l * 100))
225
+
226
+
227
+
228
+
229
+ class hsla:
230
+ """A HSL/HSLA color: is a tuple of 3 integers, representing hue (`0`-`360`), saturation (`0`-`100`), and lightness (`0`-`100`).<br>
231
+ Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`).\n
232
+ ------------------------------------------------------------------------------------------------------------------------------------
233
+ Includes methods:
234
+ - `to_rgba()` to convert to RGB color
235
+ - `to_hexa()` to convert to HEX color
236
+ - `has_alpha()` to check if the color has an alpha channel
237
+ - `lighten(amount)` to create a lighter version of the color
238
+ - `darken(amount)` to create a darker version of the color
239
+ - `saturate(amount)` to increase color saturation
240
+ - `desaturate(amount)` to decrease color saturation
241
+ - `rotate(degrees)` to rotate the hue by degrees
242
+ - `invert()` to get the inverse color
243
+ - `grayscale()` to convert to grayscale
244
+ - `blend(other, ratio)` to blend with another color
245
+ - `is_dark()` to check if the color is considered dark
246
+ - `is_light()` to check if the color is considered light
247
+ - `is_grayscale()` to check if the color is grayscale
248
+ - `is_opaque()` to check if the color has no transparency
249
+ - `with_alpha(alpha)` to create a new color with different alpha
250
+ - `complementary()` to get the complementary color"""
251
+
252
+ def __init__(self, h:int, s:int, l:int, a:float = None):
253
+ if any(isinstance(x, hsla) for x in (h, s, l)):
254
+ raise ValueError('Color is already a hsla() color')
255
+ elif not (isinstance(h, int) and (0 <= h <= 360) and all(isinstance(x, int) and (0 <= x <= 100) for x in (s, l))):
256
+ raise ValueError('HSL color must have H as integer in [0, 360] and S L as integers in [0, 100]: got', (h, s, l))
257
+ elif not a is None and (not isinstance(a, (int, float)) or not 0 <= a <= 1):
258
+ raise ValueError('Alpha channel must be a float/int in [0.0, 1.0]: got', a)
259
+ self.h, self.s, self.l, self.a = h, s, l, (1.0 if a > 1.0 else float(a)) if a else None
260
+
261
+ def __len__(self):
262
+ return 4 if self.a else 3
263
+
264
+ def __iter__(self):
265
+ return iter((self.h, self.s, self.l) + ((self.a,) if self.a else ()))
266
+
267
+ def __getitem__(self, index):
268
+ return ((self.h, self.s, self.l) + ((self.a,) if self.a else ()))[index]
269
+
270
+ def __repr__(self):
271
+ return f'hsla({self.h}, {self.s}, {self.l}{f", {self.a}" if self.a else ""})'
272
+
273
+ def __str__(self):
274
+ return f'({self.h}, {self.s}, {self.l}{f", {self.a}" if self.a else ""})'
275
+
276
+ def __eq__(self, other):
277
+ if not isinstance(other, hsla):
278
+ return False
279
+ return (self.h, self.s, self.l, self.a) == (other[0], other[1], other[2], other[3])
280
+
281
+ def list(self) -> list:
282
+ """Returns the color components as a list `[h, s, l]` or `[h, s, l, a]` if alpha is present"""
283
+ return [self.h, self.s, self.l] + ([self.a] if self.a else [])
284
+
285
+ def tuple(self) -> tuple:
286
+ """Returns the color components as a tuple `(h, s, l)` or `(h, s, l, a)` if alpha is present"""
287
+ return tuple(self.list())
288
+
289
+ def dict(self) -> dict:
290
+ """Returns the color components as a dictionary with keys `'h'`, `'s'`, `'l'` and optionally `'a'`"""
291
+ return dict(h=self.h, s=self.s, l=self.l, a=self.a) if self.a else dict(h=self.h, s=self.s, l=self.l)
292
+
293
+ def values(self) -> tuple:
294
+ """Returns the color components as separate values `h, s, l, a`"""
295
+ return self.h, self.s, self.l, self.a
296
+
297
+ def to_rgba(self) -> 'rgba':
298
+ """Returns the color as a `rgba()` color"""
299
+ return rgba(*self._hsl_to_rgb(self.h, self.s, self.l), self.a)
300
+
301
+ def to_hexa(self) -> 'hexa':
302
+ """Returns the color as a `hexa()` color"""
303
+ r, g, b = self._hsl_to_rgb(self.h, self.s, self.l)
304
+ return hexa(f'#{r:02X}{g:02X}{b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
305
+
306
+ def has_alpha(self) -> bool:
307
+ """Returns `True` if the color has an alpha channel and `False` otherwise"""
308
+ return self.a != None
309
+
310
+ def lighten(self, amount:float) -> 'hsla':
311
+ """Increases the colors lightness by the specified amount (`0.0`-`1.0`)"""
312
+ if not (isinstance(amount, (int, float)) and 0 <= amount <= 1):
313
+ raise ValueError("'amount' must be a float/int in [0.0, 1.0]")
314
+ self.l = int(min(100, self.l + (100 - self.l) * amount))
315
+ return hsla(self.h, self.s, self.l, self.a)
316
+
317
+ def darken(self, amount:float) -> 'hsla':
318
+ """Decreases the colors lightness by the specified amount (`0.0`-`1.0`)"""
319
+ if not (isinstance(amount, (int, float)) and 0 <= amount <= 1):
320
+ raise ValueError("'amount' must be a float/int in [0.0, 1.0]")
321
+ self.l = int(max(0, self.l * (1 - amount)))
322
+ return hsla(self.h, self.s, self.l, self.a)
323
+
324
+ def saturate(self, amount:float) -> 'hsla':
325
+ """Increases the colors saturation by the specified amount (`0.0`-`1.0`)"""
326
+ if not (isinstance(amount, (int, float)) and 0 <= amount <= 1):
327
+ raise ValueError("'amount' must be a float/int in [0.0, 1.0]")
328
+ self.s = int(min(100, self.s + (100 - self.s) * amount))
329
+ return hsla(self.h, self.s, self.l, self.a)
330
+
331
+ def desaturate(self, amount:float) -> 'hsla':
332
+ """Decreases the colors saturation by the specified amount (`0.0`-`1.0`)"""
333
+ if not (isinstance(amount, (int, float)) and 0 <= amount <= 1):
334
+ raise ValueError("'amount' must be a float/int in [0.0, 1.0]")
335
+ self.s = int(max(0, self.s * (1 - amount)))
336
+ return hsla(self.h, self.s, self.l, self.a)
337
+
338
+ def rotate(self, degrees:int) -> 'hsla':
339
+ """Rotates the colors hue by the specified number of degrees"""
340
+ self.h = (self.h + degrees) % 360
341
+ return hsla(self.h, self.s, self.l, self.a)
342
+
343
+ def invert(self, invert_alpha:bool = False) -> 'hsla':
344
+ """Inverts the color by rotating hue by 180 degrees and inverting lightness"""
345
+ self.h = (self.h + 180) % 360
346
+ self.l = 100 - self.l
347
+ if invert_alpha: self.a = 1 - self.a
348
+ return hsla(self.h, self.s, self.l, self.a)
349
+
350
+ def grayscale(self) -> 'hsla':
351
+ """Converts the color to grayscale using the luminance formula"""
352
+ l = Color.luminance(*self._hsl_to_rgb(self.h, self.s, self.l))
353
+ self.h, self.s, self.l, _ = rgba(l, l, l).to_hsla().values()
354
+ return hsla(self.h, self.s, self.l, self.a)
355
+
356
+ def blend(self, other:'hsla', ratio:float = 0.5, additive_alpha:bool = False) -> 'rgba':
357
+ """Blends the current color with another color using the specified ratio (`0.0`-`1.0`):<br>
358
+ If `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (1:0 mixture)<br>
359
+ If `ratio` is `0.5` it means 50% of both colors (1:1 mixture)<br>
360
+ If `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:1 mixture)"""
361
+ self.h, self.s, self.l, self.a = self.to_hsla().blend(Color.to_hsla(other), ratio, additive_alpha).values()
362
+ return hsla(self.h, self.s, self.l, self.a)
363
+
364
+ def is_dark(self) -> bool:
365
+ """Returns `True` if the color is considered dark (`lightness < 50`)"""
366
+ return self.l < 50
367
+
368
+ def is_light(self) -> bool:
369
+ """Returns `True` if the color is considered light (`lightness >= 50`)"""
370
+ return not self.is_dark()
371
+
372
+ def is_grayscale(self) -> bool:
373
+ """Returns `True` if the color is considered grayscale"""
374
+ return self.s == 0
375
+
376
+ def is_opaque(self) -> bool:
377
+ """Returns `True` if the color has no transparency"""
378
+ return self.a == 1 or self.a is None
379
+
380
+ def with_alpha(self, alpha:float) -> 'hsla':
381
+ """Returns a new color with the specified alpha value"""
382
+ if not (isinstance(alpha, (int, float)) and 0 <= alpha <= 1):
383
+ raise ValueError("'alpha' must be a float/int in [0.0, 1.0]")
384
+ return hsla(self.h, self.s, self.l, alpha)
385
+
386
+ def complementary(self) -> 'hsla':
387
+ """Returns the complementary color (180 degrees on the color wheel)"""
388
+ return hsla((self.h + 180) % 360, self.s, self.l, self.a)
389
+
390
+ def _hsl_to_rgb(self, h:int, s:int, l:int) -> tuple:
391
+ h, s, l = h / 360, s / 100, l / 100
392
+ if s == 0:
393
+ r = g = b = int(l * 255)
394
+ else:
395
+ def hue_to_rgb(p, q, t):
396
+ if t < 0: t += 1
397
+ if t > 1: t -= 1
398
+ if t < 1/6: return p + (q - p) * 6 * t
399
+ if t < 1/2: return q
400
+ if t < 2/3: return p + (q - p) * (2/3 - t) * 6
401
+ return p
402
+ q = l * (1 + s) if l < 0.5 else l + s - l * s
403
+ p = 2 * l - q
404
+ r = int(round(hue_to_rgb(p, q, h + 1/3) * 255))
405
+ g = int(round(hue_to_rgb(p, q, h) * 255))
406
+ b = int(round(hue_to_rgb(p, q, h - 1/3) * 255))
407
+ return r, g, b
408
+
409
+
410
+
411
+
412
+ class hexa:
413
+ """A HEX color: is a string representing a hexadecimal color code with optional alpha channel.\n
414
+ -------------------------------------------------------------------------------------------------
415
+ Supports formats: RGB, RGBA, RRGGBB, RRGGBBAA (with or without prefix)<br>
416
+ Includes methods:
417
+ - `to_rgba()` to convert to RGB color
418
+ - `to_hsla()` to convert to HSL color
419
+ - `has_alpha()` to check if the color has an alpha channel
420
+ - `lighten(amount)` to create a lighter version of the color
421
+ - `darken(amount)` to create a darker version of the color
422
+ - `saturate(amount)` to increase color saturation
423
+ - `desaturate(amount)` to decrease color saturation
424
+ - `rotate(degrees)` to rotate the hue by degrees
425
+ - `invert()` to get the inverse color
426
+ - `grayscale()` to convert to grayscale
427
+ - `blend(other, ratio)` to blend with another color
428
+ - `is_dark()` to check if the color is considered dark
429
+ - `is_light()` to check if the color is considered light
430
+ - `is_grayscale()` to check if the color is grayscale
431
+ - `is_opaque()` to check if the color has no transparency
432
+ - `with_alpha(alpha)` to create a new color with different alpha
433
+ - `complementary()` to get the complementary color"""
434
+
435
+ def __init__(self, color:str|int):
436
+ if isinstance(color, hexa):
437
+ raise ValueError('Color is already a hexa() color')
438
+ if isinstance(color, str):
439
+ if color.startswith('#'):
440
+ color = color[1:].upper()
441
+ elif color.startswith('0x'):
442
+ color = color[2:].upper()
443
+ if len(color) == 3: # RGB
444
+ self.r, self.g, self.b, self.a = int(color[0] * 2, 16), int(color[1] * 2, 16), int(color[2] * 2, 16), None
445
+ elif len(color) == 4: # RGBA
446
+ self.r, self.g, self.b, self.a = int(color[0] * 2, 16), int(color[1] * 2, 16), int(color[2] * 2, 16), int(color[3] * 2, 16) / 255.0
447
+ elif len(color) == 6: # RRGGBB
448
+ self.r, self.g, self.b, self.a = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), None
449
+ elif len(color) == 8: # RRGGBBAA
450
+ self.r, self.g, self.b, self.a = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), int(color[6:8], 16) / 255.0
451
+ else:
452
+ raise ValueError(f"Invalid HEX format '{color}'")
453
+ elif isinstance(color, int):
454
+ self.r, self.g, self.b, self.a = Color.hex_to_rgba(color)
455
+ else:
456
+ raise TypeError("HEX color must be of type 'str' or 'int': got", type(color))
457
+
458
+ def __len__(self):
459
+ return 4 if self.a else 3
460
+
461
+ def __iter__(self):
462
+ return iter((f'{self.r:02X}', f'{self.g:02X}', f'{self.b:02X}') + ((f'{int(self.a * 255):02X}',) if self.a else ()))
463
+
464
+ def __getitem__(self, index):
465
+ return ((f'{self.r:02X}', f'{self.g:02X}', f'{self.b:02X}') + ((f'{int(self.a * 255):02X}',) if self.a else ()))[index]
466
+
467
+ def __repr__(self):
468
+ return f'hexa(#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""})'
469
+
470
+ def __str__(self):
471
+ return f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}'
472
+
473
+ def __eq__(self, other):
474
+ if not isinstance(other, hexa): return False
475
+ return (self.r, self.g, self.b, self.a) == (other[0], other[1], other[2], other[3])
476
+
477
+ def list(self) -> list:
478
+ """Returns the color components as a list of hex strings `[RR, GG, BB]` or `[RR, GG, BB, AA]` if alpha is present"""
479
+ return [f'{self.r:02X}', f'{self.g:02X}', f'{self.b:02X}'] + ([f'{int(self.a * 255):02X}'] if self.a else [])
480
+
481
+ def tuple(self) -> tuple:
482
+ """Returns the color components as a tuple of hex strings `(RR, GG, BB)` or `(RR, GG, BB, AA)` if alpha is present"""
483
+ return tuple(self.list())
484
+
485
+ def dict(self) -> dict:
486
+ """Returns the color components as a dictionary with hex string values for keys `'r'`, `'g'`, `'b'` and optionally `'a'`"""
487
+ return dict(r=f'{self.r:02X}', g=f'{self.g:02X}', b=f'{self.b:02X}', a=f'{int(self.a * 255):02X}') if self.a else dict(r=f'{self.r:02X}', g=f'{self.g:02X}', b=f'{self.b:02X}')
488
+
489
+ def values(self) -> tuple:
490
+ """Returns the color components as separate values `r, g, b, a`"""
491
+ return self.r, self.g, self.b, self.a
492
+
493
+ def to_rgba(self, round_alpha:bool = True) -> 'rgba':
494
+ """Returns the color as a `rgba()` color"""
495
+ return rgba(self.r, self.g, self.b, (round(self.a, 2) if round_alpha else self.a) if self.a else None)
496
+
497
+ def to_hsla(self, round_alpha:bool = True) -> 'hsla':
498
+ """Returns the color as a `hsla()` color"""
499
+ return self.to_rgba(round_alpha).to_hsla()
500
+
501
+ def has_alpha(self) -> bool:
502
+ """Returns `True` if the color has an alpha channel and `False` otherwise"""
503
+ return self.a is not None
504
+
505
+ def lighten(self, amount:float) -> 'hexa':
506
+ """Increases the colors lightness by the specified amount (`0.0`-`1.0`)"""
507
+ self.r, self.g, self.b, self.a = self.to_rgba(False).lighten(amount).values()
508
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
509
+
510
+ def darken(self, amount:float) -> 'hexa':
511
+ """Decreases the colors lightness by the specified amount (`0.0`-`1.0`)"""
512
+ self.r, self.g, self.b, self.a = self.to_rgba(False).darken(amount).values()
513
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
514
+
515
+ def saturate(self, amount:float) -> 'hexa':
516
+ """Increases the colors saturation by the specified amount (`0.0`-`1.0`)"""
517
+ self.r, self.g, self.b, self.a = self.to_rgba(False).saturate(amount).values()
518
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
519
+
520
+ def desaturate(self, amount:float) -> 'hexa':
521
+ """Decreases the colors saturation by the specified amount (`0.0`-`1.0`)"""
522
+ self.r, self.g, self.b, self.a = self.to_rgba(False).desaturate(amount).values()
523
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
524
+
525
+ def rotate(self, degrees:int) -> 'hexa':
526
+ """Rotates the colors hue by the specified number of degrees"""
527
+ self.r, self.g, self.b, self.a = self.to_rgba(False).rotate(degrees).values()
528
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
529
+
530
+ def invert(self, invert_alpha:bool = False) -> 'hexa':
531
+ """Inverts the color by rotating hue by 180 degrees and inverting lightness"""
532
+ self.r, self.g, self.b, self.a = self.to_rgba(False).invert().values()
533
+ if invert_alpha: self.a = 1 - self.a
534
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
535
+
536
+ def grayscale(self) -> 'hexa':
537
+ """Converts the color to grayscale using the luminance formula"""
538
+ self.r = self.g = self.b = Color.luminance(self.r, self.g, self.b)
539
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
540
+
541
+ def blend(self, other:'hexa', ratio:float = 0.5, additive_alpha:bool = False) -> 'rgba':
542
+ """Blends the current color with another color using the specified ratio (`0.0`-`1.0`):<br>
543
+ If `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (1:0 mixture)<br>
544
+ If `ratio` is `0.5` it means 50% of both colors (1:1 mixture)<br>
545
+ If `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:1 mixture)"""
546
+ self.r, self.g, self.b, self.a = self.to_rgba(False).blend(Color.to_rgba(other), ratio, additive_alpha).values()
547
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
548
+
549
+ def is_dark(self) -> bool:
550
+ """Returns `True` if the color is considered dark (converted `lightness < 50`)"""
551
+ return self.to_hsla(False).is_dark()
552
+
553
+ def is_light(self) -> bool:
554
+ """Returns `True` if the color is considered light (`lightness >= 50`)"""
555
+ return self.to_hsla(False).is_light()
556
+
557
+ def is_grayscale(self) -> bool:
558
+ """Returns `True` if the color is grayscale (`saturation == 0`)"""
559
+ return self.to_hsla(False).is_grayscale()
560
+
561
+ def is_opaque(self) -> bool:
562
+ """Returns `True` if the color has no transparency (`alpha == 1.0`)"""
563
+ return self.to_hsla(False).is_opaque()
564
+
565
+ def with_alpha(self, alpha:float) -> 'hexa':
566
+ """Returns a new color with the specified alpha value"""
567
+ if not (isinstance(alpha, (int, float)) and 0 <= alpha <= 1):
568
+ raise ValueError("'alpha' must be in [0.0, 1.0]")
569
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
570
+
571
+ def complementary(self) -> 'hexa':
572
+ """Returns the complementary color (180 degrees on the color wheel)"""
573
+ return self.to_hsla(False).complementary().to_hexa()
574
+
575
+
576
+
577
+
578
+ class Color:
579
+
580
+ @staticmethod
581
+ def is_valid_rgba(color:str|list|tuple|dict, allow_alpha:bool = True) -> bool:
582
+ try:
583
+ if isinstance(color, rgba):
584
+ return True
585
+ elif isinstance(color, (list, tuple)):
586
+ if allow_alpha and Color.has_alpha(color):
587
+ return 0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255 and (0 <= color[3] <= 1 or color[3] is None)
588
+ return 0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255
589
+ elif isinstance(color, dict):
590
+ if allow_alpha and Color.has_alpha(color):
591
+ return 0 <= color['r'] <= 255 and 0 <= color['g'] <= 255 and 0 <= color['b'] <= 255 and (0 <= color['a'] <= 1 or color['a'] is None)
592
+ return 0 <= color['r'] <= 255 and 0 <= color['g'] <= 255 and 0 <= color['b'] <= 255
593
+ elif isinstance(color, str):
594
+ return bool(_re.fullmatch(Regex.rgba_str(), color))
595
+ return False
596
+ except: return False
597
+
598
+ @staticmethod
599
+ def is_valid_hsla(color:str|list|tuple|dict, allow_alpha:bool = True) -> bool:
600
+ try:
601
+ if isinstance(color, hsla):
602
+ return True
603
+ elif isinstance(color, (list, tuple)):
604
+ if allow_alpha and Color.has_alpha(color):
605
+ return 0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100 and (0 <= color[3] <= 1 or color[3] is None)
606
+ else:
607
+ return 0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100
608
+ elif isinstance(color, dict):
609
+ if allow_alpha and Color.has_alpha(color):
610
+ return 0 <= color['h'] <= 360 and 0 <= color['s'] <= 100 and 0 <= color['l'] <= 100 and (0 <= color['a'] <= 1 or color['a'] is None)
611
+ else:
612
+ return 0 <= color['h'] <= 360 and 0 <= color['s'] <= 100 and 0 <= color['l'] <= 100
613
+ elif isinstance(color, str):
614
+ return bool(_re.fullmatch(Regex.hsla_str(), color))
615
+ except:
616
+ return False
617
+
618
+ @staticmethod
619
+ def is_valid_hexa(color:str, allow_alpha:bool = True, get_prefix:bool = False) -> bool|tuple[bool,str]:
620
+ try:
621
+ if isinstance(color, hexa):
622
+ return (True, '#')
623
+ elif isinstance(color, int):
624
+ is_valid = 0 <= color <= (0xFFFFFFFF if allow_alpha else 0xFFFFFF)
625
+ return (is_valid, '0x') if get_prefix else is_valid
626
+ else:
627
+ if color.startswith('#'):
628
+ color, prefix = color[1:], '#'
629
+ elif color.startswith('0x'):
630
+ color, prefix = color[2:], '0x'
631
+ pattern = r'(?i)^[0-9A-F]{8}|[0-9A-F]{6}|[0-9A-F]{4}|[0-9A-F]{3}$' if allow_alpha else r'(?i)^[0-9A-F]{6}|[0-9A-F]{3}$'
632
+ return (bool(_re.fullmatch(pattern, color)), prefix) if get_prefix else bool(_re.fullmatch(pattern, color))
633
+ except:
634
+ return (False, None) if get_prefix else False
635
+
636
+ @staticmethod
637
+ def is_valid(color:str|list|tuple|dict, allow_alpha:bool = True) -> bool:
638
+ return Color.is_valid_hexa(color, allow_alpha) or Color.is_valid_rgba(color, allow_alpha) or Color.is_valid_hsla(color, allow_alpha)
639
+
640
+ @staticmethod
641
+ def has_alpha(color:rgba|hsla|hexa) -> bool:
642
+ """Check if the given color has an alpha channel.\n
643
+ --------------------------------------------------------------------------------
644
+ Input a RGBA, HSLA or HEXA color as `color`.<br>
645
+ Returns `True` if the color has an alpha channel and `False` otherwise."""
646
+ if isinstance(color, (rgba, hsla, hexa)):
647
+ return color.has_alpha()
648
+ if Color.is_valid_hexa(color):
649
+ if isinstance(color, str):
650
+ if color.startswith('#'):
651
+ color = color[1:]
652
+ return len(color) == 4 or len(color) == 8
653
+ if isinstance(color, int):
654
+ hex_length = len(f'{color:X}')
655
+ return hex_length == 4 or hex_length == 8
656
+ elif isinstance(color, (list, tuple)) and len(color) == 4 and color[3] is not None:
657
+ return True
658
+ elif isinstance(color, dict) and len(color) == 4 and color['a'] is not None:
659
+ return True
660
+ return False
661
+
662
+ @staticmethod
663
+ def to_rgba(color:hsla|hexa) -> rgba:
664
+ """Will try to convert any color type to a color of type RGBA."""
665
+ if isinstance(color, (hsla, hexa)):
666
+ return color.to_rgba()
667
+ elif Color.is_valid_hsla(color):
668
+ return hsla(*color).to_rgba() if Color.has_alpha(color) else hsla(color[0], color[1], color[2]).to_rgba()
669
+ elif Color.is_valid_hexa(color):
670
+ return hexa(color).to_rgba()
671
+ elif Color.is_valid_rgba(color):
672
+ return color if isinstance(color, rgba) else rgba(*color) if Color.has_alpha(color) else rgba(color[0], color[1], color[2])
673
+ raise ValueError(f"Invalid color format '{color}'")
674
+
675
+ @staticmethod
676
+ def to_hsla(color:rgba|hexa) -> hsla:
677
+ """Will try to convert any color type to a color of type HSLA."""
678
+ if isinstance(color, (rgba, hexa)):
679
+ return color.to_hsla()
680
+ elif Color.is_valid_rgba(color):
681
+ return rgba(*color).to_hsla() if Color.has_alpha(color) else rgba(color[0], color[1], color[2]).to_hsla()
682
+ elif Color.is_valid_hexa(color):
683
+ return hexa(color).to_hsla()
684
+ elif Color.is_valid_hsla(color):
685
+ return color if isinstance(color, hsla) else hsla(*color) if Color.has_alpha(color) else hsla(color[0], color[1], color[2])
686
+ raise ValueError(f"Invalid color format '{color}'")
687
+
688
+ @staticmethod
689
+ def to_hexa(color:rgba|hsla) -> hexa:
690
+ """Will try to convert any color type to a color of type HEXA."""
691
+ if isinstance(color, (rgba, hsla)):
692
+ return color.to_hexa()
693
+ elif Color.is_valid_rgba(color):
694
+ return rgba(*color).to_hexa() if Color.has_alpha(color) else rgba(color[0], color[1], color[2]).to_hexa()
695
+ elif Color.is_valid_hsla(color):
696
+ return hsla(*color).to_hexa() if Color.has_alpha(color) else hsla(color[0], color[1], color[2]).to_hexa()
697
+ elif Color.is_valid_hexa(color):
698
+ return color if isinstance(color, hexa) else hexa(f'#{color}')
699
+ raise ValueError(f"Invalid color format '{color}'")
700
+
701
+ @staticmethod
702
+ def str_to_rgba(string:str, only_first:bool = False) -> rgba|list[rgba]|None:
703
+ """Will try to recognize RGBA colors inside a string and output the found ones as RGBA objects.<br>
704
+ If `only_first` is `True` only the first found color will be returned (not as a list)."""
705
+ matches = _re.findall(Regex.rgb_str(allow_alpha=True), string)
706
+ if not matches: return None
707
+ result = [rgba(int(m[0]), int(m[1]), int(m[2]), ((int(m[3]) if '.' not in m[3] else float(m[3])) if m[3] else None)) for m in matches]
708
+ return result[0] if len(result) == 1 or only_first else result
709
+
710
+ @staticmethod
711
+ def rgba_to_hex(r:int, g:int, b:int, a:float = None, _preserve_original:bool = False) -> int:
712
+ """Convert RGBA channels to a HEXA integer (alpha is optional).\n
713
+ -------------------------------------------------------------------------------------------------------------------------
714
+ To preserve leading zeros, the function will add a `1` at the beginning, if the HEX value would start with a `0`.<br>
715
+ This could affect the color a little bit, but will make sure, that it won't be interpreted as a completely different<br>
716
+ color, when initializing it as a `hexa()` color or changing it back to RGBA using `Color.hex_to_rgba()`.\n
717
+ ⇾ **You can disable this behavior by setting `_preserve_original` to `True`**"""
718
+ r = max(0, min(255, int(r)))
719
+ g = max(0, min(255, int(g)))
720
+ b = max(0, min(255, int(b)))
721
+ if a is not None:
722
+ if isinstance(a, float):
723
+ a = int(a * 255)
724
+ a = max(0, min(255, int(a)))
725
+ hex_int = (r << 24) | (g << 16) | (b << 8) | a
726
+ if not _preserve_original and r == 0:
727
+ hex_int |= 0x01000000
728
+ else:
729
+ hex_int = (r << 16) | (g << 8) | b
730
+ if not _preserve_original and (hex_int & 0xF00000) == 0:
731
+ hex_int |= 0x010000
732
+ return hex_int
733
+
734
+ @staticmethod
735
+ def hex_to_rgba(hex_int:int) -> tuple[int,int,int,float|int|None]:
736
+ if not isinstance(hex_int, int):
737
+ raise ValueError('Input must be an integer (hex value)')
738
+ hex_str = f'{hex_int:x}'
739
+ if len(hex_str) <= 6:
740
+ hex_str = hex_str.zfill(6)
741
+ return int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16), None
742
+ elif len(hex_str) <= 8:
743
+ hex_str = hex_str.zfill(8)
744
+ return int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16), int(hex_str[6:8], 16) / 255.0
745
+ else:
746
+ raise ValueError(f"Invalid HEX integer '0x{hex_str}': expected in range [0x000000, 0xFFFFFF]")
747
+
748
+ @staticmethod
749
+ def luminance(r:int, g:int, b:int, output_type:type = None) -> int|float:
750
+ """Gets the colors luminance using the luminance formula.\n
751
+ ------------------------------------------------------------
752
+ The param `output_type` can be set to:<br>
753
+ *`int`* =⠀integer in [0, 100]<br>
754
+ *`float`* =⠀float in [0.0, 1.0]<br>
755
+ `None` =⠀integer in [0, 255]"""
756
+ r, g, b = r / 255.0, g / 255.0, b / 255.0
757
+ if r < 0.03928: r = r / 12.92
758
+ else: r = ((r + 0.055) / 1.055) ** 2.4
759
+ if g < 0.03928: g = g / 12.92
760
+ else: g = ((g + 0.055) / 1.055) ** 2.4
761
+ if b < 0.03928: b = b / 12.92
762
+ else: b = ((b + 0.055) / 1.055) ** 2.4
763
+ l = 0.2126 * r + 0.7152 * g + 0.0722 * b
764
+ return round(l * 100) if isinstance(output_type, int) else round(l * 255) if output_type is None else l
765
+
766
+ @staticmethod
767
+ def text_color_for_on_bg(title_bg_color:rgba|hexa = 0xFFF) -> rgba|hexa:
768
+ (was_hexa, hexa_prefix), was_int = Color.is_valid_hexa(title_bg_color, get_prefix=True), isinstance(title_bg_color, int)
769
+ title_bg_color = Color.to_rgba(title_bg_color)
770
+ brightness = 0.2126 * title_bg_color[0] + 0.7152 * title_bg_color[1] + 0.0722 * title_bg_color[2]
771
+ return (hexa(f'{hexa_prefix}FFF') if was_hexa else rgba(255, 255, 255)) if brightness < 128 else ((0x000 if was_int else hexa(f'{hexa_prefix}000')) if was_hexa else rgba(0, 0, 0))
772
+
773
+ @staticmethod
774
+ def adjust_lightness(color:rgba|hexa, brightness_change:float) -> rgba|hexa:
775
+ """In- or decrease the lightness of the input color.\n
776
+ ----------------------------------------------------------------------------------------------------
777
+ **color** (rgba|hexa): HEX or RGBA color<br>
778
+ **brightness_change** (float): float between -1.0 (darken by `100%`) and 1.0 (lighten by `100%`)\n
779
+ ----------------------------------------------------------------------------------------------------
780
+ **returns** (rgba|hexa): the adjusted color in the format of the input color"""
781
+ was_hexa = Color.is_valid_hexa(color)
782
+ color = Color.to_hsla(color)
783
+ h, s, l, a = color[0], color[1], color[2], color[3] if Color.has_alpha(color) else None
784
+ l = int(max(0, min(100, l + brightness_change * 100)))
785
+ return Color.to_hexa((h, s, l, a)) if was_hexa else Color.to_rgba((h, s, l, a))
786
+
787
+ @staticmethod
788
+ def adjust_saturation(color:rgba|hexa, saturation_change:float) -> rgba|hexa:
789
+ """In- or decrease the saturation of the input color.\n
790
+ ---------------------------------------------------------------------------------------------------------
791
+ **color** (rgba|hexa): HEX or RGBA color<br>
792
+ **saturation_change** (float): float between -1.0 (saturate by `100%`) and 1.0 (desaturate by `100%`)\n
793
+ ---------------------------------------------------------------------------------------------------------
794
+ **returns** (rgba|hexa): the adjusted color in the format of the input color"""
795
+ was_hexa = Color.is_valid_hexa(color)
796
+ color = Color.to_hsla(color)
797
+ h, s, l, a = color[0], color[1], color[2], color[3] if Color.has_alpha(color) else None
798
+ s = int(max(0, min(100, s + saturation_change * 100)))
799
+ return Color.to_hexa((h, s, l, a)) if was_hexa else Color.to_rgba((h, s, l, a))