hadalized 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hadalized/color.py ADDED
@@ -0,0 +1,595 @@
1
+ """Color string parsing and information extraction."""
2
+
3
+ from collections.abc import Callable
4
+ from enum import StrEnum, auto
5
+ from typing import Self
6
+
7
+ from coloraide import Color as ColorBase
8
+ from pydantic import Field, PrivateAttr
9
+
10
+ from hadalized.base import BaseNode
11
+
12
+ type ColorField = ColorInfo | str
13
+ """A field value containing either full ColorInfo for a specific space / gamut
14
+ parseable string representation of a color."""
15
+
16
+ type ColorFieldStr = str
17
+
18
+ type ColorFieldHandler = Callable[[ColorField], ColorField]
19
+ """A function that can be mapped across"""
20
+
21
+
22
+ class Ref:
23
+ """Color definitions references."""
24
+
25
+ black: ColorField = "oklch(0.10 0.01 220)"
26
+ darkgray: ColorField = "oklch(0.30 0.01 220)"
27
+ darkslategray: ColorField = "oklch(0.30 0.03 220)"
28
+ gray: ColorField = "oklch(0.50 0.01 220)"
29
+ slategray: ColorField = "oklch(0.600 0.03 220)"
30
+ lightgray: ColorField = "oklch(0.70 0.01 220)"
31
+ lightslategray: ColorField = "oklch(0.700 0.02 220)"
32
+ white: ColorField = "oklch(0.995 0.01 220)"
33
+ # blues, high chroma
34
+ b12: ColorField = "oklch(0.125 0.030 220)"
35
+ b13: ColorField = "oklch(0.130 0.030 220)"
36
+ b14: ColorField = "oklch(0.140 0.030 220)"
37
+ b16: ColorField = "oklch(0.1625 0.030 220)"
38
+ b20: ColorField = "oklch(0.200 .030 220)"
39
+ b25: ColorField = "oklch(0.250 .030 220)"
40
+ b30: ColorField = "oklch(0.300 .035 220)"
41
+ b35: ColorField = "oklch(0.350 .035 220)"
42
+ # grays, mid chroma
43
+ g20: ColorField = "oklch(0.200 .010 220)"
44
+ g30: ColorField = "oklch(0.300 .010 220)"
45
+ g35: ColorField = "oklch(0.350 .010 220)"
46
+ g45: ColorField = "oklch(0.450 .010 220)"
47
+ g60: ColorField = "oklch(0.600 .010 220)"
48
+ g65: ColorField = "oklch(0.650 .010 220)"
49
+ g70: ColorField = "oklch(0.700 .010 220)"
50
+ g75: ColorField = "oklch(0.750 .010 220)"
51
+ g80: ColorField = "oklch(0.800 .010 220)"
52
+ g90: ColorField = "oklch(0.900 .010 220)"
53
+ # Sun / Day high chroma
54
+ s80: ColorField = "oklch(0.800 .020 100)"
55
+ s85: ColorField = "oklch(0.850 .020 100)"
56
+ s90: ColorField = "oklch(0.900 .020 100)"
57
+ s91: ColorField = "oklch(0.910 .020 100)"
58
+ s92: ColorField = "oklch(0.925 .020 100)"
59
+ s95: ColorField = "oklch(0.950 .020 100)"
60
+ s97: ColorField = "oklch(0.975 .015 100)"
61
+ s99: ColorField = "oklch(0.990 .010 100)"
62
+ s100: ColorField = "oklch(0.995 .010 100)"
63
+ # whites, low chroma
64
+ w13: ColorField = "oklch(0.13 0.005 220)"
65
+ w14: ColorField = "oklch(0.14 0.005 220)"
66
+ w16: ColorField = "oklch(0.16 0.005 220)"
67
+ w20: ColorField = "oklch(0.20 0.005 220)"
68
+ w25: ColorField = "oklch(0.25 0.005 220)"
69
+ w30: ColorField = "oklch(0.30 0.005 220)"
70
+ w35: ColorField = "oklch(0.35 0.005 220)"
71
+ w80: ColorField = "oklch(0.800 .005 100)"
72
+ w85: ColorField = "oklch(0.850 .005 100)"
73
+ w90: ColorField = "oklch(0.900 .005 100)"
74
+ w91: ColorField = "oklch(0.910 .005 100)"
75
+ w92: ColorField = "oklch(0.925 .005 100)"
76
+ w95: ColorField = "oklch(0.950 .005 100)"
77
+ w97: ColorField = "oklch(0.975 .005 100)"
78
+ w99: ColorField = "oklch(0.990 .005 100)"
79
+ w100: ColorField = "oklch(0.995 .005 100)"
80
+
81
+
82
+ class ColorSpace(StrEnum):
83
+ """Colorspace constants."""
84
+
85
+ srgb = auto()
86
+ display_p3 = "display-p3"
87
+ oklch = auto()
88
+
89
+
90
+ class ColorFieldType(StrEnum):
91
+ """Constants representing nodes in a ColorInfo object.
92
+
93
+ Use in build directives to declaratively apply transformations to
94
+ Palette ColorMap fields.
95
+ """
96
+
97
+ info = auto()
98
+ """Indicates a ColorField is a ``ColorInfo`` instance."""
99
+ hex = auto()
100
+ """Indicates a ColorField should be a RGB hex code in a specified gamut."""
101
+ oklch = auto()
102
+ """Indicates a ColorField should be a oklch css code in a specified gamut."""
103
+ css = auto()
104
+ """Indicates a ColorField should be a css code in a specified gamut."""
105
+
106
+
107
+ class ColorInfo(BaseNode):
108
+ """Detailed information about a specific color.
109
+
110
+ Use the `parse` function to instantiate an instance rather than doing so
111
+ directly to ensure the raw value is parseable.
112
+ """
113
+
114
+ raw: str = Field(examples=["oklch(0.6 0.2 25)", "#010203"])
115
+ # parsed: ColorBase = Field(exclude=True)
116
+ """Parseable color definition, e.g., a css value."""
117
+ gamut: str = Field(
118
+ default=ColorSpace.srgb,
119
+ examples=["srgb", "display-p3"],
120
+ )
121
+ """Target gamut to fit the raw color definition to."""
122
+ raw_oklch: ColorFieldStr
123
+ """Raw input in the oklch colorspace."""
124
+ oklch: ColorFieldStr
125
+ """OKLCH value fit to the specified `gamut`."""
126
+ css: ColorFieldStr
127
+ """CSS value in the gamut."""
128
+ hex: ColorFieldStr
129
+ """24 or 32-bit hex representation for RGB gamuts."""
130
+ is_in_gamut: bool
131
+ """Indicates whether the raw value is within the color gamut."""
132
+ max_oklch_chroma: float
133
+ """The maximum oklch chroma value determined from the fit method."""
134
+ _color: ColorBase | None = PrivateAttr(None)
135
+ """Parsed instance."""
136
+
137
+ def color(self) -> ColorBase:
138
+ """Coloraide.Color object parsed from the definition.
139
+
140
+ Returns:
141
+ A coloraide.Color instance.
142
+
143
+ """
144
+ if self._color is None:
145
+ self._color = ColorBase(self.raw)
146
+ return self._color
147
+
148
+
149
+ class Parser:
150
+ """Parse raw color strings."""
151
+
152
+ def __init__(self, gamut: str = ColorSpace.srgb, fit_method: str = "raytrace"):
153
+ """Set gamut and fit method."""
154
+ self.gamut = gamut
155
+ self.fit_method = fit_method
156
+
157
+ @staticmethod
158
+ def _to_hex(val: ColorBase) -> str:
159
+ """Convert RGB to their corresponding 24-bit or 34-bit hex color code.
160
+
161
+ Used primarily to extract a hex code for use
162
+ in programs--such as neovim--that only allow specifying colors
163
+ via RGB channels.
164
+
165
+ Returns:
166
+ A hex color code.
167
+
168
+ """
169
+ if val.space() != ColorSpace.srgb:
170
+ val = ColorBase(ColorSpace.srgb, val.coords(), alpha=val.alpha())
171
+ return val.to_string(hex=True)
172
+
173
+ def _fit(self, val: ColorBase) -> ColorBase:
174
+ return val.clone().fit(self.gamut, method=self.fit_method)
175
+
176
+ def _max_oklch_chroma(self, val: ColorBase) -> float:
177
+ """Determine maximum OKLCH chroma in the gamut for fixed lightness and hue.
178
+
179
+ Returns:
180
+ OKLCH chroma value.
181
+
182
+ """
183
+ if val.space() != ColorSpace.oklch:
184
+ val = val.convert("oklch")
185
+ lightness, _, hue = val.coords()
186
+ cmax = ColorBase("oklch", (lightness, 0.4, hue))
187
+ return self._fit(cmax).get("chroma")
188
+
189
+ def __call__(self, val: ColorField) -> ColorInfo:
190
+ """Parse a string representation of a color.
191
+
192
+ Returns:
193
+ A ColorInfo instance parsed from the input string. Raises a
194
+ ValueError if the input is not parseable.
195
+
196
+ """
197
+ if isinstance(val, ColorInfo):
198
+ return val
199
+ raw_color = ColorBase(val)
200
+ if raw_color.space() != ColorSpace.oklch:
201
+ raw_oklch = raw_color.convert(ColorSpace.oklch)
202
+ else:
203
+ raw_oklch = raw_color
204
+
205
+ oklch_fit = self._fit(raw_oklch)
206
+ color = oklch_fit.convert(self.gamut)
207
+
208
+ inst = ColorInfo(
209
+ raw=val,
210
+ raw_oklch=raw_oklch.to_string(),
211
+ gamut=self.gamut,
212
+ oklch=oklch_fit.to_string(),
213
+ css=color.to_string(),
214
+ hex=self._to_hex(color),
215
+ is_in_gamut=raw_oklch.convert(self.gamut).in_gamut(),
216
+ max_oklch_chroma=self._max_oklch_chroma(raw_oklch),
217
+ )
218
+ inst._color = raw_color
219
+ return inst
220
+
221
+
222
+ class Extractor:
223
+ """A ColorFieldHandler that extracts ``ColorInfo`` field values.
224
+
225
+ Attrs:
226
+ field (ColorFieldType): Which field will be extracted.
227
+ is_identity: Indicates whether the extractor is the identity function.
228
+
229
+ """
230
+
231
+ def __init__(self, field: str | ColorFieldType):
232
+ """Validate input as a ColorFieldType."""
233
+ self.field = ColorFieldType(field)
234
+ self.is_identity = self.field == ColorFieldType.info
235
+
236
+ def __call__(self, val: ColorField) -> ColorField:
237
+ """Extract field value from the input.
238
+
239
+ Calling twice results in a TypeError, to avoid uncaught errors
240
+ when chaining extractors. An expection is when the extractor
241
+ represents the identity function.
242
+
243
+ Raises:
244
+ TypeError: When the input is not a ``ColorInfo`` instance.
245
+
246
+ Returns:
247
+ A ``ColorInfo`` field value defined by the ``field`` attr
248
+ or the ColorInfo instance itself in case when the extractor is
249
+ the identity function.
250
+
251
+ """
252
+ if not isinstance(val, ColorInfo):
253
+ clsname = ColorInfo.__name__
254
+ raise TypeError(f"Input type {type(val)} is not a {clsname} instance.")
255
+ return val if self.is_identity else val[self.field]
256
+
257
+
258
+ class Hue(StrEnum):
259
+ """Named hues. These represent the fields of a ``Hues`` instance."""
260
+
261
+ red = auto()
262
+ orange = auto()
263
+ yellow = auto()
264
+ lime = auto()
265
+ green = auto()
266
+ mint = auto()
267
+ cyan = auto()
268
+ azure = auto()
269
+ blue = auto()
270
+ violet = auto()
271
+ magenta = auto()
272
+ rose = auto()
273
+
274
+ @staticmethod
275
+ def get(index: int) -> Hue:
276
+ """Get a Hue color by integer index.
277
+
278
+ Returns:
279
+ Hue value corresponding to the index.
280
+
281
+ """
282
+ return _hue_lu[index]
283
+
284
+
285
+ _hue_lu = (
286
+ Hue.red, # 0
287
+ Hue.orange, # 1
288
+ Hue.yellow, # 2
289
+ Hue.lime, # 3
290
+ Hue.green, # 4
291
+ Hue.mint, # 5
292
+ Hue.cyan, # 6
293
+ Hue.azure, # 7
294
+ Hue.blue, # 8
295
+ Hue.violet, # 9
296
+ Hue.magenta, # 10, A
297
+ Hue.rose, # 11, B
298
+ )
299
+
300
+
301
+ class HueAlias(BaseNode):
302
+ """A mapping from indexed color names to ``Hues`` fields."""
303
+
304
+ c0: Hue = Hue.get(0)
305
+ c1: Hue = Hue.get(1)
306
+ c2: Hue = Hue.get(2)
307
+ c3: Hue = Hue.get(3)
308
+ c4: Hue = Hue.get(4)
309
+ c5: Hue = Hue.get(5)
310
+ c6: Hue = Hue.get(6)
311
+ c7: Hue = Hue.get(7)
312
+ c8: Hue = Hue.get(8)
313
+ c9: Hue = Hue.get(9)
314
+ ca: Hue = Hue.get(0xA)
315
+ cb: Hue = Hue.get(0xB)
316
+
317
+ def model_post_init(self, context, /) -> None:
318
+ """Validate each Hue appears exactly once.
319
+
320
+ Raises:
321
+ ValueError: If there are not the same number of values as field names.
322
+
323
+ """
324
+ required_len = len(self)
325
+ vals = (v for _, v in self)
326
+ if len(set(vals)) != required_len:
327
+ raise ValueError(f"Instance must contain {required_len} unique values.")
328
+ return super().model_post_init(context)
329
+
330
+
331
+ class ColorMap(BaseNode):
332
+ """Base dataclass for mappings of the form color name -> ColorInfo.
333
+
334
+ The fields can either be a complete object containing data for all
335
+ gamuts, gamut specific color info, or a string. While the model itself
336
+ does not enforce uniformity of type among the strings, the data structure
337
+ should typically be equivalent to one of
338
+ Mapping[str, ColorInfo]
339
+ Mapping[str, GamutColor]
340
+ Mapping[str, str]
341
+ Instances containing values other than ColorInfo are obtained via transform
342
+ methods.
343
+ """
344
+
345
+ _field_type: ColorFieldType | None = PrivateAttr(default=None)
346
+
347
+ @property
348
+ def field_type(self) -> ColorFieldType | None:
349
+ """What the field values represent."""
350
+ return self._field_type
351
+
352
+ def map(self, handler: ColorFieldHandler) -> Self:
353
+ """Apply a generic color field handler to each field.
354
+
355
+ Example handlers enclude
356
+ - field extractors, e.g., mapping a parsed instance to specific field
357
+ - parsers, to convert from string color definitions to ColorInfo fields
358
+
359
+ Returns:
360
+ A new ColorMap instance with the handler applied to each field.
361
+
362
+ """
363
+ data: dict[str, ColorField] = {k: handler(v) for k, v in self}
364
+ inst = self.model_validate(data)
365
+ if isinstance(handler, Extractor):
366
+ inst._field_type = handler.field
367
+ elif isinstance(handler, Parser):
368
+ inst._field_type = ColorFieldType.info
369
+ return inst
370
+
371
+
372
+ class Hues(ColorMap):
373
+ """Named accents.
374
+
375
+ A ``Hues`` instance serves primarily to color text and highlights.
376
+
377
+ """
378
+
379
+ red: ColorField = "oklch(0.575 0.185 25)"
380
+ orange: ColorField = "oklch(0.650 0.150 60)"
381
+ yellow: ColorField = "oklch(0.675 0.120 100)"
382
+ lime: ColorField = "oklch(0.650 0.130 115)"
383
+ green: ColorField = "oklch(0.575 0.165 130)"
384
+ mint: ColorField = "oklch(0.675 0.130 155)"
385
+ cyan: ColorField = "oklch(0.625 0.100 180)"
386
+ azure: ColorField = "oklch(0.675 0.110 225)"
387
+ blue: ColorField = "oklch(0.575 0.140 250)"
388
+ violet: ColorField = "oklch(0.575 0.185 290)"
389
+ magenta: ColorField = "oklch(0.575 0.185 330)"
390
+ rose: ColorField = "oklch(0.675 0.100 360)"
391
+
392
+ # @staticmethod
393
+ # def neutral() -> Hues:
394
+ # """Neutral hues.
395
+ #
396
+ # Returns:
397
+ # A neutral mode selection of hues.
398
+ #
399
+ # """
400
+ # return Hues()
401
+
402
+ @staticmethod
403
+ def dark() -> Hues:
404
+ """Dark mode hues.
405
+
406
+ Returns:
407
+ A dark mode selection of hues.
408
+
409
+ """
410
+ return Hues(
411
+ red="oklch(0.60 0.185 25)",
412
+ orange="oklch(0.650 0.150 60)",
413
+ yellow="oklch(0.700 0.120 100)",
414
+ lime="oklch(0.675 0.120 115)",
415
+ green="oklch(0.650 0.165 130)",
416
+ mint="oklch(0.715 0.130 155)",
417
+ cyan="oklch(0.650 0.100 180)",
418
+ azure="oklch(0.725 0.110 225)",
419
+ blue="oklch(0.625 0.150 250)",
420
+ violet="oklch(0.625 0.185 290)",
421
+ magenta="oklch(0.625 0.185 330)",
422
+ rose="oklch(0.700 0.100 360)",
423
+ )
424
+
425
+ @staticmethod
426
+ def light() -> Hues:
427
+ """Light mode hues.
428
+
429
+ Returns:
430
+ A light mode selection of hues.
431
+
432
+ """
433
+ return Hues(
434
+ red="oklch(0.550 0.185 25)",
435
+ orange="oklch(0.650 0.150 60)",
436
+ yellow="oklch(0.650 0.120 100)",
437
+ lime="oklch(0.650 0.130 115)",
438
+ green="oklch(0.575 0.165 130)",
439
+ mint="oklch(0.650 0.130 155)",
440
+ cyan="oklch(0.550 0.100 180)",
441
+ azure="oklch(0.650 0.110 225)",
442
+ blue="oklch(0.575 0.140 250)",
443
+ violet="oklch(0.550 0.185 290)",
444
+ magenta="oklch(0.550 0.185 330)",
445
+ rose="oklch(0.625 0.100 360)",
446
+ )
447
+
448
+ @staticmethod
449
+ def highlights() -> Hues:
450
+ """Highlight hues.
451
+
452
+ Returns:
453
+ A selection of hues to use in highlights.
454
+
455
+ """
456
+ return Hues(
457
+ red="oklch(0.800 0.100 25)",
458
+ orange="oklch(0.850 0.100 60)",
459
+ yellow="oklch(0.950 0.200 100)",
460
+ lime="oklch(0.855 0.100 115)",
461
+ green="oklch(0.85 0.100 130)",
462
+ mint="oklch(0.875 0.100 155)",
463
+ cyan="oklch(0.900 0.100 180)",
464
+ azure="oklch(0.875 0.100 225)",
465
+ blue="oklch(0.825 0.100 250)",
466
+ violet="oklch(0.825 0.200 290)",
467
+ magenta="oklch(0.825 0.200 330)",
468
+ rose="oklch(0.825 0.200 360)",
469
+ )
470
+
471
+ @staticmethod
472
+ def bright() -> Hues:
473
+ """Highlight hues.
474
+
475
+ Returns:
476
+ A selection of brighter hues.
477
+
478
+ """
479
+ return Hues(
480
+ red="oklch(0.675 0.200 25)",
481
+ orange="oklch(0.75 0.175 60)",
482
+ yellow="oklch(0.80 0.165 100)",
483
+ lime="oklch(0.800 0.185 120)",
484
+ green="oklch(0.800 0.200 135)",
485
+ mint="oklch(0.800 0.195 155)",
486
+ cyan="oklch(0.800 0.145 180)",
487
+ azure="oklch(0.800 0.135 225)",
488
+ blue="oklch(0.800 0.100 250)",
489
+ violet="oklch(0.800 0.100 290)",
490
+ magenta="oklch(0.800 0.185 330)",
491
+ rose="oklch(0.800 0.120 360)",
492
+ )
493
+
494
+
495
+ class Bases(ColorMap):
496
+ """Configuration node for foregrounds and backgrounds.
497
+
498
+ Colors are grouped primarily into
499
+
500
+ - backgrounds (main and overlays),
501
+ - foreground colors
502
+ - opposite overlays
503
+ """
504
+
505
+ bg: ColorField = Ref.b13
506
+ """Primary background color."""
507
+ bg1: ColorField = Ref.b14
508
+ """Secondary background color."""
509
+ bg2: ColorField = Ref.b16
510
+ """Tertiary background color."""
511
+ bg3: ColorField = Ref.b20
512
+ """Overlay background 1."""
513
+ bg4: ColorField = Ref.b25
514
+ """Overlay background 2."""
515
+ bg5: ColorField = Ref.b30
516
+ """Overlay background 3."""
517
+ bg6: ColorField = Ref.b35
518
+ """Overlay."""
519
+ hidden: ColorField = Ref.g45
520
+ """Strongly de-mphasized foreground text."""
521
+ subfg: ColorField = Ref.g70
522
+ """De-emphasized foreground text."""
523
+ fg: ColorField = Ref.w80
524
+ """Primary foreground text."""
525
+ emph: ColorField = Ref.w85
526
+ """Emphasized foreground text."""
527
+ op2: ColorField = Ref.s80
528
+ """Tertiary opposite background color."""
529
+ op1: ColorField = Ref.s85
530
+ """Secondary opposite background color."""
531
+ op: ColorField = Ref.s90
532
+ """Primary opposite background color."""
533
+
534
+ @staticmethod
535
+ def dark() -> Bases:
536
+ """Dark mode bases.
537
+
538
+ Returns:
539
+ A dark mode selection of bases.
540
+
541
+ """
542
+ return Bases()
543
+
544
+ @staticmethod
545
+ def light() -> Bases:
546
+ """Light mode bases.
547
+
548
+ Returns:
549
+ A dark mode selection of bases.
550
+
551
+ """
552
+ dark = Bases.dark()
553
+ return Bases(
554
+ bg=Ref.s100,
555
+ bg1=Ref.s99,
556
+ bg2=Ref.s95,
557
+ bg3=Ref.s92,
558
+ bg4=Ref.s99,
559
+ bg5=Ref.s85,
560
+ bg6=Ref.s80,
561
+ hidden=Ref.g75,
562
+ subfg=Ref.g60,
563
+ fg=Ref.g30,
564
+ emph=Ref.g20,
565
+ op2=dark.bg3,
566
+ op1=dark.bg2,
567
+ op=dark.bg,
568
+ )
569
+
570
+
571
+ class Grayscale(ColorMap):
572
+ """Grayscale monochromatic named colors that are palette independent."""
573
+
574
+ black: ColorField = "oklch(0.10 0.01 220)"
575
+ darkgray: ColorField = "oklch(0.30 0.01 220)"
576
+ neutralgray: ColorField = "oklch(0.50 0.01 220)"
577
+ lightgray: ColorField = "oklch(0.70 0.01 220)"
578
+ white: ColorField = "oklch(0.995 0.003 220)"
579
+
580
+
581
+ def parse(
582
+ val: str,
583
+ gamut: str = ColorSpace.srgb,
584
+ fit_method: str = "raytrace",
585
+ ) -> ColorInfo:
586
+ """Parse a string representation of a color.
587
+
588
+ Generate a ``Parser`` instance and call it on the input.
589
+
590
+ Returns:
591
+ A ColorInfo instance parsed from the input string. Raises a
592
+ ValueError if the input is not parseable.
593
+
594
+ """
595
+ return Parser(gamut=gamut, fit_method=fit_method)(val)